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:
NeilClack
2026-09-29 13:09:48 -05:00
parent bf465eca60
commit bbf4ac12ee
4 changed files with 435 additions and 239 deletions
@@ -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' });
});
});