From 220ff92e785e9e325e40309fb34d5f9baad78d0d Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 15:22:02 -0500 Subject: [PATCH] Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid --- .../components/schedule/WeekSchedule.svelte | 142 ++++++++++++------ .../schedule/WeekSchedule.svelte.spec.ts | 50 ++++++ src/lib/server/schedule.ts | 8 +- .../(protected)/(user)/schedule/+page.svelte | 29 +--- .../(user)/schedule/page.svelte.spec.ts | 67 +++++++++ 5 files changed, 229 insertions(+), 67 deletions(-) create mode 100644 src/routes/(protected)/(user)/schedule/page.svelte.spec.ts diff --git a/src/lib/components/schedule/WeekSchedule.svelte b/src/lib/components/schedule/WeekSchedule.svelte index b2e33ac..0c91e7b 100644 --- a/src/lib/components/schedule/WeekSchedule.svelte +++ b/src/lib/components/schedule/WeekSchedule.svelte @@ -50,7 +50,9 @@ pointerId: number; } | null>(null); - const dayMap = $derived(new Map(days.map((d) => [d.key, d]))); + function dayLabel(key: string): string { + return days.find((d) => d.key === key)?.label ?? key; + } function minutesFromEvent(event: PointerEvent, column: HTMLElement): number { const rect = column.getBoundingClientRect(); @@ -110,19 +112,69 @@ }); function blocksForDay(day: string): ScheduleBlockView[] { - return blocks.filter((b) => (b.day ?? null) === day); + // Daily schedules use a single "daily" column while their Blocks carry + // day=null; weekly columns match the `mon`..`sun` keys directly. + return blocks.filter((b) => (day === 'daily' ? b.day == null : b.day === day)); } - // Overnight blocks render as two segments: start→midnight and midnight→end. - function viewsForDay(day: string) { - return blocksForDay(day).map((block) => ({ + // The column before each day, wrapping across the schedule (mon's previous + // day is sun; the single daily column is its own previous day). Used to spill + // an overnight block's post-midnight tail into the next day's column. + const previousDayKey: Record = $derived.by(() => { + const map: Record = {}; + days.forEach((day, index) => { + map[day.key] = days[(index - 1 + days.length) % days.length].key; + }); + return map; + }); + + type DayView = { + block: ScheduleBlockView; + // A same-day block, the pre-midnight start of an overnight block, or the + // post-midnight tail of the previous day's overnight block. + part: 'whole' | 'head' | 'tail'; + top: number; + height: number; + }; + + function toView( + block: ScheduleBlockView, + part: DayView['part'], + [start, end]: [number, number] + ): DayView { + return { block, - overnight: isOvernight(block), - segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({ - top: (segmentStart / MINUTES_PER_DAY) * 100, - height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100 - })) - })); + part, + top: (start / MINUTES_PER_DAY) * 100, + height: ((end - start) / MINUTES_PER_DAY) * 100 + }; + } + + // A column shows its own blocks plus the tails of the previous day's + // overnight blocks. An overnight block contributes only its pre-midnight + // head to its start day; its tail is drawn at the top of the next day + // (weekly sun 7PM→1AM spills into mon). For daily, the previous day is the + // same single column, so both parts render together as before. + function viewsForDay(day: string): DayView[] { + const views: DayView[] = []; + + for (const block of blocksForDay(day)) { + const head = blockSegments(block)[0]; + if (!head || head[1] <= head[0]) continue; + views.push(toView(block, isOvernight(block) ? 'head' : 'whole', head)); + } + + const previous = previousDayKey[day]; + if (previous) { + for (const block of blocksForDay(previous)) { + if (!isOvernight(block)) continue; + const tail = blockSegments(block)[1]; + if (!tail || tail[1] <= tail[0]) continue; + views.push(toView(block, 'tail', tail)); + } + } + + return views; } @@ -133,7 +185,7 @@ block to edit it. -->
@@ -154,11 +206,12 @@
- + {#each days as day (day.key)}
startDrag(e, day.key)} onpointermove={moveDrag} onpointerup={endDrag} @@ -197,34 +250,35 @@ >
{/if} - {#each viewsForDay(day.key) as view (view.block.id)} - {#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)} - - {/each} + ↳ continues + {:else} + + {formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)} + + {#if view.part === 'head'} + ends next day + {/if} + {/if} + + {view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'} + + {/each}
diff --git a/src/lib/components/schedule/WeekSchedule.svelte.spec.ts b/src/lib/components/schedule/WeekSchedule.svelte.spec.ts index 9eab17d..550861d 100644 --- a/src/lib/components/schedule/WeekSchedule.svelte.spec.ts +++ b/src/lib/components/schedule/WeekSchedule.svelte.spec.ts @@ -94,6 +94,56 @@ describe('WeekSchedule', () => { await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument(); }); + it('renders daily blocks (day null) in the single daily column', async () => { + mount({ + days: [{ key: 'daily', label: 'Every day' }], + blocks: [block({ day: null })] + }); + + await expect.element(page.getByText('Peace')).toBeInTheDocument(); + await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument(); + }); + + function column(label: string): HTMLElement { + return document.querySelector(`[aria-label="Schedule grid ${label}"]`) as HTMLElement; + } + + it('spills an overnight weekly block into the next day column', async () => { + mount({ blocks: [block({ day: 'sun', startMinutes: 19 * 60, endMinutes: 60 })] }); + + // The start day keeps only the pre-midnight head. + const sunday = column('Sunday'); + expect(sunday.textContent).toContain('7:00 PM–1:00 AM'); + expect(sunday.textContent).toContain('ends next day'); + expect(sunday.textContent).not.toContain('continues'); + + // The post-midnight tail is drawn on the following day. + const monday = column('Monday'); + expect(monday.textContent).toContain('12:00 AM–1:00 AM'); + expect(monday.textContent).toContain('↳ continues'); + }); + + it('spills a Saturday overnight block into Sunday', async () => { + mount({ blocks: [block({ day: 'sat', startMinutes: 23 * 60, endMinutes: 30 })] }); + + const sunday = column('Sunday'); + expect(sunday.textContent).toContain('12:00 AM–12:30 AM'); + expect(sunday.textContent).toContain('↳ continues'); + }); + + it('keeps both parts of a daily overnight block in its single column', async () => { + mount({ + days: [{ key: 'daily', label: 'Every day' }], + blocks: [block({ day: null, startMinutes: 21 * 60, endMinutes: 9 * 60 })] + }); + + const daily = column('Every day'); + expect(daily.textContent).toContain('9:00 PM–9:00 AM'); + expect(daily.textContent).toContain('ends next day'); + expect(daily.textContent).toContain('12:00 AM–9:00 AM'); + expect(daily.textContent).toContain('↳ continues'); + }); + it('opens create on the clicked day', async () => { const onCreate = vi.fn(); mount({ onCreate }); diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts index 7a9e4db..389fd90 100644 --- a/src/lib/server/schedule.ts +++ b/src/lib/server/schedule.ts @@ -69,11 +69,15 @@ function normaliseDay(type: ScheduleType | null, day: string | null | undefined) return day ?? null; } +// Schedules only need a Proclamation's identity and the labels the portal +// shows. Selecting explicitly (rather than `include`) avoids pulling every +// Proclamation column — notably the potentially large `content` — on each load. const blockInclude = { proclamations: { orderBy: { sortOrder: 'asc' as const }, - include: { - proclamation: { include: { solfeggioFrequency: true } } + select: { + sortOrder: true, + proclamation: { select: { id: true, name: true, categoryId: true, groupId: true } } } } }; diff --git a/src/routes/(protected)/(user)/schedule/+page.svelte b/src/routes/(protected)/(user)/schedule/+page.svelte index 03165d8..5e8b4ef 100644 --- a/src/routes/(protected)/(user)/schedule/+page.svelte +++ b/src/routes/(protected)/(user)/schedule/+page.svelte @@ -1,6 +1,5 @@ diff --git a/src/routes/(protected)/(user)/schedule/page.svelte.spec.ts b/src/routes/(protected)/(user)/schedule/page.svelte.spec.ts new file mode 100644 index 0000000..cf840d2 --- /dev/null +++ b/src/routes/(protected)/(user)/schedule/page.svelte.spec.ts @@ -0,0 +1,67 @@ +import { page } from 'vitest/browser'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import type { PageProps } from './$types'; + +vi.mock('$app/forms', () => ({ + enhance: () => () => {} +})); + +import Page from './+page.svelte'; + +const LICENSE = { + id: 'lk1', + key: 'AAAA-BBBB-CCCC-DDDD', + systemName: 'Living Room', + active: true, + hasSchedule: false +}; + +const block = { + id: 'b1', + day: null as string | null, + startMinutes: 6 * 60, + endMinutes: 8 * 60, + dynamicAudio: true, + proclamations: [{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 }] +}; + +function makeData(overrides: Partial = {}): PageProps['data'] { + return { + userRoleId: null, + licenses: [LICENSE], + selectedLicenseId: LICENSE.id, + type: null, + blocks: [], + categories: [{ id: 1, name: 'Physical' }], + groups: [{ id: 1, name: 'General' }], + proclamations: [{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 }], + ...overrides + }; +} + +function mountPage(data: PageProps['data']) { + return render(Page, { params: {}, data, form: null }); +} + +describe('schedule page', () => { + it('renders a schedule loaded for the selected system (same system, new data)', async () => { + const result = mountPage(makeData()); + + await expect + .element(page.getByText('No schedule. Choose Daily or Weekly to start.')) + .toBeInTheDocument(); + + // The server reloads `data` after an action while the selected system id + // stays the same; the view must follow the new data rather than a stale + // local mirror. + await result.rerender({ data: makeData({ type: 'daily', blocks: [block] }) }); + + await expect.element(page.getByText('Every day')).toBeInTheDocument(); + await expect.element(page.getByText('Peace')).toBeInTheDocument(); + await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument(); + await expect + .element(page.getByText('No schedule. Choose Daily or Weekly to start.')) + .not.toBeInTheDocument(); + }); +});