Portal: calendar-style weekly schedule grid, responsive on mobile
Replace the stacked per-day lists with a Google-Calendar-style WeekSchedule: day columns side by side over a shared, sticky time gutter. Click or drag an empty slot to create a block at that time; click a block to edit it. The daily schedule reuses the same grid with a single 'Every day' column so both modes look consistent. On narrow screens the row scrolls horizontally with short day labels and the gutter pinned, keeping it usable on phones. Also change the type switcher and copy dialog to enhanced form submissions (no full-page reload) so they respond immediately. Removes the now-unused DaySchedule component.
This commit is contained in:
@@ -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