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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .agents/docs/packages/libui.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,23 @@ There is no root `.` export — always import from a subpath.

### Translation — this is a hard rule

Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file:
Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, es, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file:

```tsx
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';

const { t } = useTranslation(); // inline strings
const { t } = useTranslation('datahub'); // scoped to a translation namespace

<Button>{t({ en: 'Accept', fr: 'Accepter' })}</Button>;
<Button>{t({ en: 'Accept', es: 'Aceptar', fr: 'Accepter' })}</Button>;
```

**Every interface language needs an entry.** A missing one resolves to English silently, so
`requireCompleteTranslations` is declared for `apps/web`, `apps/gateway` and `packages/react-core`
(`packages/react-core/src/complete-translations.d.ts` and each app's `src/services/i18n.ts`), which
makes an incomplete `t({ … })` a type error caught by `pnpm lint`. `apps/playground` is deliberately
outside that opt-in — its language selector offers only English and French.

`useTranslation` also returns `resolvedLanguage` when you need the active locale.

### Typing i18n to this app (`apps/web/src/services/i18n.ts`)
Expand Down
2 changes: 1 addition & 1 deletion .agents/docs/playbooks/add-web-data-hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ step type-checks and compiles, and fails at runtime or serves the wrong cached d
(`throwOnError: true` for both queries and mutations, in `apps/web/src/services/react-query.ts`,
which routes errors to the route error boundary). Set `throwOnError: false` on the mutation _and_
`meta: { disableDefaultErrorNotification: true }` on the request, then notify in `onError` with
`getApiErrorMessage(err, t({ en: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows
`getApiErrorMessage(err, t({ en: …, es: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows
two toasts. Canonical: `apps/web/src/hooks/useDeleteSeriesInstrumentMutation.ts`.

11. **Write the unit test in `apps/web/src/hooks/__tests__/<hookName>.test.ts` — never under
Expand Down
2 changes: 1 addition & 1 deletion .agents/docs/playbooks/promote-to-react-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ so nothing that passed lint in `apps/web` fails on style here; what breaks is re
the component is an internal part of `InstrumentRenderer` rather than something a consumer imports
directly, leave it off the barrel and add it to that AGENTS.md's deliberately-unexported list.

4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', fr: '…' })`** — libui's own
4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', es: '…', fr: '…' })`** — libui's own
registered namespace is the exception (`.agents/skills/odc-frontend/SKILL.md`). `apps/gateway`
initialises i18n with no resources (`apps/gateway/src/services/i18n.ts`), and libui's
`Translator.t()` logs `Failed to extract translation from object '{}'` and returns the
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ not every tool loads nested files.
version. `minimumReleaseAge` is 7 days, so a freshly published package will be rejected.
- **Every change needs a unit test _and_ an end-to-end test in `testing/`.** See
`.agents/docs/playbooks/add-e2e-test.md`.
- **All frontend user-facing strings go through `useTranslation`.** Prefer inline
`t({ en: '...', fr: '...' })` unless the string is used more than once.
- **All frontend user-facing strings go through `useTranslation`.** Every language the frontend
exposes gets an entry. In web, gateway and react-core, prefer inline
`t({ en: '...', es: '...', fr: '...' })` unless the string is used more than once; each workspace
enables `requireCompleteTranslations`, so omitting a language is a type error caught by `pnpm lint`.
- **Never run the `apps/web` route-tree generator.** `src/route-tree.ts` is generated and
git-tracked, but the user regenerates it manually after route changes. Never hand-edit it either.
- **If code needs a comment to be understood, the code is wrong** — rewrite it. Comments are for
Expand Down
4 changes: 3 additions & 1 deletion apps/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ hydrated tree can disagree with the SSR'd HTML until you do.
- The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`,
`jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still
required, and there are no translation resource files — `src/services/i18n.ts` initializes with
`{}`, so every string is inline `t({ en, fr })`.
`{}`, so every string is inline `t({ en, es, fr })`. Every interface language needs an entry;
`requireCompleteTranslations` in that service makes a missing one a type error caught by
`pnpm lint`.
- Shared UI comes from `packages/react-core` (`InstrumentRenderer`, `Branding`). Put anything both
apps need there, not here.
- Validation messages are localized by `src/services/zod.ts`, a thin call to react-core's
Expand Down
3 changes: 3 additions & 0 deletions apps/gateway/src/components/Cap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve })

const label = t({
en: "I'm a human",
es: 'Soy una persona',
fr: 'Je suis un humain'
});

Expand All @@ -33,10 +34,12 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve })
data-cap-api-endpoint="/api/auth/"
data-cap-i18n-error-label={t({
en: 'Error',
es: 'Error',
fr: 'Erreur'
})}
data-cap-i18n-initial-state={t({
en: "I'm a human",
es: 'Soy una persona',
fr: 'Je suis un humain'
})}
data-cap-i18n-solved-label={label}
Expand Down
3 changes: 3 additions & 0 deletions apps/gateway/src/services/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ axios.interceptors.request.use((config) => {
config.timeout = 10000; // abort request after 10 seconds
config.timeoutErrorMessage = i18n.t({
en: 'Network Error',
es: 'Error de red',
fr: 'Erreur de réseau'
});
return config;
Expand All @@ -20,6 +21,7 @@ axios.interceptors.response.use(
notifications.addNotification({
message: i18n.t({
en: 'Unknown Error',
es: 'Error desconocido',
fr: 'Erreur inconnue'
}),
type: 'error'
Expand All @@ -30,6 +32,7 @@ axios.interceptors.response.use(
notifications.addNotification({
message: i18n.t({
en: 'HTTP Request Failed',
es: 'Error en la solicitud HTTP',
fr: 'Échec de la requête HTTP'
}),
title: error.response?.status.toString(),
Expand Down
11 changes: 11 additions & 0 deletions apps/gateway/src/services/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
/* eslint-disable @typescript-eslint/no-namespace */

import { i18n } from '@douglasneuroinformatics/libui/i18n';

declare module '@douglasneuroinformatics/libui/i18n' {
export namespace UserConfig {
export interface Options {
requireCompleteTranslations: true;
}
}
}

// `defaultLanguage` is deliberately left unset. It is what `t()` falls back to when a string has no
// entry in the resolved language, so pointing it at the patient's language would make every string
// missing a translation render as nothing. The session's language is set with `changeLanguage`.
Expand Down
9 changes: 7 additions & 2 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,17 @@ In components use a selector: `useAppStore((store) => store.currentGroup)`. Outs
`useTranslation` comes from `@douglasneuroinformatics/libui/hooks`. Two forms:

```tsx
t({ en: 'Connection Problem', fr: 'Problème de connexion' }); // inline — prefer this
t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' }); // inline — prefer this
t('layout.tabs.table'); // keyed, from a namespace JSON
```

**Every interface language needs an entry.** libui resolves `obj[resolvedLanguage] ?? obj.en`, so a
missing language renders in English and nothing complains — which is why
`requireCompleteTranslations` is set in `src/services/i18n.ts`: omitting a language from an inline
`t()` call is a type error caught by `pnpm lint`.

Use the keyed form only when a string is reused. Resource files are `src/translations/*.json`, keyed
per-leaf as `{ "en": ..., "fr": ... }` and kept sorted by
per-leaf as `{ "en": ..., "es": ..., "fr": ... }` and kept sorted by
`pnpm --filter @opendatacapture/web format:translations`.

**Adding a namespace means three edits in `src/services/i18n.ts`** — the import, the `declare module`
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/AppErrorComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,24 @@ export const AppErrorComponent = ({ error, reset }: ErrorComponentProps) => {
<WifiOff aria-hidden className="text-muted-foreground h-8! w-8!" />
)}
<h1 className="mt-4 text-2xl font-extrabold tracking-tight sm:text-3xl">
{t({ en: 'Connection Problem', fr: 'Problème de connexion' })}
{t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' })}
</h1>
<p className="text-muted-foreground mt-2 max-w-prose text-sm sm:text-base">
{isOnline
? t({
en: "We couldn't reach the server. This is usually temporary — please try again.",
es: 'No se pudo contactar con el servidor. Suele ser algo temporal: vuelva a intentarlo.',
fr: 'Impossible de joindre le serveur. Le problème est généralement temporaire — veuillez réessayer.'
})
: t({
en: "You appear to be offline. We'll reconnect automatically as soon as your connection returns.",
es: 'Parece que no tiene conexión. Nos reconectaremos automáticamente en cuanto se restablezca.',
fr: 'Vous semblez être hors ligne. La reconnexion se fera automatiquement dès le retour de votre connexion.'
})}
</p>
<div className="mt-6">
<Button type="button" variant="primary" onClick={handleRetry}>
{t({ en: 'Try again', fr: 'Réessayer' })}
{t({ en: 'Try again', es: 'Vuelva a intentarlo', fr: 'Réessayer' })}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
// The group's active template sorts first; boolean subtraction is a total order, unlike the
// ternary chain it replaces, so the result does not depend on the sort's visiting order.
const templateOptions = [
{ label: t({ en: 'Built-in default', fr: 'Modèle par défaut' }), value: DEFAULT_TEMPLATE_OPTION },
{
label: t({ en: 'Built-in default', es: 'Predeterminada integrada', fr: 'Modèle par défaut' }),
value: DEFAULT_TEMPLATE_OPTION
},
...templates.map((template) => ({ label: template.name, value: template.id }))
].sort((a, b) => Number(b.value === activeValue) - Number(a.value === activeValue));

Expand All @@ -68,7 +71,11 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
setFeedback(null);
const fail = (message: string) => {
setFeedback({ message, tone: 'error' });
addNotification({ message, title: t({ en: 'Email failed', fr: 'Échec du courriel' }), type: 'error' });
addNotification({
message,
title: t({ en: 'Email failed', es: 'Error al enviar el correo', fr: 'Échec du courriel' }),
type: 'error'
});
};
sendEmailMutation.mutate(
{
Expand All @@ -78,18 +85,33 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
templateId: selectedTemplate === DEFAULT_TEMPLATE_OPTION ? null : selectedTemplate
},
{
onError: () => fail(t({ en: 'The email could not be sent', fr: "Le courriel n'a pas pu être envoyé" })),
onError: () =>
fail(
t({
en: 'The email could not be sent',
es: 'No se pudo enviar el correo',
fr: "Le courriel n'a pas pu être envoyé"
})
),
onSuccess: (result) => {
if (result.status !== 'SENT') {
fail(mailErrorMessage(result.error));
return;
}
const message = t(
{ en: 'Assignment link sent to {}', fr: "Lien d'évaluation envoyé à {}" },
{
en: 'Assignment link sent to {}',
es: 'Enlace de la tarea enviado a {}',
fr: "Lien d'évaluation envoyé à {}"
},
{ args: [recipient] }
);
setFeedback({ message, tone: 'success' });
addNotification({ message, title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), type: 'success' });
addNotification({
message,
title: t({ en: 'Email sent', es: 'Correo enviado', fr: 'Courriel envoyé' }),
type: 'success'
});
setRecipient('');
}
}
Expand All @@ -104,7 +126,9 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
<div className="flex flex-col gap-2" data-testid="assignment-email-form">
{templates.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="assignment-template">{t({ en: 'Email template', fr: 'Modèle de courriel' })}</Label>
<Label htmlFor="assignment-template">
{t({ en: 'Email template', es: 'Plantilla de correo', fr: 'Modèle de courriel' })}
</Label>
<Select value={selectedTemplate} onValueChange={setTemplateChoice}>
<Select.Trigger className="w-full" data-testid="assignment-template" id="assignment-template">
<Select.Value />
Expand All @@ -121,7 +145,9 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="assignment-language">{t({ en: 'Email language', fr: 'Langue du courriel' })}</Label>
<Label htmlFor="assignment-language">
{t({ en: 'Email language', es: 'Idioma del correo', fr: 'Langue du courriel' })}
</Label>
<Tooltip>
<Tooltip.Trigger className="p-0 hover:bg-transparent" size="icon" variant="ghost">
<CircleHelpIcon className="text-muted-foreground h-4 w-4" />
Expand All @@ -131,6 +157,7 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
{/* Only the email is localized: the gateway does not read the link's `lang` param. */}
{t({
en: 'The email is sent in the selected language when the template has been written in it. Participants choose their own language when they open the assignment.',
es: 'El correo se envía en el idioma seleccionado cuando la plantilla se ha redactado en ese idioma. Los participantes eligen su propio idioma al abrir la tarea.',
fr: "Le courriel est envoyé dans la langue sélectionnée lorsque le modèle a été rédigé dans celle-ci. Les participants choisissent leur propre langue à l'ouverture de l'évaluation."
})}
</p>
Expand All @@ -146,14 +173,22 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
/>
</div>
<Label htmlFor="assignment-email">
{t({ en: 'Email link to participant', fr: 'Envoyer le lien au participant par courriel' })}
{t({
en: 'Email link to participant',
es: 'Enviar el enlace al participante por correo',
fr: 'Envoyer le lien au participant par courriel'
})}
</Label>
<div className="flex gap-2">
<Input
className="h-9"
data-testid="assignment-email"
id="assignment-email"
placeholder={t({ en: 'recipient@example.org', fr: 'destinataire@exemple.org' })}
placeholder={t({
en: 'recipient@example.org',
es: 'destinatario@example.org',
fr: 'destinataire@exemple.org'
})}
type="email"
value={recipient}
onChange={(event) => setRecipient(event.target.value)}
Expand All @@ -171,8 +206,8 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
onClick={sendEmail}
>
{sendEmailMutation.isPending
? t({ en: 'Sending…', fr: 'Envoi en cours…' })
: t({ en: 'Email assignment', fr: 'Envoyer par courriel' })}
? t({ en: 'Sending…', es: 'Enviando…', fr: 'Envoi en cours…' })
: t({ en: 'Email assignment', es: 'Enviar la tarea por correo', fr: 'Envoyer par courriel' })}
</Button>
</div>
{feedback && (
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ConnectivityBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ export const ConnectivityBanner = () => {
{isOnline
? t({
en: 'Reconnecting…',
es: 'Reconectando…',
fr: 'Reconnexion…'
})
: t({
en: 'Offline — waiting for connection…',
es: 'Sin conexión: esperando la conexión…',
fr: 'Hors ligne — en attente de connexion…'
})}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const EmailTemplateEditor = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-language`}>{t({ en: 'Language', fr: 'Langue' })}</Label>
<Label htmlFor={`${idPrefix}-language`}>{t({ en: 'Language', es: 'Idioma', fr: 'Langue' })}</Label>
<LanguageSelect
data-testid={`${idPrefix}-language`}
id={`${idPrefix}-language`}
Expand All @@ -63,7 +63,7 @@ export const EmailTemplateEditor = ({
</div>

<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-subject`}>{t({ en: 'Subject', fr: 'Objet' })}</Label>
<Label htmlFor={`${idPrefix}-subject`}>{t({ en: 'Subject', es: 'Asunto', fr: 'Objet' })}</Label>
<Input
data-testid={`${idPrefix}-subject`}
id={`${idPrefix}-subject`}
Expand All @@ -77,10 +77,12 @@ export const EmailTemplateEditor = ({

<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor={`${idPrefix}-body`}>{t({ en: 'Body', fr: 'Corps' })}</Label>
<Label htmlFor={`${idPrefix}-body`}>{t({ en: 'Body', es: 'Cuerpo', fr: 'Corps' })}</Label>
{!readOnly && variables.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground text-xs">{t({ en: 'Insert:', fr: 'Insérer :' })}</span>
<span className="text-muted-foreground text-xs">
{t({ en: 'Insert:', es: 'Insertar:', fr: 'Insérer :' })}
</span>
{variables.map((variable) => {
// Placeholder syntax, not copy — it must render verbatim in every language.
const tag = `{{${variable}}}`;
Expand Down
Loading
Loading