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:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -177,7 +177,7 @@ export function validateSchedule(type: ScheduleType, blocks: ValidationBlock[]):
|
||||
}
|
||||
const overlap = findScheduleOverlap(type, blocks);
|
||||
if (overlap) {
|
||||
return { ok: false, error: overlapError(overlap.a, overlap.b) };
|
||||
return { ok: false, error: overlapError(overlap.a) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -195,12 +195,12 @@ export function validateBlockAgainstSchedule(
|
||||
if (!shape.ok) return shape;
|
||||
const overlap = findScheduleOverlap(type, [...siblings, candidate]);
|
||||
if (overlap) {
|
||||
return { ok: false, error: overlapError(overlap.a, overlap.b) };
|
||||
return { ok: false, error: overlapError(overlap.a) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function overlapError(a: ValidationBlock, b: ValidationBlock): string {
|
||||
function overlapError(a: ValidationBlock): string {
|
||||
return `This overlaps with your ${formatTime(a.startMinutes)}–${formatTime(a.endMinutes)} block.`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user