Dev #1
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user