From ab2f10bffc295430a15fc3f64f16f5026a7766a1 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 8 Jun 2026 02:23:20 +0300 Subject: [PATCH] i18n: convert settings, chat, patient records and notes; update docs Finish the i18n pass: settings panels (profile/care-team/signing), the chat heading + input, the patient cards / detail / create-edit form, and the notes page + rich-text editor are all keyed in en/translation.json. All 526 static t() keys resolve. Document the new backend resources + Socket.io realtime (backend README/CLAUDE) and the i18n coverage (frontend CLAUDE). Co-Authored-By: Claude Opus 4.8 --- backend/CLAUDE.md | 8 + backend/README.md | 16 + frontend/CLAUDE.md | 10 +- frontend/components/chat/chat-input.tsx | 106 +++--- frontend/components/chat/chat-panel.tsx | 6 +- frontend/components/chat/patient-cards.tsx | 194 ++++++----- .../components/chat/patient-form-dialog.tsx | 186 ++++++----- .../components/notes/note-detail-sheet.tsx | 7 +- frontend/components/notes/notes-editor.tsx | 38 ++- frontend/components/notes/notes-view.tsx | 42 +-- .../patients/patient-detail-sheet.tsx | 8 +- .../components/patients/patient-detail.tsx | 98 +++--- .../components/settings/settings-billing.tsx | 38 ++- .../settings/settings-care-team.tsx | 28 +- .../components/settings/settings-parts.tsx | 4 +- .../settings/settings-preferences.tsx | 121 ++++--- frontend/lib/i18n/locales/en/translation.json | 304 ++++++++++++++++++ 17 files changed, 850 insertions(+), 364 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index ff4e048..b524524 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -38,6 +38,14 @@ No test runner is configured. Verify by running the stack (`docker compose up`) (`src/types/patient.ts`, mirrors `../frontend/lib/patients.ts`). **`src/routes/patients.ts`** is org-scoped CRUD, gated by **`src/middleware/auth.ts`** (`requireAuth` → `requireOrg` → `requirePermission`). +- **Other resources** follow the patients/notes pattern (schema → validation → types → service → + org-scoped route): **appointments**, **prescriptions**, **tasks** (RBAC-gated like patients), + plus **activity** (an audit log written best-effort from every resource route via + `services/activity.ts`), **analytics** (computed aggregates), **messaging** (conversations / + participants / messages) and **notifications** (per-recipient, auto-generated). +- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server + in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via + `emitToUser` / `emitToConversation` (no direct socket import, so no circular deps). - **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset. ## Gotchas / conventions diff --git a/backend/README.md b/backend/README.md index 2bd7827..9ad7930 100644 --- a/backend/README.md +++ b/backend/README.md @@ -57,6 +57,22 @@ organization; access is gated by the caller's clinic role. | PUT | `/api/patients/:fileNumber` | `patient:write` | replace the full record | | DELETE | `/api/patients/:fileNumber` | `patient:delete` | remove a patient | +Other org-scoped resources follow the same pattern (CRUD, role-gated): + +| Resource | Base path | Permission | Notes | +| --- | --- | --- | --- | +| Appointments | `/api/appointments` | `appointment:*` | list / create / update / delete | +| Prescriptions | `/api/prescriptions` | `prescription:*` | prescriber defaults to the signed-in user | +| Tasks | `/api/tasks` | `task:*` | `PATCH /:id` for partial updates / the done toggle | +| Notes | `/api/notes` | — (author-scoped) | private to the signed-in author | +| Activity | `GET /api/activity` | — (any member) | audit feed of record changes | +| Analytics | `GET /api/analytics` | — (any member) | computed clinic aggregates | +| Conversations | `/api/conversations` | — (participant-scoped) | staff messaging; real-time over Socket.io | +| Notifications | `/api/notifications` | — (per-recipient) | auto-generated; `read-all` + per-id read | + +Real-time messaging and live notifications are delivered over **Socket.io**, attached to the same +HTTP server; the handshake is authenticated with the Better Auth session cookie. + Auth endpoints (sign up / in / out, verify email, reset password, organizations & invitations) are served by Better Auth under `/api/auth/*`. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index dff7749..a53c6a1 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -108,8 +108,14 @@ white/6%), so layered surfaces stay close in lightness. `lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in `app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`) in **client** components. To add a language, drop a `locales//translation.json` and register it -in `resources`/`supportedLngs` in `config.ts`. Auth forms, sidebar nav, and settings tabs are -converted as the reference pattern; other strings can be migrated incrementally. +in `resources`/`supportedLngs` in `config.ts`. + +**Coverage:** essentially all user-facing strings are now keyed (every app page + its dialogs/sheets, +auth pages, settings panels, the sidebar/user menu, chat input, patient cards/detail/form, messages, +notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCard.*`, `messages.*`, +…). When adding UI, add a key rather than a literal. The only intentional literals left are clinical +**option values** stored as data (appointment types, dose frequencies/durations) and proper nouns +(e.g. `Ed25519`). There is no language switcher yet (English only); locale is auto-detected. ## Gotchas diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index af75266..f43bca8 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -19,9 +19,11 @@ import { type KeyboardEvent, type ReactNode, useCallback, + useMemo, useRef, useState, } from "react"; +import { useTranslation } from "react-i18next"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import { @@ -40,34 +42,35 @@ type ChatInputProps = { }; type Option = { value: string; label: string }; +type OptionKey = { value: string; labelKey: string }; -const ACCESS_OPTIONS: Option[] = [ - { value: "standard", label: "Standard access" }, - { value: "break-glass", label: "Break-glass (emergency)" }, - { value: "read-only", label: "Read-only" }, +const ACCESS_OPTIONS: OptionKey[] = [ + { value: "standard", labelKey: "chat.input.access.standard" }, + { value: "break-glass", labelKey: "chat.input.access.breakGlass" }, + { value: "read-only", labelKey: "chat.input.access.readOnly" }, ]; -const RESPONSE_OPTIONS: Option[] = [ - { value: "concise", label: "Concise" }, - { value: "detailed", label: "Detailed" }, - { value: "comprehensive", label: "Comprehensive" }, +const RESPONSE_OPTIONS: OptionKey[] = [ + { value: "concise", labelKey: "chat.input.response.concise" }, + { value: "detailed", labelKey: "chat.input.response.detailed" }, + { value: "comprehensive", labelKey: "chat.input.response.comprehensive" }, ]; -const SPECIALTY_OPTIONS: Option[] = [ - { value: "internal-medicine", label: "Internal Medicine" }, - { value: "cardiology", label: "Cardiology" }, - { value: "pediatrics", label: "Pediatrics" }, - { value: "emergency", label: "Emergency" }, - { value: "all", label: "All specialties" }, +const SPECIALTY_OPTIONS: OptionKey[] = [ + { value: "internal-medicine", labelKey: "chat.input.specialtyOptions.internalMedicine" }, + { value: "cardiology", labelKey: "chat.input.specialtyOptions.cardiology" }, + { value: "pediatrics", labelKey: "chat.input.specialtyOptions.pediatrics" }, + { value: "emergency", labelKey: "chat.input.specialtyOptions.emergency" }, + { value: "all", labelKey: "chat.input.specialtyOptions.all" }, ]; -const FACILITY_OPTIONS: Option[] = [ - { value: "main-hospital", label: "Main Hospital" }, - { value: "north-clinic", label: "North Clinic" }, - { value: "telehealth", label: "Telehealth" }, +const FACILITY_OPTIONS: OptionKey[] = [ + { value: "main-hospital", labelKey: "chat.input.facilityOptions.mainHospital" }, + { value: "north-clinic", labelKey: "chat.input.facilityOptions.northClinic" }, + { value: "telehealth", labelKey: "chat.input.facilityOptions.telehealth" }, ]; -const TIME_OPTIONS: Option[] = [ - { value: "30d", label: "Last 30 days" }, - { value: "12m", label: "Last 12 months" }, - { value: "5y", label: "Last 5 years" }, - { value: "all", label: "All time" }, +const TIME_OPTIONS: OptionKey[] = [ + { value: "30d", labelKey: "chat.input.timeOptions.30d" }, + { value: "12m", labelKey: "chat.input.timeOptions.12m" }, + { value: "5y", labelKey: "chat.input.timeOptions.5y" }, + { value: "all", labelKey: "chat.input.timeOptions.all" }, ]; const iconButton = @@ -128,6 +131,21 @@ function SelectPill({ } export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { + const { t } = useTranslation(); + const toOptions = useCallback( + (opts: OptionKey[]): Option[] => + opts.map((o) => ({ value: o.value, label: t(o.labelKey) })), + [t], + ); + const accessOptions = useMemo(() => toOptions(ACCESS_OPTIONS), [toOptions]); + const responseOptions = useMemo(() => toOptions(RESPONSE_OPTIONS), [toOptions]); + const specialtyOptions = useMemo( + () => toOptions(SPECIALTY_OPTIONS), + [toOptions], + ); + const facilityOptions = useMemo(() => toOptions(FACILITY_OPTIONS), [toOptions]); + const timeOptions = useMemo(() => toOptions(TIME_OPTIONS), [toOptions]); + const [value, setValue] = useState(""); const [files, setFiles] = useState([]); const [access, setAccess] = useState("standard"); @@ -203,11 +221,11 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { {/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}