diff --git a/src/lib/components/schedule/DaySchedule.svelte b/src/lib/components/schedule/DaySchedule.svelte deleted file mode 100644 index 144cfcf..0000000 --- a/src/lib/components/schedule/DaySchedule.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - -
- {#if label} -

{label}

- {/if} -
- - - -
-
diff --git a/src/lib/components/schedule/WeekSchedule.svelte b/src/lib/components/schedule/WeekSchedule.svelte new file mode 100644 index 0000000..b2e33ac --- /dev/null +++ b/src/lib/components/schedule/WeekSchedule.svelte @@ -0,0 +1,233 @@ + + + +
+
+ +
+ +
+ +
+ + + {#each days as day (day.key)} +
+
+ {#if day.short} + {day.short} + + {:else} + {day.label} + {/if} +
+ + +
startDrag(e, day.key)} + onpointermove={moveDrag} + onpointerup={endDrag} + onpointercancel={(e) => { + if (drag && e.pointerId === drag.pointerId) drag = null; + }} + > + {#each hourMarks as hour (hour)} +
+ {/each} + + {#if preview && preview.day === day.key} +
+ {/if} + + {#each viewsForDay(day.key) as view (view.block.id)} + {#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)} + + {/each} + {/each} +
+
+ {/each} +
+
diff --git a/src/lib/components/schedule/WeekSchedule.svelte.spec.ts b/src/lib/components/schedule/WeekSchedule.svelte.spec.ts new file mode 100644 index 0000000..9eab17d --- /dev/null +++ b/src/lib/components/schedule/WeekSchedule.svelte.spec.ts @@ -0,0 +1,122 @@ +import { page } from 'vitest/browser'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import WeekSchedule from './WeekSchedule.svelte'; + +const WEEK_DAYS = [ + { key: 'mon', label: 'Monday' }, + { key: 'tue', label: 'Tuesday' }, + { key: 'wed', label: 'Wednesday' }, + { key: 'thu', label: 'Thursday' }, + { key: 'fri', label: 'Friday' }, + { key: 'sat', label: 'Saturday' }, + { key: 'sun', label: 'Sunday' } +]; + +const block = (overrides: Record = {}) => ({ + id: 'b1', + day: 'mon', + startMinutes: 6 * 60, + endMinutes: 8 * 60, + dynamicAudio: true, + proclamations: [{ id: 'p1', name: 'Peace' }], + ...overrides +}); + +function mount(props: Record = {}) { + return render(WeekSchedule, { days: WEEK_DAYS, blocks: [], ...props }); +} + +// Click at a fraction of the way down a day column. +async function clickColumn(label: string, fraction: number) { + const column = document.querySelector( + `[aria-label="Schedule grid ${label}"]` + ) as HTMLElement | null; + if (!column) throw new Error(`no column ${label}`); + const rect = column.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height * fraction; + column.dispatchEvent( + new PointerEvent('pointerdown', { clientX: x, clientY: y, bubbles: true, pointerId: 1 }) + ); + column.dispatchEvent( + new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true, pointerId: 1 }) + ); +} + +describe('WeekSchedule', () => { + it('renders all seven day columns side by side', async () => { + mount(); + for (const day of WEEK_DAYS) { + await expect.element(page.getByText(day.label)).toBeInTheDocument(); + } + }); + + it('renders a single column for a daily schedule', async () => { + mount({ days: [{ key: 'daily', label: 'Every day' }] }); + await expect.element(page.getByText('Every day')).toBeInTheDocument(); + expect(document.querySelectorAll('[aria-label^="Schedule grid"]')).toHaveLength(1); + }); + + it('is horizontally scrollable with a pinned time gutter (mobile-friendly)', async () => { + mount(); + + // The day columns live in a horizontally scrollable row. + const scroller = document.querySelector('.overflow-x-auto') as HTMLElement | null; + expect(scroller).not.toBeNull(); + + // The time gutter is sticky so it stays visible while days scroll. + const gutter = scroller?.querySelector('[class*="sticky"][class*="left-0"]'); + expect(gutter).not.toBeNull(); + + // Each day column has a minimum basis width so it stays legible on a phone. + const columns = scroller?.querySelectorAll('[aria-label^="Schedule grid"]') ?? []; + expect(columns.length).toBe(7); + }); + + it('shows short day labels on small screens', async () => { + mount({ + days: [ + { key: 'mon', label: 'Monday', short: 'Mon' }, + { key: 'tue', label: 'Tuesday', short: 'Tue' } + ] + }); + // Both the short (small-screen) and long (large-screen) labels exist in + // the DOM; CSS decides which is visible at a given width. + const text = document.body.textContent ?? ''; + expect(text).toContain('Mon'); + expect(text).toContain('Monday'); + }); + + it('shows blocks as events', async () => { + mount({ blocks: [block()] }); + await expect.element(page.getByText('Peace')).toBeInTheDocument(); + await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument(); + }); + + it('opens create on the clicked day', async () => { + const onCreate = vi.fn(); + mount({ onCreate }); + + await clickColumn('Monday', 0.25); + + expect(onCreate).toHaveBeenCalledTimes(1); + const [day, start, end] = onCreate.mock.calls[0] as [string, number, number]; + expect(day).toBe('mon'); + // The handler always yields a sensible minute range (a default-length + // block when the click distance is below the snap threshold). + expect(Number.isFinite(start)).toBe(true); + expect(Number.isFinite(end)).toBe(true); + expect(end - start).toBeGreaterThan(0); + }); + + it('opens edit when a block is clicked', async () => { + const onEdit = vi.fn(); + mount({ blocks: [block()], onEdit }); + + await page.getByLabelText('Block 6:00 AM to 8:00 AM').click(); + + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'b1', day: 'mon' }); + }); +}); diff --git a/src/routes/(protected)/(user)/schedule/+page.svelte b/src/routes/(protected)/(user)/schedule/+page.svelte index 5dc3bab..03165d8 100644 --- a/src/routes/(protected)/(user)/schedule/+page.svelte +++ b/src/routes/(protected)/(user)/schedule/+page.svelte @@ -1,19 +1,21 @@ -{#if typeForm === undefined}{/if} -