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 {t("x.loadError")} {t("x.saveError")}
- {t("admin.section.encryption.loadError")} -
+ {statusQuery.isError ? ( +- {t("admin.invites.loadError")} -
+- {t("admin.providerHealth.loadError")} -
+
{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 ? (
{t("common.loadFailed")}
-,
) {
- 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({
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
- {t("medications.detail.verlauf.loadError")}
-
- {t("medications.detail.zeitplan.history.loadError")}
-
- {t("common.loadFailed")}
-
- {t("mentalHealth.error")}
-
- {t("settings.appleHealth.status.unavailable")}
-
- {t(errorKeyOf(places.error))}
-
{t("settings.privacy.retention.unavailable")}
{failure.message}