From 5dfd4d299b1c798fe5316f1e23b958712eb93a4b Mon Sep 17 00:00:00 2001 From: xsthar Date: Mon, 19 Oct 2026 14:19:53 +0000 Subject: [PATCH 1/2] feat(platform): W7 data routers, mutation feedback, pickers and upload (#112-#115) Data routers (#112): createHashRouter, lazy route modules, React Query loader prefetch using the same keys/shapes pages read, loader guards that await the single-flight POST /auth/refresh restore, per-section RouteError and a hydration fallback; RequireAuth is gone. Mutation feedback (#113): toast store + accessible Toaster, useOptimisticMutation with snapshot rollback and error toasts (message mark-read, invite revoke) and useDeferredAction for delayed-commit undo (queue start/complete). Money movements are documented as excluded. Pickers (#114): WAI-ARIA DatePicker/TimePicker with keyboard grid/list navigation, typed entry, min/max, disabled dates, locale formatting, explicit IANA time zones and 44px targets; every native date input replaced. Upload (#115): drag-and-drop/camera Upload with per-file progress, cancel, retry, signature-based type sniffing, size limits, image downscaling and a new XHR apiUpload helper with credentials/progress/abort. Docs updated (ROADMAP W7 + changelog, ARCHITECTURE_ESSENTIALS assumptions). --- ARCHITECTURE_ESSENTIALS.md | 52 +- ROADMAP.md | 14 +- ...15-data-routers-feedback-pickers-upload.md | 21 + src/App.tsx | 298 +---------- src/components/DatePicker.stories.tsx | 61 +++ src/components/DatePicker.test.tsx | 115 ++++ src/components/DatePicker.tsx | 396 ++++++++++++++ src/components/DateTimePicker.module.css | 263 +++++++++ src/components/RequireAuth.test.tsx | 56 -- src/components/RequireAuth.tsx | 20 - src/components/RouteError.module.css | 56 ++ src/components/RouteError.tsx | 30 ++ src/components/RouteLoading.tsx | 27 + src/components/TimePicker.stories.tsx | 55 ++ src/components/TimePicker.test.tsx | 90 ++++ src/components/TimePicker.tsx | 322 +++++++++++ src/components/Toast.module.css | 104 ++++ src/components/Toast.test.tsx | 99 ++++ src/components/Toast.tsx | 87 +++ src/components/Upload.module.css | 203 +++++++ src/components/Upload.stories.tsx | 66 +++ src/components/Upload.test.tsx | 177 ++++++ src/components/Upload.tsx | 405 ++++++++++++++ src/components/date-utils.ts | 188 +++++++ src/components/image-downscale.test.ts | 63 +++ src/components/image-downscale.ts | 62 +++ src/components/upload-utils.ts | 162 ++++++ .../components/AuditFilterForm.tsx | 17 +- .../components/AuditLogViewer.test.tsx | 12 +- .../AdminDisputesRoute.test.tsx | 16 +- .../components/FeatureFlagsConsole.test.tsx | 12 +- .../components/StuckReferralsConsole.test.tsx | 12 +- .../components/SystemHealthConsole.test.tsx | 19 +- .../SpecialtyClaimsConsole.test.tsx | 12 +- .../dependents/components/DependentForm.tsx | 8 +- .../facility-team/hooks/useFacilityTeam.ts | 18 +- .../insights/components/DateRangeSelector.tsx | 23 +- .../messaging/hooks/useMessageThreads.ts | 38 +- .../queue/components/QueuePanel.test.tsx | 2 +- src/features/queue/components/QueuePanel.tsx | 28 +- src/features/queue/hooks/useQueue.ts | 93 ++-- src/hooks/useDeferredAction.test.tsx | 92 ++++ src/hooks/useDeferredAction.ts | 108 ++++ src/hooks/useOptimisticMutation.test.tsx | 135 +++++ src/hooks/useOptimisticMutation.ts | 95 ++++ src/main.tsx | 11 +- src/mocks/handlers/auth.ts | 7 + src/pages/PatientDashboardPage.test.tsx | 30 +- src/pages/PatientDashboardPage.tsx | 102 +--- src/pages/PatientHomePage.tsx | 77 +++ src/router.test.tsx | 99 ++++ src/router.tsx | 503 ++++++++++++++++++ src/services/api-client.test.ts | 132 +++++ src/services/api-client.ts | 114 ++++ src/services/auth-service.ts | 7 + src/store/auth-store.ts | 52 +- src/store/toast-store.ts | 62 +++ src/test/render.tsx | 43 +- 58 files changed, 4770 insertions(+), 701 deletions(-) create mode 100644 changelog.d/112-115-data-routers-feedback-pickers-upload.md create mode 100644 src/components/DatePicker.stories.tsx create mode 100644 src/components/DatePicker.test.tsx create mode 100644 src/components/DatePicker.tsx create mode 100644 src/components/DateTimePicker.module.css delete mode 100644 src/components/RequireAuth.test.tsx delete mode 100644 src/components/RequireAuth.tsx create mode 100644 src/components/RouteError.module.css create mode 100644 src/components/RouteError.tsx create mode 100644 src/components/RouteLoading.tsx create mode 100644 src/components/TimePicker.stories.tsx create mode 100644 src/components/TimePicker.test.tsx create mode 100644 src/components/TimePicker.tsx create mode 100644 src/components/Toast.module.css create mode 100644 src/components/Toast.test.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/components/Upload.module.css create mode 100644 src/components/Upload.stories.tsx create mode 100644 src/components/Upload.test.tsx create mode 100644 src/components/Upload.tsx create mode 100644 src/components/date-utils.ts create mode 100644 src/components/image-downscale.test.ts create mode 100644 src/components/image-downscale.ts create mode 100644 src/components/upload-utils.ts create mode 100644 src/hooks/useDeferredAction.test.tsx create mode 100644 src/hooks/useDeferredAction.ts create mode 100644 src/hooks/useOptimisticMutation.test.tsx create mode 100644 src/hooks/useOptimisticMutation.ts create mode 100644 src/pages/PatientHomePage.tsx create mode 100644 src/router.test.tsx create mode 100644 src/router.tsx create mode 100644 src/store/toast-store.ts diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 7b99ee73..668598b7 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -103,11 +103,15 @@ land and consume `appointmentKeys.list(profileId)`. **preview**, not production: the free-tier backend spins down after 15 minutes idle (~1 minute cold-start delay on the next request), and its Postgres expires 2026-10-14 unless renewed — data is disposable. -- Router is `HashRouter`, not `BrowserRouter` — GitHub Pages has no - server-side rewrite for client-side routes, so a deep link like `/login` - would 404 on refresh under history-API routing. Switch back to - `BrowserRouter` if this ever deploys somewhere with a real rewrite rule - (e.g. behind `docforum-core` itself, or Vercel/Netlify). +- Routing is a `createHashRouter` data router (`src/router.tsx`), not + `BrowserRouter` — GitHub Pages has no server-side rewrite for + client-side routes, so a deep link like `/login` would 404 on refresh + under history-API routing. Route modules are lazy, loaders prefetch + React Query data (`ensureQueryData` with the same keys/shapes the pages + use), guards are loaders that await session restore, and each section + has a route-level `RouteError`. Switch back to `createBrowserRouter` if + this ever deploys somewhere with a real rewrite rule (e.g. behind + `docforum-core` itself, or Vercel/Netlify). - `vite.config.ts`'s `base` is conditional on `GITHUB_PAGES=true` (set by `npm run build:pages`) — plain `npm run build` still serves from `/`. @@ -156,11 +160,13 @@ envelope, `fullName` is a real column, `role` restricted to `patient`/ real backend's refresh cookie is `SameSite=None; Secure`, cross-site between this repo's `github.io` domain and Render's, so it's required for the cookie to be sent/received at all). One gap remains: -- **`POST /auth/refresh` exists on the backend but nothing here calls - it.** `auth-store.ts` still has no silent-reauth-on-reload flow — a - page refresh loses the session even though the refresh cookie would - still be valid. Tracked, not built (Phase W1 was scoped to - signup/login only). +- **`POST /auth/refresh` silent re-auth is implemented** by the W7 + data-router migration (#112): `restoreSession()` in `auth-store.ts` + calls it once (single-flight) and every loader guard awaits that + promise, so a deep link no longer bounces through `/login`. The + response is assumed to be the same `{ user, accessToken }` envelope as + login; issue #9 stays open for an end-to-end check against the real + backend. - Local-dev-only caveat inherited from the backend: its refresh cookie is `sameSite: 'lax'`/`secure: false`, which may not survive true cross-origin (different ports) `fetch` + `credentials: 'include'` in @@ -649,6 +655,32 @@ localStorage/sessionStorage); EXIF is never read or displayed here (the server strips it). The full thread UX (optimistic send, live updates, per-side layout) is #22's scope — `MessageThread` here is a minimal shared shell so the attachment composer/preview have a real home. +### Phase W7 — Platform quality (issues #112–#115) + +- **Session restore contract (#112, depends on #9):** `POST /auth/refresh` + (no body; the httpOnly cookie carries the token, sent because + `apiFetch` uses `credentials: 'include'`) returns the same + `{user: User, accessToken: string}` envelope as `POST /auth/login`. A + failure is treated as "signed out", not as an app error. The frontend + never stores the refresh token. +- **Upload contract (#115):** `POST /uploads` (overridable via the + component's `endpoint`/`uploadFile` props) sends `multipart/form-data` + with the file under `file` (plus optional text `fields`), bearer auth, + `credentials: 'include'`, and returns `{ id?, url?, ... }`. Progress and + cancellation use XHR because `fetch` exposes no upload-progress event. + Client-side type validation sniffs magic bytes (JPEG/PNG/GIF/WebP/PDF/ + ZIP + text); the server must re-check. Assumed pending a real endpoint + in docforum-core. +- **Picker semantics (#114):** dates are plain `YYYY-MM-DD` calendar + strings, never instants. The optional `timeZone` (IANA) only decides + what "today" is and what is shown as a hint; formatting anchors at UTC + noon so a date never shifts. Times are `HH:mm` 24-hour strings. +- **Toast/optimistic rule (#113):** `useOptimisticMutation` and + `useDeferredAction` must never be used for money movements (payment + intent fund/release/refund) — those always show the server's result. + Toast messages complement, never replace, inline form errors. Undo + uses a delayed commit; if the component unmounts before the window + elapses, the optimistic change is reverted rather than left uncommitted. ## Roadmap / status See `ROADMAP.md` in this repo, and Phase 7 in `docforum-core`'s diff --git a/ROADMAP.md b/ROADMAP.md index fb05260c..3ab93efc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,11 @@ **Status: Phase W1 built and connected to a live backend.** The deployed Pages preview (https://docforum.github.io/docforum-web/) points at -`docforum-core`'s Render deployment; signup/login work end-to-end. One -gap remains — see `ARCHITECTURE_ESSENTIALS.md` "Known gaps against the -real API" (no refresh-on-reload flow). +`docforum-core`'s Render deployment; signup/login work end-to-end. The +original W1 gap — no refresh-on-reload flow — is closed behaviourally by +the W7 data-router migration (#112), whose guards await the single-flight +`POST /auth/refresh` restore before deciding; issue #9 stays open only +for a real-backend end-to-end check. ## Phase W1 — Shell & auth - [x] Vite app wired up — real `main.tsx`/`App.tsx`, React Router, React @@ -136,7 +138,7 @@ Admin-only screens beyond payments. Everything goes through `docforum-core`'s ad - [x] **PR malware scan** — `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions → PR malware scan → Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check. - [x] Mock API for local development (MSW). Tracked as [issue #95](https://github.com/DocForum/docforum-web/issues/95) (High, 200 pts) — `npm run dev:mock` serves every endpoint the app uses through the normal service layer via `src/mocks/`: seeded obviously-fake fixtures, a dev-panel role switcher (patient/doctor/facility/admin), scenario toggles (empty states, forced errors), a latency slider, and a fixture reset. 26 handler tests drive the real `src/services/` layer against MSW; CI fails if mock code ever enters a production bundle (`npm run check:dist`). Documented in `CONTRIBUTING.md`. - [ ] Generate MSW handlers and fixtures from the backend OpenAPI spec. Tracked as [issue #96](https://github.com/DocForum/docforum-web/issues/96) (High, 200 pts) — **blocked**: depends on [docforum-core#112](https://github.com/DocForum/docforum-core/issues/112) (OpenAPI 3.1 spec generated from the Zod schemas), which is still open — there is no authoritative `openapi.json` to generate from yet. Do not fabricate one; see that issue. -- [ ] Storybook component library with interaction and accessibility tests. Tracked as [issue #97](https://github.com/DocForum/docforum-web/issues/97) (High, 200 pts). +- [ ] Storybook component library with interaction and accessibility tests. Tracked as [issue #97](https://github.com/DocForum/docforum-web/issues/97) (High, 200 pts). CSF story files for `DatePicker`, `TimePicker` and `Upload` already exist; #97 is the wiring/config half. - [ ] Performance budget: route-level code splitting, lazy GSAP, and Lighthouse CI. Tracked as [issue #99](https://github.com/DocForum/docforum-web/issues/99) (High, 200 pts). - [x] React Query key factory and cache invalidation conventions. Tracked as [issue #122](https://github.com/DocForum/docforum-web/issues/122) (High, 200 pts) — `src/query-keys.ts` (typed factory per resource, profile-scoped keys where the API scopes by profile, one invalidation root each) and `src/invalidations.ts` (the mutation → keys map, refreshed through `invalidateFor`); every query and mutation hook migrated off hand-written key arrays. Payment actions now refresh the affected intent and the facility ledgers, a confirmed visit action refreshes the dashboard appointment list and queue (the stand-in for booking until #2/#3 land and consume `appointmentKeys.list(profileId)`), and health writes stay scoped to the acting profile's one section. New tests: `query-keys.test.ts`, `invalidations.test.ts`, plus a hook-level invalidation case in `useQueue.test.tsx`. Conventions documented in `ARCHITECTURE_ESSENTIALS.md` "Query keys & cache invalidation". - [x] API client: request cancellation, de-duplication, timeouts and retry policy. Tracked as [issue #123](https://github.com/DocForum/docforum-web/issues/123) (High, 200 pts) — `src/services/api-client.ts` now owns the whole HTTP lifecycle: every request has a timeout (`DEFAULT_TIMEOUT_MS` 60 s, deliberately longer than a cold start so the `useSlowRequestHint` explanation isn't turned into a fake failure; slow endpoints — data-export create/download, admin job run — pass `SLOW_REQUEST_TIMEOUT_MS` 120 s) failing with a typed `ApiTimeoutError` that is distinct from `ApiError` and from an abort; identical concurrent GETs share one network request and one parsed result (keyed by URL, bearer token and timeout), with **independent cancellation per caller** — one caller walking away never fails the others, and the shared request is aborted only when its last caller does; mutations are never de-duplicated. Every service function used by a query takes an optional `AbortSignal` and every `queryFn` passes React Query's signal through, so navigating away actually stops the request; catch-all fallbacks rethrow aborts/timeouts instead of caching placeholder data (`useCareThreads`, `useFacilityLedger`). The shared `QueryClient`'s retry policy retries transient failures once and never retries a cancellation (`isAbortError`). @@ -146,6 +148,10 @@ Admin-only screens beyond payments. Everything goes through `docforum-core`'s ad - [x] Privacy-preserving product analytics (no PHI, no third-party tracking). Tracked as [issue #118](https://github.com/DocForum/docforum-web/issues/118) (High, 200 pts) — `src/utils/analytics.ts`: cookieless, self-hostable, disabled unless `VITE_ANALYTICS_ENDPOINT` is set. Explicit `ALLOWED_EVENTS` allowlist with no PHI. `scrubUrl` strips query strings and ids. DNT/GPC respected. `useAnalyticsPageview` hook. `/privacy` page. Env vars in `.env.example`. - [x] ADR + migration: configurable router (BrowserRouter / HashRouter). Tracked as [issue #119](https://github.com/DocForum/docforum-web/issues/119) (High, 200 pts) — `docs/adr/0001-routing-strategy.md` compares hosting options. `main.tsx` selects `BrowserRouter` by default, `HashRouter` when `VITE_USE_HASH_ROUTER=true`. `deploy-pages.yml` sets that flag for GitHub Pages. `HashRedirectShim` converts old `#/path` links to clean paths transparently. - [x] **PR conflict notifier** — `.github/workflows/pr-conflict-notifier.yml` + `.github/scripts/pr-conflict-notifier.cjs`. On every push to `main` (and every 3h) it labels open PRs that conflict with `main` as `needs-rebase` and @mentions the author once per push with rebase steps; the label clears itself when the conflict is resolved. Runs from `main` only and never checks out PR code. +- [x] Data routers with loaders and route-level error elements. Tracked as [issue #112](https://github.com/DocForum/docforum-web/issues/112) (High, 200 pts) — `src/router.tsx`, a configurable data router (`createBrowserRouter` by default, `createHashRouter` for GitHub Pages), every lazily imported route, loaders prefetching the same React Query keys/shapes the pages read, `RouteError` per section, `RouteLoading` as the hydration fallback, and no `RequireAuth` component left. Route guards await the single-flight `POST /auth/refresh` restore: signed-out deep links redirect to `/login`; wrong-role deep links redirect to the user's own dashboard. +- [x] Mutation feedback framework: toasts, optimistic updates and undo. Tracked as [issue #113](https://github.com/DocForum/docforum-web/issues/113) (High, 200 pts) — `src/store/toast-store.ts` + `Toast`/`Toaster` (live region, dismissible, auto-timeout paused on hover/focus), `useOptimisticMutation` (snapshot rollback, error toast, optional refetch) applied to message mark-read and invite revoke, and `useDeferredAction` (delayed commit + Undo toast + rollback on failure or unmount) applied to the doctor queue's start/complete. Payments stay free of optimistic updates by rule. +- [x] Accessible date and time pickers. Tracked as [issue #114](https://github.com/DocForum/docforum-web/issues/114) (High, 200 pts) — `DatePicker`/`TimePicker` following the WAI-ARIA date picker dialog and listbox patterns: keyboard grid and list navigation, typed entry (ISO and locale order / 12- or 24-hour), min/max and disabled dates, locale-aware formatting, explicit IANA time zone, 44px touch targets. Replaces every native date input (dependent date of birth, audit `from`/`to`, insights custom range). +- [x] Shared file upload component. Tracked as [issue #115](https://github.com/DocForum/docforum-web/issues/115) (High, 200 pts) — `Upload` (drag-and-drop plus button, mobile camera capture, per-file progress, cancel and retry, accessible announcements), client-side signature-based type sniffing plus size limits in `upload-utils.ts`, `downscaleImage` with a quality setting, and an XHR multipart uploader with credentials/progress/abort in `api-client.ts` (`apiUpload`). File contents stay in component state, never in a global store. ## Changelog diff --git a/changelog.d/112-115-data-routers-feedback-pickers-upload.md b/changelog.d/112-115-data-routers-feedback-pickers-upload.md new file mode 100644 index 00000000..d3ddba20 --- /dev/null +++ b/changelog.d/112-115-data-routers-feedback-pickers-upload.md @@ -0,0 +1,21 @@ +Phase W7: data routers, mutation feedback, accessible pickers and shared +file upload +([issue #112](https://github.com/DocForum/docforum-web/issues/112), +[#113](https://github.com/DocForum/docforum-web/issues/113), +[#114](https://github.com/DocForum/docforum-web/issues/114), +[#115](https://github.com/DocForum/docforum-web/issues/115)). + +`src/router.tsx` replaces the component tree and `RequireAuth` with a +configurable data router (`createBrowserRouter` by default, `createHashRouter` +when `VITE_USE_HASH_ROUTER=true`), lazy route modules, loaders that prefetch +the exact React Query keys/shapes pages read, per-section `RouteError`, and +guards that await the single-flight `POST /auth/refresh` restore. Toasts, +`useOptimisticMutation` and `useDeferredAction` now back message mark-read, +invite revoke and queue start/complete (payments excluded). `DatePicker` and +`TimePicker` replace every native date input with keyboard/screen-reader +support, typed entry, min/max, disabled dates and explicit time zones. +`Upload` adds drag-and-drop, mobile camera capture, progress/cancel/retry, +signature-based type sniffing, size limits and image downscaling through a new +`apiUploadFile` XHR helper. CSF story files for the three components are in +place ahead of Storybook (#97); new assumed shapes (refresh, upload) are +recorded in `ARCHITECTURE_ESSENTIALS.md`. diff --git a/src/App.tsx b/src/App.tsx index 8fd40da1..b4b96678 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,292 +1,22 @@ -import { Route, Routes } from 'react-router-dom'; -import { HashRedirectShim } from './components/HashRedirectShim'; -import { useAnalyticsPageview } from './hooks/useAnalyticsPageview'; -import { Layout } from './components/Layout'; -import { RequireAuth } from './components/RequireAuth'; -import { HomePage } from './pages/HomePage'; -import { LoginPage } from './pages/LoginPage'; -import { SignupPage } from './pages/SignupPage'; -import { PrivacyPage } from './pages/PrivacyPage'; -import { PatientDashboardPage } from './pages/PatientDashboardPage'; -import { DoctorDashboardPage } from './pages/DoctorDashboardPage'; -import { FacilityDashboardPage } from './pages/FacilityDashboardPage'; -import { AdminDashboardPage } from './pages/AdminDashboardPage'; -import { OrderDetailPage } from './pages/OrderDetailPage'; -import { ClaimAccountPage } from './pages/ClaimAccountPage'; -import { AccessLogPage } from './pages/AccessLogPage'; -import { PaymentDetailPage } from './pages/PaymentDetailPage'; -import { PaymentHistoryPage } from './pages/PaymentHistoryPage'; -import { CareThreadPage } from './pages/CareThreadPage'; -import { AccountDeletedPage } from './pages/AccountDeletedPage'; -import { NotFoundPage } from './pages/NotFoundPage'; -import { ConsultationNote } from './features/consultation/components/ConsultationNote'; -import { DoctorInbox } from './features/messaging/components/DoctorInbox'; -import { DoctorPracticeInsightsPage } from './pages/DoctorPracticeInsightsPage'; -import { FacilityTeamPage } from './pages/FacilityTeamPage'; -import { AdminAuditPage } from './pages/AdminAuditPage'; -import { AdminReferralsPage } from './pages/AdminReferralsPage'; -import { AdminSystemPage } from './pages/AdminSystemPage'; -import { AdminVerificationPage } from './pages/AdminVerificationPage'; -import { AdminFlagsPage } from './pages/AdminFlagsPage'; -import { AdminDisputesPage } from './pages/AdminDisputesPage'; -import { AdminDisputeDetailPage } from './pages/AdminDisputeDetailPage'; -import { AdminVerificationsPage } from './pages/AdminVerificationsPage'; -import { AdminVerificationDetailPage } from './pages/AdminVerificationDetailPage'; -import { OrderVerifyPage } from './pages/OrderVerifyPage'; -import { FacilityPaymentsPage } from './pages/FacilityPaymentsPage'; -import { FacilityIntegrationsPage } from './pages/FacilityIntegrationsPage'; -import { FacilityInsightsPage } from './pages/FacilityInsightsPage'; -import { PatientChartPage } from "./pages/PatientChartPage"; -import { MessageThreadPage } from './pages/MessageThreadPage'; +import { useState } from 'react'; +import { RouterProvider } from 'react-router-dom'; +import type { createAppRouter } from './router'; +import { createAppRouter as makeRouter } from './router'; +import { queryClient } from './query-client'; +import { Toaster } from './components/Toast'; -const useHashRouter = import.meta.env.VITE_USE_HASH_ROUTER === 'true'; +interface AppProps { + /** Injectable for tests; production uses the shared query client. */ + router?: ReturnType; +} -export default function App() { - useAnalyticsPageview(); +export default function App({ router }: AppProps) { + const [appRouter] = useState(() => router ?? makeRouter(queryClient)); return ( <> - {!useHashRouter && } - - }> - } /> - } /> - } /> - } /> - {/* Issue-path routes (#34 `/account/access-log`, #37 `/payments`) stay - at the literal path the issues name, guarded to patients, rather - than being folded under /patient where the hub's nested routes - live. #32's detail page has no path of its own, so it nests. */} - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - - + + ); } diff --git a/src/components/DatePicker.stories.tsx b/src/components/DatePicker.stories.tsx new file mode 100644 index 00000000..6290ed79 --- /dev/null +++ b/src/components/DatePicker.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import type { ReactElement } from 'react'; +import { DatePicker } from './DatePicker'; +import type { DatePickerProps } from './DatePicker'; + +type Story = { + name?: string; + args: DatePickerProps; + render: (args: DatePickerProps) => ReactElement; +}; + +const meta = { + title: 'Components/DatePicker', + component: DatePicker, + parameters: { layout: 'padded' }, +}; + +export default meta; + +function Controlled(args: DatePickerProps) { + const [value, setValue] = useState(args.value); + return ; +} + +const baseArgs: DatePickerProps = { + label: 'Appointment date', + value: '2026-10-19', + onChange: () => undefined, + timeZone: 'Europe/London', + locale: 'en-GB', + hint: 'Times are shown in your selected clinic time zone.', +}; + +export const Default: Story = { + args: baseArgs, + render: (args) => , +}; + +export const WithMinAndMax: Story = { + name: 'Min and max dates', + args: { ...baseArgs, min: '2026-10-01', max: '2026-12-31' }, + render: (args) => , +}; + +export const DisabledDates: Story = { + name: 'Disabled dates (weekends)', + args: { + ...baseArgs, + disabledDates: (date) => { + const weekday = new Date(`${date}T12:00:00Z`).getUTCDay(); + return weekday === 0 || weekday === 6; + }, + }, + render: (args) => , +}; + +export const WithError: Story = { + name: 'Validation error', + args: { ...baseArgs, value: '', error: 'Date of birth is required.' }, + render: (args) => , +}; diff --git a/src/components/DatePicker.test.tsx b/src/components/DatePicker.test.tsx new file mode 100644 index 00000000..10a2bc7a --- /dev/null +++ b/src/components/DatePicker.test.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../test/render'; +import { DatePicker } from './DatePicker'; +import type { DatePickerProps } from './DatePicker'; + +function Controlled(props: Partial) { + const [value, setValue] = useState(props.value ?? ''); + return ( + { + setValue(next); + props.onChange?.(next); + }} + {...props} + /> + ); +} + +describe('DatePicker', () => { + it('accepts ISO typed entry and announces the formatted date', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('Appointment date'); + await user.type(input, '2026-10-19'); + + expect(input).toHaveValue('2026-10-19'); + expect(screen.getByText(/Monday, 19 October 2026/i)).toBeInTheDocument(); + }); + + it('rejects an impossible typed date with an inline error', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Appointment date'), '2026-02-30'); + await user.tab(); + + expect(screen.getByRole('alert')).toHaveTextContent(/Enter a date like/i); + }); + + it('opens the dialog, supports arrow keys, and selects with Enter', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + const first = await screen.findByRole('button', { name: /Monday, 19 October 2026/i }); + await waitFor(() => expect(first).toHaveFocus()); + + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', { name: /Tuesday, 20 October 2026/i })).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenLastCalledWith('2026-10-20'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Appointment date')).toHaveFocus(); + }); + + it('pages months with PageDown and Home/End', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await screen.findByRole('button', { name: /Monday, 19 October 2026/i }); + + await user.keyboard('{PageDown}'); + expect(await screen.findByRole('heading', { name: 'November 2026' })).toBeInTheDocument(); + + await user.keyboard('{Home}'); + expect(screen.getByRole('button', { name: /Sunday, 1 November 2026/i })).toHaveFocus(); + await user.keyboard('{End}'); + expect(screen.getByRole('button', { name: /Monday, 30 November 2026/i })).toHaveFocus(); + }); + + it('disables dates outside min/max and custom disabled dates', async () => { + const user = userEvent.setup(); + render( + date === '2026-10-20'} + />, + ); + + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + + expect(screen.getByRole('button', { name: /Friday, 9 October 2026/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Tuesday, 20 October 2026/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Monday, 26 October 2026/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Monday, 19 October 2026/i })).toBeEnabled(); + }); + + it('closes on Escape and returns focus to the input', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await screen.findByRole('dialog'); + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Appointment date')).toHaveFocus(); + }); + + it('shows the configured time zone', () => { + render(); + expect(screen.getByText(/Monday, 19 October 2026/i)).toBeInTheDocument(); + expect(screen.getByText(/Europe\/Berlin/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx new file mode 100644 index 00000000..9a5a73d2 --- /dev/null +++ b/src/components/DatePicker.tsx @@ -0,0 +1,396 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + addDays, + addMonths, + dateParts, + firstDayOfWeek, + formatDateLabel, + formatMonthLabel, + formatTypedDateExample, + isValidIsoDate, + isoDateFromParts, + parseDateInput, + todayInTimeZone, + weekdayLabels, +} from './date-utils'; +import styles from './DateTimePicker.module.css'; + +export interface DatePickerProps { + label: string; + /** Calendar date as `YYYY-MM-DD`, or an empty string. */ + value: string; + onChange: (value: string) => void; + min?: string; + max?: string; + /** Extra rule, e.g. weekends or blackout dates. */ + disabledDates?: (date: string) => boolean; + /** IANA zone used to decide which day "today" is. */ + timeZone?: string; + locale?: string; + id?: string; + name?: string; + required?: boolean; + disabled?: boolean; + error?: string; + hint?: string; +} + +const FOCUSABLE = + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export function DatePicker({ + label, + value, + onChange, + min, + max, + disabledDates, + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + locale = 'en-GB', + id, + name, + required, + disabled, + error, + hint, +}: DatePickerProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const dialogId = `${inputId}-dialog`; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(value); + const [typedError, setTypedError] = useState(null); + const [focusedDate, setFocusedDate] = useState(value || todayInTimeZone(timeZone)); + const inputRef = useRef(null); + const gridRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + setDraft(value); + }, [value]); + + const today = todayInTimeZone(timeZone); + const firstDay = firstDayOfWeek(locale); + const weekdays = useMemo(() => weekdayLabels(locale, firstDay), [firstDay, locale]); + + const isDisabled = useCallback( + (date: string) => { + if (min && date < min) return true; + if (max && date > max) return true; + return disabledDates?.(date) ?? false; + }, + [disabledDates, max, min], + ); + + // A 6-week grid keeps the dialog height stable while paging months. + const gridDays = useMemo(() => { + const { year, month } = dateParts(focusedDate); + const firstOfMonth = isoDateFromParts(year, month, 1); + const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay(); + const offset = (firstWeekday - firstDay + 7) % 7; + const start = addDays(firstOfMonth, -offset); + return Array.from({ length: 42 }, (_, index) => addDays(start, index)); + }, [firstDay, focusedDate]); + + const monthLabel = formatMonthLabel(focusedDate, locale); + const selected = isValidIsoDate(value) ? value : ''; + + const close = useCallback(() => { + setOpen(false); + inputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!open) return; + const button = gridRef.current?.querySelector(`[data-date="${focusedDate}"]`); + button?.focus(); + }, [focusedDate, open]); + + useEffect(() => { + if (!open) return; + const handle = (event: MouseEvent) => { + const dialog = document.getElementById(dialogId); + if (dialog && !dialog.contains(event.target as Node)) close(); + }; + document.addEventListener('mousedown', handle); + return () => document.removeEventListener('mousedown', handle); + }, [close, dialogId, open]); + + const commitTyped = () => { + const parsed = parseDateInput(draft, locale); + if (parsed === null) { + setTypedError(`Enter a date like ${formatTypedDateExample(locale)}.`); + return; + } + setTypedError(null); + if (parsed !== value) onChange(parsed); + }; + + const openDialog = () => { + setFocusedDate(selected || today); + setOpen(true); + }; + + const selectDate = (date: string) => { + if (isDisabled(date)) return; + onChange(date); + setTypedError(null); + setOpen(false); + inputRef.current?.focus(); + }; + + const moveFocus = (target: string, direction: number) => { + let next = target; + // Skip disabled days in the direction of travel rather than trapping + // keyboard focus on a button that cannot be selected. + for (let guard = 0; guard < 400 && isDisabled(next); guard += 1) { + next = addDays(next, direction); + } + setFocusedDate(next); + }; + + const handleGridKeyDown = (event: React.KeyboardEvent) => { + let target: string | null = null; + switch (event.key) { + case 'ArrowLeft': + target = addDays(focusedDate, -1); + break; + case 'ArrowRight': + target = addDays(focusedDate, 1); + break; + case 'ArrowUp': + target = addDays(focusedDate, -7); + break; + case 'ArrowDown': + target = addDays(focusedDate, 7); + break; + case 'PageUp': + target = addMonths(focusedDate, -1); + break; + case 'PageDown': + target = addMonths(focusedDate, 1); + break; + case 'Home': { + const { year, month } = dateParts(focusedDate); + target = isoDateFromParts(year, month, 1); + break; + } + case 'End': { + const { year, month } = dateParts(focusedDate); + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + target = isoDateFromParts(year, month, lastDay); + break; + } + case 'Enter': + case ' ': + event.preventDefault(); + if (!isDisabled(focusedDate)) selectDate(focusedDate); + return; + case 'Escape': + event.preventDefault(); + close(); + return; + default: + return; + } + event.preventDefault(); + const backwards = + event.key === 'ArrowLeft' || event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home'; + moveFocus(target, backwards ? -1 : 1); + }; + + // Keep Tab inside the modal while it is open. + const handleDialogKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Tab') return; + const dialog = event.currentTarget; + const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const describedBy = [hint ? hintId : null, error || typedError ? errorId : null].filter(Boolean).join(' '); + const displayError = error ?? typedError; + + return ( +
+ +
+ { + const next = event.target.value; + setDraft(next); + setTypedError(null); + const parsed = parseDateInput(next, locale); + if (parsed !== null && parsed !== value) onChange(parsed); + }} + onBlur={commitTyped} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitTyped(); + } + if (event.key === 'ArrowDown' && event.altKey) { + event.preventDefault(); + openDialog(); + } + }} + required={required} + disabled={disabled} + aria-invalid={Boolean(displayError)} + aria-describedby={describedBy || undefined} + autoComplete="off" + /> + +
+ {hint && ( +

+ {hint} +

+ )} + {displayError && ( + + )} +

