From bbf4ac12eec1cb120632a102741297f20e42a5c8 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 13:09:48 -0500 Subject: [PATCH] Portal: calendar-style weekly schedule grid, responsive on mobile Replace the stacked per-day lists with a Google-Calendar-style WeekSchedule: day columns side by side over a shared, sticky time gutter. Click or drag an empty slot to create a block at that time; click a block to edit it. The daily schedule reuses the same grid with a single 'Every day' column so both modes look consistent. On narrow screens the row scrolls horizontally with short day labels and the gutter pinned, keeping it usable on phones. Also change the type switcher and copy dialog to enhanced form submissions (no full-page reload) so they respond immediately. Removes the now-unused DaySchedule component. --- .../components/schedule/DaySchedule.svelte | 190 -------------- .../components/schedule/WeekSchedule.svelte | 233 ++++++++++++++++++ .../schedule/WeekSchedule.svelte.spec.ts | 122 +++++++++ .../(protected)/(user)/schedule/+page.svelte | 129 ++++++---- 4 files changed, 435 insertions(+), 239 deletions(-) delete mode 100644 src/lib/components/schedule/DaySchedule.svelte create mode 100644 src/lib/components/schedule/WeekSchedule.svelte create mode 100644 src/lib/components/schedule/WeekSchedule.svelte.spec.ts 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} -