Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions apps/web/a11y/planner.a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import AxeBuilder from '@axe-core/playwright';
import { scanWcag22AaViolations } from '@davidvornholt/a11y-testing/axe';
import { expect, test as it, test } from '@playwright/test';
import { defaultSettings } from '../src/shared/hooks/use-settings-schema';
import { createSessionCookies } from './auth-fixture';

const timetable = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'].map(
(day) => ({
day,
slots: [{ id: `${day}-1`, subjectId: 'math' }],
}),
);
const plannerSettings = {
...defaultSettings,
subjects: [{ id: 'math', name: 'Math' }],
timetable,
};

for (const timezoneId of ['Europe/Berlin', 'America/Los_Angeles']) {
test.describe(timezoneId, () => {
it.use({ timezoneId });
for (const date of ['2026-09-08T12:00:00', '2025-12-30T12:00:00']) {
it(`Tuesday exceptions stay on Tuesday on ${date}`, async ({
page,
context,
}) => {
await context.addCookies(await createSessionCookies());
await page.clock.setFixedTime(new Date(date));
await page.addInitScript((settings) => {
localStorage.setItem('papersync-settings', JSON.stringify(settings));
}, plannerSettings);
await page.goto('/planner');
const tuesday = page
.getByRole('listitem')
.filter({ has: page.getByText('Tue', { exact: true }) });
const monday = page
.getByRole('listitem')
.filter({ has: page.getByText('Mon', { exact: true }) });
const exceptionButton = tuesday.getByRole('button');
await exceptionButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Tuesday');
await dialog.getByLabel('Reason (optional)').fill('Museum visit');
await dialog.getByRole('button', { name: 'Remove class' }).click();
expect(await scanWcag22AaViolations(page)).toEqual([]);
await dialog
.getByRole('button', { name: 'Add exception', exact: true })
.click();
await expect(tuesday).toContainText('Museum visit');
await expect(tuesday).toContainText('No classes');
await expect(monday).not.toContainText('Museum visit');
await expect(monday).toContainText('Math');
const savedExceptionAccessibility = await new AxeBuilder({ page })
.withRules(['label-content-name-mismatch'])
.analyze();
expect(savedExceptionAccessibility.violations).toEqual([]);
await expect(exceptionButton).toHaveAccessibleName(
'Edit exception for Tuesday',
);
await exceptionButton.click();
await expect(dialog.getByLabel('Reason (optional)')).toHaveValue(
'Museum visit',
);
await dialog.getByLabel('Reason (optional)').fill('Museum trip');
await dialog.getByRole('button', { name: 'Save changes' }).click();
await expect(tuesday).toContainText('Museum trip');
const { promise: generationHeld, resolve: releaseGeneration } =
Promise.withResolvers<void>();
let requests = 0;
await page.route('**/api/planner', async (route) => {
requests += 1;
if (requests === 1) {
await generationHeld;
}
const payload = route.request().postDataJSON();
expect(payload.timetable).toEqual(
timetable.map((day) =>
day.day === 'tuesday' ? { ...day, slots: [] } : day,
),
);
await route.fulfill({
contentType: 'application/pdf',
body: '%PDF-1.4\n%%EOF',
});
});
await page
.getByRole('button', { name: 'Generate PDF', exact: true })
.click();
await expect(
page.getByText('Generating PDF…', { exact: true }),
).toBeVisible();
await exceptionButton.click();
await dialog
.getByLabel('Reason (optional)')
.fill('Museum trip, updated');
await dialog.getByRole('button', { name: 'Save changes' }).click();
const staleResponse = page.waitForResponse('**/api/planner');
releaseGeneration();
await staleResponse;
await expect(
page.getByText('Review the schedule, then generate your PDF'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Download', exact: true }),
).toHaveCount(0);
await page
.getByRole('button', { name: 'Generate PDF', exact: true })
.click();
await expect(
page.getByRole('button', { name: 'Download', exact: true }),
).toBeVisible();
await exceptionButton.click();
await dialog
.getByRole('button', { name: 'Remove exception', exact: true })
.click();
await expect(tuesday).not.toContainText('Museum trip');
await expect(tuesday).toContainText('Math');
await expect(
tuesday.getByRole('button', { name: 'Exception for Tuesday' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Download', exact: true }),
).toHaveCount(0);
expect(await scanWcag22AaViolations(page)).toEqual([]);
});
}
});
}
20 changes: 15 additions & 5 deletions apps/web/src/features/planner/hooks/use-planner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { Data, Effect } from 'effect';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { getWeekDateRange, getWeekId } from '@/shared/planner/week';
import type { Subject, WeekId } from '@/shared/types/schemas';
import { downloadPlannerPdf } from '../services/generator';
Expand Down Expand Up @@ -83,22 +83,31 @@ export const usePlanner = (initialWeekId?: WeekId): UsePlannerReturn => {
const dateRange = getWeekDateRange(weekId);

const [state, setState] = useState<PlannerState>({ status: 'idle' });
const revisionRef = useRef(0);

const generate = useCallback(
(
subjects: ReadonlyArray<Subject>,
timetable: ReadonlyArray<TimetableDay>,
): Promise<void> => {
revisionRef.current += 1;
const revision = revisionRef.current;
setState({ status: 'generating' });
return Effect.runPromise(
fetchPdfEffect(weekId, subjects, timetable).pipe(
Effect.tap((blob) =>
Effect.sync(() => setState({ status: 'generated', blob })),
Effect.sync(() => {
if (revision === revisionRef.current) {
setState({ status: 'generated', blob });
}
}),
),
Effect.catchAll((error) =>
Effect.sync(() =>
setState({ status: 'error', error: error.message }),
),
Effect.sync(() => {
if (revision === revisionRef.current) {
setState({ status: 'error', error: error.message });
}
}),
),
Effect.asVoid,
),
Expand All @@ -125,6 +134,7 @@ export const usePlanner = (initialWeekId?: WeekId): UsePlannerReturn => {
}, [state]);

const reset = useCallback((): void => {
revisionRef.current += 1;
setState({ status: 'idle' });
}, []);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { Button } from '@papersync/ui/button';
import { useEffect, useId, useState } from 'react';
import { Modal } from '@/shared/components/modal';
import type { DayOfWeek, ISODate, Subject } from '@/shared/types/schemas';
import { getIsoDate } from '@/shared/planner/week';
import type { DayOfWeek, Subject } from '@/shared/types/schemas';
import type { ScheduleException } from '../planner-screen-types';
import { ExceptionSlotEditor } from './exception-slot-editor';

Expand Down Expand Up @@ -51,7 +52,7 @@ export const ExceptionEditorModal = ({
<Modal
isOpen={isOpen}
onClose={onClose}
title={exception ? 'Edit Exception' : 'Add Exception'}
title={exception ? 'Edit exception' : 'Add exception'}
description={`Modify the schedule for ${dateStr}`}
size="md"
footer={
Expand All @@ -73,7 +74,7 @@ export const ExceptionEditorModal = ({
</Button>
<Button
onClick={() => {
const isoDate = date.toISOString().split('T')[0] as ISODate;
const isoDate = getIsoDate(date);
onSave({
date: isoDate,
dayOfWeek,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const ExceptionSlotEditor = ({
{String(index + 1).padStart(2, '0')}
</span>
<Select
aria-label={`Exception class ${index + 1}`}
value={slot.subjectId}
onChange={(e) => onChangeSlot(slot.id, e.target.value)}
className="min-w-0 flex-1 text-[14px]"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const WeekScheduleOverview = ({

const exception = exceptions.find((entry) => entry.date === isoDate);
const hasException = exception !== undefined;
const exceptionAction = hasException
? { text: 'Edit', label: 'Edit exception' }
: { text: 'Exception', label: 'Exception' };
const slots = hasException
? exception.slots
: (daySchedule?.slots ?? []);
Expand Down Expand Up @@ -103,9 +106,10 @@ export const WeekScheduleOverview = ({
variant="ghost"
size="sm"
onClick={() => onEditException(dayDate, day)}
aria-label={`${exceptionAction.label} for ${dayDate.toLocaleDateString('en-US', { weekday: 'long' })}`}
className={hasException ? 'text-warning' : ''}
>
{hasException ? 'Edit' : 'Exception'}
{exceptionAction.text}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TimetableDay } from '@/shared/hooks/use-settings-schema';
import type { DayOfWeek, ISODate, Subject } from '@/shared/types/schemas';
import { getIsoDate } from '@/shared/planner/week';
import type { DayOfWeek, Subject } from '@/shared/types/schemas';
import type { PlannerState } from '../hooks/use-planner';
import type {
PreviewPanelState,
Expand Down Expand Up @@ -72,7 +73,7 @@ export const applyExceptionsToTimetable = (
const dayIndex = WEEKDAYS.indexOf(daySchedule.day);
const dayDate = new Date(weekStartDate);
dayDate.setDate(weekStartDate.getDate() + dayIndex);
const isoDate = dayDate.toISOString().split('T')[0] as ISODate;
const isoDate = getIsoDate(dayDate);

const exception = exceptions.find((entry) => entry.date === isoDate);
if (!exception) {
Expand All @@ -97,7 +98,7 @@ export const getExceptionForDate = (
exceptions: ReadonlyArray<ScheduleException>,
date: Date,
): ScheduleException | null => {
const isoDate = date.toISOString().split('T')[0] as ISODate;
const isoDate = getIsoDate(date);
return exceptions.find((entry) => entry.date === isoDate) ?? null;
};

Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/features/planner/screens/use-planner-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { useMemo, useState } from 'react';
import { useToast } from '@/shared/components/use-toast';
import { useSettings } from '@/shared/hooks/use-settings';
import { getWeekId, getWeekStartDate } from '@/shared/planner/week';
import type { DayOfWeek, ISODate, WeekId } from '@/shared/types/schemas';
import { getIsoDate, getWeekId, getWeekStartDate } from '@/shared/planner/week';
import type { DayOfWeek, WeekId } from '@/shared/types/schemas';
import { usePlanner } from '../hooks/use-planner';
import {
applyExceptionsToTimetable,
Expand Down Expand Up @@ -71,6 +71,7 @@ export const usePlannerScreen = () => {
exceptionData: Omit<ScheduleException, 'id'>,
): void => {
setExceptions((prev) => upsertException(prev, exceptionData));
planner.reset();
addToast('Schedule exception saved', 'success');
};

Expand All @@ -79,10 +80,9 @@ export const usePlannerScreen = () => {
return;
}

const isoDate = exceptionEditingDate.date
.toISOString()
.split('T')[0] as ISODate;
const isoDate = getIsoDate(exceptionEditingDate.date);
setExceptions((prev) => prev.filter((entry) => entry.date !== isoDate));
planner.reset();
addToast('Exception removed', 'info');
};

Expand Down
Loading