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
@@ -1,23 +1,109 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props();
const WEEK_DAYS: { key: string; label: string }[] = [
{ key: 'mon', label: 'Monday' },
{ key: 'tue', label: 'Tuesday' },
{ key: 'wed', label: 'Wednesday' },
{ key: 'thu', label: 'Thursday' },
{ key: 'fri', label: 'Friday' },
{ key: 'sat', label: 'Saturday' },
{ key: 'sun', label: 'Sunday' }
];
type BlockView = {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
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;
}
});
let typePending = $state<'daily' | 'weekly' | null>(null);
let showTypeConfirm = $state(false);
let successMessage = $state('');
let errorMessage = $state('');
let modalOpen = $state(false);
let modalMode = $state<'create' | 'edit'>('create');
let modalDay = $state<string | null>(null);
let editingId = $state<string | undefined>(undefined);
let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(false);
let successMessage = $state('');
let initialDynamicAudio = $state(true);
let typeForm: HTMLFormElement | undefined = $state();
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function openCreate(startMinutes: number, endMinutes: number) {
const selectedLicense = $derived(
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
);
const licenseLabel = $derived(
selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : 'No systems'
);
const dailyBlocks = $derived(blocks.filter((b) => b.day === null));
const hasBlocks = $derived(blocks.length > 0);
function blocksForDay(day: string): BlockView[] {
return blocks.filter((b) => b.day === day);
}
function flash(message: string) {
successMessage = message;
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
}
function selectSystem(licenseKeyId: string) {
const url = new URL(window.location.href);
if (licenseKeyId) url.searchParams.set('license', licenseKeyId);
else url.searchParams.delete('license');
window.location.href = url.toString();
}
function requestType(next: 'daily' | 'weekly' | null) {
if (next === type) return;
if (hasBlocks && type !== null && next !== null) {
typePending = next;
showTypeConfirm = true;
return;
}
applyType(next);
}
function applyType(next: 'daily' | 'weekly' | null) {
showTypeConfirm = false;
const form = typeForm;
if (!form) return;
(form.querySelector('input[name="type"]') as HTMLInputElement).value = next ?? '';
(form.querySelector('input[name="confirm"]') as HTMLInputElement).value = 'true';
form.requestSubmit();
}
function openCreate(day: string | null, startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
modalDay = day;
initialStart = startMinutes;
initialEnd = endMinutes;
initialIds = [];
@@ -27,6 +113,7 @@
function openEdit(block: {
id: string;
day: string | null;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
@@ -34,9 +121,10 @@
}) {
modalMode = 'edit';
editingId = block.id;
modalDay = block.day;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id);
initialIds = block.proclamations.map((p) => p.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true;
}
@@ -47,12 +135,17 @@
function handleSaved() {
modalOpen = false;
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
flash(modalMode === 'edit' ? 'Block saved.' : 'Block created.');
}
</script>
{#if typeForm === undefined}{/if}
<form method="POST" action="?/setType" use:enhance bind:this={typeForm} class="hidden">
<input type="hidden" name="licenseKeyId" value={data.selectedLicenseId ?? ''} />
<input type="hidden" name="type" value="" />
<input type="hidden" name="confirm" value="false" />
</form>
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
@@ -60,14 +153,23 @@
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build your daily listening schedule. Up to 4 blocks per day.
Build a listening schedule for a system. Up to 4 blocks per day.
</p>
</div>
<span
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{data.blocks.length} of 4 blocks
</span>
{#if data.licenses.length > 0}
<label class="flex items-center gap-2 text-sm">
<span class="text-slate-500 dark:text-slate-400">System</span>
<select
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={data.selectedLicenseId ?? ''}
onchange={(e) => selectSystem(e.currentTarget.value)}
>
{#each data.licenses as license (license.id)}
<option value={license.id}>{license.systemName ?? license.key}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if successMessage}
@@ -77,24 +179,154 @@
{successMessage}
</div>
{/if}
{#if errorMessage}
<div
class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{errorMessage}
</div>
{/if}
<div class="mt-6">
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
</div>
{#if !selectedLicense}
<div
class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-sm text-slate-600 dark:text-slate-400">
You don't have any systems yet. A system appears here once a Kiosk validates its License
Key.
</p>
</div>
{:else}
<!-- System + mode -->
<div class="mt-6 flex flex-wrap items-center gap-4">
<div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">System</span>
<p class="text-sm text-slate-500 dark:text-slate-400">
{licenseLabel}
{#if !selectedLicense.systemName}
<span class="text-slate-400 dark:text-slate-500">(unnamed system)</span>
{/if}
</p>
</div>
<div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Schedule type</span>
<div
class="mt-1 inline-flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
>
<button
type="button"
class="px-3 py-1.5 text-sm {type === 'daily'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300'}"
onclick={() => requestType('daily')}>Daily</button
>
<button
type="button"
class="px-3 py-1.5 text-sm {type === 'weekly'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300'}"
onclick={() => requestType('weekly')}>Weekly</button
>
</div>
{#if type !== null}
<button
type="button"
class="ml-2 text-sm text-red-600 hover:underline dark:text-red-400"
onclick={() => requestType(null)}>Delete schedule</button
>
{/if}
</div>
</div>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={data.blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
{#if type === null}
<div
class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-sm text-slate-600 dark:text-slate-400">
No schedule. Choose Daily or Weekly to start.
</p>
</div>
<div
class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
>
Times run in the Kiosk's own timezone ({selectedLicense.systemName ?? selectedLicense.key}
sets its own timezone). They are not tied to your browser's timezone.
</div>
{:else if type === 'daily'}
<div class="mt-6">
<DaySchedule
blocks={dailyBlocks}
onCreate={(s, e) => openCreate(null, s, e)}
onEdit={openEdit}
/>
</div>
{:else}
<div class="mt-6 flex flex-col gap-6">
{#each WEEK_DAYS as day (day.key)}
<DaySchedule
blocks={blocksForDay(day.key)}
label={day.label}
onCreate={(s, e) => openCreate(day.key, s, e)}
onEdit={openEdit}
/>
{/each}
</div>
{/if}
<p class="mt-4 text-sm text-slate-500 dark:text-slate-400">
Gaps between blocks are fine: the Kiosk plays its locally selected Proclamations during a gap.
Times run in the Kiosk's own timezone ({selectedLicense.systemName ?? selectedLicense.key}),
not your browser's.
</p>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
day={modalDay}
licenseKeyId={data.selectedLicenseId ?? ''}
scheduleType={type}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
{/if}
</div>
{#if showTypeConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4">
<div
class="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">Change schedule type?</h2>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
Switching type replaces the current blocks.
{#if typePending === 'weekly'}
The Daily blocks will be copied to all seven days.
{:else}
The existing blocks will be cleared.
{/if}
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
onclick={() => (showTypeConfirm = false)}>Cancel</button
>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
onclick={() => applyType(typePending)}>Continue</button
>
</div>
</div>
</div>
{/if}