diff --git a/apps/web/a11y/planner.a11y.ts b/apps/web/a11y/planner.a11y.ts new file mode 100644 index 0000000..5e486ee --- /dev/null +++ b/apps/web/a11y/planner.a11y.ts @@ -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(); + 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([]); + }); + } + }); +} diff --git a/apps/web/src/features/planner/hooks/use-planner.ts b/apps/web/src/features/planner/hooks/use-planner.ts index 75538e4..8a099e4 100644 --- a/apps/web/src/features/planner/hooks/use-planner.ts +++ b/apps/web/src/features/planner/hooks/use-planner.ts @@ -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'; @@ -83,22 +83,31 @@ export const usePlanner = (initialWeekId?: WeekId): UsePlannerReturn => { const dateRange = getWeekDateRange(weekId); const [state, setState] = useState({ status: 'idle' }); + const revisionRef = useRef(0); const generate = useCallback( ( subjects: ReadonlyArray, timetable: ReadonlyArray, ): Promise => { + 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, ), @@ -125,6 +134,7 @@ export const usePlanner = (initialWeekId?: WeekId): UsePlannerReturn => { }, [state]); const reset = useCallback((): void => { + revisionRef.current += 1; setState({ status: 'idle' }); }, []); diff --git a/apps/web/src/features/planner/screens/components/exception-editor-modal.tsx b/apps/web/src/features/planner/screens/components/exception-editor-modal.tsx index 5fc6c4d..cc9f7ed 100644 --- a/apps/web/src/features/planner/screens/components/exception-editor-modal.tsx +++ b/apps/web/src/features/planner/screens/components/exception-editor-modal.tsx @@ -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'; @@ -51,7 +52,7 @@ export const ExceptionEditorModal = ({