Portal: per-system daily/weekly schedule editor

System picker lists the account's License Keys labelled by System Name
(fallback: key). Schedule type selector (Daily|Weekly) with a confirmation
before switching a populated schedule (daily->weekly prefills all seven
days; otherwise clears). Weekly shows a per-day editor. Blocks use
TimeInput, allow adjacent/midnight-crossing ranges (labelled 'ends next
day'), block empty blocks client-side, default dynamic audio on, and show
title/category/group only. Adds gap helper text and an informational
kiosk-timezone note (browser fallback; no external/paid dependency).
This commit is contained in:
NeilClack
2026-09-29 10:28:05 -05:00
parent 3bb8e0189b
commit 9a2888af36
5 changed files with 371 additions and 111 deletions
+17 -6
View File
@@ -8,7 +8,7 @@
hmToMinutes,
isOvernight,
toHm,
validateBlock,
validateBlockAgainstSchedule,
type BlockSibling
} from '$lib/schedule-utils';
@@ -25,10 +25,13 @@
open = false,
mode = 'create',
blockId,
day = null,
licenseKeyId = '',
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = true,
scheduleType = 'daily' as 'daily' | 'weekly' | null,
categories = [],
groups = [],
proclamations = [],
@@ -42,10 +45,13 @@
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
day?: string | null;
licenseKeyId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
scheduleType?: 'daily' | 'weekly' | null;
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
@@ -105,10 +111,11 @@
function validate(): string | null {
if (!startValid || !endValid) return 'Enter a valid start and end time.';
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
if (scheduleType === null) return 'Set a schedule type first.';
const result = validateBlockAgainstSchedule(
scheduleType,
{ day, startMinutes, endMinutes, proclamationIds: selectedIds },
siblings
);
return result.ok ? null : result.error;
}
@@ -193,6 +200,10 @@
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="licenseKeyId" value={licenseKeyId} />
{#if day}
<input type="hidden" name="day" value={day} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
@@ -280,7 +291,7 @@
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (next day)'
? ' (ends next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
@@ -100,7 +100,7 @@ describe('BlockModal', () => {
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (next day)')).toBeInTheDocument();
await expect.element(page.getByText('9:00 PM – 9:00 AM (ends next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
+87 -70
View File
@@ -10,6 +10,16 @@
type ScheduleBlockView = {
id: string;
day?: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
type OpenBlock = {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
@@ -18,12 +28,14 @@
let {
blocks = [],
label,
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
label?: string;
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
onEdit?: (block: OpenBlock) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
@@ -97,77 +109,82 @@
);
</script>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{formatTime(hour * 60)}
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blockViews 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);
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${segment.top}%;height:${segment.height}%`}
<div class="flex flex-col gap-1">
{#if label}
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{label}</p>
{/if}
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{#if segmentIndex === 0}
<span class="block font-semibold">
{formatTime(view.block.startMinutes)} – {formatTime(
view.block.endMinutes
)}{view.overnight ? ' (next day)' : ''}
</span>
{: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>
{formatTime(hour * 60)}
</div>
{/each}
{/each}
</div>
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blockViews 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-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${segment.top}%;height:${segment.height}%`}
>
{#if segmentIndex === 0}
<span class="block font-semibold">
{formatTime(view.block.startMinutes)} – {formatTime(
view.block.endMinutes
)}{view.overnight ? ' (next day)' : ''}
</span>
{: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}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
</div>