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">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import type { PageProps } from './$types';
|
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';
|
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
const WEEK_DAYS: { key: string; label: string }[] = [
|
const WEEK_DAYS: { key: string; label: string; short: string }[] = [
|
||||||
{ key: 'mon', label: 'Monday' },
|
{ key: 'mon', label: 'Monday', short: 'Mon' },
|
||||||
{ key: 'tue', label: 'Tuesday' },
|
{ key: 'tue', label: 'Tuesday', short: 'Tue' },
|
||||||
{ key: 'wed', label: 'Wednesday' },
|
{ key: 'wed', label: 'Wednesday', short: 'Wed' },
|
||||||
{ key: 'thu', label: 'Thursday' },
|
{ key: 'thu', label: 'Thursday', short: 'Thu' },
|
||||||
{ key: 'fri', label: 'Friday' },
|
{ key: 'fri', label: 'Friday', short: 'Fri' },
|
||||||
{ key: 'sat', label: 'Saturday' },
|
{ key: 'sat', label: 'Saturday', short: 'Sat' },
|
||||||
{ key: 'sun', label: 'Sunday' }
|
{ key: 'sun', label: 'Sunday', short: 'Sun' }
|
||||||
];
|
];
|
||||||
|
|
||||||
type BlockView = {
|
type BlockView = {
|
||||||
@@ -52,15 +54,17 @@
|
|||||||
let initialIds = $state<string[]>([]);
|
let initialIds = $state<string[]>([]);
|
||||||
let initialDynamicAudio = $state(true);
|
let initialDynamicAudio = $state(true);
|
||||||
|
|
||||||
let typeForm: HTMLFormElement | undefined = $state();
|
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
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.
|
// Copy-to dialog state.
|
||||||
let copyOpen = $state(false);
|
let copyOpen = $state(false);
|
||||||
let copyTargets = $state<string[]>([]);
|
let copyTargets = $state<string[]>([]);
|
||||||
let copyError = $state('');
|
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 copyCandidates = $derived(data.licenses.filter((l) => l.id !== data.selectedLicenseId));
|
||||||
const overwritingTargets = $derived(
|
const overwritingTargets = $derived(
|
||||||
@@ -68,21 +72,6 @@
|
|||||||
);
|
);
|
||||||
const needsCopyConfirm = $derived(overwritingTargets.length > 0);
|
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(
|
const selectedLicense = $derived(
|
||||||
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
|
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
|
||||||
);
|
);
|
||||||
@@ -92,10 +81,6 @@
|
|||||||
const dailyBlocks = $derived(blocks.filter((b) => b.day === null));
|
const dailyBlocks = $derived(blocks.filter((b) => b.day === null));
|
||||||
const hasBlocks = $derived(blocks.length > 0);
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
|
|
||||||
function blocksForDay(day: string): BlockView[] {
|
|
||||||
return blocks.filter((b) => b.day === day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash(message: string) {
|
function flash(message: string) {
|
||||||
successMessage = message;
|
successMessage = message;
|
||||||
clearTimeout(closeTimer);
|
clearTimeout(closeTimer);
|
||||||
@@ -119,8 +104,12 @@
|
|||||||
applyType(next);
|
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) {
|
function applyType(next: 'daily' | 'weekly' | null) {
|
||||||
showTypeConfirm = false;
|
showTypeConfirm = false;
|
||||||
|
errorMessage = '';
|
||||||
const form = typeForm;
|
const form = typeForm;
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
(form.querySelector('input[name="type"]') as HTMLInputElement).value = next ?? '';
|
(form.querySelector('input[name="type"]') as HTMLInputElement).value = next ?? '';
|
||||||
@@ -128,6 +117,20 @@
|
|||||||
form.requestSubmit();
|
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) {
|
function openCreate(day: string | null, startMinutes: number, endMinutes: number) {
|
||||||
modalMode = 'create';
|
modalMode = 'create';
|
||||||
editingId = undefined;
|
editingId = undefined;
|
||||||
@@ -184,14 +187,37 @@
|
|||||||
copyError = 'Pick at least one system to copy to.';
|
copyError = 'Pick at least one system to copy to.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const form = copyForm;
|
copyForm?.requestSubmit();
|
||||||
if (!form) return;
|
|
||||||
form.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>
|
</script>
|
||||||
|
|
||||||
{#if typeForm === undefined}{/if}
|
<form
|
||||||
<form method="POST" action="?/setType" use:enhance bind:this={typeForm} class="hidden">
|
method="POST"
|
||||||
|
action="?/setType"
|
||||||
|
use:enhance={handleTypeSubmit}
|
||||||
|
bind:this={typeForm}
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
<input type="hidden" name="licenseKeyId" value={data.selectedLicenseId ?? ''} />
|
<input type="hidden" name="licenseKeyId" value={data.selectedLicenseId ?? ''} />
|
||||||
<input type="hidden" name="type" value="" />
|
<input type="hidden" name="type" value="" />
|
||||||
<input type="hidden" name="confirm" value="false" />
|
<input type="hidden" name="confirm" value="false" />
|
||||||
@@ -313,22 +339,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if type === 'daily'}
|
{:else if type === 'daily'}
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<DaySchedule
|
<WeekSchedule
|
||||||
|
days={[{ key: 'daily', label: 'Every day' }]}
|
||||||
blocks={dailyBlocks}
|
blocks={dailyBlocks}
|
||||||
onCreate={(s, e) => openCreate(null, s, e)}
|
onCreate={(_day, s, e) => openCreate(null, s, e)}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-6 flex flex-col gap-6">
|
<div class="mt-6">
|
||||||
{#each WEEK_DAYS as day (day.key)}
|
<WeekSchedule
|
||||||
<DaySchedule
|
days={WEEK_DAYS}
|
||||||
blocks={blocksForDay(day.key)}
|
{blocks}
|
||||||
label={day.label}
|
onCreate={(day, s, e) => openCreate(day, s, e)}
|
||||||
onCreate={(s, e) => openCreate(day.key, s, e)}
|
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
/>
|
/>
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -486,7 +511,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ?? ''} />
|
<input type="hidden" name="sourceLicenseKeyId" value={data.selectedLicenseId ?? ''} />
|
||||||
{#each copyTargets as target (target)}
|
{#each copyTargets as target (target)}
|
||||||
<input type="hidden" name="targetIds" value={target} />
|
<input type="hidden" name="targetIds" value={target} />
|
||||||
|
|||||||
Reference in New Issue
Block a user