Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid
This commit is contained in:
@@ -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,8 +250,7 @@
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#each viewsForDay(day.key) as view (view.block.id)}
|
||||
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
|
||||
{#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)}
|
||||
<button
|
||||
type="button"
|
||||
onpointerdown={(event) => event.stopPropagation()}
|
||||
@@ -208,24 +260,26 @@
|
||||
}}
|
||||
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}%`}
|
||||
style={`top:${view.top}%;height:${view.height}%`}
|
||||
>
|
||||
{#if segmentIndex === 0}
|
||||
{#if view.part === 'tail'}
|
||||
<span class="block truncate font-semibold tabular-nums">
|
||||
{formatTime(0)}–{formatTime(view.block.endMinutes)}
|
||||
</span>
|
||||
<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.overnight}
|
||||
{#if view.part === 'head'}
|
||||
<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'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user