diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ee80675e3..4629570e2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3516,9 +3516,10 @@ paths: $ref: "#/components/schemas/ApiTokenRevokeEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing - was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is - off, so its owner can still see it here even though this call will not kill it. + description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the + status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way — + a token stays live while the switch is off, so its owner can still see it here even though this call will + not kill it. content: application/json: schema: @@ -7710,7 +7711,7 @@ paths: `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message - `health-score-config.body.invalid_json` with no errorCode." + `health-score-config.body.invalid_json` with the status default `meta.errorCode: http.invalid`." content: application/json: schema: @@ -21524,7 +21525,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: No account on this instance carries that identifier. + description: "No account on this instance carries that identifier (`meta.errorCode: sharing.invite.no_account`)." content: application/json: schema: @@ -21715,8 +21716,8 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": - description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none - anywhere in this API; branch on the status. Nothing was created. + description: "More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status + default; a 429 has one meaning here, so the status alone is enough to branch on). Nothing was created." content: application/json: schema: @@ -21799,8 +21800,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": - description: More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch - on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at. + description: "More than ten edits from one caller within the hour — the ceiling creation carries. `meta.errorCode: + http.rate_limited`, the status default. Nothing was changed, and the refusal lands before the record named + in the path is looked at." content: application/json: schema: @@ -21885,11 +21887,10 @@ paths: summary: Invite another Guardian description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on - `meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route - answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account - carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A - client branching on the status first would tell somebody to check a spelling when the real answer is that the - record is no longer theirs to administer." + `meta.errorCode`, not on the status**, because this route answers 404 twice and the two need different next + steps: `sharing.invite.no_account` means no account carries that identifier, while `managed_profile.not_found` + means the caller is not a Guardian of that profile at all. A client branching on the status would tell somebody + to check a spelling when the real answer is that the record is no longer theirs to administer." parameters: - in: path name: id @@ -21916,11 +21917,11 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this - instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST - /api/account/grants` makes, and codeless there too, so this family does not answer one way here and another - way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is - not one of its Guardians." + description: "Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no + account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller + that `POST /api/account/grants` makes, with the same code, so this family does not answer one way here and + another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the + caller is not one of its Guardians." content: application/json: schema: @@ -21943,7 +21944,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": - description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status. + description: "More than ten invitations from one caller within the hour (`meta.errorCode: http.rate_limited`, the status + default)." content: application/json: schema: @@ -36232,9 +36234,9 @@ components: share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, - sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, - sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; diff --git a/messages/de.json b/messages/de.json index a71da0818..9bab26a04 100644 --- a/messages/de.json +++ b/messages/de.json @@ -56,9 +56,9 @@ "errorIdLabel": "Fehler-ID", "errorIdCopy": "Fehler-ID kopieren", "errorIdCopied": "Fehler-ID kopiert", - "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal.", - "sessionEnded": "Deine Sitzung ist abgelaufen. Melde dich erneut an, um weiterzumachen.", - "signInAgain": "Anmelden" + "readThrottled": "Gerade gibt es zu viele Anfragen, versuch es später noch einmal.", + "sessionEnded": "Deine Sitzung ist abgelaufen, melde dich erneut an, um weiterzumachen.", + "signIn": "Anmelden" }, "apiErrors": { "measurement": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", "maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", "energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet." - } + }, + "loadError": "Deine Erholungswerte konnten nicht geladen werden." }, "bodyTempDeviation": { "title": "Körpertemperatur-Abweichung", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)" + "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)", + "loadError": "Wasser konnte nicht geladen werden." }, "caffeine": { "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)", diff --git a/messages/en.json b/messages/en.json index 98a9f9034..eb2056a98 100644 --- a/messages/en.json +++ b/messages/en.json @@ -56,9 +56,9 @@ "errorIdLabel": "Error ID", "errorIdCopy": "Copy error ID", "errorIdCopied": "Error ID copied", - "readThrottled": "Too many requests just now. Try again later.", - "sessionEnded": "Your session has ended. Sign in again to continue.", - "signInAgain": "Sign in" + "readThrottled": "There are too many requests just now, so try again later.", + "sessionEnded": "Your session has ended, so sign in again to continue.", + "signIn": "Sign in" }, "apiErrors": { "measurement": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.", "maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.", "energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively." - } + }, + "loadError": "Your recovery readings could not be loaded." }, "bodyTempDeviation": { "title": "Body temperature deviation", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Reference intake {value} {unit}/day (EFSA)" + "referenceMeta": "Reference intake {value} {unit}/day (EFSA)", + "loadError": "Could not load water." }, "caffeine": { "ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)", diff --git a/messages/es.json b/messages/es.json index e601d625c..2596d7dd2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -56,9 +56,9 @@ "errorIdLabel": "ID de error", "errorIdCopy": "Copiar ID de error", "errorIdCopied": "ID de error copiado", - "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde.", - "sessionEnded": "Tu sesión ha caducado. Inicia sesión de nuevo para continuar.", - "signInAgain": "Iniciar sesión" + "readThrottled": "Hay demasiadas solicitudes ahora mismo, así que vuelve a intentarlo más tarde.", + "sessionEnded": "Tu sesión ha caducado, así que inicia sesión de nuevo para continuar.", + "signIn": "Iniciar sesión" }, "apiErrors": { "measurement": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.", "maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.", "energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa." - } + }, + "loadError": "No se pudieron cargar tus valores de recuperación." }, "bodyTempDeviation": { "title": "Desviación de la temperatura corporal", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)" + "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)", + "loadError": "No se pudo cargar el agua." }, "caffeine": { "ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)", diff --git a/messages/fr.json b/messages/fr.json index 44e6024ed..89593958e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -56,9 +56,9 @@ "errorIdLabel": "ID d'erreur", "errorIdCopy": "Copier l'ID d'erreur", "errorIdCopied": "ID d'erreur copié", - "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard.", - "sessionEnded": "Ta session a expiré. Reconnecte-toi pour continuer.", - "signInAgain": "Se connecter" + "readThrottled": "Il y a trop de requêtes pour le moment, réessayez plus tard.", + "sessionEnded": "Votre session a expiré, reconnectez-vous pour continuer.", + "signIn": "Se connecter" }, "apiErrors": { "measurement": { @@ -4668,7 +4668,7 @@ "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", "empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.", - "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnecte Oura une fois dans Intégrations et autorise-le.", + "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnectez Oura une fois dans Intégrations et autorisez-le.", "emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.", "emptyAction": "Ouvrir les intégrations", "block": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.", "maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.", "energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement." - } + }, + "loadError": "Vos valeurs de récupération n'ont pas pu être chargées." }, "bodyTempDeviation": { "title": "Écart de température corporelle", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)" + "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)", + "loadError": "Impossible de charger l'eau." }, "caffeine": { "ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)", diff --git a/messages/it.json b/messages/it.json index 80fdced4b..16190ea09 100644 --- a/messages/it.json +++ b/messages/it.json @@ -56,9 +56,9 @@ "errorIdLabel": "ID errore", "errorIdCopy": "Copia ID errore", "errorIdCopied": "ID errore copiato", - "readThrottled": "Troppe richieste in questo momento. Riprova più tardi.", - "sessionEnded": "La tua sessione è scaduta. Accedi di nuovo per continuare.", - "signInAgain": "Accedi" + "readThrottled": "Ci sono troppe richieste in questo momento, quindi riprova più tardi.", + "sessionEnded": "La tua sessione è scaduta, quindi accedi di nuovo per continuare.", + "signIn": "Accedi" }, "apiErrors": { "measurement": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.", "maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.", "energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo." - } + }, + "loadError": "Non è stato possibile caricare i tuoi valori di recupero." }, "bodyTempDeviation": { "title": "Deviazione della temperatura corporea", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)" + "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)", + "loadError": "Impossibile caricare l'acqua." }, "caffeine": { "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)", diff --git a/messages/ko.json b/messages/ko.json index d224ba278..022fde09c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -56,9 +56,9 @@ "errorIdLabel": "오류 ID", "errorIdCopy": "오류 ID 복사", "errorIdCopied": "오류 ID 복사됨", - "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요.", - "sessionEnded": "세션이 만료되었어요. 계속하려면 다시 로그인해 주세요.", - "signInAgain": "로그인" + "readThrottled": "지금은 요청이 너무 많으니 나중에 다시 시도해 주세요.", + "sessionEnded": "세션이 만료되었으니 계속하려면 다시 로그인해 주세요.", + "signIn": "로그인" }, "apiErrors": { "measurement": { @@ -4398,8 +4398,8 @@ "composition": "구성" }, "gap": { - "fewNights": "아직 밤이 부족함 ({count}/{needed})", - "noMainSleep": "최근 하루는 낮잠만 있음" + "fewNights": "아직 밤이 부족해요 ({needed}일 중 {count}일)", + "noMainSleep": "최근 하루는 낮잠만 있었어요" } }, "READINESS": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.", "maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.", "energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)." - } + }, + "loadError": "회복 값을 불러오지 못했어요." }, "bodyTempDeviation": { "title": "체온 편차", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)" + "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)", + "loadError": "수분을 불러오지 못했어요." }, "caffeine": { "ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)", diff --git a/messages/nl.json b/messages/nl.json index b6dd5fe1e..9a519e18a 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -56,9 +56,9 @@ "errorIdLabel": "Fout-ID", "errorIdCopy": "Fout-ID kopiëren", "errorIdCopied": "Fout-ID gekopieerd", - "readThrottled": "Er zijn net te veel verzoeken. Probeer het later opnieuw.", - "sessionEnded": "Je sessie is verlopen. Meld je opnieuw aan om verder te gaan.", - "signInAgain": "Aanmelden" + "readThrottled": "Er zijn net te veel verzoeken, dus probeer het later opnieuw.", + "sessionEnded": "Je sessie is verlopen, dus meld je opnieuw aan om verder te gaan.", + "signIn": "Aanmelden" }, "apiErrors": { "measurement": { @@ -4679,7 +4679,8 @@ }, "emptyAction": "Integraties openen", "emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.", - "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe." + "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe.", + "loadError": "Je herstelwaarden konden niet worden geladen." }, "bodyTempDeviation": { "title": "Afwijking lichaamstemperatuur", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)" + "referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)", + "loadError": "Water kon niet worden geladen." }, "caffeine": { "ceilingMeta": "Veilige bovengrens {value} {unit}/dag (EFSA)", diff --git a/messages/pl.json b/messages/pl.json index 5cd38bb74..3c2c5f26e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -56,9 +56,9 @@ "errorIdLabel": "ID błędu", "errorIdCopy": "Kopiuj ID błędu", "errorIdCopied": "Skopiowano ID błędu", - "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później.", - "sessionEnded": "Twoja sesja wygasła. Zaloguj się ponownie, aby kontynuować.", - "signInAgain": "Zaloguj się" + "readThrottled": "W tej chwili jest zbyt wiele zapytań, więc spróbuj ponownie później.", + "sessionEnded": "Twoja sesja wygasła, więc zaloguj się ponownie, aby kontynuować.", + "signIn": "Zaloguj się" }, "apiErrors": { "measurement": { @@ -4679,7 +4679,8 @@ "averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.", "maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.", "energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia." - } + }, + "loadError": "Nie udało się załadować Twoich wartości regeneracji." }, "bodyTempDeviation": { "title": "Odchylenie temperatury ciała", @@ -11907,7 +11908,8 @@ } }, "hydration": { - "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)" + "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)", + "loadError": "Nie udało się załadować wody." }, "caffeine": { "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)", diff --git a/src/__tests__/query-error-error-prop-guard.test.ts b/src/__tests__/query-error-error-prop-guard.test.ts index 0a61c87f1..57befcb56 100644 --- a/src/__tests__/query-error-error-prop-guard.test.ts +++ b/src/__tests__/query-error-error-prop-guard.test.ts @@ -1,7 +1,7 @@ /** * Every read-failure primitive is handed the error it reports. * - * `QueryErrorRow` and `QueryErrorCard` decide what a failed read offers from + * `QueryErrorRow`, `QueryErrorCard` and `ChartErrorState` decide what a failed read offers from * the error itself: nothing (or the surface's refused state) for a 403, a * note without a retry for a 429, sign-in for a 401, a retry otherwise. A * call site that renders one without the error falls back to "failed" for @@ -14,6 +14,16 @@ * and refuses both. The matcher is whitespace-tolerant (tags span lines) and * the match count is asserted, so a matcher that stops finding the tags fails * rather than passing over nothing. + * + * Handing the error over is only half of it: the error has to still be the + * `ApiError` the read threw. A `queryFn` that catches it and throws + * `new Error(t("…loadError"))` instead strips the status, so every primitive + * downstream reads "failed" and an ended session offers a retry. The second + * block reads every `queryFn` value under `src/` (`.ts` and `.tsx`, since + * query hooks live in both and a cache key is shared across files) and + * refuses a catch that re-throws a plain `Error`, or a translated message + * thrown in its place. The message belongs on the primitive (`message` / + * `title`), not inside the error. */ import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; @@ -21,12 +31,16 @@ import { describe, expect, it } from "vitest"; const SRC = join(__dirname, ".."); -function walk(dir: string, out: string[] = []): string[] { +function walk( + dir: string, + out: string[] = [], + ext: RegExp = /\.tsx$/, +): string[] { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === "__tests__" || entry.name === "generated") continue; const path = join(dir, entry.name); - if (entry.isDirectory()) walk(path, out); - else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test.")) + if (entry.isDirectory()) walk(path, out, ext); + else if (ext.test(entry.name) && !entry.name.includes(".test.")) out.push(path); } return out; @@ -35,7 +49,7 @@ function walk(dir: string, out: string[] = []): string[] { /** The opening tag's attribute text, from the name to its closing `>`. */ function openingTags(source: string): string[] { const tags: string[] = []; - const open = /<(QueryErrorRow|QueryErrorCard)(?=[\s/>])/g; + const open = /<(QueryErrorRow|QueryErrorCard|ChartErrorState)(?=[\s/>])/g; for (let m = open.exec(source); m; m = open.exec(source)) { let depth = 0; let i = m.index + m[0].length; @@ -80,3 +94,74 @@ describe("read-failure primitives carry their error", () => { expect(offenders).toEqual([]); }); }); + +/** + * The text of every `queryFn` value: from the key to the comma, or the + * closing bracket, that ends the property at its own depth. + */ +function queryFnBodies(source: string): string[] { + const bodies: string[] = []; + const key = /\bqueryFn\s*:/g; + for (let m = key.exec(source); m; m = key.exec(source)) { + let depth = 0; + let i = m.index + m[0].length; + for (; i < source.length; i++) { + const ch = source[i]; + if (ch === "(" || ch === "{" || ch === "[") depth++; + else if (ch === ")" || ch === "}" || ch === "]") { + if (depth === 0) break; + depth--; + } else if (ch === "," && depth === 0) break; + } + bodies.push(source.slice(m.index, i)); + } + return bodies; +} + +/** A catch that throws a plain Error, or a translated line thrown as one. */ +function masksTheReadError(body: string): boolean { + return ( + /\bcatch\b\s*(\([^)]*\))?\s*\{[^}]*\bthrow\s+new\s+Error\(/.test(body) || + /\bthrow\s+new\s+Error\(\s*t\(/.test(body) + ); +} + +const queryFns = walk(SRC, [], /\.tsx?$/).flatMap((file) => + queryFnBodies(stripComments(readFileSync(file, "utf8"))).map((body) => ({ + file: relative(SRC, file), + body, + })), +); + +describe("a read's ApiError reaches the primitive intact", () => { + it("finds the queryFns it guards", () => { + // About 310 at the time of writing. + expect(queryFns.length).toBeGreaterThan(250); + }); + + it("recognises the masking shape it refuses", () => { + expect( + masksTheReadError( + 'queryFn: async () => { try { return await apiGet(u); } catch { throw new Error(t("x.loadError")); } }', + ), + ).toBe(true); + expect( + masksTheReadError( + 'queryFn: async () => { try { return await apiGet(u); } catch (e) { throw new Error("failed"); } }', + ), + ).toBe(true); + expect(masksTheReadError("queryFn: () => apiGet(u)")).toBe(false); + expect( + masksTheReadError( + 'queryFn: async () => { if (!id) throw new Error("missing id"); return apiGet(u); }', + ), + ).toBe(false); + }); + + it("never swaps the read's error for a plain Error", () => { + const offenders = queryFns + .filter(({ body }) => masksTheReadError(body)) + .map(({ file, body }) => `${file}: ${body.split("\n")[0]}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/read-failure-markup-guard.test.ts b/src/__tests__/read-failure-markup-guard.test.ts new file mode 100644 index 000000000..b8c46a4c6 --- /dev/null +++ b/src/__tests__/read-failure-markup-guard.test.ts @@ -0,0 +1,182 @@ +/** + * A failed read draws a read-failure primitive, and draws it the right way. + * + * Two shapes kept coming back beside the primitives: + * + * - A read's `isError` branch rendering its own markup: a bare + * `text-destructive` paragraph with no retry, a muted line that reads as + * "still learning", a hand-rolled row. None of them knows an ended session + * from a refusal, which is the decision `QueryErrorRow`, `QueryErrorCard` + * and `ChartErrorState` make from the error. + * - A `QueryErrorRow` with its own frame inside a card that already draws + * one, a card inside a card. Inside a card body the row is `inset`. + * + * Both readers are lexical. A read is a hook call (`const x = useFoo(` or a + * destructure with `isError`) that is not `useMutation` and whose result is + * never `.mutate`d; a mutation's error is inline `role="alert"` text by the + * standards, so it is left alone. The branch is the text after `isError … ?` + * or `&&` up to the next `) : `. Nesting is the count of card tags opened and + * not closed before the row. Each reader is checked against a fixture of the + * shape it refuses, and the tree must yield a floor of matches, so a matcher + * that stops finding anything fails. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "__tests__" || entry.name === "generated") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path, out); + else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test.")) + out.push(path); + } + return out; +} + +function escape(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const PRIMITIVE = /QueryErrorRow|QueryErrorCard|ChartErrorState/; + +/** Every read `isError` branch: its line and whether it hand-rolls markup. */ +function readErrorBranches( + source: string, +): Array<{ line: number; handRolled: boolean }> { + const reads = new Set(); + for (const m of source.matchAll( + /const\s+(\w+)\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g, + )) { + if (m[2] === "useMutation") continue; + if (new RegExp(`${escape(m[1])}\\.mutate(?:Async)?\\(`).test(source)) + continue; + reads.add(m[1]); + } + const alternatives = [...reads].map((v) => `${escape(v)}\\.isError`); + const destructured = [ + ...source.matchAll( + /const\s*\{[^}]*\bisError\b[^}]*\}\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g, + ), + ].some((m) => m[1] !== "useMutation"); + if (destructured) alternatives.push("(? = []; + for (const m of source.matchAll(branch)) { + if (source[m.index - 1] === "!") continue; + const body = source.slice( + m.index + m[0].length, + m.index + m[0].length + 500, + ); + const arm = body.split(") : ")[0]; + found.push({ + line: source.slice(0, m.index).split("\n").length, + handRolled: + !PRIMITIVE.test(arm) && /<(p|div|span)\b/.test(arm.slice(0, 250)), + }); + } + return found; +} + +const CARD_TAGS = ["SettingsCard", "Card", "CardContent", "InsightSectionCard"]; + +/** Lines of `QueryErrorRow`s without `inset` inside an open card tag. */ +function framedRowsInCards(source: string): number[] { + const lines: number[] = []; + for (const m of source.matchAll(//g)) { + if (/\binset\b/.test(m[1])) continue; + const before = source.slice(0, m.index); + let depth = 0; + for (const tag of CARD_TAGS) { + depth += (before.match(new RegExp(`<${tag}(?=[\\s>])`, "g")) ?? []) + .length; + depth -= (before.match(new RegExp(``, "g")) ?? []).length; + depth -= (before.match(new RegExp(`<${tag}(?=\\s)[^>]*?/>`, "g")) ?? []) + .length; + } + if (depth > 0) lines.push(before.split("\n").length); + } + return lines; +} + +const files = walk(SRC).map((file) => ({ + file: relative(SRC, file), + source: readFileSync(file, "utf8"), +})); + +describe("read failures render through a primitive", () => { + it("recognises a hand-rolled read failure", () => { + const rolled = ` + const { data, isError } = useQuery({ queryKey: k, queryFn: f }); + return isError ? ( +