+ {selected ? `${formatDateLabel(selected, locale)} · ${timeZone}` : `Dates use ${timeZone}`} +

+ + {open && + createPortal( +
event.target === event.currentTarget && close()}> + +
, + document.body, + )} +
+ ); +} diff --git a/src/components/DateTimePicker.module.css b/src/components/DateTimePicker.module.css new file mode 100644 index 00000000..05563863 --- /dev/null +++ b/src/components/DateTimePicker.module.css @@ -0,0 +1,263 @@ +.field { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} + +.label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +.control { + display: flex; + gap: var(--space-2); + align-items: stretch; +} + +.input { + flex: 1; + min-width: 0; + min-height: 44px; + padding: 0.55rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; +} + +.input:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: 1px; +} + +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + min-height: 44px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; +} + +.trigger:hover:not(:disabled) { + background: var(--color-surface-hover); +} + +.trigger:focus-visible, +.navButton:focus-visible, +.day:focus-visible, +.timeOption:focus-visible, +.textButton:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.hint { + margin: 0; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.error { + margin: 0; + font-size: var(--text-xs); + color: var(--color-danger); +} + +.overlay { + position: fixed; + inset: 0; + z-index: 900; + display: flex; + align-items: flex-start; + justify-content: center; + padding: max(var(--space-4), 10vh) var(--space-4) var(--space-4); + background: rgba(28, 24, 21, 0.38); +} + +.dialog { + width: min(22rem, 100%); + max-height: 90vh; + overflow: auto; + padding: var(--space-4); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.timeDialog { + width: min(16rem, 100%); +} + +.dialogHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.month { + margin: 0; + font-family: var(--font-display); + font-size: var(--text-lg); + text-align: center; + flex: 1; +} + +.navButton { + width: 44px; + height: 44px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; +} + +.navButton:hover { + background: var(--color-surface-hover); +} + +.gridWrap { + overflow: hidden; +} + +.grid { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.weekday { + padding: var(--space-1); + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-muted); + text-align: center; +} + +.weekday abbr { + text-decoration: none; +} + +.cell { + padding: 1px; + text-align: center; +} + +.day { + width: 40px; + height: 40px; + border: 1px solid transparent; + border-radius: var(--radius-full); + background: transparent; + color: var(--color-text); + font: inherit; + font-size: var(--text-sm); + cursor: pointer; +} + +.day:hover:not(:disabled) { + background: var(--color-surface-hover); +} + +.day:disabled { + color: var(--color-text-faint); + cursor: not-allowed; + opacity: 0.55; +} + +.outside { + color: var(--color-text-faint); +} + +.today { + border-color: var(--color-border-strong); +} + +.selectedDay, +.day.selectedDay { + background: var(--color-accent); + color: var(--color-accent-contrast); + font-weight: 700; +} + +.dialogFooter { + display: flex; + justify-content: space-between; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.textButton { + min-height: 40px; + padding: 0.35rem 0.7rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.textButton:hover:not(:disabled) { + background: var(--color-surface-hover); +} + +.listHint { + margin: 0 0 var(--space-2); + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.timeList { + list-style: none; + margin: 0; + padding: 0; + max-height: 16rem; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.timeOption { + min-height: 44px; + display: flex; + align-items: center; + padding: 0 var(--space-4); + cursor: pointer; + font-size: var(--text-sm); +} + +.timeOption:hover, +.timeOption[aria-selected='true'] { + background: var(--color-accent-subtle); + color: var(--color-text); +} + +.timeOption[aria-selected='true'] { + font-weight: 700; +} + +@media (max-width: 480px) { + .overlay { + padding: var(--space-2); + align-items: flex-end; + } + .dialog { + width: 100%; + } +} diff --git a/src/components/RequireAuth.test.tsx b/src/components/RequireAuth.test.tsx deleted file mode 100644 index 0fc7732d..00000000 --- a/src/components/RequireAuth.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { render, screen } from '@testing-library/react'; -import { RequireAuth } from './RequireAuth'; -import { useAuthStore } from '../store/auth-store'; -import type { User } from '../types/models'; - -const patient: User = { - id: 'user-1', - email: 'patient@example.com', - role: 'patient', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), -}; - -function renderGuarded(initialPath: string) { - return render( - - - Login page} /> - Home page} /> - -
Patient dashboard
- - } - /> -
-
, - ); -} - -describe('RequireAuth', () => { - afterEach(() => { - useAuthStore.getState().clearSession(); - }); - - it('redirects to /login when no user is signed in', () => { - renderGuarded('/patient'); - expect(screen.getByText('Login page')).toBeInTheDocument(); - }); - - it('renders the protected content for an allowed role', () => { - useAuthStore.getState().setSession(patient, 'token'); - renderGuarded('/patient'); - expect(screen.getByText('Patient dashboard')).toBeInTheDocument(); - }); - - it('redirects home when the signed-in role is not allowed', () => { - useAuthStore.getState().setSession({ ...patient, role: 'doctor' }, 'token'); - renderGuarded('/patient'); - expect(screen.getByText('Home page')).toBeInTheDocument(); - }); -}); diff --git a/src/components/RequireAuth.tsx b/src/components/RequireAuth.tsx deleted file mode 100644 index 245aab39..00000000 --- a/src/components/RequireAuth.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; -import { Navigate } from 'react-router-dom'; -import { useAuthStore } from '../store/auth-store'; -import type { Role } from '../types/models'; - -interface RequireAuthProps { - allowedRoles: Role[]; - children: ReactNode; -} - -/** Route guard: redirects to /login if unauthenticated, or to / if the - * signed-in user's role isn't allowed for this route. */ -export function RequireAuth({ allowedRoles, children }: RequireAuthProps) { - const user = useAuthStore((state) => state.user); - - if (!user) return ; - if (!allowedRoles.includes(user.role)) return ; - - return <>{children}; -} diff --git a/src/components/RouteError.module.css b/src/components/RouteError.module.css new file mode 100644 index 00000000..3b7d0d95 --- /dev/null +++ b/src/components/RouteError.module.css @@ -0,0 +1,56 @@ +.wrapper { + display: flex; + justify-content: center; + padding: var(--space-16) var(--space-4); +} + +.card { + max-width: 34rem; + width: 100%; + padding: var(--space-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-sm); +} + +.title { + margin: 0 0 var(--space-3); + font-family: var(--font-display); + font-size: var(--text-xl); +} + +.message { + margin: 0 0 var(--space-6); + color: var(--color-text-muted); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); + margin: 0; +} + +.link { + color: var(--color-accent); + font-weight: 600; +} + +.retry { + padding: 0.6rem 1rem; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.retry:focus-visible, +.link:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} diff --git a/src/components/RouteError.tsx b/src/components/RouteError.tsx new file mode 100644 index 00000000..75fd8bd2 --- /dev/null +++ b/src/components/RouteError.tsx @@ -0,0 +1,30 @@ +import { Link, isRouteErrorResponse, useRouteError } from 'react-router-dom'; +import styles from './RouteError.module.css'; + +/** Route-level error boundary. Data-router loaders and lazy route modules + * throw into here instead of blanking the app. */ +export function RouteError() { + const error = useRouteError(); + const message = isRouteErrorResponse(error) + ? `${error.status} ${error.statusText}`.trim() + : error instanceof Error + ? error.message + : 'Something went wrong while loading this page.'; + + return ( +
+
+

This page didn’t load

+

{message}

+

+ + Back to home + + +

+
+
+ ); +} diff --git a/src/components/RouteLoading.tsx b/src/components/RouteLoading.tsx new file mode 100644 index 00000000..a86a5c42 --- /dev/null +++ b/src/components/RouteLoading.tsx @@ -0,0 +1,27 @@ +import { Spinner } from './Spinner'; + +/** Shown while a route's lazy module or loaders resolve. */ +const visuallyHidden = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +} as const; + +export function RouteLoading() { + return ( +
+ + Loading page… +
+ ); +} diff --git a/src/components/TimePicker.stories.tsx b/src/components/TimePicker.stories.tsx new file mode 100644 index 00000000..60809560 --- /dev/null +++ b/src/components/TimePicker.stories.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import type { ReactElement } from 'react'; +import { TimePicker } from './TimePicker'; +import type { TimePickerProps } from './TimePicker'; + +type Story = { + name?: string; + args: TimePickerProps; + render: (args: TimePickerProps) => ReactElement; +}; + +const meta = { + title: 'Components/TimePicker', + component: TimePicker, + parameters: { layout: 'padded' }, +}; + +export default meta; + +function Controlled(args: TimePickerProps) { + const [value, setValue] = useState(args.value); + return ; +} + +const baseArgs: TimePickerProps = { + label: 'Appointment time', + value: '09:30', + onChange: () => undefined, + timeZone: 'Europe/London', + locale: 'en-GB', + hint: 'Clinic hours are shown in the clinic time zone.', +}; + +export const Default: Story = { + args: baseArgs, + render: (args) => , +}; + +export const FifteenMinuteSteps: Story = { + name: '15-minute steps', + args: { ...baseArgs, stepMinutes: 15 }, + render: (args) => , +}; + +export const LimitedHours: Story = { + name: 'Clinic hours (09:00–17:00)', + args: { ...baseArgs, min: '09:00', max: '17:00' }, + render: (args) => , +}; + +export const WithError: Story = { + name: 'Validation error', + args: { ...baseArgs, value: '', error: 'Choose a time for the appointment.' }, + render: (args) => , +}; diff --git a/src/components/TimePicker.test.tsx b/src/components/TimePicker.test.tsx new file mode 100644 index 00000000..447b9c1c --- /dev/null +++ b/src/components/TimePicker.test.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from '../test/render'; +import { TimePicker } from './TimePicker'; + +function Controlled(props: Partial>) { + const [value, setValue] = useState(props.value ?? ''); + return ( + { + setValue(next); + props.onChange?.(next); + }} + {...props} + /> + ); +} + +describe('TimePicker', () => { + it('accepts typed 12-hour and 24-hour entry', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('Appointment time'); + await user.type(input, '9:30 PM'); + expect(input).toHaveValue('21:30'); + + await user.clear(input); + await user.type(input, '08:15'); + expect(input).toHaveValue('08:15'); + }); + + it('rejects an invalid typed time', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Appointment time'), '25:00'); + await user.tab(); + + expect(screen.getByRole('alert')).toHaveTextContent(/Enter a time like/i); + }); + + it('opens a listbox, moves with arrow keys, and chooses with Enter', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open time options' })); + const list = await screen.findByRole('listbox', { name: /appointment time options/i }); + const selected = within(list).getByRole('option', { name: '09:00' }); + await waitFor(() => expect(selected).toHaveFocus()); + + await user.keyboard('{ArrowDown}'); + expect(within(list).getByRole('option', { name: '09:30' })).toHaveFocus(); + await user.keyboard('{Enter}'); + + expect(onChange).toHaveBeenLastCalledWith('09:30'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Appointment time')).toHaveFocus(); + }); + + it('limits options to min/max and the step', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open time options' })); + const list = await screen.findByRole('listbox'); + expect(within(list).getAllByRole('option').map((option) => option.textContent)).toEqual([ + '09:00', + '09:30', + '10:00', + ]); + }); + + it('closes on Escape and shows the time zone', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText(/Europe\/Berlin/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Open time options' })); + await screen.findByRole('dialog'); + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Appointment time')).toHaveFocus(); + }); +}); diff --git a/src/components/TimePicker.tsx b/src/components/TimePicker.tsx new file mode 100644 index 00000000..69397299 --- /dev/null +++ b/src/components/TimePicker.tsx @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + formatTimeLabel, + minutesFromTime, + parseTimeInput, + timeFromMinutes, +} from './date-utils'; +import styles from './DateTimePicker.module.css'; + +export interface TimePickerProps { + label: string; + /** 24-hour `HH:mm`, or an empty string. */ + value: string; + onChange: (value: string) => void; + min?: string; + max?: string; + /** Granularity of the suggestion list; typing still accepts any minute. */ + stepMinutes?: number; + /** IANA zone the time is interpreted in (shown to the user). */ + timeZone?: string; + locale?: string; + id?: string; + name?: string; + required?: boolean; + disabled?: boolean; + error?: string; + hint?: string; +} + +const FOCUSABLE = + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export function TimePicker({ + label, + value, + onChange, + min = '00:00', + max = '23:59', + stepMinutes = 30, + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + locale = 'en-GB', + id, + name, + required, + disabled, + error, + hint, +}: TimePickerProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const dialogId = `${inputId}-dialog`; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(value); + const [typedError, setTypedError] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(0); + const inputRef = useRef(null); + const triggerRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + setDraft(value); + }, [value]); + + const options = useMemo(() => { + const step = Math.max(1, Math.min(60, Math.round(stepMinutes))); + const start = minutesFromTime(min); + const end = minutesFromTime(max); + const times: string[] = []; + for (let minute = 0; minute < 24 * 60; minute += step) { + if (minute >= start && minute <= end) times.push(timeFromMinutes(minute)); + } + // A typed time that isn't on the step grid still needs an option. + if (value && minutesFromTime(value) >= start && minutesFromTime(value) <= end && !times.includes(value)) { + times.push(value); + times.sort((a, b) => minutesFromTime(a) - minutesFromTime(b)); + } + return times; + }, [max, min, stepMinutes, value]); + + const selectedIndex = Math.max(0, options.indexOf(value)); + const selectedLabel = value ? formatTimeLabel(value, locale) : ''; + + const close = useCallback(() => { + setOpen(false); + inputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!open) return; + setFocusedIndex(selectedIndex); + // Wait for the dialog to paint before moving focus into the list. + const frame = requestAnimationFrame(() => { + listRef.current?.querySelector('[role="option"][tabindex="0"]')?.focus(); + }); + return () => cancelAnimationFrame(frame); + // Only on open; arrow keys update the index themselves. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (!open) return; + const option = listRef.current?.querySelector(`[data-index="${focusedIndex}"]`); + option?.focus(); + }, [focusedIndex, open]); + + useEffect(() => { + if (!open) return; + const handle = (event: MouseEvent) => { + const dialog = document.getElementById(dialogId); + if (dialog && !dialog.contains(event.target as Node)) close(); + }; + document.addEventListener('mousedown', handle); + return () => document.removeEventListener('mousedown', handle); + }, [close, dialogId, open]); + + const commitTyped = () => { + const parsed = parseTimeInput(draft); + if (parsed === null) { + setTypedError('Enter a time like 09:30 or 9:30 PM.'); + return; + } + setTypedError(null); + if (parsed !== value) onChange(parsed); + }; + + const choose = (time: string) => { + onChange(time); + setTypedError(null); + setOpen(false); + inputRef.current?.focus(); + }; + + const handleListKeyDown = (event: React.KeyboardEvent) => { + let next = focusedIndex; + switch (event.key) { + case 'ArrowDown': + next = Math.min(options.length - 1, focusedIndex + 1); + break; + case 'ArrowUp': + next = Math.max(0, focusedIndex - 1); + break; + case 'Home': + next = 0; + break; + case 'End': + next = options.length - 1; + break; + case 'PageDown': + next = Math.min(options.length - 1, focusedIndex + 6); + break; + case 'PageUp': + next = Math.max(0, focusedIndex - 6); + break; + case 'Enter': + case ' ': + event.preventDefault(); + if (options[focusedIndex]) choose(options[focusedIndex]); + return; + case 'Escape': + event.preventDefault(); + close(); + return; + default: + return; + } + event.preventDefault(); + setFocusedIndex(next); + }; + + const handleDialogKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Tab') return; + const dialog = event.currentTarget; + const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const describedBy = [hint ? hintId : null, error || typedError ? errorId : null].filter(Boolean).join(' '); + const displayError = error ?? typedError; + const minLabel = formatTimeLabel(min, locale); + const maxLabel = formatTimeLabel(max, locale); + + return ( +
+ +
+ { + const next = event.target.value; + setDraft(next); + setTypedError(null); + const parsed = parseTimeInput(next); + if (parsed !== null && parsed !== value) onChange(parsed); + }} + onBlur={commitTyped} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitTyped(); + } + if (event.key === 'ArrowDown' && event.altKey) { + event.preventDefault(); + setOpen(true); + } + }} + required={required} + disabled={disabled} + aria-invalid={Boolean(displayError)} + aria-describedby={describedBy || undefined} + autoComplete="off" + /> + +
+ {hint && ( +

+ {hint} +

+ )} + {displayError && ( + + )} +

