Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid

This commit is contained in:
NeilClack
2026-09-29 15:22:02 -05:00
parent cc4aa8717e
commit 220ff92e78
5 changed files with 229 additions and 67 deletions
+98 -44
View File
@@ -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<string, string> = $derived.by(() => {
const map: Record<string, string> = {};
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;
}
</script>
@@ -133,7 +185,7 @@
block to edit it.
-->
<div
class="overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
class="isolate overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex overflow-x-auto" style="overscroll-behavior-x: contain;">
<!-- Time gutter (pinned while the day columns scroll horizontally). -->
@@ -154,11 +206,12 @@
</div>
</div>
<!-- Day columns. Each is at least 6.5rem wide so the grid stays usable on
a phone; when the seven days do not fit, the row scrolls. -->
<!-- Day columns share the available width equally so a weekly view fits
without scrolling on desktop. Each keeps a minimum width, so when the
seven days cannot fit (phones) the row scrolls horizontally. -->
{#each days as day (day.key)}
<div
class="w-28 shrink-0 grow basis-28 border-r border-slate-200 last:border-r-0 sm:basis-40 dark:border-slate-800"
class="min-w-24 flex-1 basis-0 border-r border-slate-200 last:border-r-0 dark:border-slate-800"
>
<div
class="flex h-12 items-center justify-center border-b border-slate-200 px-1 text-xs font-semibold text-slate-700 sm:text-sm dark:border-slate-800 dark:text-slate-200"
@@ -174,8 +227,8 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative h-[720px] cursor-pointer select-none"
style="touch-action: pan-x;"
aria-label={`Schedule grid ${dayMap.get(day.key)?.label ?? day.key}`}
style="touch-action: pan-x pan-y;"
aria-label={`Schedule grid ${dayLabel(day.key)}`}
onpointerdown={(e) => startDrag(e, day.key)}
onpointermove={moveDrag}
onpointerup={endDrag}
@@ -197,34 +250,35 @@
></div>
{/if}
{#each viewsForDay(day.key) as view (view.block.id)}
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.({ ...view.block, day: view.block.day ?? null });
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-0.5 z-20 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-1.5 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/70 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${segment.top}%;height:${segment.height}%`}
>
{#if segmentIndex === 0}
<span class="block truncate font-semibold tabular-nums">
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
</span>
{#if view.overnight}
<span class="block truncate text-[11px] opacity-80">ends next day</span>
{/if}
{:else}
<span class="block font-semibold">↳ continues</span>
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
{#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.({ ...view.block, day: view.block.day ?? null });
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-0.5 z-20 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-1.5 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/70 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${view.top}%;height:${view.height}%`}
>
{#if view.part === 'tail'}
<span class="block truncate font-semibold tabular-nums">
{formatTime(0)}–{formatTime(view.block.endMinutes)}
</span>
</button>
{/each}
<span class="block truncate text-[11px] opacity-80">↳ continues</span>
{:else}
<span class="block truncate font-semibold tabular-nums">
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
</span>
{#if view.part === 'head'}
<span class="block truncate text-[11px] opacity-80">ends next day</span>
{/if}
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
</div>
</div>
@@ -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 });
+6 -2
View File
@@ -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 } }
}
}
};
@@ -1,6 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
import WeekSchedule from '$lib/components/schedule/WeekSchedule.svelte';
@@ -27,19 +26,10 @@
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
};
// Local mirrors so the UI updates after actions. They reset whenever the
// selected system (and therefore the loaded schedule) changes.
let type = $state<'daily' | 'weekly' | null>(null);
let blocks = $state<BlockView[]>([]);
let lastLicenseId = $state<string | null>(null);
$effect(() => {
if (data.selectedLicenseId !== lastLicenseId) {
lastLicenseId = data.selectedLicenseId;
type = data.type;
blocks = data.blocks;
showTypeConfirm = false;
}
});
// Render straight from the server data. Every action invalidates and reloads
// it, so the view stays in sync without a local mirror that can go stale.
const type = $derived(data.type);
const blocks: BlockView[] = $derived(data.blocks);
let typePending = $state<'daily' | 'weekly' | null>(null);
let showTypeConfirm = $state(false);
let successMessage = $state('');
@@ -104,9 +94,8 @@
applyType(next);
}
// Submit the hidden ?/setType form. The enhance callback updates the local
// view immediately (the server then prefills/clears as needed) and refreshes
// the per-system flags without a full navigation.
// Submit the hidden ?/setType form. The enhance callback reloads the server
// data (prefilling/clearing Blocks as needed) without a full navigation.
function applyType(next: 'daily' | 'weekly' | null) {
showTypeConfirm = false;
errorMessage = '';
@@ -124,10 +113,9 @@
(result.data as { error?: string })?.error ?? 'Could not change the schedule type.';
return;
}
// Show the new type straight away; invalidateAll() then loads the
// server's blocks (e.g. daily→weekly prefill) and hasSchedule flags.
// update() applies the action result and reloads all data (it calls
// invalidateAll by default), pulling the new type, blocks and flags.
await update();
await invalidateAll();
};
};
@@ -206,7 +194,6 @@
copyOpen = false;
flash(`Copied to ${targets.join(', ')}.`);
await update();
await invalidateAll();
};
};
</script>
@@ -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']> = {}): 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();
});
});