{t("x.loadError")}

+ ) : null;`; + expect(readErrorBranches(rolled)).toEqual([{ line: 3, handRolled: true }]); + const mutation = ` + const save = useSave(); + save.mutate(body); + return save.isError ?

{t("x.saveError")}

: null;`; + expect(readErrorBranches(mutation)).toEqual([]); + const primitive = ` + const list = useList(); + return list.isError ? ( + + ) : null;`; + expect(readErrorBranches(primitive)).toEqual([ + { line: 3, handRolled: false }, + ]); + }); + + it("finds the read branches it guards", () => { + const branches = files.flatMap(({ source }) => readErrorBranches(source)); + // About 100 at the time of writing. + expect(branches.length).toBeGreaterThan(80); + }); + + it("never hand-rolls one beside a read", () => { + const offenders = files.flatMap(({ file, source }) => + readErrorBranches(source) + .filter((b) => b.handRolled) + .map((b) => `${file}:${b.line}`), + ); + expect(offenders).toEqual([]); + }); +}); + +describe("an error row inside a card draws no frame of its own", () => { + it("recognises a framed row inside a card", () => { + expect( + framedRowsInCards( + `\n\n r()} />\n\n`, + ), + ).toEqual([3]); + expect( + framedRowsInCards( + `\n\n r()} />`, + ), + ).toEqual([]); + }); + + it("finds inset rows in the tree, so the reader is live", () => { + // About 40 files at the time of writing. + const inset = files.filter(({ source }) => + / { + const offenders = files.flatMap(({ file, source }) => + framedRowsInCards(source).map((line) => `${file}:${line}`), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx index 67f84b3ff..26dafe0fb 100644 --- a/src/__tests__/sharing-recoverable-errors.test.tsx +++ b/src/__tests__/sharing-recoverable-errors.test.tsx @@ -335,9 +335,12 @@ describe("a refused invitation keeps what was typed", () => { expect(inviteErrorKey(new TypeError("Failed to fetch"))).toBe( "recordSharing.invite.errorOffline", ); - expect(inviteErrorKey(apiError(404))).toBe( + expect(inviteErrorKey(apiError(404, "sharing.invite.no_account"))).toBe( "recordSharing.invite.errorNoAccount", ); + expect(inviteErrorKey(apiError(404, "http.not_found"))).toBe( + "recordSharing.invite.errorFailed", + ); expect(inviteErrorKey(apiError(409, "sharing.invite.duplicate"))).toBe( "recordSharing.invite.errorDuplicate", ); diff --git a/src/__tests__/translate-probe-guard.test.ts b/src/__tests__/translate-probe-guard.test.ts new file mode 100644 index 000000000..19896de8a --- /dev/null +++ b/src/__tests__/translate-probe-guard.test.ts @@ -0,0 +1,96 @@ +/** + * Whether the catalogue has a sentence is asked in one place. + * + * `t()` answers a missing key with the key itself, so "does this code have a + * sentence" is a comparison of the answer with the question. Written inline it + * was copied once per surface that wanted a code's sentence or else a + * fallback. It lives in `src/lib/i18n/translate-code.ts` now + * (`translateOrNull`, `translateCode`), and this guard refuses the comparison + * anywhere else under `src/`. + * + * Two shapes are read: a translation held in a variable and compared with the + * key it was asked for (`const v = t(key); v === key`), and the inline form + * (`t(k) !== k`). The helper itself must match, so a matcher that stops + * finding the shape fails instead of passing over nothing. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); +const HELPER = "lib/i18n/translate-code.ts"; + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "__tests__" || entry.name === "generated") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path, out); + else if (/\.tsx?$/.test(entry.name) && !entry.name.includes(".test.")) + out.push(path); + } + return out; +} + +function escape(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** The probes in one source text, one line each. */ +function probes(source: string): string[] { + const found: string[] = []; + // Inline: `t(K) !== K` / `t(K, p) === K`, K an identifier or a template. + const inline = + /\bt\(\s*(`[^`]*`|[A-Za-z_$][\w$]*)\s*(?:,[^()]*)?\)\s*[!=]==\s*(`[^`]*`|[A-Za-z_$][\w$]*)/g; + for (const m of source.matchAll(inline)) { + if (m[1] === m[2]) found.push(m[0]); + } + // Held: `const v = t(K…)` (or `K ? t(K) : null`) and later `v === K` or + // `K !== v`. + const held = + /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$.]+\s*\?\s*)?t\(\s*([A-Za-z_$][\w$]*)\b/g; + for (const m of source.matchAll(held)) { + const [, value, key] = m; + const compare = new RegExp( + `\\b${escape(value)}\\s*[!=]==\\s*${escape(key)}\\b|\\b${escape(key)}\\s*[!=]==\\s*${escape(value)}\\b`, + ); + if (compare.test(source.slice(m.index))) found.push(m[0]); + } + return found; +} + +const files = walk(SRC).map((file) => ({ + file: relative(SRC, file), + source: readFileSync(file, "utf8"), +})); + +describe("the catalogue probe lives in one helper", () => { + it("recognises both shapes", () => { + expect(probes("const s = t(key);\nreturn s === key ? a : s;")).toHaveLength( + 1, + ); + expect(probes("if (t(`x.${c}`) !== `x.${c}`) go();")).toHaveLength(1); + expect( + probes("const label = t(key, params); if (key !== label) {}"), + ).toHaveLength(1); + expect( + probes("const v = key ? t(key) : null; return v && v !== key ? v : f;"), + ).toHaveLength(1); + expect(probes("const s = t(key); return s;")).toEqual([]); + expect(probes("if (row.key === key) {}")).toEqual([]); + }); + + it("finds the probe in the helper", () => { + const helper = files.find(({ file }) => file === HELPER); + expect(helper).toBeDefined(); + expect(probes(helper!.source).length).toBeGreaterThan(0); + }); + + it("finds it nowhere else", () => { + const offenders = files + .filter(({ file }) => file !== HELPER) + .flatMap(({ file, source }) => + probes(source).map((p) => `${file}: ${p}`), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts index 57426b41b..242353000 100644 --- a/src/__tests__/zod-refusal-issue-list-guard.test.ts +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -188,7 +188,7 @@ const TOKEN_IN_ERROR_422: Record = { "src/app/api/auth/me/health-score-config/route.ts health-score-config.body.invalid_json": "The published 422 description spells this exact body out as what a non-JSON request earns. It is the one operation whose contract promises the token, so the token is what it answers with.", "src/app/api/coach/reminder-suggestions/route.ts coach.suggestion.unknownCadence": - "Predates this sweep and carries no `meta.errorCode` yet; the Coach reminder surfaces move as one piece or not at all.", + "Predates this sweep and carries only the status default code; the Coach reminder surfaces move as one piece or not at all.", "src/app/api/coach/reminders/route.ts coach.reminder.invalidWhen": "Predates this sweep, same surface.", "src/app/api/coach/reminders/[id]/route.ts coach.reminder.invalidWhen": diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 5d093073b..2851c662e 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -66,7 +66,11 @@ import { prisma } from "@/lib/db"; import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { + GRANT_PARTY_SELECT, + INVITE_NO_ACCOUNT_CODE, + toGrantView, +} from "@/lib/sharing/grant-view"; import { GrantError, inviteGrant } from "@/lib/sharing/grants"; import { inviteGrantSchema } from "@/lib/validations/account-sharing"; import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; @@ -221,7 +225,9 @@ export const POST = apiHandler(async (request: NextRequest) => { select: GRANT_PARTY_SELECT, }); if (!invitee) { - return apiError("No account with that name or e-mail", 404); + return apiError("No account with that name or e-mail", 404, { + errorCode: INVITE_NO_ACCOUNT_CODE, + }); } await auth.commitElevation(); diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 9c46f189c..b948897de 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -322,8 +322,8 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { }); it("reads latest-ever-per-type one index probe per type, floored at one year", async () => { - // Audit High finding 3 established the 365-day floor. The read is the - // shared `readLatestEver`: driven from the type list, one LIMIT 1 probe + // The read is floored at 365 days. It is the shared `readLatestEver`: + // driven from the type list, one LIMIT 1 probe // per type, so a dense type never sorts a year of raw rows to keep one. await callGet(makeReq()); diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts index 8afbb7e28..01e51b55b 100644 --- a/src/app/api/managed-profiles/[id]/guardians/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -20,7 +20,10 @@ import { } from "@/lib/managed-profiles/guardian-list"; import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle"; import { checkRateLimit } from "@/lib/rate-limit"; -import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view"; +import { + GRANT_PARTY_SELECT, + INVITE_NO_ACCOUNT_CODE, +} from "@/lib/sharing/grant-view"; import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants"; import { inviteManagedProfileGuardianSchema } from "@/lib/validations/managed-profiles"; @@ -106,16 +109,16 @@ export const POST = apiHandler( }, select: GRANT_PARTY_SELECT, }); - // No `errorCode`, deliberately, and the same for the 429 above. Rate-limit - // refusals carry none anywhere in this application, and the sibling - // invitation route (`POST /api/account/grants`) answers an unknown - // identifier with a bare 404 too. A code here would make one route in the - // family unlike both. The published contract says so, and says that a - // client resolves on `meta.errorCode` FIRST: this route's other 404 — - // "you are not a Guardian of that profile" — does carry one, and branching - // on the status would tell somebody to check a spelling when the real - // answer is that the profile is no longer theirs to administer. - if (!invitee) return apiError("No account with that name or e-mail", 404); + // The same code the sibling invitation route (`POST /api/account/grants`) + // answers an unknown identifier with. This route has a second 404 — "you + // are not a Guardian of that profile", `managed_profile.not_found` — and + // the two need different next steps, so each names itself and a client + // never has to tell them apart by status. + if (!invitee) { + return apiError("No account with that name or e-mail", 404, { + errorCode: INVITE_NO_ACCOUNT_CODE, + }); + } try { const grant = await inviteManagedProfileGuardian({ diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 38bda883e..88425d5f7 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -365,6 +365,7 @@ export default function MetricCatalogPage() { {failed.length > 0 ? ( { - try { - return await apiGet("/api/insights/comprehensive"); - } catch { - throw new Error(t("insights.loadError")); - } - }, + queryFn: () => apiGet("/api/insights/comprehensive"), enabled: isAuthenticated, }); @@ -485,6 +479,7 @@ export default function InsightsPageClient() { read={dashboardDerived.read} isLoading={dashboardDerived.isLoading || cycleRing.pending} isError={dashboardDerived.isError} + error={dashboardDerived.error} refetch={dashboardDerived.refetch} // v1.15.3 — the cycle ring rides the scores strip as a gated sibling // tile, only for a cycle-tracking account, so its calendar read never diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index d1592e047..0cf9cdd60 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** * v1.4.32 — `/insights/workouts`. @@ -82,9 +83,7 @@ export default function InsightsWorkoutsPageClient() { const sportLabel = (sport: string) => { // A sport enum the bundle has no copy for renders as its canonical // string rather than a raw key — same fallback the list rows use. - const key = `insights.workouts.sport.${sport}`; - const label = t(key); - return label === key ? sport : label; + return translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport; }; return ( diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 22ab09ad6..a4bc7ee67 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -104,6 +104,7 @@ export function AiQualitySection() { ) : query.isError ? ( void query.refetch()} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 6adca7386..16b6e5ed4 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -1243,6 +1243,7 @@ export function BackupsSection() { ) : isError ? ( void refetch()} diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 6c07d5854..d62a4f8ba 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -99,6 +99,7 @@ export function CoachFeedbackSection() { ) : query.isError ? ( void query.refetch()} diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 0783af6a5..78230e0cf 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -52,6 +52,7 @@ import { } from "@/components/settings/security-section/use-recent-proof"; import { KeyBackupCard } from "./key-backup-card"; import { useKeyBackupStatus } from "./use-key-backup-status"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface ColumnScan { model: string; @@ -182,9 +183,15 @@ export function EncryptionSection() { {t("admin.section.encryption.coverageDetail")}

-

- {t("admin.section.encryption.loadError")} -

+ {statusQuery.isError ? ( + void statusQuery.refetch()} + /> + ) : null} ); diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index cca03537f..20987aa64 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -198,6 +198,7 @@ export function HostMetricsChart() { } /> void refetch()} diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 0eca4c66a..107fdca41 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -75,6 +75,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface InviteRedemptionEntry { id: string; @@ -192,7 +193,12 @@ export function InviteTokensSection() { const [copied, setCopied] = useState(false); const [revokeTarget, setRevokeTarget] = useState(null); - const { data: invites, isError } = useQuery({ + const { + data: invites, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.adminInvites(), queryFn: async () => { return apiGet("/api/admin/invites"); @@ -313,9 +319,13 @@ export function InviteTokensSection() { /> {isError && ( -

- {t("admin.invites.loadError")} -

+ void refetch()} + /> )} {invites && invites.length === 0 && ( diff --git a/src/components/admin/key-backup-card.tsx b/src/components/admin/key-backup-card.tsx index 2b894cf6a..5b06d2b1e 100644 --- a/src/components/admin/key-backup-card.tsx +++ b/src/components/admin/key-backup-card.tsx @@ -155,6 +155,7 @@ export function KeyBackupCard() { /> {status.isError ? ( void status.refetch()} diff --git a/src/components/admin/notification-test-failures.ts b/src/components/admin/notification-test-failures.ts index caf578b50..9cf7b6726 100644 --- a/src/components/admin/notification-test-failures.ts +++ b/src/components/admin/notification-test-failures.ts @@ -2,6 +2,7 @@ import { CHANNEL_TYPE_LABELS, type ChannelType, } from "@/lib/notifications/types"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; /** One channel's row in the admin notification test response. */ export interface ChannelTestResult { @@ -27,14 +28,10 @@ export function describeChannelTestFailures( .filter((r) => !r.success) .map((r) => { const label = CHANNEL_TYPE_LABELS[r.channel as ChannelType] ?? r.channel; - const key = r.errorCode - ? `settings.testConnection.errors.${r.errorCode}` - : null; - const translated = key ? t(key) : null; const cause = - translated && translated !== key - ? translated - : (r.error ?? t("admin.notificationTestFailed")); + translateCodeOrNull(t, "settings.testConnection.errors", r.errorCode) ?? + r.error ?? + t("admin.notificationTestFailed"); const code = typeof r.upstreamStatus === "number" ? ` (HTTP ${r.upstreamStatus})` diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 2339a0353..99ffb6af2 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -170,6 +170,7 @@ export function OffhostBackupsSection() { ) : isError ? ( void refetch()} diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index 4b4c8c185..3032ee8ef 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.37.31 — the delivery-health readout the `provider_health` ledger never @@ -66,7 +67,7 @@ export function ProviderHealthSection() { const groupDigits = (value: number): string => new Intl.NumberFormat(locale).format(value); - const { data, isPending, isError } = useQuery({ + const { data, isPending, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminProviderHealth(), queryFn: () => apiGet<{ @@ -134,9 +135,13 @@ export function ProviderHealthSection() { {t("admin.providerHealth.loading")}

) : isError ? ( -

- {t("admin.providerHealth.loadError")} -

+ void refetch()} + /> ) : providers.length === 0 ? (

{t("admin.providerHealth.empty")} diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 4d4fa4e13..cab8589b7 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -84,6 +84,7 @@ export function RecentAuditPreview() { ) : isError ? ( void refetch()} diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 6eaa8aecd..6282cb243 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -277,6 +277,7 @@ export function SystemStatusSection() { // transient 500 (rolling deploy, DB blip) doesn't require a // full page reload to recover. void refetch()} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 62c6c6961..7c412113c 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -110,6 +110,7 @@ export function SystemStatusSummary() { ) : isError ? ( void refetch()} diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx index 9197fce14..df069e970 100644 --- a/src/components/charts/__tests__/chart-error-state.test.tsx +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -130,16 +130,32 @@ describe(" — announcement, sizing, accessible name", () => { props: Partial = {}, ) { const { ChartErrorState } = await import("../chart-error-state"); + const { I18nProvider } = await import("@/lib/i18n/context"); return renderToStaticMarkup( - undefined} - {...props} - />, + + undefined} + {...props} + /> + , ); } + it("decides from the error, like the other read-failure primitives", async () => { + const { ApiError } = await import("@/lib/api/api-fetch"); + // A refused read draws nothing, not a retry that cannot help. + expect(await renderState({ error: new ApiError("x", 403) })).toBe(""); + const ended = await renderState({ error: new ApiError("x", 401) }); + expect(ended).toContain('data-slot="query-error-sign-in"'); + expect(ended).not.toContain("Retry"); + const throttled = await renderState({ error: new ApiError("x", 429) }); + expect(throttled).not.toContain(" { const html = await renderState(); expect(html).toContain('role="status"'); diff --git a/src/components/charts/chart-error-state.tsx b/src/components/charts/chart-error-state.tsx index b81af179c..b75814847 100644 --- a/src/components/charts/chart-error-state.tsx +++ b/src/components/charts/chart-error-state.tsx @@ -4,6 +4,7 @@ import { Component, type ReactElement, type ReactNode } from "react"; import { AlertTriangle, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useReadFailure } from "@/hooks/use-read-failure"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -22,9 +23,18 @@ import { cn } from "@/lib/utils"; * reflows — with a compact action button. `` wraps * each lazy chart mount so a failed chunk degrades to one card with a * reload affordance instead of taking the page down. + * + * Like `QueryErrorCard` and `QueryErrorRow`, the card reads the failure + * itself (`error`): a refusal draws `refused` (nothing by default), a spent + * budget says so without an action, an ended session offers sign-in, and only + * a real failure offers `onAction`. */ export interface ChartErrorStateProps { + /** The failure. It decides what the card offers. */ + error: unknown; + /** What a refused (403) read draws instead of the card. */ + refused?: ReactNode; /** Translated headline, e.g. "Data could not be loaded". */ title: string; /** Translated action-button label, e.g. "Retry". */ @@ -54,6 +64,8 @@ export interface ChartErrorStateProps { } export function ChartErrorState({ + error, + refused = null, title, actionLabel, onAction, @@ -61,6 +73,10 @@ export function ChartErrorState({ actionContext, role = "status", }: ChartErrorStateProps): ReactElement { + const failure = useReadFailure(error, { message: title, onRetry: onAction }); + if (!failure) return <>{refused}; + const { action } = failure; + const label = action?.kind === "retry" ? actionLabel : action?.label; return (

); } interface BoundaryProps { - fallback: ReactNode; + /** Drawn once caught; a function receives the caught error. */ + fallback: ReactNode | ((error: Error) => ReactNode); children: ReactNode; /** Called once the boundary has caught, after the fallback commits. */ onError?: (error: Error) => void; } interface BoundaryState { - failed: boolean; + error: Error | null; } /** Exported for its unit test; mount `` instead. */ export class ChartBoundary extends Component { - state: BoundaryState = { failed: false }; + state: BoundaryState = { error: null }; - static getDerivedStateFromError(): BoundaryState { - return { failed: true }; + static getDerivedStateFromError(error: Error): BoundaryState { + return { error }; } componentDidCatch(error: Error): void { @@ -125,7 +147,10 @@ export class ChartBoundary extends Component { } render(): ReactNode { - return this.state.failed ? this.props.fallback : this.props.children; + const { error } = this.state; + if (!error) return this.props.children; + const { fallback } = this.props; + return typeof fallback === "function" ? fallback(error) : fallback; } } @@ -152,7 +177,7 @@ export function ChartErrorBoundary({ return ( ( // Wrapped in the chart-card shell so a chunk failure renders // as one solid card among the other cards, not a bare dashed // box; `role="alert"` because the whole chart is gone, not @@ -162,13 +187,14 @@ export function ChartErrorBoundary({ className="bg-card border-border rounded-xl border p-4 md:p-6" > window.location.reload()} role="alert" /> - } + )} > {children} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 96c31223d..f42233f75 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -90,6 +90,7 @@ import { rangeWindowDays, } from "./chart-range-tabs"; import { trendLineByTime } from "@/lib/stats/linear-fit"; +import { translateOrNull } from "@/lib/i18n/translate-code"; // The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and // the "All" tab's ten-year fetch span live with the shared @@ -400,8 +401,7 @@ function getTypeLabel( const key = BASE_TYPE_LABEL_KEYS[type]; if (key) return t(key); const measurementKey = measurementLabelKey(type); - const label = t(measurementKey); - return label === measurementKey ? type : label; + return translateOrNull(t, measurementKey) ?? type; } /** @@ -783,7 +783,7 @@ export function HealthChart({ ); }, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]); - const { data, isLoading, isError, isPlaceholderData, refetch } = useQuery({ + const chartQuery = useQuery({ // A range-tab change re-keys the query; without placeholder data every // switch dropped the painted chart to a skeleton for the fetch round-trip. // Keeping the previous range's series visible is only honest because the @@ -1059,6 +1059,8 @@ export function HealthChart({ ), enabled: isAuthenticated, }); + const { data, isLoading, isError, error, isPlaceholderData, refetch } = + chartQuery; // v1.16.0 — report the settled data query to the dashboard's shared // reveal gate. `isLoading` only covers the INITIAL fetch (a later @@ -1884,6 +1886,7 @@ export function HealthChart({ // Pre-fix `isError` fell through to the empty branch and an // outage read as "you have no measurements". => { return apiGet( @@ -548,6 +548,7 @@ export function MedicationComplianceChart({ // through to the empty branch (data undefined → hasData false) // and an outage read as "no doses tracked". => { return apiGet("/api/mood/analytics"); @@ -719,6 +719,7 @@ export function MoodChart({ // v1.16.8 — a failed query paints as an ERROR with a retry // affordance instead of unmounting the card (see the gate above). ({ const calendarState = vi.hoisted(() => ({ current: { - data: undefined as CalendarResponse | undefined, + data: undefined, isLoading: false, isError: false, + } as { + data: CalendarResponse | undefined; + isLoading: boolean; + isError: boolean; + error?: unknown; + refetch?: () => unknown; }, })); const insightsState = vi.hoisted(() => ({ @@ -223,13 +229,17 @@ describe("", () => { expect(html).toBe(""); }); - it("renders nothing on a hard calendar read error", () => { + it("says a hard calendar read error, with a retry", () => { + // Drawing nothing would read as "no cycle tracked". calendarState.current = { data: undefined, isLoading: false, isError: true, + error: new Error("boom"), + refetch: () => undefined, }; const html = render(); - expect(html).toBe(""); + expect(html).toContain('data-slot="cycle-insight-summary-error"'); + expect(html).toContain('data-slot="query-error-row-retry"'); }); }); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index 673b82af3..ee1b2e0c1 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -9,6 +9,7 @@ import { SectionHeading } from "@/components/ui/section-heading"; import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; import { PHASE_HUE } from "./phase-tokens"; import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.15.2 — the gated cycle-insights summary card for the main Insights page. @@ -56,11 +57,29 @@ export function CycleInsightSummaryCard() { // The server's resolved verdict — the teaser reports it, never recomputes it. const verdict = calendar.data?.verdict; - // Stay silent until the calendar read resolves, and on a hard error: the - // overview must never show a half-painted or error-framed cycle teaser. The - // full cycle page owns the retry affordance. + // Stay silent until the calendar read resolves: the overview must never + // show a half-painted teaser. A failed read is said with a retry, since + // drawing nothing would read as "no cycle tracked". if (calendar.isLoading && !calendar.data) return null; - if (calendar.isError && !calendar.data) return null; + if (calendar.isError && !calendar.data) { + return ( +
+ + void calendar.refetch()} + /> +
+ ); + } const phase = verdict?.phase ?? null; const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL; diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 1b273ee93..276aa37c1 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -363,10 +363,10 @@ export function CycleView() { // Inside the calendar card already — a borderless row keeps // it in line with the plain loading line above it (§3/§6). void calendar.refetch()} - className="border-0 bg-transparent p-0" /> ) : ( <> diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index 170ee2639..6cac8c733 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -91,10 +91,12 @@ describe("", () => { expect(html).toContain('data-slot="skeleton"'); }); - it("renders a retry card instead of the empty state on a failed fetch", () => { + it("renders a retry row instead of the empty state on a failed fetch", () => { mockResult.isError = true; const html = render(); - expect(html).toContain('data-slot="query-error-card"'); + // A row without a frame of its own: the tile is already the card. + expect(html).toContain('data-slot="recent-workouts-error"'); + expect(html).not.toContain('data-slot="query-error-card"'); // A failed fetch must never read as the honest "no workouts yet" // empty state — that would misreport an outage as an empty account. expect(html).not.toContain('data-slot="recent-workouts-empty"'); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 0b75c76f6..c1fb81f46 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -10,8 +10,9 @@ import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { iconForSport } from "@/lib/workouts/sport-icons"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** * v1.4.32 — ``. @@ -104,10 +105,11 @@ export function RecentWorkoutsTile() { ))} ) : isError ? ( - refetch()} - className="border-0 bg-transparent shadow-none" + slot="recent-workouts-error" + onRetry={() => void refetch()} /> ) : workouts.length === 0 ? (
@@ -122,9 +124,8 @@ export function RecentWorkoutsTile() {
    {workouts.map((workout) => { const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; - const sportLabel = t(sportLabelKey); const sportName = - sportLabel === sportLabelKey ? workout.sportType : sportLabel; + translateOrNull(t, sportLabelKey) ?? workout.sportType; return (
  • void window.refetch()} - /> + + + + + + void window.refetch()} + /> + + ); } // Nothing to show is nothing drawn: still loading, or a quiet window. The @@ -209,7 +226,6 @@ export function SinceLastVisit({ if (rows.length === 0) return null; const visible = showAll ? rows : rows.slice(0, VISIBLE_ROWS); - const lastLabel = fmt.dateShortSmartCalendar(lastDay); const nextDay = dateKeyOfInstant(next.occurredAt, timeZone); return ( diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 303a41ce4..c5594528f 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -103,6 +103,7 @@ import { useSuggestDetails, } from "./use-document-assist"; import { formatBytes } from "./vault-utils"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; type PatchInput = { title?: string | null; @@ -895,9 +896,13 @@ export function DocumentDetailSheet({ ) : null} {detail.isError ? ( -

    - {t("documents.detail.loadError")} -

    + void detail.refetch()} + /> ) : null} {doc ? ( diff --git a/src/components/encounters/__tests__/procedures-section.test.tsx b/src/components/encounters/__tests__/procedures-section.test.tsx index 515c593cb..7d6233b39 100644 --- a/src/components/encounters/__tests__/procedures-section.test.tsx +++ b/src/components/encounters/__tests__/procedures-section.test.tsx @@ -164,7 +164,7 @@ describe("", () => { bodySites: [], total: 1, }); - expect(html).toMatch(/font-medium">Meniscus repair]*>Meniscus repair since the last visit", () => { expect(html).toContain('data-slot="since-last-visit-error"'); expect(html).toContain('role="alert"'); expect(html).not.toContain('data-slot="since-last-visit"'); - expect(upcomingCards(html)).toBe(upcomingCards(baseline)); + // The failed window keeps its own card and title, so the page does not + // jump; the visit cards beside it stay as they were. + expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); + expect(html).toContain("Since the last visit on"); }); // Visits need the profile section, the window reads measurements, and a diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 2377256e9..b8b662be6 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -28,6 +28,7 @@ import { encounterKindText, encounterStatusText, } from "./encounter-labels"; +import { TileHeader } from "@/components/insights/tile-header"; /** The two statuses worth calling out on a row; the rest the section implies. */ function isNoteworthy(status: EncounterStatus): boolean { @@ -97,23 +98,20 @@ export function VisitCard({ > -
    - -
    -

    - {heading} -

    -

    - {format.dateTime(encounter.occurredAt)} -

    -
    - - {encounterKindText(t, encounter.kind as EncounterKind)} - -
    + + {encounterKindText(t, encounter.kind as EncounterKind)} + + } + /> +

    + {format.dateTime(encounter.occurredAt)} +

    {reasonLine ? (

    {reasonLine}

    diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 58c196889..fb2091bfb 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -18,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useIllnessCorrelation } from "./use-illness"; import type { IllnessCorrelationValue, IllnessVitalDeviation } from "./types"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** Translate a MeasurementType to its illness-surface label. */ function useVitalLabel() { @@ -168,7 +169,8 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) { const { t } = useTranslations(); - const { data, isLoading, isError } = useIllnessCorrelation(episodeId); + const { data, isLoading, isError, error, refetch } = + useIllnessCorrelation(episodeId); return ( @@ -186,7 +188,15 @@ export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
- ) : isError || !data || data.status !== "ok" || !data.value ? ( + ) : isError && !data ? ( + // A failed read is not "still learning": it says so and can retry. + void refetch()} + /> + ) : !data || data.status !== "ok" || !data.value ? (

{t("illness.correlation.learning")}

diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index fada9076b..dbf60b7b6 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -25,6 +25,7 @@ import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLogList } from "./use-illness"; import type { IllnessDayLogDTO } from "./types"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; function symptomLabelKey(key: string): string | null { return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null; @@ -106,7 +107,10 @@ export function IllnessDayTimeline({ // v1.36.x — logging a day is not a delegated verb. const { canManageDomain } = useRecordCapabilities(); const canManageIllness = canManageDomain("illness"); - const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc"); + const { data, isLoading, isError, error, refetch } = useIllnessDayLogList( + episodeId, + "desc", + ); const dayLogs = data?.dayLogs ?? []; @@ -122,9 +126,13 @@ export function IllnessDayTimeline({ ) : isError ? ( -

- {t("illness.timeline.error")} -

+ void refetch()} + /> ) : dayLogs.length === 0 ? (

diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx index 64f84232b..b15c14335 100644 --- a/src/components/illness/illness-insights-card.tsx +++ b/src/components/illness/illness-insights-card.tsx @@ -20,6 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { useIllnessInsights } from "./use-illness"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; export function IllnessInsightsCard({ windowDays = 365, @@ -55,6 +56,13 @@ export function IllnessInsightsCard({

+ ) : counts.isError && !data ? ( + void counts.refetch()} + /> ) : !hasEpisodes ? (

{t("illness.insights.none")} @@ -83,11 +91,14 @@ export function IllnessInsightsCard({

- ) : gap.isError || !gap.data ? ( -

- {t("illness.insights.typicalGapLearning")} -

- ) : gap.data.typicalRecoveryGapDays === null ? ( + ) : gap.isError && !gap.data ? ( + void gap.refetch()} + /> + ) : !gap.data || gap.data.typicalRecoveryGapDays === null ? (

{t("illness.insights.typicalGapLearning")}

diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx index 198aeb846..e0fb10ba2 100644 --- a/src/components/insights/__tests__/ecg-section.test.tsx +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -28,7 +28,14 @@ vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: unknown) => useQueryMock(opts), })); +const apiGetMock = vi.fn(); +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + apiGet: (...args: unknown[]) => apiGetMock(...args), +})); + const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section"); +const { ApiError } = await import("@/lib/api/api-fetch"); type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem; const IRREGULAR_REC: EcgRecordingListItem = { @@ -72,6 +79,36 @@ function countRows(html: string): number { } describe("", () => { + it("hands the read's ApiError through untouched", async () => { + // The error row decides sign-in vs retry from the status; a queryFn that + // re-threw a plain Error would turn an ended session into a retry. + useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); + renderSection(undefined); + const { queryFn } = useQueryMock.mock.calls.at(-1)![0] as { + queryFn: () => Promise; + }; + const refusal = new ApiError("Unauthorized", 401); + apiGetMock.mockRejectedValueOnce(refusal); + await expect(queryFn()).rejects.toBe(refusal); + }); + + it("says an ended session instead of falling through to nothing", () => { + useQueryMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new ApiError("Unauthorized", 401), + refetch: vi.fn(), + }); + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-slot="ecg-section-error"'); + expect(html).toContain('data-slot="query-error-sign-in"'); + }); + it("renders nothing before the payload resolves", () => { useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); const html = renderToStaticMarkup( diff --git a/src/components/insights/__tests__/recovery-empty-state.test.tsx b/src/components/insights/__tests__/recovery-empty-state.test.tsx index 57036b5f0..fdc4547b8 100644 --- a/src/components/insights/__tests__/recovery-empty-state.test.tsx +++ b/src/components/insights/__tests__/recovery-empty-state.test.tsx @@ -54,12 +54,12 @@ const OURA_SUMMARIES = { HRV_RMSSD: summary(30), }; -function render(recoveryEmpty: string | null) { +function render(recoveryEmpty: string | null, error: unknown = null) { analyticsMock.mockReturnValue({ - data: { summaries: OURA_SUMMARIES, recoveryEmpty }, + data: error ? undefined : { summaries: OURA_SUMMARIES, recoveryEmpty }, isLoading: false, isEmpty: false, - error: null, + error, refetch: () => {}, }); const queryClient = new QueryClient({ @@ -81,7 +81,6 @@ beforeEach(() => { describe(" empty note", () => { it.each([ ["oura_permission", "heart-health access"], - ["oura_no_resilience", "as soon as Oura provides one"], ["general", "WHOOP or Polar"], ])("renders the server's %s reason with the one action", (reason, text) => { const html = render(reason); @@ -90,6 +89,20 @@ describe(" empty note", () => { expect(html).toContain('href="/settings/integrations"'); }); + it("offers nothing to open while Oura's first level is on its way", () => { + // The connection is fine; there is nothing under Integrations to do. + const html = render("oura_no_resilience"); + expect(html).toContain('data-empty-case="oura_no_resilience"'); + expect(html).toContain("as soon as Oura provides one"); + expect(html).not.toContain('href="/settings/integrations"'); + }); + + it("says a failed read instead of a skeleton that never ends", () => { + const html = render(null, new Error("boom")); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).not.toContain('data-slot="recovery-loading"'); + }); + it("shows no note when the server says the page has data", () => { const html = render(null); expect(html).not.toContain("data-empty-case"); diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx index 223c6cb7d..1195d8f6c 100644 --- a/src/components/insights/__tests__/rhythm-events-card.test.tsx +++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx @@ -28,7 +28,14 @@ vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: unknown) => useQueryMock(opts), })); +const apiGetMock = vi.fn(); +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + apiGet: (...args: unknown[]) => apiGetMock(...args), +})); + const { RhythmEventsCard } = await import("../rhythm-events-card"); +const { ApiError } = await import("@/lib/api/api-fetch"); interface RhythmEvent { id: string; @@ -72,6 +79,21 @@ const STEADINESS_EVENT: RhythmEvent = { }; describe("", () => { + it("hands the read's ApiError through, so a 401 offers sign-in", async () => { + useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); + renderToStaticMarkup( + + + , + ); + const { queryFn } = useQueryMock.mock.calls.at(-1)![0] as { + queryFn: () => Promise; + }; + const refusal = new ApiError("Unauthorized", 401); + apiGetMock.mockRejectedValueOnce(refusal); + await expect(queryFn()).rejects.toBe(refusal); + }); + it("renders nothing before the payload resolves", () => { useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); const html = renderToStaticMarkup( diff --git a/src/components/insights/coach-panel/assumption-line.tsx b/src/components/insights/coach-panel/assumption-line.tsx index ab84da991..ea2603489 100644 --- a/src/components/insights/coach-panel/assumption-line.tsx +++ b/src/components/insights/coach-panel/assumption-line.tsx @@ -21,6 +21,7 @@ import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types"; import { SuggestedReplies } from "./suggested-replies"; import { COACH_FOCUS_RING } from "./focus-ring"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** The alternatives the server offers for one assumption. */ export function alternativesFor( @@ -39,8 +40,7 @@ function optionLabel( t: (key: string) => string, option: CoachAssumption["value"], ): string { - const local = t(option.labelKey); - return local === option.labelKey ? option.label : local; + return translateOrNull(t, option.labelKey) ?? option.label; } export interface CoachAssumptionLineProps { diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index 732754dda..e230593c4 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.29.x (S7) — the "Choose from documents" picker for attaching stored vault @@ -181,9 +182,13 @@ export function AttachmentPicker({ ))} ) : list.isError ? ( -

- {t("documents.list.loadError")} -

+ void list.refetch()} + /> ) : documents.length === 0 ? (

{t("insights.coach.attach.pickerEmpty")} diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 96125f191..0c0cec5b3 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -38,6 +38,7 @@ import type { CoachMemoryNote as CoachMemoryNoteFrame, CoachProvenanceMetric, } from "@/lib/ai/coach/types"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** * Per-message bubble renderer for the Coach thread, split out of @@ -239,8 +240,8 @@ function coachChartTitle( ): string { const override = CHART_TITLE_KEY_OVERRIDE[metric]; if (override) { - const resolved = t(override); - if (resolved !== override) return resolved; + const resolved = translateOrNull(t, override); + if (resolved !== null) return resolved; } const camel = metric .toLowerCase() @@ -250,8 +251,7 @@ function coachChartTitle( ) .join(""); const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; - const resolved = t(key); - return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved; + return translateOrNull(t, key) ?? metric.replace(/_/g, " ").toLowerCase(); } interface ChatBubbleProps { @@ -564,16 +564,11 @@ function ChatBubbleImpl({ // provider load (retry in ~5 min). Codes that have no dedicated // translation fall back to the generic provider-unavailable copy. const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null; - const errorMessage = errorKey ? t(errorKey, {}) : null; - // When a translated message comes back unchanged (i.e. key missing) - // we fall back to a generic provider error string so the bubble - // doesn't surface raw `coach.http.503` text to the user. + // A code without a sentence falls back to the generic provider error, so + // the bubble never surfaces raw `coach.http.503` text to the user. const safeError = - errorMessage && errorMessage !== errorKey - ? errorMessage - : errorCode - ? t("insights.coach.errorProvider") - : null; + (errorKey ? translateOrNull(t, errorKey) : null) ?? + (errorCode ? t("insights.coach.errorProvider") : null); const method = metricSource?.method ?? null; diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index f758f694c..733e78050 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -67,6 +67,7 @@ import { useDetachCoachDocument, useSendCoachMessage, } from "./use-coach"; +import { translateOrNull } from "@/lib/i18n/translate-code"; /** * v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer @@ -987,11 +988,8 @@ export function CoachConversation({ // from a metric surface names the metric there until its first turn. const a2Metric = launchScope?.metric ?? null; - // `t()` returns the key string itself when a key is missing, so the only - // reliable "is this key defined" signal is `t(key) !== key`. function tOrNull(key: string): string | null { - const resolved = t(key); - return resolved === key ? null : resolved; + return translateOrNull(t, key); } // The resolved metric label: prefers a per-source i18n key, falls back to diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx index 3c6a3d543..7ff77cef3 100644 --- a/src/components/insights/coach-panel/coach-model-picker.tsx +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -24,6 +24,7 @@ import { invalidateKeys, queryKeys, } from "@/lib/query-keys"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * The quick model choice in the Coach's settings overlay: which provider @@ -195,21 +196,15 @@ export function CoachModelPicker() { if (chainQuery.isError || providerQuery.isError) { return ( -

-

- {t("common.loadFailed")} -

- -
+ { + void chainQuery.refetch(); + void providerQuery.refetch(); + }} + /> ); } diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 3ecaa120f..a1b8f3178 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -7,7 +7,6 @@ import { MoreHorizontal, Paperclip, Pencil, - RotateCw, Trash2, } from "lucide-react"; import { toast } from "sonner"; @@ -30,6 +29,7 @@ import { useCoachConversationHistory, useDeleteCoachConversationWithUndo, } from "./use-coach"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * The Coach's conversation list: the full history grouped by recency @@ -88,6 +88,7 @@ export function HistoryRail({ conversations, isLoading, isError, + error, refetch, hasNextPage, isFetchingNextPage, @@ -160,16 +161,13 @@ export function HistoryRail({

) : isError && visible.length === 0 ? ( // A load failure must not read as "no conversations yet". -
-

{t("common.loadFailed")}

- -
+ void refetch()} + className="px-2 py-3" + /> ) : groups.length === 0 ? (

, ) { - const value = t(key, params); - return value === key ? fallback : value; + return translateOrNull(t, key, params) ?? fallback; } // ── Steps (messages saved before v1.41, and the fetch rows) ──────────────── @@ -180,9 +180,8 @@ export function legacyAreaLabels( const out: string[] = []; for (const area of areas ?? []) { if (area === "general") continue; - const key = `insights.coach.metric.${area}`; - const label = t(key); - if (label === key || out.includes(label)) continue; + const label = translateOrNull(t, `insights.coach.metric.${area}`); + if (label === null || out.includes(label)) continue; out.push(label); } return out; diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 2edc88416..f5983b58a 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -130,11 +130,12 @@ export function CoachReadStrip({ {isError ? ( void refetch()} - className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0" + className="flex-row items-center justify-between" /> ) : ( diff --git a/src/components/insights/derived/score-trend-chart.tsx b/src/components/insights/derived/score-trend-chart.tsx index c6a0f5464..04b0cc326 100644 --- a/src/components/insights/derived/score-trend-chart.tsx +++ b/src/components/insights/derived/score-trend-chart.tsx @@ -203,6 +203,7 @@ export function ScoreTrendChart({ ) : query.isError ? ( void; /** @@ -273,6 +268,7 @@ export function WellnessScores({ read, isLoading, isError = false, + error = null, refetch, extraTile, hideStrain = false, @@ -351,24 +347,13 @@ export function WellnessScores({ title={t("insights.derived.scores.sectionTitle")} subtitle={t("insights.derived.scores.sectionSubtitle")} /> -

- {t("insights.derived.scores.loadError")} - -
+ refetch?.()} + /> ); } diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx index 0624ba083..ff840a099 100644 --- a/src/components/insights/ecg-detail.tsx +++ b/src/components/insights/ecg-detail.tsx @@ -47,15 +47,8 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.insightsEcgDetail(recordingId, false), - queryFn: async () => { - try { - return await apiGet( - `/api/insights/ecg/${recordingId}`, - ); - } catch { - throw new Error(t("insights.ecg.loadError")); - } - }, + queryFn: () => + apiGet(`/api/insights/ecg/${recordingId}`), }); if (isError) { diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index 34e724bf3..e3bd802b5 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -6,6 +6,7 @@ import { Activity, ChevronRight, HeartPulse } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { ecgResultLabel, type EcgClassification, @@ -85,9 +86,41 @@ export function EcgSection({ const { t } = useTranslations(); const fmt = useFormatters(); - const { data: page, isLoading } = useEcgList(enabled); + const { + data: page, + isLoading, + isError, + error, + refetch, + } = useEcgList(enabled); const data = page?.data; + // A failed read is said, never swallowed into the empty gate below: on the + // dedicated page this list is the whole content. + if (isError && !data) { + return ( +
+ {!hideHeading && ( + + )} + void refetch()} + /> +
+ ); + } + // Data-availability gate — never paint an empty card. if (isLoading || !data || !data.hasRecordings) return null; diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index deff1ae99..da0cabb36 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -23,7 +23,7 @@ import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { DayLink } from "@/components/day/day-link"; import { Button } from "@/components/ui/button"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { axisUnitSuffix } from "@/lib/charts/axis-unit"; import { @@ -77,7 +77,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440]; * coarser hourly grain instead of an empty chart — `resolution` on the DTO * tells the caption which, and the tension read is always absent on an * hourly day (it needs per-sample resolution to stay honest). A failed fetch - * renders `` with a retry action instead of falling through + * renders `` with a retry action instead of falling through * to the empty-day copy. * * `initialDateKey` pins the first day shown, for callers that already know @@ -254,10 +254,11 @@ export function IntradayPulseChart({ {isLoading ? ( ) : isError ? ( - refetch()} - className="border-0 bg-transparent shadow-none" + slot="intraday-pulse-error" + onRetry={() => void refetch()} /> ) : (data?.series?.length ?? 0) < MIN_BUCKETS_FOR_COURSE ? ( // A lone reading or two on a 24-hour axis drew a dot in an empty diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx index 46148152d..c16adb3a7 100644 --- a/src/components/insights/mood/mood-what-stands-out.tsx +++ b/src/components/insights/mood/mood-what-stands-out.tsx @@ -81,6 +81,7 @@ export function MoodWhatStandsOut({ ) : null} {isError ? ( void refetch()} - /> + + + + + + void refetch()} + /> + + ); } diff --git a/src/components/insights/nutrients/hydration-card.tsx b/src/components/insights/nutrients/hydration-card.tsx index 2f722cba9..5d61aa219 100644 --- a/src/components/insights/nutrients/hydration-card.tsx +++ b/src/components/insights/nutrients/hydration-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { GlassWater } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; @@ -38,8 +38,25 @@ export function HydrationCard() { ), }); - if (isError) { - return ; + // The card keeps its frame and title on a failed read, like the caffeine + // card beside it, so the page does not change shape under the reader. + if (isError && !data) { + return ( + + + + + + void refetch()} + /> + + + ); } const todayTotal = data?.days.at(-1)?.amount ?? 0; diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 2495101b1..94249a5a1 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -18,6 +18,7 @@ import { AskCoachIconButton } from "@/components/insights/ask-coach-action"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { apiGet } from "@/lib/api/api-fetch"; import { InsightSectionCard } from "./insight-section-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.11.0 — period-narrative card (Pillar P1). @@ -121,13 +122,33 @@ export function PeriodNarrativeCard({ ); } - // The route never errors the page; on a fetch error or whenever there is no - // real generated narrative yet (empty account, or a first warm still in - // flight) the card un-mounts itself rather than showing an error, a - // "preparing" husk, or the methodology framing. The query stays mounted and - // polls in the background, so the card appears the moment a narrative lands. + // A failed read is said, with a retry: hiding it would look exactly like + // "no narrative yet". Whenever there is no real generated narrative yet + // (empty account, the capability off, or a first warm still in flight) the + // card un-mounts itself rather than showing a "preparing" husk or the + // methodology framing. The query stays mounted and polls in the background, + // so the card appears the moment a narrative lands. + if (query.isError && !query.data) { + return ( +
+ + void query.refetch()} + /> +
+ ); + } const narrative = query.data?.narrative ?? null; - if (query.isError || !narrative) { + if (!narrative) { return null; } diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 060b067ab..f21fa05de 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -21,6 +21,7 @@ import type { RecoveryEmptyReason, } from "@/lib/insights/recovery-empty"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import type { RestModeAnnotation } from "@/lib/analytics/score/types"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; @@ -79,7 +80,8 @@ const EMPTY_COPY = { */ export function RecoverySection() { const { t } = useTranslations(); - const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION"); + const { data, isLoading, error, refetch } = + useInsightsAnalytics("SLEEP_DURATION"); // v1.18.1 — Rest Mode parity. The annotation rides the thick analytics // payload's `healthScore` block (server-authoritative; iOS mirrors it). We // read it off the same `["analytics"]` cache the insights mother page @@ -190,6 +192,17 @@ export function RecoverySection() { // answer (`recoveryEmpty`), so web and iOS read one decision. const emptyReason = data?.recoveryEmpty ?? null; + // A failed read is said, never left as a skeleton that loads forever. + if (error && summaries == null) { + return ( + + ); + } + if (isLoading || summaries == null) { return (
@@ -207,11 +220,15 @@ export function RecoverySection() { title={t("insights.recovery.emptyTitle")} description={t(EMPTY_COPY[emptyReason])} action={ - + // Waiting for a first resilience level has nothing to open: the + // connection is fine, the level simply has not arrived. + emptyReason === "oura_no_resilience" ? undefined : ( + + ) } /> ); diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index 92d7ee1da..981b1057d 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -100,15 +100,10 @@ export function RhythmEventsCard({ refetch, } = useQuery({ queryKey: queryKeys.insightsRhythmEvents(), - queryFn: async () => { - try { - return await apiGetList( - "/api/insights/rhythm-events", - ); - } catch { - throw new Error(t("insights.rhythmEvents.loadError")); - } - }, + // The ApiError passes through untouched: the error row reads its status + // to tell an ended session from a real failure. + queryFn: () => + apiGetList("/api/insights/rhythm-events"), enabled: enabled && isAuthenticated, }); const data = page?.data; diff --git a/src/components/insights/use-ecg-list.ts b/src/components/insights/use-ecg-list.ts index f49491178..6a2b41dd0 100644 --- a/src/components/insights/use-ecg-list.ts +++ b/src/components/insights/use-ecg-list.ts @@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { apiGetList } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { EcgRecordingListItem } from "./ecg-section"; @@ -22,16 +21,11 @@ export interface EcgListResponse { */ export function useEcgList(enabled = true) { const { isAuthenticated } = useAuth(); - const { t } = useTranslations(); return useQuery({ queryKey: queryKeys.insightsEcgList(), - queryFn: async () => { - try { - return await apiGetList("/api/insights/ecg"); - } catch { - throw new Error(t("insights.ecg.loadError")); - } - }, + // The ApiError passes through untouched: the error row reads its status + // and code to tell an ended session from a real failure. + queryFn: () => apiGetList("/api/insights/ecg"), enabled: enabled && isAuthenticated, }); } diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx index 5286b8ee4..d262f13d3 100644 --- a/src/components/insights/workout-detail/day-context.tsx +++ b/src/components/insights/workout-detail/day-context.tsx @@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({ {sleep.isError ? ( // A failed read is not an absent night. {mood.isError ? ( { const Icon = iconForSport(workout.sportType); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; - const sportLabel = t(sportLabelKey); // Translation keys missing from one locale fall back to the // canonical sport-type string so a new HK enum value never // renders the raw `insights.workouts.sport.x` placeholder. const sportName = - sportLabel === sportLabelKey ? workout.sportType : sportLabel; + translateOrNull(t, sportLabelKey) ?? workout.sportType; return ( // v1.42 — the row opens the workout and its date opens the day: diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx index 010cfacfa..aadcfd923 100644 --- a/src/components/links/related-records.tsx +++ b/src/components/links/related-records.tsx @@ -63,6 +63,7 @@ import type { EncounterDTO } from "@/lib/encounters/dto"; import type { IllnessEpisodeDTO } from "@/lib/illness/dto"; import type { RelatedLink } from "@/lib/links/related"; import type { EncounterKind } from "@/generated/prisma/client"; +import { translateOrNull } from "@/lib/i18n/translate-code"; type Translate = ReturnType["t"]; type RelatedKind = RelatedLink["kind"]; @@ -114,9 +115,8 @@ function rowLabel(t: Translate, row: RelatedLink): string { // A dose without a written name arrives as its catalogue slug ("tetanus"), // named here from the reader's bundle the way the dose picker names it. if (row.kind === "vaccination" && /^[a-z0-9_-]+$/.test(row.label)) { - const key = `vaccinations.catalog.${row.label}`; - const name = t(key); - if (name !== key) return name; + const name = translateOrNull(t, `vaccinations.catalog.${row.label}`); + if (name !== null) return name; } return row.label; } diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index a586e60c3..161f6551e 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -44,6 +44,7 @@ import { useMeasurementReminderMutations, type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; +import { translateOrNull } from "@/lib/i18n/translate-code"; const SUMMARY_LIMIT = 3; @@ -61,8 +62,7 @@ function resolveLabel( t: (key: string) => string, ): string { if (reminder.origin === "COACH") { - const translated = t(reminder.label); - return translated === reminder.label ? reminder.label : translated; + return translateOrNull(t, reminder.label) ?? reminder.label; } return reminder.label; } @@ -166,7 +166,7 @@ export function VorsorgeDashboardCard() { ) : isError ? ( // A read failure must not read as "no upcoming reminders" — a missed // checkup is the harm behind that fall-through (§6). - refetch()} /> + refetch()} /> ) : upcoming.length === 0 ? (

diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 7407ef3df..d813ebbcd 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -103,6 +103,7 @@ import { shiftDateKey, userDayKey, } from "@/lib/tz/format"; +import { translateOrNull } from "@/lib/i18n/translate-code"; // v1.18.1 — the active-measurement set, grouped so the dropdown // doesn't read as a flat 15-item list. Mirrors the Zod allow-list in @@ -169,10 +170,9 @@ function resolveReminderLabel( t: (key: string) => string, ): string { if (reminder.origin === "COACH") { - const translated = t(reminder.label); - // `t()` echoes the key back on a miss; fall back to the raw value so a - // future catalog addition never surfaces a bare key. - return translated === reminder.label ? reminder.label : translated; + // A label the catalogue has no sentence for shows as written, so a future + // catalog addition never surfaces a bare key. + return translateOrNull(t, reminder.label) ?? reminder.label; } return reminder.label; } diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx index 26f2475d3..e6649a806 100644 --- a/src/components/measurement-reminders/vorsorge-settings.tsx +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -29,14 +29,14 @@ import { useMeasurementReminderMutations, type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; +import { translateOrNull } from "@/lib/i18n/translate-code"; function resolveLabel( reminder: MeasurementReminder, t: (key: string) => string, ): string { if (reminder.origin === "COACH") { - const translated = t(reminder.label); - return translated === reminder.label ? reminder.label : translated; + return translateOrNull(t, reminder.label) ?? reminder.label; } return reminder.label; } diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index e52462fd2..0ff9aa79a 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -114,6 +114,7 @@ import { } from "@/components/medications/dose-history-ledger-compute"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -210,7 +211,7 @@ export function DoseHistoryLedger({ // `from` travels: the server's `to` defaults to its own now. const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey, queryFn: async () => { const from = new Date(Date.now() - windowDays * DAY_MS).toISOString(); @@ -465,9 +466,14 @@ export function DoseHistoryLedger({ )} {isError && ( -

- {t("medications.detail.verlauf.loadError")} -

+ void refetch()} + className="py-6" + /> )} {data && !isLoading && groups.length === 0 && ( diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 82d8ee2be..628da2c17 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -60,6 +60,7 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface RevisionEntrySummary { timesOfDay: string[]; @@ -116,7 +117,7 @@ export function ScheduleHistoryTimeline({ null, ); - const { data, isError } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: queryKeys.medicationScheduleRevisions(medicationId), queryFn: async () => apiGet( @@ -151,11 +152,15 @@ export function ScheduleHistoryTimeline({ }, }); - if (isError) { + if (isError && !data) { return ( -

- {t("medications.detail.zeitplan.history.loadError")} -

+ void refetch()} + /> ); } if (!data) return null; diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index bb2bed055..5c00ea61f 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -49,6 +49,7 @@ import { queryKeys, } from "@/lib/query-keys"; import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; type PhaseMode = "MINUTES" | "PERCENT"; @@ -137,7 +138,7 @@ export function PhaseConfigSheet({ }: PhaseConfigSheetProps) { const { t } = useTranslations(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.medicationPhaseConfig(medicationId), queryFn: async () => { return fromApi( @@ -170,13 +171,12 @@ export function PhaseConfigSheet({ />
) : isError ? ( -

- {t("common.loadFailed")} -

+ void refetch()} + /> ) : data ? ( apiGet<{ assessment: AssessmentDetail }>( @@ -62,13 +63,13 @@ export function AssessmentItemBreakdown({ if (isError || !data) { return ( -

- {t("mentalHealth.error")} -

+ void refetch()} + /> ); } diff --git a/src/components/onboarding/baseline-form-utils.ts b/src/components/onboarding/baseline-form-utils.ts index c51a95722..5fefd2ffb 100644 --- a/src/components/onboarding/baseline-form-utils.ts +++ b/src/components/onboarding/baseline-form-utils.ts @@ -5,6 +5,7 @@ import { type RejectedProfileField, } from "@/lib/profile/rejected-fields"; import { toProfileSex } from "@/lib/profile/sex"; +import { translateCode } from "@/lib/i18n/translate-code"; /** * Reading the answer to the onboarding baseline step's profile write. @@ -181,10 +182,5 @@ function localizedCode( errorCode: string | undefined, t: (key: string, params?: Record) => string, ): string { - if (errorCode) { - const key = `apiErrors.${errorCode}`; - const translated = t(key); - if (translated !== key) return translated; - } - return t("onboarding.errorGeneric"); + return translateCode(t, "apiErrors", errorCode, "onboarding.errorGeneric"); } diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 1ccb24492..4601462d1 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -837,14 +837,16 @@ describe("what a refused guardian invitation says", () => { why: "a 404 WITH a code — not the same as an unknown identifier", }, { - err: new ApiError("refused", 404), + err: new ApiError("refused", 404, { + errorCode: "sharing.invite.no_account", + }), key: "recordSharing.managed.guardianErrorNoAccount", - why: "the codeless 404: no account carries that name or e-mail", + why: "the other 404: no account carries that name or e-mail", }, { - err: new ApiError("refused", 429), + err: new ApiError("refused", 429, { errorCode: "http.rate_limited" }), key: "recordSharing.managed.guardianErrorRateLimit", - why: "the other codeless arm, where waiting is the whole recovery", + why: "a 429 has one meaning, where waiting is the whole recovery", }, { err: new ApiError("refused", 401, { @@ -872,16 +874,28 @@ describe("what a refused guardian invitation says", () => { } it("tells the two 404s apart, which is the whole reason for the order", () => { - // The route publishes a code on one 404 and not on the other. Branching on - // status first would tell somebody to check a spelling when the real - // answer is that they are no longer a Guardian of that profile. + // Each 404 names itself. Branching on the status would tell somebody to + // check a spelling when the real answer is that they are no longer a + // Guardian of that profile, and a 404 that names neither is not an + // unknown name either. expect( guardianInviteErrorKey( new ApiError("refused", 404, { errorCode: "managed_profile.not_found", }), ), - ).not.toBe(guardianInviteErrorKey(new ApiError("refused", 404))); + ).not.toBe( + guardianInviteErrorKey( + new ApiError("refused", 404, { + errorCode: "sharing.invite.no_account", + }), + ), + ); + expect( + guardianInviteErrorKey( + new ApiError("refused", 404, { errorCode: "http.not_found" }), + ), + ).toBe("recordSharing.managed.guardianErrorFailed"); // And all nine cases above resolve to distinct sentences rather than one: // the failure this table is written against is one message for everything. expect( diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index a4828182a..eb3544759 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -747,12 +747,11 @@ export function inviteErrorKey(err: unknown): string { // sent" is not. case "sharing.invite.manage_browser_only": return "recordSharing.invite.errorManageBrowserOnly"; + case "sharing.invite.no_account": + return "recordSharing.invite.errorNoAccount"; default: break; } - if (err instanceof ApiError && err.status === 404) { - return "recordSharing.invite.errorNoAccount"; - } if (err instanceof ApiError && err.status === 429) { return "recordSharing.invite.errorRateLimited"; } diff --git a/src/components/settings/access/managed-profile-guardians.tsx b/src/components/settings/access/managed-profile-guardians.tsx index b6f831e2f..0526868ba 100644 --- a/src/components/settings/access/managed-profile-guardians.tsx +++ b/src/components/settings/access/managed-profile-guardians.tsx @@ -308,18 +308,12 @@ export function ManagedProfileGuardians({ /** * The message for a refused Guardian invitation. * - * Six refusals, and **two of them carry no `errorCode`** — the rate limit - * (`guardians/route.ts`, before the body is even read) and the unknown-account - * 404. That asymmetry is the route's actual shape, not laziness here: the - * other four arms publish a stable code and are matched on it, and these two - * have to be told apart by status because there is nothing else to branch on. - * Recorded in `02-13.1-CONTRACT-FINDINGS.md` so whoever freezes the contract - * decides deliberately whether to close it. - * - * The code switch runs FIRST, and the order matters: `managed_profile.not_found` - * is a 404 WITH a code, and an unknown account is a 404 without one. Branching - * on status first would tell somebody to check the spelling of a name when the - * real answer is that they are not a Guardian of that profile any more. + * Six refusals, matched on their code. The route answers 404 twice, and the + * two need different next steps: `sharing.invite.no_account` asks somebody to + * check the spelling of a name, `managed_profile.not_found` says they are not + * a Guardian of that profile any more. Branching on the status would collapse + * them, so only the statuses with a single meaning fall back to the status: a + * 429 (wait, whatever the code) and a 401 (the step-up gate). */ export function guardianInviteErrorKey(err: unknown): string { if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline"; @@ -340,14 +334,13 @@ export function guardianInviteErrorKey(err: unknown): string { return "recordSharing.managed.guardianErrorManagedInvitee"; case "managed_profile.not_found": return "recordSharing.actionError.profileNotFound"; + case "sharing.invite.no_account": + return "recordSharing.managed.guardianErrorNoAccount"; default: break; } - // The two codeless arms. Waiting is the recovery for one and correcting the - // identifier is the recovery for the other, so they must not collapse. if (err.status === 429) return "recordSharing.managed.guardianErrorRateLimit"; - if (err.status === 404) return "recordSharing.managed.guardianErrorNoAccount"; // The step-up gate this whole family carries. Not a failure — and it comes // in two shapes, because an account with no factor enrolled cannot clear it. if (err.status === 401) return stepUpErrorKey(err); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 76dcbfc95..860586e40 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -58,6 +58,7 @@ import { import { AvatarSection } from "./avatar-section"; import { SetupCard } from "./setup-card"; import { dateOnlyKey } from "@/lib/tz/date-only"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -368,10 +369,7 @@ export function AccountSection() { setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t)); if (errorCode === "profile.update.nothingSaved" && field) { setSaveMsg({ key: "settings.profileNothingSaved", params: { field } }); - } else if ( - errorCode && - t(`apiErrors.${errorCode}`) !== `apiErrors.${errorCode}` - ) { + } else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) { // Only a code with a sentence of its own: every refusal carries a // code now, and the generic `http.*` family has no translation. setSaveMsg({ key: `apiErrors.${errorCode}` }); diff --git a/src/components/settings/coach-conversations-memory-card.tsx b/src/components/settings/coach-conversations-memory-card.tsx index aeacc80c0..1fde00d09 100644 --- a/src/components/settings/coach-conversations-memory-card.tsx +++ b/src/components/settings/coach-conversations-memory-card.tsx @@ -80,6 +80,7 @@ export function CoachConversationsMemoryCard({ /> {history.isError ? ( void history.refetch()} /> diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index bb3b2f94e..c5e34a9e2 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -326,6 +326,7 @@ export function CoachMemorySection({ {query.isError && ( query.refetch()} diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index b340500a5..3c040e3ba 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -188,6 +188,7 @@ export function CoachRemindersSection({ {query.isError && ( query.refetch()} diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index cbcb16491..4ba5a3912 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -16,6 +16,7 @@ import { HeartPulse, Upload } from "lucide-react"; import { formatRelative } from "@/components/settings/integration-status-pill"; import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; @@ -303,6 +304,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { data: status, isLoading, isError, + error, + refetch, } = useQuery({ queryKey: queryKeys.healthKitStatus(), queryFn: () => apiGet("/api/integrations/healthkit"), @@ -384,9 +387,12 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { } > {isError ? ( -

- {t("settings.appleHealth.status.unavailable")} -

+ void refetch()} + /> ) : null} ); diff --git a/src/components/settings/integrations/dawarich-card.tsx b/src/components/settings/integrations/dawarich-card.tsx index 4fd606aad..a0ca2cf70 100644 --- a/src/components/settings/integrations/dawarich-card.tsx +++ b/src/components/settings/integrations/dawarich-card.tsx @@ -47,6 +47,7 @@ import { integrationDocsHref, } from "./setup-guide-link"; import type { IntegrationStatusViewModel } from "./shared"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface DawarichStatus { connected: boolean; @@ -298,9 +299,13 @@ function DawarichHome() { {t("settings.dawarichHomePlace")} {places.isError ? ( -

- {t(errorKeyOf(places.error))} -

+ void places.refetch()} + /> ) : ( apiGet("/api/settings/privacy-summary"), @@ -141,7 +144,15 @@ export function PrivacySection() {
- ) : summaryError || !summary ? ( + ) : summaryError && !summary ? ( + void refetchSummary()} + /> + ) : !summary ? (

{t("settings.privacy.retention.unavailable")}

diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 985e771ff..cab9665b6 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -445,6 +445,7 @@ export function ScoreSection() { {analyticsQuery.isError ? ( refetch()} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index d2059b6db..69373a1f5 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -165,6 +165,7 @@ export function SecuritySessionsCard({ {isError && ( refetch()} diff --git a/src/components/settings/test-connection-button.tsx b/src/components/settings/test-connection-button.tsx index 568e7cb9b..d0db1a295 100644 --- a/src/components/settings/test-connection-button.tsx +++ b/src/components/settings/test-connection-button.tsx @@ -6,6 +6,7 @@ import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { translateCode } from "@/lib/i18n/translate-code"; /** * Shared "Test connection" UX for the settings integrations + notifications @@ -151,13 +152,12 @@ export function TestConnectionButton({ } function describeError(errorCode: string): string { - const key = `settings.testConnection.errors.${errorCode}`; - const translated = t(key); - // `t()` returns the raw key when missing — fall back to generic. - if (translated === key) { - return t("settings.testConnection.errors.generic"); - } - return translated; + return translateCode( + t, + "settings.testConnection.errors", + errorCode, + "settings.testConnection.errors.generic", + ); } return ( diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 093f0de21..20fa7b45f 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -105,6 +105,7 @@ export function TrustedDevicesCard({ {isError && ( refetch()} diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index 632fd3691..f09528c7e 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -24,6 +24,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import type { WebhookPayloadFormat } from "@/lib/notifications/types"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; interface WebhookSettings { enabled: boolean; @@ -89,13 +90,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { }; // A coded refusal (the private-origin policy) translates like the // test button's; anything else shows the server sentence. - const code = json.meta?.errorCode; - const key = code ? `settings.testConnection.errors.${code}` : null; - const translated = key ? t(key) : null; throw new Error( - translated && translated !== key - ? translated - : json.error || t("common.error"), + translateCodeOrNull( + t, + "settings.testConnection.errors", + json.meta?.errorCode, + ) ?? + (json.error || t("common.error")), ); } }, diff --git a/src/components/timeline/__tests__/no-template-key-guard.test.ts b/src/components/timeline/__tests__/no-template-key-guard.test.ts index 7c460c90c..65060f24f 100644 --- a/src/components/timeline/__tests__/no-template-key-guard.test.ts +++ b/src/components/timeline/__tests__/no-template-key-guard.test.ts @@ -24,8 +24,12 @@ import { const ROOTS = ["src/components/timeline", "src/components/day"] as const; -/** `t(` or `tCount(` whose key is a template literal, across line breaks. */ -const TEMPLATE_KEY = /(?> = { "src/components/timeline/readiness-inventory.tsx": { diff --git a/src/components/timeline/readiness-inventory.tsx b/src/components/timeline/readiness-inventory.tsx index 68c27df50..84c004e96 100644 --- a/src/components/timeline/readiness-inventory.tsx +++ b/src/components/timeline/readiness-inventory.tsx @@ -34,6 +34,7 @@ import { } from "./label-keys"; import { formatMonthYear } from "./timeline-dates"; import { orderedReadinessLanes, readinessTally } from "./readiness-model"; +import { translateOrNull } from "@/lib/i18n/translate-code"; type Translate = ReturnType["t"]; type TranslateCount = ReturnType["tCount"]; @@ -65,11 +66,10 @@ export function wordKey( params: Record, ): string | null { const count = typeof params.count === "number" ? params.count : null; - if (count !== null && t(`${base}Other`) !== `${base}Other`) { + if (count !== null && translateOrNull(t, `${base}Other`) !== null) { return tCount(base, count, params); } - const single = t(base, params); - return single === base ? null : single; + return translateOrNull(t, base, params); } export function laneLabel(t: Translate, key: TimelineReadinessLane["key"]) { diff --git a/src/components/ui/__tests__/query-error-primitives.test.tsx b/src/components/ui/__tests__/query-error-primitives.test.tsx index 3edb66fa3..4a63fe044 100644 --- a/src/components/ui/__tests__/query-error-primitives.test.tsx +++ b/src/components/ui/__tests__/query-error-primitives.test.tsx @@ -58,6 +58,21 @@ describe("readFailureKind", () => { expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); expect(readFailureKind(null)).toBe("failed"); }); + + it("reads the code before the status", () => { + const withCode = (status: number, errorCode: string) => + readFailureKind(new ApiError("x", status, { errorCode })); + // A step-up or re-proof gate answers 401 on a session that is still good: + // sending that reader to sign-in would end it. + expect(withCode(401, "auth.stepup.required")).toBe("failed"); + expect(withCode(401, "auth.reproof.required")).toBe("failed"); + expect(withCode(401, "auth.mfa.code_invalid")).toBe("failed"); + expect(withCode(401, "auth.token.expired")).toBe("unauthenticated"); + expect(withCode(401, "http.unauthenticated")).toBe("unauthenticated"); + // An unknown code falls back to the status. + expect(withCode(401, "auth.something.new")).toBe("unauthenticated"); + expect(withCode(403, "http.forbidden")).toBe("refused"); + }); }); describe.each([ @@ -76,7 +91,7 @@ describe.each([ it("says a spent budget without offering a retry", () => { const html = render(new ApiError("Too many", 429)); - expect(html).toContain("Too many requests just now."); + expect(html).toContain("There are too many requests just now"); expect(html).not.toContain("retry"); expect(html).not.toContain("Could not load"); expect(html).not.toContain("Something on our side."); @@ -85,7 +100,7 @@ describe.each([ it("offers sign-in for an ended session, never a blank", () => { const html = render(new ApiError("Unauthorized", 401)); expect(html).not.toBe(""); - expect(html).toContain("Your session has ended."); + expect(html).toContain("Your session has ended"); expect(html).toContain('data-slot="query-error-sign-in"'); expect(html).not.toContain("retry"); }); diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx index 8177e297c..0ac706170 100644 --- a/src/components/ui/query-error-card.tsx +++ b/src/components/ui/query-error-card.tsx @@ -53,10 +53,10 @@ export function QueryErrorCard({