+ {selectedLabel ? `${selectedLabel} · ${timeZone}` : `Times use ${timeZone}`} +

+ + {open && + createPortal( +
event.target === event.currentTarget && close()}> + +
, + document.body, + )} +
+ ); +} diff --git a/src/components/Toast.module.css b/src/components/Toast.module.css new file mode 100644 index 00000000..20c44bf5 --- /dev/null +++ b/src/components/Toast.module.css @@ -0,0 +1,104 @@ +.viewport { + position: fixed; + z-index: 1000; + right: var(--space-4); + bottom: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); + width: min(24rem, calc(100vw - var(--space-8))); + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-strong); + border-left-width: 4px; + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-lg); + color: var(--color-text); + font-size: var(--text-sm); + pointer-events: auto; +} + +.success { + border-left-color: var(--color-success); +} + +.error { + border-left-color: var(--color-danger); + background: var(--color-danger-surface); +} + +.info { + border-left-color: var(--color-accent-2); +} + +.message { + margin: 0; + flex: 1; + overflow-wrap: anywhere; +} + +.actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.action { + min-height: 36px; + padding: 0.25rem 0.65rem; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.action:hover { + background: var(--color-surface-hover); +} + +.dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--color-text-muted); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; +} + +.dismiss:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.action:focus-visible, +.dismiss:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +@media (max-width: 640px) { + .viewport { + right: var(--space-2); + left: var(--space-2); + bottom: var(--space-2); + width: auto; + } +} diff --git a/src/components/Toast.test.tsx b/src/components/Toast.test.tsx new file mode 100644 index 00000000..cbcac774 --- /dev/null +++ b/src/components/Toast.test.tsx @@ -0,0 +1,99 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast, useToastStore } from '../store/toast-store'; +import { Toaster } from './Toast'; + +beforeEach(() => { + useToastStore.getState().clear(); +}); + +afterEach(() => { + vi.useRealTimers(); + useToastStore.getState().clear(); +}); + +describe('Toaster', () => { + it('announces successes politely and errors assertively', () => { + render(); + act(() => { + toast.success('Invitation sent.'); + toast.error('Could not save.'); + }); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('Invitation sent.'); + expect(status).toHaveAttribute('aria-live', 'polite'); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Could not save.'); + expect(alert).toHaveAttribute('aria-live', 'assertive'); + }); + + it('auto-dismisses after the timeout', () => { + vi.useFakeTimers(); + render(); + act(() => { + toast.info('Saved.', { duration: 1000 }); + }); + + expect(screen.getByText('Saved.')).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.queryByText('Saved.')).not.toBeInTheDocument(); + }); + + it('pauses the timeout while hovered and resumes on mouse leave', () => { + vi.useFakeTimers(); + render(); + act(() => { + toast.info('Saved.', { duration: 1000 }); + }); + + const toastNode = screen.getByText('Saved.').closest('[role="status"]') as HTMLElement; + fireEvent.mouseEnter(toastNode); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(screen.getByText('Saved.')).toBeInTheDocument(); + + fireEvent.mouseLeave(toastNode); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.queryByText('Saved.')).not.toBeInTheDocument(); + }); + + it('runs an action and dismisses the toast', () => { + const onClick = vi.fn(); + render(); + act(() => { + toast.success('Message archived.', { action: { label: 'Undo', onClick } }); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Message archived.')).not.toBeInTheDocument(); + }); + + it('can be dismissed with the close button and keeps focus-safe labels', () => { + render(); + act(() => { + toast.error('Upload failed.'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); + expect(screen.queryByText('Upload failed.')).not.toBeInTheDocument(); + }); + + it('caps the viewport at four toasts', () => { + render(); + act(() => { + for (let index = 0; index < 6; index += 1) toast.info(`Toast ${index}`); + }); + + expect(screen.getAllByRole('status')).toHaveLength(4); + expect(screen.getByText('Toast 5')).toBeInTheDocument(); + expect(screen.queryByText('Toast 1')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 00000000..9d64c54d --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,87 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { useToastStore } from '../store/toast-store'; +import type { Toast as ToastData } from '../store/toast-store'; +import styles from './Toast.module.css'; + +const TONE_CLASS = { + success: styles.success, + error: styles.error, + info: styles.info, +}; + +function ToastItem({ toast }: { toast: ToastData }) { + const dismiss = useToastStore((state) => state.dismiss); + const [paused, setPaused] = useState(false); + const titleId = useId(); + const timer = useRef | null>(null); + + useEffect(() => { + if (paused || toast.duration <= 0) return; + timer.current = setTimeout(() => dismiss(toast.id), toast.duration); + return () => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + }; + }, [dismiss, paused, toast.duration, toast.id]); + + // Keep the timer paused while focus moves between the toast's own buttons. + const handleBlur = (event: React.FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setPaused(false); + }; + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={handleBlur} + > +

+ {toast.message} +

+
+ {toast.action && ( + + )} + +
+
+ ); +} + +/** Fixed toast viewport. `aria-live` on the region announces each toast + * without moving focus; errors additionally use `role="alert"`. */ +export function Toaster() { + const toasts = useToastStore((state) => state.toasts); + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} diff --git a/src/components/Upload.module.css b/src/components/Upload.module.css new file mode 100644 index 00000000..81b0290b --- /dev/null +++ b/src/components/Upload.module.css @@ -0,0 +1,203 @@ +.upload { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.dropzone { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-6); + border: 2px dashed var(--color-border-strong); + border-radius: var(--radius-lg); + background: var(--color-surface); + text-align: center; +} + +.dragging { + border-color: var(--color-accent); + background: var(--color-accent-subtle); +} + +.disabledZone { + opacity: 0.6; +} + +.dropText { + margin: 0; + color: var(--color-text-muted); +} + +.buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-2); +} + +.choose, +.chooseSecondary { + min-height: 44px; + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.choose { + border: 1px solid var(--color-accent); + background: var(--color-accent); + color: var(--color-accent-contrast); +} + +.chooseSecondary { + border: 1px solid var(--color-border-strong); + background: transparent; + color: var(--color-text); +} + +.choose:hover:not(:disabled), +.chooseSecondary:hover:not(:disabled) { + filter: brightness(0.96); +} + +.choose:focus-visible, +.chooseSecondary:focus-visible, +.action:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.choose:disabled, +.chooseSecondary:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.input { + display: none; +} + +.hint { + margin: 0; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.live { + margin: 0; + min-height: 1px; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.item { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.preview { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: var(--radius-sm); + flex: none; +} + +.fileIcon { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: var(--color-accent-subtle); + color: var(--color-text-muted); + font-size: var(--text-xs); + font-weight: 700; + flex: none; +} + +.details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.name { + margin: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + margin: 0; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.progress { + width: 100%; + height: 8px; + accent-color: var(--color-accent); +} + +.error { + margin: 0; + font-size: var(--text-xs); + color: var(--color-danger); +} + +.actions { + display: flex; + gap: var(--space-1); + flex: none; +} + +.action { + min-height: 36px; + padding: 0.25rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font: inherit; + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; +} + +.action:hover { + background: var(--color-surface-hover); +} + +@media (max-width: 480px) { + .item { + flex-wrap: wrap; + } + .actions { + width: 100%; + justify-content: flex-end; + } +} diff --git a/src/components/Upload.stories.tsx b/src/components/Upload.stories.tsx new file mode 100644 index 00000000..e37a2a50 --- /dev/null +++ b/src/components/Upload.stories.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import { Upload } from './Upload'; +import type { UploadFileFn, UploadProps } from './Upload'; + +type Story = { + name?: string; + args: UploadProps; + render: (args: UploadProps) => ReactElement; +}; + +const meta = { + title: 'Components/Upload', + component: Upload, + parameters: { layout: 'padded' }, +}; + +export default meta; + +// Simulates a throttled connection so progress, cancel and retry can be +// exercised without a backend. +const demoUpload: UploadFileFn = (file, { onProgress, signal }) => + new Promise((resolve, reject) => { + let loaded = 0; + const timer = window.setInterval(() => { + loaded = Math.min(100, loaded + 8); + onProgress({ loaded, total: 100, percent: loaded }); + if (loaded === 100) { + window.clearInterval(timer); + resolve({ id: 'demo-file', url: URL.createObjectURL(file) }); + } + }, 250); + signal.addEventListener('abort', () => { + window.clearInterval(timer); + reject(new DOMException('Upload cancelled', 'AbortError')); + }); + }); + +const baseArgs: UploadProps = { + label: 'Lab result upload', + hint: 'PDF or a photo of the result. Patient identifiers are never shown in previews.', + accept: 'image/*,application/pdf', + maxBytes: 10 * 1024 * 1024, + uploadFile: demoUpload, +}; + +export const Default: Story = { + args: baseArgs, + render: (args) => , +}; + +export const ImagesOnly: Story = { + name: 'Images only (camera capture)', + args: { ...baseArgs, label: 'Fulfilment proof', accept: 'image/*' }, + render: (args) => , +}; + +export const SingleFile: Story = { + name: 'Single file', + args: { ...baseArgs, label: 'Credential upload', multiple: false }, + render: (args) => , +}; + +export const Disabled: Story = { + args: { ...baseArgs, disabled: true }, + render: (args) => , +}; diff --git a/src/components/Upload.test.tsx b/src/components/Upload.test.tsx new file mode 100644 index 00000000..63a2a422 --- /dev/null +++ b/src/components/Upload.test.tsx @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from '../test/render'; +import { Upload } from './Upload'; +import type { UploadHelpers, UploadResult } from './Upload'; + +const PNG_HEAD = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]; + +function pngFile(name = 'scan.png', extraBytes = 32): File { + return new File([new Uint8Array([...PNG_HEAD, ...new Array(extraBytes).fill(1)])], name, { + type: 'image/png', + }); +} + +function controlledUpload() { + const calls: { + file: File; + helpers: UploadHelpers; + resolve: (result: UploadResult) => void; + reject: (error: unknown) => void; + }[] = []; + + const uploadFile = vi.fn((file: File, helpers: UploadHelpers) => { + return new Promise((resolve, reject) => { + helpers.signal.addEventListener('abort', () => + reject(new DOMException('Upload cancelled', 'AbortError')), + ); + calls.push({ file, helpers, resolve, reject }); + }); + }); + + return { uploadFile, calls }; +} + +describe('Upload', () => { + it('uploads a valid image and reports success', async () => { + const user = userEvent.setup(); + const uploadFile = vi.fn().mockResolvedValue({ id: 'file-1', url: 'https://cdn.test/scan.png' }); + const onUploaded = vi.fn(); + + render(); + await user.upload(screen.getByTestId('upload-input'), pngFile()); + + expect(await screen.findByText('Uploaded')).toBeInTheDocument(); + expect(uploadFile).toHaveBeenCalledTimes(1); + expect(onUploaded).toHaveBeenCalledWith(expect.any(File), { + id: 'file-1', + url: 'https://cdn.test/scan.png', + }); + expect(within(screen.getByRole('list', { name: 'Selected files' })).getByText('scan.png')).toBeInTheDocument(); + }); + + it('announces progress and exposes it as a progressbar', async () => { + const user = userEvent.setup(); + const { uploadFile, calls } = controlledUpload(); + + render(); + await user.upload(screen.getByTestId('upload-input'), pngFile()); + + await waitFor(() => expect(calls).toHaveLength(1)); + calls[0].helpers.onProgress({ loaded: 45, total: 100, percent: 45 }); + + const progress = await screen.findByRole('progressbar', { name: /upload progress for scan\.png/i }); + expect(progress).toHaveAttribute('value', '45'); + expect(screen.getByText(/Uploading 45%/)).toBeInTheDocument(); + + calls[0].resolve({ id: 'file-1' }); + expect(await screen.findByText('Uploaded')).toBeInTheDocument(); + }); + + it('cancels an in-flight upload and can retry it', async () => { + const user = userEvent.setup(); + const { uploadFile, calls } = controlledUpload(); + + render(); + await user.upload(screen.getByTestId('upload-input'), pngFile()); + + await waitFor(() => expect(calls).toHaveLength(1)); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(calls[0].helpers.signal.aborted).toBe(true); + expect(await screen.findByText('Cancelled')).toBeInTheDocument(); + + const retry = screen.getByRole('button', { name: 'Retry' }); + await user.click(retry); + await waitFor(() => expect(calls).toHaveLength(2)); + + calls[1].resolve({ id: 'file-2' }); + expect(await screen.findByText('Uploaded')).toBeInTheDocument(); + expect(uploadFile).toHaveBeenCalledTimes(2); + }); + + it('rejects a file whose bytes do not match its claimed image type', async () => { + const user = userEvent.setup(); + const uploadFile = vi.fn().mockResolvedValue({ id: 'file-1' }); + const spoofed = new File([new TextEncoder().encode('this is not a png')], 'evil.png', { + type: 'image/png', + }); + + render(); + await user.upload(screen.getByTestId('upload-input'), spoofed); + + expect(await screen.findByRole('alert')).toHaveTextContent(/file type is not accepted/i); + expect(uploadFile).not.toHaveBeenCalled(); + }); + + it('rejects files larger than maxBytes before reading them', async () => { + const user = userEvent.setup(); + const file = new File([new Uint8Array(2048)], 'big.pdf', { type: 'application/pdf' }); + + render(); + await user.upload(screen.getByTestId('upload-input'), file); + + expect(await screen.findByRole('alert')).toHaveTextContent(/larger than 1\.0 KB/i); + }); + + it('accepts text formats matched by extension', async () => { + const user = userEvent.setup(); + const uploadFile = vi.fn().mockResolvedValue({ id: 'file-1' }); + const csv = new File(['name,value\nana,1'], 'results.csv', { type: 'text/csv' }); + + render(); + await user.upload(screen.getByTestId('upload-input'), csv); + + expect(await screen.findByText('Uploaded')).toBeInTheDocument(); + expect(uploadFile).toHaveBeenCalledWith(csv, expect.anything()); + }); + + it('downscales images before uploading them', async () => { + const user = userEvent.setup(); + const uploadFile = vi.fn().mockResolvedValue({ id: 'file-1' }); + const resized = pngFile('scan-resized.png'); + const downscale = vi.fn().mockResolvedValue(resized); + + render(); + await user.upload(screen.getByTestId('upload-input'), pngFile()); + + await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(1)); + expect(downscale).toHaveBeenCalledWith(expect.any(File), {}); + expect(uploadFile.mock.calls[0][0]).toBe(resized); + }); + + it('exposes a mobile camera input with environment capture', () => { + render(); + const camera = screen.getByTestId('upload-camera-input'); + expect(camera).toHaveAttribute('capture', 'environment'); + expect(camera).toHaveAttribute('accept', 'image/*'); + expect(screen.getByRole('button', { name: 'Take photo' })).toBeInTheDocument(); + }); + + it('keeps only the first file when multiple is false', async () => { + const user = userEvent.setup(); + const uploadFile = vi.fn().mockResolvedValue({ id: 'file-1' }); + + render(); + await user.upload(screen.getByTestId('upload-input'), [pngFile('a.png'), pngFile('b.png')]); + + await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(1)); + expect(screen.queryByText(/b\.png/)).not.toBeInTheDocument(); + }); + + it('removes a file from the list and revokes its preview', async () => { + const user = userEvent.setup(); + const revoke = vi.spyOn(URL, 'revokeObjectURL'); + const onRemove = vi.fn(); + + render(); + await user.upload(screen.getByTestId('upload-input'), pngFile()); + await screen.findByText('Uploaded'); + + await user.click(screen.getByRole('button', { name: /remove scan\.png/i })); + + expect(screen.queryByRole('list', { name: 'Selected files' })).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledTimes(1); + expect(revoke).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Upload.tsx b/src/components/Upload.tsx new file mode 100644 index 00000000..7daf059b --- /dev/null +++ b/src/components/Upload.tsx @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import type { ChangeEvent, DragEvent } from 'react'; +import { apiUploadFile } from '../services/api-client'; +import type { UploadProgress } from '../services/api-client'; +import { downscaleImage } from './image-downscale'; +import type { DownscaleOptions } from './image-downscale'; +import { formatBytes, validateFile } from './upload-utils'; +import styles from './Upload.module.css'; + +export interface UploadResult { + id?: string; + url?: string; + [key: string]: unknown; +} + +export interface UploadHelpers { + onProgress: (progress: UploadProgress) => void; + signal: AbortSignal; +} + +export type UploadFileFn = (file: File, helpers: UploadHelpers) => Promise; + +export interface UploadProps { + /** POST target on the core API. Assumed contract: multipart with `file`. */ + endpoint?: string; + fieldName?: string; + fields?: Record; + accessToken?: string | null; + uploadFile?: UploadFileFn; + label?: string; + hint?: string; + accept?: string; + maxBytes?: number; + multiple?: boolean; + allowCamera?: boolean; + disabled?: boolean; + downscale?: (file: File, options: DownscaleOptions) => Promise; + downscaleOptions?: DownscaleOptions; + onUploaded?: (file: File, result: UploadResult) => void; + onRemove?: (itemId: string) => void; +} + +type ItemStatus = 'validating' | 'ready' | 'uploading' | 'success' | 'error' | 'cancelled'; + +interface UploadItem { + id: string; + file: File; + previewUrl?: string; + status: ItemStatus; + progress: number; + error?: string; + result?: UploadResult; +} + +const DEFAULT_ACCEPT = '.jpg,.jpeg,.png,.webp,.gif,.pdf,image/*,application/pdf'; +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; + +let fallbackId = 0; +function nextId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); + fallbackId += 1; + return `upload-${fallbackId}`; +} + +export function Upload({ + endpoint = '/uploads', + fieldName = 'file', + fields, + accessToken, + uploadFile, + label = 'Upload files', + hint, + accept = DEFAULT_ACCEPT, + maxBytes = DEFAULT_MAX_BYTES, + multiple = true, + allowCamera = true, + disabled = false, + downscale = downscaleImage, + downscaleOptions, + onUploaded, + onRemove, +}: UploadProps) { + const [items, setItems] = useState([]); + const [dragging, setDragging] = useState(false); + const [announcement, setAnnouncement] = useState(''); + + const inputId = useId(); + const hintId = `${inputId}-hint`; + const fileInputRef = useRef(null); + const cameraInputRef = useRef(null); + const controllers = useRef(new Map()); + const itemsRef = useRef(items); + const dragDepth = useRef(0); + + itemsRef.current = items; + + useEffect( + () => () => { + for (const controller of controllers.current.values()) controller.abort(); + for (const item of itemsRef.current) { + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); + } + }, + [], + ); + + const updateItem = useCallback((id: string, patch: Partial) => { + setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item))); + }, []); + + const beginUpload = useCallback( + async (id: string, file: File) => { + const controller = new AbortController(); + controllers.current.set(id, controller); + updateItem(id, { status: 'uploading', progress: 0, error: undefined }); + + const send = + uploadFile ?? + ((uploading: File, helpers: UploadHelpers) => + apiUploadFile(endpoint, uploading, { + fieldName, + fields, + accessToken, + signal: helpers.signal, + onProgress: helpers.onProgress, + })); + + try { + const result = await send(file, { + signal: controller.signal, + onProgress: (progress) => + setItems((current) => + current.map((item) => + item.id === id && progress.percent - item.progress >= 5 + ? { ...item, progress: progress.percent } + : item, + ), + ), + }); + controllers.current.delete(id); + updateItem(id, { status: 'success', progress: 100, result }); + setAnnouncement(`${file.name} uploaded.`); + onUploaded?.(file, result); + } catch (error) { + controllers.current.delete(id); + if (controller.signal.aborted) { + updateItem(id, { status: 'cancelled', progress: 0 }); + setAnnouncement(`${file.name} upload cancelled.`); + } else { + const message = error instanceof Error ? error.message : 'Upload failed.'; + updateItem(id, { status: 'error', error: message }); + setAnnouncement(`${file.name} failed to upload: ${message}`); + } + } + }, + [accessToken, endpoint, fieldName, fields, onUploaded, updateItem, uploadFile], + ); + + const addFiles = useCallback( + async (incoming: File[]) => { + if (disabled || incoming.length === 0) return; + const selected = multiple ? incoming : incoming.slice(0, 1); + if (!multiple) { + for (const controller of controllers.current.values()) controller.abort(); + controllers.current.clear(); + for (const item of itemsRef.current) { + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); + } + setItems([]); + } + + setAnnouncement(selected.length === 1 ? 'Checking 1 file.' : `Checking ${selected.length} files.`); + + for (const file of selected) { + const id = nextId(); + const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined; + setItems((current) => [ + ...current, + { id, file, previewUrl, status: 'validating', progress: 0 }, + ]); + + const validation = await validateFile(file, { accept, maxBytes }); + if (!validation.ok) { + updateItem(id, { status: 'error', error: validation.error }); + setAnnouncement(`${file.name}: ${validation.error}`); + continue; + } + + let processed = file; + if (file.type.startsWith('image/')) { + try { + processed = await downscale(file, downscaleOptions ?? {}); + } catch { + processed = file; + } + if (processed.size > maxBytes) { + updateItem(id, { + status: 'error', + error: `The file is larger than ${formatBytes(maxBytes)} even after resizing.`, + }); + continue; + } + } + + updateItem(id, { file: processed, status: 'ready' }); + void beginUpload(id, processed); + } + }, + [accept, beginUpload, disabled, downscale, downscaleOptions, maxBytes, multiple, updateItem], + ); + + const handleInput = (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ''; + void addFiles(files); + }; + + const handleDragEnter = (event: DragEvent) => { + event.preventDefault(); + if (disabled) return; + dragDepth.current += 1; + setDragging(true); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setDragging(false); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + dragDepth.current = 0; + setDragging(false); + if (disabled) return; + void addFiles(Array.from(event.dataTransfer?.files ?? [])); + }; + + const cancel = (item: UploadItem) => { + controllers.current.get(item.id)?.abort(); + updateItem(item.id, { status: 'cancelled', progress: 0 }); + setAnnouncement(`${item.file.name} upload cancelled.`); + }; + + const remove = (item: UploadItem) => { + controllers.current.get(item.id)?.abort(); + controllers.current.delete(item.id); + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); + setItems((current) => current.filter((entry) => entry.id !== item.id)); + onRemove?.(item.id); + }; + + const retry = (item: UploadItem) => { + void beginUpload(item.id, item.file); + }; + + const describedBy = hint ? hintId : undefined; + + return ( +
+
event.preventDefault()} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > +

+ Drag and drop files here, or +

+
+ + {allowCamera && accept.includes('image') && ( + + )} +
+ + {allowCamera && accept.includes('image') && ( + + )} + {hint && ( +

+ {hint} +

+ )} +

+ Up to {formatBytes(maxBytes)} per file. Images are resized before upload. +

+
+ +

+ {announcement} +

+ + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + {item.previewUrl ? ( + + ) : ( + + )} +
    +

    + {item.file.name} +

    +

    + {formatBytes(item.file.size)} · {statusLabel(item)} +

    + {item.status === 'uploading' && ( + + )} + {item.error && ( +

    + {item.error} +

    + )} +
    +
    + {item.status === 'uploading' && ( + + )} + {(item.status === 'error' || item.status === 'cancelled') && ( + + )} + +
    +
  • + ))} +
+ )} +
+ ); +} + +function statusLabel(item: UploadItem): string { + switch (item.status) { + case 'validating': + return 'Checking…'; + case 'ready': + return 'Ready'; + case 'uploading': + return `Uploading ${item.progress}%`; + case 'success': + return 'Uploaded'; + case 'cancelled': + return 'Cancelled'; + case 'error': + return 'Failed'; + } +} diff --git a/src/components/date-utils.ts b/src/components/date-utils.ts new file mode 100644 index 00000000..4628fb25 --- /dev/null +++ b/src/components/date-utils.ts @@ -0,0 +1,188 @@ +// Calendar dates are plain `YYYY-MM-DD` strings — never JS Date instants — +// so a date never shifts when the browser or the configured zone differs. +// `timeZone` only decides what "today" is. + +export const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +export const TIME_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)$/; + +export function isValidIsoDate(value: string): boolean { + if (!ISO_DATE_PATTERN.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); +} + +export function isoDateFromParts(year: number, month: number, day: number): string { + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day + .toString() + .padStart(2, '0')}`; +} + +export function dateParts(value: string): { year: number; month: number; day: number } { + const [year, month, day] = value.split('-').map(Number); + return { year, month, day }; +} + +export function addDays(value: string, amount: number): string { + const { year, month, day } = dateParts(value); + const date = new Date(Date.UTC(year, month - 1, day + amount)); + return isoDateFromParts(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); +} + +export function addMonths(value: string, amount: number): string { + const { year, month, day } = dateParts(value); + const target = new Date(Date.UTC(year, month - 1 + amount, 1)); + const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate(); + return isoDateFromParts( + target.getUTCFullYear(), + target.getUTCMonth() + 1, + Math.min(day, lastDay), + ); +} + +/** Today's calendar date in an explicit IANA time zone. */ +export function todayInTimeZone(timeZone: string, now = new Date()): string { + try { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value; + return `${get('year')}-${get('month')}-${get('day')}`; + } catch { + // Invalid zone in older runtimes: fall back to the local calendar day. + return isoDateFromParts(now.getFullYear(), now.getMonth() + 1, now.getDate()); + } +} + +function numericDateOrder(locale: string): ('year' | 'month' | 'day')[] { + const parts = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }).formatToParts(new Date(2001, 10, 22)); + return parts + .filter((part) => part.type === 'year' || part.type === 'month' || part.type === 'day') + .map((part) => part.type as 'year' | 'month' | 'day'); +} + +/** + * Parse typed manual entry. ISO (`2026-10-19`) always works; otherwise the + * locale's numeric order is used (`10/19/2026` for en-US, `19.10.2026` for + * de-DE). Month names are intentionally not guessed. + */ +export function parseDateInput(text: string, locale: string): string | null { + const value = text.trim(); + if (!value) return ''; + if (ISO_DATE_PATTERN.test(value)) return isValidIsoDate(value) ? value : null; + + const numbers = value.split(/[^\d]+/).filter(Boolean).map(Number); + if (numbers.length !== 3 || numbers.some((number) => !Number.isFinite(number))) return null; + + const order = numericDateOrder(locale); + const parts: Record<'year' | 'month' | 'day', number> = { year: 0, month: 0, day: 0 }; + order.forEach((key, index) => { + parts[key] = numbers[index]; + }); + + // Two-digit years are ambiguous; require four digits for the year. + if (String(parts.year).length < 4) return null; + const iso = isoDateFromParts(parts.year, parts.month, parts.day); + return isValidIsoDate(iso) ? iso : null; +} + +export function formatDateLabel(value: string, locale: string): string { + if (!isValidIsoDate(value)) return ''; + const { year, month, day } = dateParts(value); + return new Intl.DateTimeFormat(locale, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }).format(new Date(Date.UTC(year, month - 1, day, 12))); +} + +export function formatMonthLabel(value: string, locale: string): string { + const { year, month } = dateParts(value); + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + timeZone: 'UTC', + }).format(new Date(Date.UTC(year, month - 1, 1, 12))); +} + +export function weekdayLabels(locale: string, firstDay: number): { narrow: string; long: string }[] { + return Array.from({ length: 7 }, (_, index) => { + // 2023-01-01 was a Sunday; offset from it to each weekday. + const date = new Date(Date.UTC(2023, 0, 1 + ((firstDay + index) % 7), 12)); + return { + narrow: new Intl.DateTimeFormat(locale, { weekday: 'narrow', timeZone: 'UTC' }).format(date), + long: new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(date), + }; + }); +} + +/** WAI-ARIA/CLDR first day of week (0 = Sunday), with a safe fallback. */ +export function firstDayOfWeek(locale: string): number { + try { + const info = (new Intl.Locale(locale) as Intl.Locale & { weekInfo?: { firstDay: number } }).weekInfo; + if (info?.firstDay) return info.firstDay % 7; + } catch { + // Intl.Locale unavailable — fall through. + } + return /^(en-US|en-CA|ja|ko|zh)/.test(locale) ? 0 : 1; +} + +export function formatTypedDateExample(locale: string): string { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + }).format(new Date(Date.UTC(2001, 10, 22, 12))); +} + +export function parseTimeInput(text: string): string | null { + const value = text.trim(); + if (!value) return ''; + const match = /^(\d{1,2}):(\d{2})\s*([ap]m)?$/i.exec(value); + if (!match) return null; + let hours = Number(match[1]); + const minutes = Number(match[2]); + const meridiem = match[3]?.toLowerCase(); + if (meridiem) { + if (hours < 1 || hours > 12) return null; + if (meridiem === 'pm' && hours !== 12) hours += 12; + if (meridiem === 'am' && hours === 12) hours = 0; + } + if (hours > 23 || minutes > 59) return null; + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; +} + +export function formatTimeLabel(value: string, locale: string): string { + const [hours, minutes] = value.split(':').map(Number); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return ''; + return new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + }).format(new Date(Date.UTC(2001, 0, 1, hours, minutes, 12))); +} + +export function minutesFromTime(value: string): number { + const [hours, minutes] = value.split(':').map(Number); + return hours * 60 + minutes; +} + +export function timeFromMinutes(total: number): string { + const hours = Math.floor(total / 60); + const minutes = total % 60; + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; +} diff --git a/src/components/image-downscale.test.ts b/src/components/image-downscale.test.ts new file mode 100644 index 00000000..e93c4694 --- /dev/null +++ b/src/components/image-downscale.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { downscaleImage } from './image-downscale'; + +function imageFile(name = 'photo.jpg', type = 'image/jpeg'): File { + return new File(['image-bytes'], name, { type }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('downscaleImage', () => { + it('returns non-image and animated/SVG files untouched', async () => { + const pdf = new File(['%PDF-1.4'], 'scan.pdf', { type: 'application/pdf' }); + const gif = imageFile('anim.gif', 'image/gif'); + const svg = new File([''], 'icon.svg', { type: 'image/svg+xml' }); + + await expect(downscaleImage(pdf)).resolves.toBe(pdf); + await expect(downscaleImage(gif)).resolves.toBe(gif); + await expect(downscaleImage(svg)).resolves.toBe(svg); + }); + + it('leaves images that already fit within maxDimension alone', async () => { + const close = vi.fn(); + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 800, height: 600, close })); + const file = imageFile(); + + await expect(downscaleImage(file, { maxDimension: 1600 })).resolves.toBe(file); + expect(close).toHaveBeenCalled(); + }); + + it('draws larger images to a canvas and re-encodes with the quality setting', async () => { + const close = vi.fn(); + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 3200, height: 1600, close })); + const drawImage = vi.fn(); + const toBlob = vi.fn((callback: (blob: Blob | null) => void, type: string, quality: number) => { + callback(new Blob(['small'], { type })); + expect(type).toBe('image/jpeg'); + expect(quality).toBe(0.6); + }); + const canvas = { + width: 0, + height: 0, + getContext: () => ({ drawImage }), + toBlob, + }; + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return canvas as unknown as HTMLElement; + return originalCreateElement(tag); + }); + + const result = await downscaleImage(imageFile('holiday.JPG'), { maxDimension: 1600, quality: 0.6 }); + + expect(canvas.width).toBe(1600); + expect(canvas.height).toBe(800); + expect(drawImage).toHaveBeenCalled(); + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('holiday.jpg'); + expect(result.type).toBe('image/jpeg'); + }); +}); diff --git a/src/components/image-downscale.ts b/src/components/image-downscale.ts new file mode 100644 index 00000000..e2b6b92a --- /dev/null +++ b/src/components/image-downscale.ts @@ -0,0 +1,62 @@ +import { replaceExtension } from './upload-utils'; + +export interface DownscaleOptions { + /** Longest edge in pixels. Images already smaller are returned untouched. */ + maxDimension?: number; + /** Encoder quality 0–1 for JPEG/WebP output. */ + quality?: number; +} + +const DEFAULT_MAX_DIMENSION = 1600; +const DEFAULT_QUALITY = 0.8; +const SKIP_TYPES = new Set(['image/gif', 'image/svg+xml']); + +export async function downscaleImage(file: File, options: DownscaleOptions = {}): Promise { + if (!file.type.startsWith('image/') || SKIP_TYPES.has(file.type)) return file; + if (typeof document === 'undefined' || typeof createImageBitmap !== 'function') return file; + + const maxDimension = options.maxDimension ?? DEFAULT_MAX_DIMENSION; + const quality = Math.min(1, Math.max(0.1, options.quality ?? DEFAULT_QUALITY)); + + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + return file; + } + + try { + const longest = Math.max(bitmap.width, bitmap.height); + if (longest <= maxDimension) return file; + + const scale = maxDimension / longest; + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) return file; + context.drawImage(bitmap, 0, 0, width, height); + + const outputType = outputMimeType(file.type); + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, outputType, quality); + }); + if (!blob) return file; + + return new File([blob], replaceExtension(file.name, outputType), { + type: outputType, + lastModified: Date.now(), + }); + } finally { + bitmap.close?.(); + } +} + +function outputMimeType(input: string): string { + if (input === 'image/png') return 'image/png'; + if (input === 'image/webp') return 'image/webp'; + return 'image/jpeg'; +} diff --git a/src/components/upload-utils.ts b/src/components/upload-utils.ts new file mode 100644 index 00000000..0d7429ab --- /dev/null +++ b/src/components/upload-utils.ts @@ -0,0 +1,162 @@ +export interface FileSignature { + mimeType: string; + bytes: number[]; + offset?: number; + extra?: { offset: number; bytes: number[] }; +} + +// Only formats the app uploads are sniffed. The declared `File.type` is +// attacker-controlled; the bytes are what the API will actually receive. +export const FILE_SIGNATURES: FileSignature[] = [ + { mimeType: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] }, + { mimeType: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, + { mimeType: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] }, + { + mimeType: 'image/webp', + bytes: [0x52, 0x49, 0x46, 0x46], + extra: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] }, + }, + { mimeType: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46, 0x2d] }, + { mimeType: 'application/zip', bytes: [0x50, 0x4b, 0x03, 0x04] }, +]; + +const EXTENSION_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf', + zip: 'application/zip', + docx: 'application/zip', + xlsx: 'application/zip', + txt: 'text/plain', + csv: 'text/csv', + json: 'application/json', +}; + +const TEXT_TYPES = new Set([ + 'text/plain', + 'text/csv', + 'application/json', + 'application/xml', + 'text/xml', +]); + +export function extensionOf(name: string): string { + const index = name.lastIndexOf('.'); + return index === -1 ? '' : name.slice(index + 1).toLowerCase(); +} + +export function mimeTypeForName(name: string): string | null { + return EXTENSION_TYPES[extensionOf(name)] ?? null; +} + +export function matchesAccept(mimeType: string, accept: string): boolean { + const tokens = accept + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter(Boolean); + if (tokens.length === 0 || tokens.includes('*/*') || tokens.includes('*')) return true; + + return tokens.some((token) => { + if (token.startsWith('.')) { + return EXTENSION_TYPES[token.slice(1)] === mimeType; + } + if (token.endsWith('/*')) return mimeType.startsWith(token.slice(0, -1)); + return token === mimeType; + }); +} + +/** Read the magic bytes and return the MIME type the bytes actually are. */ +export async function sniffMimeType(file: Blob): Promise { + const head = new Uint8Array(await file.slice(0, 32).arrayBuffer()); + + for (const signature of FILE_SIGNATURES) { + const offset = signature.offset ?? 0; + const matches = signature.bytes.every((byte, index) => head[offset + index] === byte); + if (!matches) continue; + if (signature.extra) { + const extraMatches = signature.extra.bytes.every( + (byte, index) => head[signature.extra!.offset + index] === byte, + ); + if (!extraMatches) continue; + } + return signature.mimeType; + } + + // Text formats have no magic number; accept them only when every byte is + // printable UTF-8-ish (no NUL/control bytes). The declared type still has + // to be one of the text types we know. + if ( + head.length > 0 && + !head.some((byte) => byte === 0 || (byte < 9 && byte !== 0) || (byte > 13 && byte < 32)) + ) { + return 'text/plain'; + } + return null; +} + +export interface ValidationOptions { + accept: string; + maxBytes: number; +} + +export type FileValidation = + | { ok: true; mimeType: string } + | { ok: false; error: string; reason: 'empty' | 'size' | 'type' }; + +export async function validateFile(file: File, options: ValidationOptions): Promise { + if (file.size === 0) { + return { ok: false, error: 'The file is empty.', reason: 'empty' }; + } + if (file.size > options.maxBytes) { + return { + ok: false, + error: `The file is larger than ${formatBytes(options.maxBytes)}.`, + reason: 'size', + }; + } + + const sniffed = await sniffMimeType(file); + const actual = + sniffed === 'text/plain' && file.type && TEXT_TYPES.has(file.type) ? file.type : (sniffed ?? null); + + if (!actual) { + return { + ok: false, + error: 'This file type is not supported. Choose an image or PDF.', + reason: 'type', + }; + } + if (!matchesAccept(actual, options.accept)) { + const expected = options.accept.split(',').map((token) => token.trim()).join(', '); + return { + ok: false, + error: `This file type is not accepted (${expected}).`, + reason: 'type', + }; + } + + return { ok: true, mimeType: actual }; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`; +} + +export function replaceExtension(name: string, mimeType: string): string { + const extension = + mimeType === 'image/jpeg' ? 'jpg' : mimeType === 'image/webp' ? 'webp' : mimeType === 'image/png' ? 'png' : ''; + const base = name.replace(/\.[^./\\]+$/, ''); + return extension ? `${base}.${extension}` : base; +} diff --git a/src/features/admin-audit/components/AuditFilterForm.tsx b/src/features/admin-audit/components/AuditFilterForm.tsx index 1c929182..5d628a06 100644 --- a/src/features/admin-audit/components/AuditFilterForm.tsx +++ b/src/features/admin-audit/components/AuditFilterForm.tsx @@ -2,6 +2,7 @@ import { useId, useState } from 'react'; import type { FormEvent } from 'react'; import { Button } from '../../../components/Button'; import { TextField } from '../../../components/TextField'; +import { DatePicker } from '../../../components/DatePicker'; import { checkFilters, FIELD_LABELS } from '../filters'; import type { AuditTab, FilterDraft, FilterErrors } from '../filters'; import styles from './AuditLogViewer.module.css'; @@ -57,8 +58,20 @@ export function AuditFilterForm({ tab, initial, canClear, onApply, onClear }: Au {tab === 'events' && ( )} - - + setDraft((current) => ({ ...current, from: value }))} + max={draft.to || undefined} + error={errors.from} + /> + setDraft((current) => ({ ...current, to: value }))} + min={draft.from || undefined} + error={errors.to} + /> diff --git a/src/features/admin-audit/components/AuditLogViewer.test.tsx b/src/features/admin-audit/components/AuditLogViewer.test.tsx index c6200f6b..e9ed9f20 100644 --- a/src/features/admin-audit/components/AuditLogViewer.test.tsx +++ b/src/features/admin-audit/components/AuditLogViewer.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import App from '../../../App'; +import { renderRoute } from '../../../test/render'; import { AuditLogViewer } from './AuditLogViewer'; import { ApiError } from '../../../services/api-client'; import * as auditService from '../../../services/audit-service'; @@ -329,15 +329,7 @@ describe('AuditLogViewer — rows', () => { describe('/admin/audit route', () => { function renderApp() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - , - ); + return renderRoute('/admin/audit'); } it('is reachable by admins', async () => { diff --git a/src/features/admin-disputes/AdminDisputesRoute.test.tsx b/src/features/admin-disputes/AdminDisputesRoute.test.tsx index 7879329c..e6ac7b34 100644 --- a/src/features/admin-disputes/AdminDisputesRoute.test.tsx +++ b/src/features/admin-disputes/AdminDisputesRoute.test.tsx @@ -1,8 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import App from '../../App'; +import { renderRoute, screen } from '../../test/render'; import * as disputesService from '../../services/disputes-service'; import { useAuthStore } from '../../store/auth-store'; import type { Role } from '../../types/models'; @@ -17,16 +14,7 @@ function signIn(role: Role) { } function renderAt(path: string) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return render( - - - - - , - ); + return renderRoute(path); } describe('admin dispute routes', () => { diff --git a/src/features/admin-flags/components/FeatureFlagsConsole.test.tsx b/src/features/admin-flags/components/FeatureFlagsConsole.test.tsx index 7a830ca2..df570118 100644 --- a/src/features/admin-flags/components/FeatureFlagsConsole.test.tsx +++ b/src/features/admin-flags/components/FeatureFlagsConsole.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import App from '../../../App'; +import { renderRoute } from '../../../test/render'; import { FeatureFlagsConsole } from './FeatureFlagsConsole'; import { FLAGS_KEY } from '../hooks/useFeatureFlags'; import { ApiError } from '../../../services/api-client'; @@ -544,15 +544,7 @@ describe('FeatureFlagsConsole — editing', () => { describe('/admin/flags route', () => { function renderApp() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - , - ); + return renderRoute('/admin/flags'); } it('is reachable by admins', async () => { diff --git a/src/features/admin-referrals/components/StuckReferralsConsole.test.tsx b/src/features/admin-referrals/components/StuckReferralsConsole.test.tsx index 34af0bf0..7e24a1f2 100644 --- a/src/features/admin-referrals/components/StuckReferralsConsole.test.tsx +++ b/src/features/admin-referrals/components/StuckReferralsConsole.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import App from '../../../App'; +import { renderRoute } from '../../../test/render'; import { StuckReferralsConsole } from './StuckReferralsConsole'; import { ApiError } from '../../../services/api-client'; import * as referralsService from '../../../services/admin-referrals-service'; @@ -485,15 +485,7 @@ describe('StuckReferralsConsole — cancel', () => { describe('/admin/referrals route', () => { function renderApp() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - , - ); + return renderRoute('/admin/referrals'); } it('is reachable by admins', async () => { diff --git a/src/features/admin-system/components/SystemHealthConsole.test.tsx b/src/features/admin-system/components/SystemHealthConsole.test.tsx index dfcb1e9b..576aad0b 100644 --- a/src/features/admin-system/components/SystemHealthConsole.test.tsx +++ b/src/features/admin-system/components/SystemHealthConsole.test.tsx @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter, useLocation } from 'react-router-dom'; -import App from '../../../App'; +import { MemoryRouter } from 'react-router-dom'; +import { renderRoute } from '../../../test/render'; import { SystemHealthConsole } from './SystemHealthConsole'; import { ApiError } from '../../../services/api-client'; import * as systemService from '../../../services/system-health-service'; @@ -117,11 +117,6 @@ const ALL_JOBS = [HEALTHY, FAILING, OVERDUE, RUNNING, NEVER_RUN]; const OUTBOX: OutboxSummary = { pendingCount: 120, deadLetterCount: 0, oldestPendingAt: ago(3 * MINUTE) }; -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - function signIn(role: Role) { useAuthStore.setState({ accessToken: `${role}-token`, @@ -541,15 +536,7 @@ describe('SystemHealthConsole — auto-refresh', () => { describe('/admin/system route', () => { function renderApp() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - , - ); + return renderRoute('/admin/system'); } it('is reachable by admins and linked from the admin nav', async () => { diff --git a/src/features/admin-verification/components/SpecialtyClaimsConsole.test.tsx b/src/features/admin-verification/components/SpecialtyClaimsConsole.test.tsx index 6d670389..4989d652 100644 --- a/src/features/admin-verification/components/SpecialtyClaimsConsole.test.tsx +++ b/src/features/admin-verification/components/SpecialtyClaimsConsole.test.tsx @@ -3,11 +3,11 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import App from '../../../App'; import { SpecialtyClaimsConsole } from './SpecialtyClaimsConsole'; import { ApiError } from '../../../services/api-client'; import * as verificationService from '../../../services/admin-verification-service'; import { useAuthStore } from '../../../store/auth-store'; +import { renderRoute } from '../../../test/render'; import type { Role, Specialty, SpecialtyClaim } from '../../../types/models'; vi.mock('../../../services/admin-verification-service'); @@ -407,15 +407,7 @@ describe('SpecialtyClaimsConsole — reject', () => { describe('/admin/verification route', () => { function renderApp() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - - - - - , - ); + return renderRoute('/admin/verification'); } it('is reachable by admins', async () => { diff --git a/src/features/dependents/components/DependentForm.tsx b/src/features/dependents/components/DependentForm.tsx index 51a9a531..70bb75e9 100644 --- a/src/features/dependents/components/DependentForm.tsx +++ b/src/features/dependents/components/DependentForm.tsx @@ -1,5 +1,7 @@ import { type FormEvent, useState } from 'react'; import { TextField } from '../../../components/TextField'; +import { DatePicker } from '../../../components/DatePicker'; +import { todayInTimeZone } from '../../../components/date-utils'; import { Button } from '../../../components/Button'; import { FormError } from '../../../components/FormError'; import type { Dependent, DependentInput, Relationship } from '../../../types/models'; @@ -52,11 +54,11 @@ export function DependentForm({ initial, onSubmit, onCancel, loading, error }: P error={fieldErrors.fullName} required /> - setDateOfBirth(e.target.value)} + onChange={setDateOfBirth} + max={todayInTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC')} error={fieldErrors.dateOfBirth} required /> diff --git a/src/features/facility-team/hooks/useFacilityTeam.ts b/src/features/facility-team/hooks/useFacilityTeam.ts index c58d75a3..d995ca69 100644 --- a/src/features/facility-team/hooks/useFacilityTeam.ts +++ b/src/features/facility-team/hooks/useFacilityTeam.ts @@ -1,9 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useOptimisticMutation } from '../../../hooks/useOptimisticMutation'; import * as teamService from '../../../services/facility-team-service'; import { useAuthStore } from '../../../store/auth-store'; import { invalidateFor } from '../../../invalidations'; import { facilityKeys } from '../../../query-keys'; -import type { InviteMemberInput } from '../../../types/models'; +import type { FacilityInvite, InviteMemberInput } from '../../../types/models'; export function useFacilityMembers(facilityId: string) { const accessToken = useAuthStore((state) => state.accessToken); @@ -49,12 +50,21 @@ export function useResendInvite(facilityId: string) { export function useRevokeInvite(facilityId: string) { const accessToken = useAuthStore((state) => state.accessToken); - const queryClient = useQueryClient(); - return useMutation({ + // Second example of the optimistic helper: a revoked invitation leaves + // the table immediately and comes back if docforum-core rejects it. + return useOptimisticMutation< + { success: boolean }, + Error, + string, + FacilityInvite[] + >({ mutationFn: (inviteId: string) => teamService.revokeFacilityInvite(facilityId, inviteId, accessToken!), - onSuccess: () => { void invalidateFor(queryClient, 'facility.invite-write', facilityId); }, + queryKey: facilityKeys.invites(facilityId), + update: (invites, inviteId) => invites?.filter((invite) => invite.id !== inviteId), + successMessage: 'Invitation revoked.', + errorMessage: "Couldn't revoke that invitation. It has been restored.", }); } diff --git a/src/features/insights/components/DateRangeSelector.tsx b/src/features/insights/components/DateRangeSelector.tsx index 1dce0795..2e433e53 100644 --- a/src/features/insights/components/DateRangeSelector.tsx +++ b/src/features/insights/components/DateRangeSelector.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { DateRangePreset } from '../../../types/models'; +import { DatePicker } from '../../../components/DatePicker'; import styles from './PracticeInsights.module.css'; interface DateRangeSelectorProps { @@ -50,26 +51,20 @@ export function DateRangeSelector({ {currentPreset === 'custom' && (
- - setCustomStart(e.target.value)} + onChange={setCustomStart} + max={customEnd || undefined} required - aria-label="Start date" /> - - setCustomEnd(e.target.value)} + onChange={setCustomEnd} + min={customStart || undefined} required - aria-label="End date" /> - reverts in {Math.round(UNDO_WINDOW_MS / 1000)}s - - )} {byPosition.length === 0 ? (

No checked-in patients yet.

@@ -100,10 +84,7 @@ export function QueuePanel() { {entry.status === 'checked_in' && (