Portal: fix schedule type toggle, daily rendering, overnight spill, and mobile grid
This commit is contained in:
@@ -50,7 +50,9 @@
|
|||||||
pointerId: number;
|
pointerId: number;
|
||||||
} | null>(null);
|
} | 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 {
|
function minutesFromEvent(event: PointerEvent, column: HTMLElement): number {
|
||||||
const rect = column.getBoundingClientRect();
|
const rect = column.getBoundingClientRect();
|
||||||
@@ -110,19 +112,69 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function blocksForDay(day: string): ScheduleBlockView[] {
|
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.
|
// The column before each day, wrapping across the schedule (mon's previous
|
||||||
function viewsForDay(day: string) {
|
// day is sun; the single daily column is its own previous day). Used to spill
|
||||||
return blocksForDay(day).map((block) => ({
|
// 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,
|
block,
|
||||||
overnight: isOvernight(block),
|
part,
|
||||||
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
|
top: (start / MINUTES_PER_DAY) * 100,
|
||||||
top: (segmentStart / MINUTES_PER_DAY) * 100,
|
height: ((end - start) / MINUTES_PER_DAY) * 100
|
||||||
height: ((segmentEnd - segmentStart) / 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>
|
</script>
|
||||||
|
|
||||||
@@ -133,7 +185,7 @@
|
|||||||
block to edit it.
|
block to edit it.
|
||||||
-->
|
-->
|
||||||
<div
|
<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;">
|
<div class="flex overflow-x-auto" style="overscroll-behavior-x: contain;">
|
||||||
<!-- Time gutter (pinned while the day columns scroll horizontally). -->
|
<!-- Time gutter (pinned while the day columns scroll horizontally). -->
|
||||||
@@ -154,11 +206,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Day columns. Each is at least 6.5rem wide so the grid stays usable on
|
<!-- Day columns share the available width equally so a weekly view fits
|
||||||
a phone; when the seven days do not fit, the row scrolls. -->
|
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)}
|
{#each days as day (day.key)}
|
||||||
<div
|
<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
|
<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"
|
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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="relative h-[720px] cursor-pointer select-none"
|
class="relative h-[720px] cursor-pointer select-none"
|
||||||
style="touch-action: pan-x;"
|
style="touch-action: pan-x pan-y;"
|
||||||
aria-label={`Schedule grid ${dayMap.get(day.key)?.label ?? day.key}`}
|
aria-label={`Schedule grid ${dayLabel(day.key)}`}
|
||||||
onpointerdown={(e) => startDrag(e, day.key)}
|
onpointerdown={(e) => startDrag(e, day.key)}
|
||||||
onpointermove={moveDrag}
|
onpointermove={moveDrag}
|
||||||
onpointerup={endDrag}
|
onpointerup={endDrag}
|
||||||
@@ -197,8 +250,7 @@
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each viewsForDay(day.key) as view (view.block.id)}
|
{#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)}
|
||||||
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onpointerdown={(event) => event.stopPropagation()}
|
onpointerdown={(event) => event.stopPropagation()}
|
||||||
@@ -208,24 +260,26 @@
|
|||||||
}}
|
}}
|
||||||
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
|
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"
|
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">
|
<span class="block truncate font-semibold tabular-nums">
|
||||||
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
|
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
|
||||||
</span>
|
</span>
|
||||||
{#if view.overnight}
|
{#if view.part === 'head'}
|
||||||
<span class="block truncate text-[11px] opacity-80">ends next day</span>
|
<span class="block truncate text-[11px] opacity-80">ends next day</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
|
||||||
<span class="block font-semibold">↳ continues</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<span class="block truncate">
|
<span class="block truncate">
|
||||||
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
|
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -94,6 +94,56 @@ describe('WeekSchedule', () => {
|
|||||||
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
|
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 () => {
|
it('opens create on the clicked day', async () => {
|
||||||
const onCreate = vi.fn();
|
const onCreate = vi.fn();
|
||||||
mount({ onCreate });
|
mount({ onCreate });
|
||||||
|
|||||||
@@ -69,11 +69,15 @@ function normaliseDay(type: ScheduleType | null, day: string | null | undefined)
|
|||||||
return day ?? null;
|
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 = {
|
const blockInclude = {
|
||||||
proclamations: {
|
proclamations: {
|
||||||
orderBy: { sortOrder: 'asc' as const },
|
orderBy: { sortOrder: 'asc' as const },
|
||||||
include: {
|
select: {
|
||||||
proclamation: { include: { solfeggioFrequency: true } }
|
sortOrder: true,
|
||||||
|
proclamation: { select: { id: true, name: true, categoryId: true, groupId: true } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import WeekSchedule from '$lib/components/schedule/WeekSchedule.svelte';
|
import WeekSchedule from '$lib/components/schedule/WeekSchedule.svelte';
|
||||||
@@ -27,19 +26,10 @@
|
|||||||
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
|
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local mirrors so the UI updates after actions. They reset whenever the
|
// Render straight from the server data. Every action invalidates and reloads
|
||||||
// selected system (and therefore the loaded schedule) changes.
|
// it, so the view stays in sync without a local mirror that can go stale.
|
||||||
let type = $state<'daily' | 'weekly' | null>(null);
|
const type = $derived(data.type);
|
||||||
let blocks = $state<BlockView[]>([]);
|
const blocks: BlockView[] = $derived(data.blocks);
|
||||||
let lastLicenseId = $state<string | null>(null);
|
|
||||||
$effect(() => {
|
|
||||||
if (data.selectedLicenseId !== lastLicenseId) {
|
|
||||||
lastLicenseId = data.selectedLicenseId;
|
|
||||||
type = data.type;
|
|
||||||
blocks = data.blocks;
|
|
||||||
showTypeConfirm = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let typePending = $state<'daily' | 'weekly' | null>(null);
|
let typePending = $state<'daily' | 'weekly' | null>(null);
|
||||||
let showTypeConfirm = $state(false);
|
let showTypeConfirm = $state(false);
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
@@ -104,9 +94,8 @@
|
|||||||
applyType(next);
|
applyType(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit the hidden ?/setType form. The enhance callback updates the local
|
// Submit the hidden ?/setType form. The enhance callback reloads the server
|
||||||
// view immediately (the server then prefills/clears as needed) and refreshes
|
// data (prefilling/clearing Blocks as needed) without a full navigation.
|
||||||
// the per-system flags without a full navigation.
|
|
||||||
function applyType(next: 'daily' | 'weekly' | null) {
|
function applyType(next: 'daily' | 'weekly' | null) {
|
||||||
showTypeConfirm = false;
|
showTypeConfirm = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
@@ -124,10 +113,9 @@
|
|||||||
(result.data as { error?: string })?.error ?? 'Could not change the schedule type.';
|
(result.data as { error?: string })?.error ?? 'Could not change the schedule type.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Show the new type straight away; invalidateAll() then loads the
|
// update() applies the action result and reloads all data (it calls
|
||||||
// server's blocks (e.g. daily→weekly prefill) and hasSchedule flags.
|
// invalidateAll by default), pulling the new type, blocks and flags.
|
||||||
await update();
|
await update();
|
||||||
await invalidateAll();
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,7 +194,6 @@
|
|||||||
copyOpen = false;
|
copyOpen = false;
|
||||||
flash(`Copied to ${targets.join(', ')}.`);
|
flash(`Copied to ${targets.join(', ')}.`);
|
||||||
await update();
|
await update();
|
||||||
await invalidateAll();
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
</script>
|
</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