Dev #1

Merged
NeilClack merged 25 commits from dev into main 2026-09-30 04:29:28 +00:00
4 changed files with 435 additions and 239 deletions
Showing only changes of commit bbf4ac12ee - Show all commits
@@ -1,190 +0,0 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
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;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
label,
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
label?: string;
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: OpenBlock) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
// Overnight blocks render as two segments: start→midnight and midnight→end.
const blockViews = $derived(
blocks.map((block) => ({
block,
overnight: isOvernight(block),
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
top: (segmentStart / MINUTES_PER_DAY) * 100,
height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100
}))
}))
);
</script>
<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}%`}
>
{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, 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>
@@ -0,0 +1,233 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
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;
proclamations: { id: string; name: string }[];
};
let {
days,
blocks = [],
onCreate,
onEdit
}: {
days: { key: string; label: string; short?: string }[];
blocks?: ScheduleBlockView[];
onCreate?: (day: string, startMinutes: number, endMinutes: number) => void;
onEdit?: (block: OpenBlock) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
// Which grid the pointer is currently over, so the live preview shows up in
// the right day column (Google-Calendar style).
let drag = $state<{
day: string;
anchor: number;
current: number;
pointerId: number;
} | null>(null);
const dayMap = $derived(new Map(days.map((d) => [d.key, d])));
function minutesFromEvent(event: PointerEvent, column: HTMLElement): number {
const rect = column.getBoundingClientRect();
// Guard against an unmeasured/zero-height column (e.g. before layout) so
// a tap still yields a valid, finite start minute instead of NaN.
if (!rect.height) return 0;
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
// Pointer capture happens on the column element, so moves/ups land there even
// when the pointer leaves the small cell.
function startDrag(event: PointerEvent, day: string) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const column = event.currentTarget as HTMLElement;
const minutes = minutesFromEvent(event, column);
drag = { day, anchor: minutes, current: minutes, pointerId: event.pointerId };
column.setPointerCapture(event.pointerId);
event.preventDefault();
}
function moveDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const column = event.currentTarget as HTMLElement;
drag = { ...drag, current: minutesFromEvent(event, column) };
}
function endDrag(event: PointerEvent) {
if (!drag || event.pointerId !== drag.pointerId) return;
const { day, anchor, current } = drag;
const column = event.currentTarget as HTMLElement;
column.releasePointerCapture?.(event.pointerId);
drag = null;
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(day, start, end);
return;
}
// A plain click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.min(MINUTES_PER_DAY, clickStart + defaultDuration);
onCreate?.(day, Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
day: drag.day,
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
function blocksForDay(day: string): ScheduleBlockView[] {
return blocks.filter((b) => (b.day ?? null) === day);
}
// Overnight blocks render as two segments: start→midnight and midnight→end.
function viewsForDay(day: string) {
return blocksForDay(day).map((block) => ({
block,
overnight: isOvernight(block),
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
top: (segmentStart / MINUTES_PER_DAY) * 100,
height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100
}))
}));
}
</script>
<!--
Schedule as a Google-Calendar-style grid: day columns side by side with a
shared time gutter. Scrolls horizontally on narrow screens (the gutter stays
pinned); tap/click or drag an empty slot to create a block at that time, tap a
block to edit it.
-->
<div
class="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). -->
<div
class="sticky left-0 z-30 w-20 shrink-0 border-r border-slate-200 bg-white sm:w-24 dark:border-slate-800 dark:bg-slate-900"
>
<!-- Spacer aligning with the day header row. -->
<div class="h-12 border-b border-slate-200 dark:border-slate-800"></div>
<div class="relative h-[720px]" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-2 -translate-y-1/2 text-xs whitespace-nowrap text-slate-400 tabular-nums dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{formatTime(hour * 60)}
</div>
{/each}
</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. -->
{#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"
>
<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"
>
{#if day.short}
<span class="sm:hidden">{day.short}</span>
<span class="hidden sm:inline">{day.label}</span>
{:else}
{day.label}
{/if}
</div>
<!-- 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}`}
onpointerdown={(e) => startDrag(e, day.key)}
onpointermove={moveDrag}
onpointerup={endDrag}
onpointercancel={(e) => {
if (drag && e.pointerId === drag.pointerId) 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 && preview.day === day.key}
<div
class="pointer-events-none absolute inset-x-1 z-10 rounded-md bg-indigo-200/70 dark:bg-indigo-700/50"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each viewsForDay(day.key) 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-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}%`}
>
{#if segmentIndex === 0}
<span class="block truncate font-semibold tabular-nums">
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
</span>
{#if view.overnight}
<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}
</div>
</div>
@@ -0,0 +1,122 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import WeekSchedule from './WeekSchedule.svelte';
const WEEK_DAYS = [
{ 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' }
];
const block = (overrides: Record<string, unknown> = {}) => ({
id: 'b1',
day: 'mon',
startMinutes: 6 * 60,
endMinutes: 8 * 60,
dynamicAudio: true,
proclamations: [{ id: 'p1', name: 'Peace' }],
...overrides
});
function mount(props: Record<string, unknown> = {}) {
return render(WeekSchedule, { days: WEEK_DAYS, blocks: [], ...props });
}
// Click at a fraction of the way down a day column.
async function clickColumn(label: string, fraction: number) {
const column = document.querySelector(
`[aria-label="Schedule grid ${label}"]`
) as HTMLElement | null;
if (!column) throw new Error(`no column ${label}`);
const rect = column.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height * fraction;
column.dispatchEvent(
new PointerEvent('pointerdown', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
);
column.dispatchEvent(
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
);
}
describe('WeekSchedule', () => {
it('renders all seven day columns side by side', async () => {
mount();
for (const day of WEEK_DAYS) {
await expect.element(page.getByText(day.label)).toBeInTheDocument();
}
});
it('renders a single column for a daily schedule', async () => {
mount({ days: [{ key: 'daily', label: 'Every day' }] });
await expect.element(page.getByText('Every day')).toBeInTheDocument();
expect(document.querySelectorAll('[aria-label^="Schedule grid"]')).toHaveLength(1);
});
it('is horizontally scrollable with a pinned time gutter (mobile-friendly)', async () => {
mount();
// The day columns live in a horizontally scrollable row.
const scroller = document.querySelector('.overflow-x-auto') as HTMLElement | null;
expect(scroller).not.toBeNull();
// The time gutter is sticky so it stays visible while days scroll.
const gutter = scroller?.querySelector('[class*="sticky"][class*="left-0"]');
expect(gutter).not.toBeNull();
// Each day column has a minimum basis width so it stays legible on a phone.
const columns = scroller?.querySelectorAll('[aria-label^="Schedule grid"]') ?? [];
expect(columns.length).toBe(7);
});
it('shows short day labels on small screens', async () => {
mount({
days: [
{ key: 'mon', label: 'Monday', short: 'Mon' },
{ key: 'tue', label: 'Tuesday', short: 'Tue' }
]
});
// Both the short (small-screen) and long (large-screen) labels exist in
// the DOM; CSS decides which is visible at a given width.
const text = document.body.textContent ?? '';
expect(text).toContain('Mon');
expect(text).toContain('Monday');
});
it('shows blocks as events', async () => {
mount({ blocks: [block()] });
await expect.element(page.getByText('Peace')).toBeInTheDocument();
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
});
it('opens create on the clicked day', async () => {
const onCreate = vi.fn();
mount({ onCreate });
await clickColumn('Monday', 0.25);
expect(onCreate).toHaveBeenCalledTimes(1);
const [day, start, end] = onCreate.mock.calls[0] as [string, number, number];
expect(day).toBe('mon');
// The handler always yields a sensible minute range (a default-length
// block when the click distance is below the snap threshold).
expect(Number.isFinite(start)).toBe(true);
expect(Number.isFinite(end)).toBe(true);
expect(end - start).toBeGreaterThan(0);
});
it('opens edit when a block is clicked', async () => {
const onEdit = vi.fn();
mount({ blocks: [block()], onEdit });
await page.getByLabelText('Block 6:00 AM to 8:00 AM').click();
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'b1', day: 'mon' });
});
});
@@ -1,19 +1,21 @@
<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 DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import WeekSchedule from '$lib/components/schedule/WeekSchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data, form }: PageProps = $props();
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' }
const WEEK_DAYS: { key: string; label: string; short: string }[] = [
{ key: 'mon', label: 'Monday', short: 'Mon' },
{ key: 'tue', label: 'Tuesday', short: 'Tue' },
{ key: 'wed', label: 'Wednesday', short: 'Wed' },
{ key: 'thu', label: 'Thursday', short: 'Thu' },
{ key: 'fri', label: 'Friday', short: 'Fri' },
{ key: 'sat', label: 'Saturday', short: 'Sat' },
{ key: 'sun', label: 'Sunday', short: 'Sun' }
];
type BlockView = {
@@ -52,15 +54,17 @@
let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(true);
let typeForm: HTMLFormElement | undefined = $state();
let closeTimer: ReturnType<typeof setTimeout> | undefined;
// Hidden forms submit the ?/setType and ?/copy actions with use:enhance so the
// result comes back as a proper ActionResult (no full-page reload).
let typeForm: HTMLFormElement | undefined = $state();
let copyForm: HTMLFormElement | undefined = $state();
// Copy-to dialog state.
let copyOpen = $state(false);
let copyTargets = $state<string[]>([]);
let copyError = $state('');
let copyForm: HTMLFormElement | undefined = $state();
let copyTimer: ReturnType<typeof setTimeout> | undefined;
const copyCandidates = $derived(data.licenses.filter((l) => l.id !== data.selectedLicenseId));
const overwritingTargets = $derived(
@@ -68,21 +72,6 @@
);
const needsCopyConfirm = $derived(overwritingTargets.length > 0);
// Surface the copy action's result (summary or error) from the form action.
$effect(() => {
if (!form || (form as { action?: string }).action !== 'copy') return;
if ((form as { success?: boolean }).success) {
const targets = (form as { targets?: { label: string }[] }).targets ?? [];
copyOpen = false;
flash(`Copied to ${targets.map((t) => t.label).join(', ')}.`);
// Refresh so hasSchedule flags and the selected system are current.
clearTimeout(copyTimer);
copyTimer = setTimeout(() => window.location.reload(), 800);
} else {
copyError = (form as { error?: string }).error ?? 'Copy failed.';
}
});
const selectedLicense = $derived(
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
);
@@ -92,10 +81,6 @@
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);
@@ -119,8 +104,12 @@
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.
function applyType(next: 'daily' | 'weekly' | null) {
showTypeConfirm = false;
errorMessage = '';
const form = typeForm;
if (!form) return;
(form.querySelector('input[name="type"]') as HTMLInputElement).value = next ?? '';
@@ -128,6 +117,20 @@
form.requestSubmit();
}
const handleTypeSubmit: SubmitFunction = () => {
return async ({ result, update }) => {
if (result.type === 'failure') {
errorMessage =
(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.
await update();
await invalidateAll();
};
};
function openCreate(day: string | null, startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
@@ -184,14 +187,37 @@
copyError = 'Pick at least one system to copy to.';
return;
}
const form = copyForm;
if (!form) return;
form.requestSubmit();
copyForm?.requestSubmit();
}
const handleCopySubmit: SubmitFunction = () => {
return async ({ result, update }) => {
if (result.type === 'failure') {
copyError = (result.data as { error?: string })?.error ?? 'Copy failed.';
return;
}
if (result.type === 'error') {
copyError = 'Copy failed.';
return;
}
const data =
'data' in result ? (result.data as { targets?: { label: string }[] }) : undefined;
const targets = (data?.targets ?? []).map((t) => t.label);
copyOpen = false;
flash(`Copied to ${targets.join(', ')}.`);
await update();
await invalidateAll();
};
};
</script>
{#if typeForm === undefined}{/if}
<form method="POST" action="?/setType" use:enhance bind:this={typeForm} class="hidden">
<form
method="POST"
action="?/setType"
use:enhance={handleTypeSubmit}
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" />
@@ -313,22 +339,21 @@
</div>
{:else if type === 'daily'}
<div class="mt-6">
<DaySchedule
<WeekSchedule
days={[{ key: 'daily', label: 'Every day' }]}
blocks={dailyBlocks}
onCreate={(s, e) => openCreate(null, s, e)}
onCreate={(_day, 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 class="mt-6">
<WeekSchedule
days={WEEK_DAYS}
{blocks}
onCreate={(day, s, e) => openCreate(day, s, e)}
onEdit={openEdit}
/>
</div>
{/if}
@@ -486,7 +511,13 @@
</button>
</div>
<form method="POST" action="?/copy" use:enhance bind:this={copyForm} class="hidden">
<form
method="POST"
action="?/copy"
use:enhance={handleCopySubmit}
bind:this={copyForm}
class="hidden"
>
<input type="hidden" name="sourceLicenseKeyId" value={data.selectedLicenseId ?? ''} />
{#each copyTargets as target (target)}
<input type="hidden" name="targetIds" value={target} />