Merge branch 'fix/v1437-r2-errors' into release/v1.43.7

# Conflicts:
#	src/components/charts/health-chart.tsx
#	src/components/insights/ecg-cross-link.tsx
#	src/components/insights/ecg-section.tsx
#	src/components/insights/rhythm-events-card.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:17:01 +02:00
commit 86eb743a69
134 files changed
+1567 -594

No files matched your search

+25 -23
View File
@@ -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;
+7 -5
View File
@@ -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)",
+7 -5
View File
@@ -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)",
+7 -5
View File
@@ -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)",
+8 -6
View File
@@ -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)",
+7 -5
View File
@@ -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)",
+9 -7
View File
@@ -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)",
+7 -5
View File
@@ -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)",
+7 -5
View File
@@ -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)",
@@ -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([]);
});
});
@@ -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<string>();
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("(?<![\\w.])isError");
if (alternatives.length === 0) return [];
const branch = new RegExp(
`(?:${alternatives.join("|")})\\b[^\\n]*?(\\?|&&)`,
"g",
);
const found: Array<{ line: number; handRolled: boolean }> = [];
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(/<QueryErrorRow\b([\s\S]*?)\/>/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(`</${tag}>`, "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 ? (
<p className="text-destructive text-sm">{t("x.loadError")}</p>
) : null;`;
expect(readErrorBranches(rolled)).toEqual([{ line: 3, handRolled: true }]);
const mutation = `
const save = useSave();
save.mutate(body);
return save.isError ? <p role="alert">{t("x.saveError")}</p> : null;`;
expect(readErrorBranches(mutation)).toEqual([]);
const primitive = `
const list = useList();
return list.isError ? (
<QueryErrorRow error={list.error} onRetry={list.refetch} />
) : 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(
`<Card>\n<CardContent>\n<QueryErrorRow error={e} onRetry={() => r()} />\n</CardContent>\n</Card>`,
),
).toEqual([3]);
expect(
framedRowsInCards(
`<Card>\n</Card>\n<QueryErrorRow error={e} onRetry={() => 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 }) =>
/<QueryErrorRow\b[^/]*\binset\b/.test(source),
);
expect(inset.length).toBeGreaterThan(20);
});
it("insets every row a card already frames", () => {
const offenders = files.flatMap(({ file, source }) =>
framedRowsInCards(source).map((line) => `${file}:${line}`),
);
expect(offenders).toEqual([]);
});
});
@@ -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",
);
@@ -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([]);
});
});
@@ -188,7 +188,7 @@ const TOKEN_IN_ERROR_422: Record<string, string> = {
"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":
+8 -2
View File
@@ -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();
@@ -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());
@@ -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({
+1
View File
@@ -365,6 +365,7 @@ export default function MetricCatalogPage() {
<CardContent className="space-y-2">
{failed.length > 0 ? (
<QueryErrorRow
inset
error={probes[failed[0]].error}
slot="catalog-group-error"
message={t("metricCatalog.loadError")}
+2 -7
View File
@@ -327,13 +327,7 @@ export default function InsightsPageClient() {
// the regular shell and the tiles fill in as their data lands.
const { data, isLoading, isFetched, isError } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
try {
return await apiGet<ComprehensiveData>("/api/insights/comprehensive");
} catch {
throw new Error(t("insights.loadError"));
}
},
queryFn: () => apiGet<ComprehensiveData>("/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
+2 -3
View File
@@ -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 (
@@ -104,6 +104,7 @@ export function AiQualitySection() {
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
+1
View File
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
+10 -3
View File
@@ -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")}
</p>
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
{statusQuery.isError ? (
<QueryErrorRow
inset
error={statusQuery.error}
slot="encryption-status-error"
message={t("admin.section.encryption.loadError")}
onRetry={() => void statusQuery.refetch()}
/>
) : null}
</SettingsCard>
</div>
);
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
}
/>
<QueryErrorRow
inset
error={error}
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
+14 -4
View File
@@ -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<AdminInvite | null>(null);
const { data: invites, isError } = useQuery({
const {
data: invites,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.adminInvites(),
queryFn: async () => {
return apiGet<AdminInvite[]>("/api/admin/invites");
@@ -313,9 +319,13 @@ export function InviteTokensSection() {
/>
{isError && (
<p className="text-destructive text-sm">
{t("admin.invites.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="admin-invites-error"
message={t("admin.invites.loadError")}
onRetry={() => void refetch()}
/>
)}
{invites && invites.length === 0 && (
+1
View File
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
/>
{status.isError ? (
<QueryErrorRow
inset
error={status.error}
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
@@ -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})`
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.offhost.loadError")}
onRetry={() => void refetch()}
@@ -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")}
</p>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="provider-health-error"
message={t("admin.providerHealth.loadError")}
onRetry={() => void refetch()}
/>
) : providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.empty")}
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<QueryErrorRow
inset
error={error}
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
@@ -110,6 +110,7 @@ export function SystemStatusSummary() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}
@@ -130,16 +130,32 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
props: Partial<import("../chart-error-state").ChartErrorStateProps> = {},
) {
const { ChartErrorState } = await import("../chart-error-state");
const { I18nProvider } = await import("@/lib/i18n/context");
return renderToStaticMarkup(
<ChartErrorState
title="Data could not be loaded"
actionLabel="Retry"
onAction={() => undefined}
{...props}
/>,
<I18nProvider initialLocale="en">
<ChartErrorState
error={new Error("boom")}
title="Data could not be loaded"
actionLabel="Retry"
onAction={() => undefined}
{...props}
/>
</I18nProvider>,
);
}
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("<button");
expect(await renderState()).toContain("Retry");
});
it("announces the failure as a status by default", async () => {
const html = await renderState();
expect(html).toContain('role="status"');
+47 -21
View File
@@ -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. `<ChartErrorBoundary>` 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 (
<div
data-slot="chart-error-state"
@@ -73,42 +89,48 @@ export function ChartErrorState({
style={height === undefined ? undefined : { height }}
>
<AlertTriangle
className="text-muted-foreground h-8 w-8"
className="text-muted-foreground size-6"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title}</p>
<Button
variant="outline"
size="sm"
onClick={onAction}
aria-label={
actionContext ? `${actionLabel}: ${actionContext}` : undefined
}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
{actionLabel}
</Button>
<p className="text-sm font-medium">{failure.message}</p>
{action ? (
<Button
variant="outline"
size="sm"
onClick={action.onClick}
data-slot={
action.kind === "signIn" ? "query-error-sign-in" : undefined
}
aria-label={actionContext ? `${label}: ${actionContext}` : undefined}
>
{action.kind === "retry" ? (
<RotateCw className="size-3.5" aria-hidden="true" />
) : null}
{label}
</Button>
) : null}
</div>
);
}
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 `<ChartErrorBoundary>` instead. */
export class ChartBoundary extends Component<BoundaryProps, BoundaryState> {
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<BoundaryProps, BoundaryState> {
}
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 (
<ChartBoundary
onError={onError}
fallback={
fallback={(error) => (
// 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"
>
<ChartErrorState
error={error}
title={t("charts.loadErrorTitle")}
actionLabel={t("charts.loadErrorReload")}
onAction={() => window.location.reload()}
role="alert"
/>
</div>
}
)}
>
{children}
</ChartBoundary>
+6 -3
View File
@@ -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".
<ChartErrorState
error={error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={title}
@@ -270,7 +270,7 @@ export function MedicationComplianceChart({
// `["dashboard-medication-compliance"]` lands in
// `medicationDependentKeys` and an intake POST refreshes the chart
// immediately rather than waiting for `staleTime` (audit L4).
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.dashboardMedicationCompliance(days),
queryFn: async (): Promise<DailyCompliancePoint[]> => {
return apiGet<DailyCompliancePoint[]>(
@@ -548,6 +548,7 @@ export function MedicationComplianceChart({
// through to the empty branch (data undefined → hasData false)
// and an outage read as "no doses tracked".
<ChartErrorState
error={error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={displayTitle}
+2 -1
View File
@@ -369,7 +369,7 @@ export function MoodChart({
// cache fresh; the chart's own subscription becomes the cache writer
// for any insights / mood sub-page that mounts the chart without the
// dashboard parent having fetched first.
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async (): Promise<MoodAnalyticsData> => {
return apiGet<MoodAnalyticsData>("/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).
<ChartErrorState
error={error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={displayTitle}
@@ -61,9 +61,15 @@ vi.mock("next/link", () => ({
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("<CycleInsightSummaryCard>", () => {
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(<CycleInsightSummaryCard />);
expect(html).toBe("");
expect(html).toContain('data-slot="cycle-insight-summary-error"');
expect(html).toContain('data-slot="query-error-row-retry"');
});
});
@@ -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 (
<section
data-slot="cycle-insight-summary-section"
aria-label={t("cycle.insightsSummary.title")}
className="space-y-3"
>
<SectionHeading
icon={Droplets}
title={t("cycle.insightsSummary.title")}
/>
<QueryErrorRow
error={calendar.error}
slot="cycle-insight-summary-error"
onRetry={() => void calendar.refetch()}
/>
</section>
);
}
const phase = verdict?.phase ?? null;
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
+1 -1
View File
@@ -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).
<QueryErrorRow
inset
error={calendar.error}
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
/>
) : (
<>
@@ -91,10 +91,12 @@ describe("<RecentWorkoutsTile>", () => {
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"');
@@ -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 — `<RecentWorkoutsTile>`.
@@ -104,10 +105,11 @@ export function RecentWorkoutsTile() {
))}
</div>
) : isError ? (
<QueryErrorCard
<QueryErrorRow
inset
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
slot="recent-workouts-error"
onRetry={() => void refetch()}
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
@@ -122,9 +124,8 @@ export function RecentWorkoutsTile() {
<ul className="space-y-2">
{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 (
<li key={workout.id}>
<Link
+23 -7
View File
@@ -194,14 +194,31 @@ export function SinceLastVisit({
})
: [];
const lastLabel = fmt.dateShortSmartCalendar(lastDay);
// A failed read keeps the card and its title, so the page does not jump
// and the reader still sees what could not be loaded.
if (window.isError && rows.length === 0) {
return (
<QueryErrorRow
error={window.error}
slot="since-last-visit-error"
message={t("day.sinceLastVisit.loadError")}
onRetry={() => void window.refetch()}
/>
<Card className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
size="sm"
icon={Stethoscope}
titleAs="h3"
title={t("day.sinceLastVisit.title", { date: lastLabel })}
/>
</CardHeader>
<CardContent>
<QueryErrorRow
inset
error={window.error}
slot="since-last-visit-error"
message={t("day.sinceLastVisit.loadError")}
onRetry={() => void window.refetch()}
/>
</CardContent>
</Card>
);
}
// 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 (
@@ -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 ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
<QueryErrorRow
inset
error={detail.error}
slot="document-detail-error"
message={t("documents.detail.loadError")}
onRetry={() => void detail.refetch()}
/>
) : null}
{doc ? (
@@ -164,7 +164,7 @@ describe("<ProceduresSection>", () => {
bodySites: [],
total: 1,
});
expect(html).toMatch(/font-medium">Meniscus repair</);
expect(html).toMatch(/card-title"[^>]*>Meniscus repair</);
expect(html).toContain("Sample clinic");
expect(html.match(/Meniscus repair/g)).toHaveLength(1);
});
@@ -261,7 +261,10 @@ describe("<VisitsSection> 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
+15 -17
View File
@@ -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({
>
<CardContent className="space-y-2">
<Body onOpen={onOpen} encounter={encounter}>
<div className="flex items-start gap-2">
<CalendarClock
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{heading}
</p>
<p className="text-muted-foreground text-xs">
{format.dateTime(encounter.occurredAt)}
</p>
</div>
<Badge variant="outline" className="shrink-0">
{encounterKindText(t, encounter.kind as EncounterKind)}
</Badge>
</div>
<TileHeader
size="sm"
icon={CalendarClock}
title={heading}
titleClassName="min-w-0 truncate"
right={
<Badge variant="outline" className="shrink-0">
{encounterKindText(t, encounter.kind as EncounterKind)}
</Badge>
}
/>
<p className="text-muted-foreground text-xs">
{format.dateTime(encounter.occurredAt)}
</p>
{reasonLine ? (
<p className="text-foreground line-clamp-1 text-sm">{reasonLine}</p>
@@ -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 (
<Card>
@@ -186,7 +188,15 @@ export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : isError || !data || data.status !== "ok" || !data.value ? (
) : isError && !data ? (
// A failed read is not "still learning": it says so and can retry.
<QueryErrorRow
inset
error={error}
slot="illness-correlation-error"
onRetry={() => void refetch()}
/>
) : !data || data.status !== "ok" || !data.value ? (
<p className="text-muted-foreground text-sm">
{t("illness.correlation.learning")}
</p>
@@ -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({
<Skeleton className="h-4 w-2/3" />
</div>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("illness.timeline.error")}
</p>
<QueryErrorRow
inset
error={error}
slot="illness-timeline-error"
message={t("illness.timeline.error")}
onRetry={() => void refetch()}
/>
) : dayLogs.length === 0 ? (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
@@ -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({
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : counts.isError && !data ? (
<QueryErrorRow
inset
error={counts.error}
slot="illness-insights-error"
onRetry={() => void counts.refetch()}
/>
) : !hasEpisodes ? (
<p className="text-muted-foreground text-sm">
{t("illness.insights.none")}
@@ -83,11 +91,14 @@ export function IllnessInsightsCard({
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
</div>
) : gap.isError || !gap.data ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : gap.data.typicalRecoveryGapDays === null ? (
) : gap.isError && !gap.data ? (
<QueryErrorRow
inset
error={gap.error}
slot="illness-insights-gap-error"
onRetry={() => void gap.refetch()}
/>
) : !gap.data || gap.data.typicalRecoveryGapDays === null ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
@@ -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<typeof import("@/lib/api/api-fetch")>()),
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("<EcgSection>", () => {
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<unknown>;
};
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(
<I18nProvider initialLocale="en">
<EcgSection />
</I18nProvider>,
);
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(
@@ -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("<RecoverySection> 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("<RecoverySection> 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");
@@ -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<typeof import("@/lib/api/api-fetch")>()),
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("<RhythmEventsCard>", () => {
it("hands the read's ApiError through, so a 401 offers sign-in", async () => {
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RhythmEventsCard />
</I18nProvider>,
);
const { queryFn } = useQueryMock.mock.calls.at(-1)![0] as {
queryFn: () => Promise<unknown>;
};
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(
@@ -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 {
@@ -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({
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
<QueryErrorRow
inset
error={list.error}
slot="coach-attachment-picker-error"
message={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
@@ -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;
@@ -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
@@ -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 (
<div className="flex flex-col items-start gap-2 text-sm">
<p role="alert" className="text-destructive">
{t("common.loadFailed")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void chainQuery.refetch();
void providerQuery.refetch();
}}
>
{t("common.retry")}
</Button>
</div>
<QueryErrorRow
inset
error={chainQuery.error ?? providerQuery.error}
slot="coach-model-picker-error"
onRetry={() => {
void chainQuery.refetch();
void providerQuery.refetch();
}}
/>
);
}
@@ -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({
</p>
) : isError && visible.length === 0 ? (
// A load failure must not read as "no conversations yet".
<div
data-slot="coach-history-error"
className="text-muted-foreground flex flex-col items-start gap-2 px-2 py-3 text-sm"
>
<p>{t("common.loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RotateCw className="size-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
<QueryErrorRow
inset
error={error}
slot="coach-history-error"
onRetry={() => void refetch()}
className="px-2 py-3"
/>
) : groups.length === 0 ? (
<p
data-slot="coach-history-empty"
@@ -42,6 +42,7 @@ import {
COACH_MEMORY_KEYS,
COACH_PLAN_KEYS,
} from "@/lib/ai/coach/memory/shared";
import { translateOrNull } from "@/lib/i18n/translate-code";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -215,8 +216,7 @@ export function decisionReplies(
]
: choices;
for (const choice of ordered.slice(0, 4)) {
const local = t(choice.labelKey);
const label = local === choice.labelKey ? choice.label : local;
const label = translateOrNull(t, choice.labelKey) ?? choice.label;
out.push({
id: `clarify-${choice.id}`,
label,
@@ -82,6 +82,7 @@ import type {
import { COACH_FOCUS_RING } from "./focus-ring";
import { CoachMethodLine } from "./method-line";
import { translateOrNull } from "@/lib/i18n/translate-code";
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
@@ -106,8 +107,7 @@ function tOr(
fallback: string,
params?: Record<string, string | number>,
) {
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;
@@ -130,11 +130,12 @@ export function CoachReadStrip({
<CardContent className={STRIP_BODY}>
{isError ? (
<QueryErrorRow
inset
error={error}
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0"
className="flex-row items-center justify-between"
/>
) : (
<Skeleton className="h-5.5 w-3/4" />
@@ -203,6 +203,7 @@ export function ScoreTrendChart({
<Skeleton className={`w-full ${heightClass}`} />
) : query.isError ? (
<ChartErrorState
error={query.error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={label}
@@ -3,16 +3,9 @@
import { useState, type ComponentType } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
Activity,
Flame,
Gauge,
HeartPulse,
Moon,
RefreshCw,
} from "lucide-react";
import { Activity, Flame, Gauge, HeartPulse, Moon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
@@ -65,6 +58,8 @@ interface ScoreStripProps {
* Vitals slot).
*/
isError?: boolean;
/** The batch query's error, which decides what the error row offers. */
error?: unknown;
/** Refetch the one shared batch query. Recovers both strips at once. */
refetch?: () => 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")}
/>
<div
data-slot="wellness-scores-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.derived.scores.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch?.()}
data-slot="wellness-scores-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
error={error}
slot="wellness-scores-error"
retrySlot="wellness-scores-retry"
message={t("insights.derived.scores.loadError")}
onRetry={() => refetch?.()}
/>
</section>
);
}
+2 -9
View File
@@ -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<EcgDetailResponse>(
`/api/insights/ecg/${recordingId}`,
);
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
queryFn: () =>
apiGet<EcgDetailResponse>(`/api/insights/ecg/${recordingId}`),
});
if (isError) {
+34 -1
View File
@@ -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 (
<section
id="ecg"
data-slot="ecg-section"
aria-label={t("insights.ecg.sectionTitle")}
className={cn("scroll-mt-24 space-y-3", className)}
>
{!hideHeading && (
<SectionHeading
icon={Activity}
title={t("insights.ecg.sectionTitle")}
/>
)}
<QueryErrorRow
error={error}
slot="ecg-section-error"
message={t("insights.ecg.loadError")}
onRetry={() => void refetch()}
/>
</section>
);
}
// Data-availability gate — never paint an empty card.
if (isLoading || !data || !data.hasRecordings) return null;
@@ -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 `<QueryErrorCard>` with a retry action instead of falling through
* renders `<QueryErrorRow>` 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 ? (
<Skeleton className="h-[180px] w-full rounded-lg" />
) : isError ? (
<QueryErrorCard
<QueryErrorRow
inset
error={error}
onRetry={() => 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
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
) : null}
{isError ? (
<QueryErrorRow
inset
error={error}
slot="mood-discovery-error"
message={t("insights.pattern.loadError")}
@@ -41,14 +41,24 @@ export function CaffeineCard() {
staleTime: 60_000,
});
// The card keeps its frame and title on a failed read, like the hydration
// card beside it.
if (isError && !data) {
return (
<QueryErrorRow
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
onRetry={() => void refetch()}
/>
<Card data-slot="nutrients-caffeine-card">
<CardHeader>
<TileHeader icon={Coffee} title={t("nutrients.names.caffeine")} />
</CardHeader>
<CardContent>
<QueryErrorRow
inset
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
onRetry={() => void refetch()}
/>
</CardContent>
</Card>
);
}
@@ -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 <QueryErrorCard error={error} onRetry={refetch} />;
// 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 (
<Card data-slot="nutrients-hydration-card">
<CardHeader>
<TileHeader icon={GlassWater} title={t("nutrients.names.water")} />
</CardHeader>
<CardContent>
<QueryErrorRow
inset
error={error}
slot="nutrients-hydration-error"
message={t("nutrients.hydration.loadError")}
onRetry={() => void refetch()}
/>
</CardContent>
</Card>
);
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
@@ -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 (
<section
data-slot="period-narrative-section"
aria-label={t("insights.narrativeTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
/>
<QueryErrorRow
error={query.error}
slot="period-narrative-error"
onRetry={() => void query.refetch()}
/>
</section>
);
}
const narrative = query.data?.narrative ?? null;
if (query.isError || !narrative) {
if (!narrative) {
return null;
}
@@ -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 (
<QueryErrorCard
error={error}
title={t("insights.recovery.loadError")}
onRetry={refetch}
/>
);
}
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
@@ -207,11 +220,15 @@ export function RecoverySection() {
title={t("insights.recovery.emptyTitle")}
description={t(EMPTY_COPY[emptyReason])}
action={
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
// 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 : (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
)
}
/>
);
@@ -100,15 +100,10 @@ export function RhythmEventsCard({
refetch,
} = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
queryFn: async () => {
try {
return await apiGetList<RhythmEventsResponse>(
"/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<RhythmEventsResponse>("/api/insights/rhythm-events"),
enabled: enabled && isAuthenticated,
});
const data = page?.data;
+3 -9
View File
@@ -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<EcgListResponse>("/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<EcgListResponse>("/api/insights/ecg"),
enabled: enabled && isAuthenticated,
});
}
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
{sleep.isError ? (
// A failed read is not an absent night.
<QueryErrorRow
inset
error={sleep.error}
onRetry={sleep.refetch}
slot="workout-detail-day-sleep-error"
@@ -170,6 +171,7 @@ export function WorkoutDetailDayContext({
</p>
{mood.isError ? (
<QueryErrorRow
inset
error={mood.error}
onRetry={mood.refetch}
slot="workout-detail-day-mood-error"
@@ -7,6 +7,7 @@ import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistance } from "./format";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -34,9 +35,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const fmt = useFormatters();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
const sportName = translateOrNull(t, sportLabelKey) ?? workout.sportType;
return (
<header
+2 -2
View File
@@ -13,6 +13,7 @@ import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
import { translateOrNull } from "@/lib/i18n/translate-code";
/**
* v1.4.32 — workout list row primitive.
@@ -63,12 +64,11 @@ export function WorkoutList({
{workouts.map((workout) => {
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:
+3 -3
View File
@@ -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<typeof useTranslations>["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;
}
@@ -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).
<QueryErrorRow error={error} onRetry={() => refetch()} />
<QueryErrorRow inset error={error} onRetry={() => refetch()} />
) : upcoming.length === 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
@@ -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;
}
@@ -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;
}
@@ -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<LedgerPayload>({
const { data, isLoading, isError, error, refetch } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
@@ -465,9 +466,14 @@ export function DoseHistoryLedger({
)}
{isError && (
<p className="text-destructive py-6 text-sm">
{t("medications.detail.verlauf.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="dose-history-error"
message={t("medications.detail.verlauf.loadError")}
onRetry={() => void refetch()}
className="py-6"
/>
)}
{data && !isLoading && groups.length === 0 && (
@@ -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<ScheduleRevisionsResponse>(
@@ -151,11 +152,15 @@ export function ScheduleHistoryTimeline({
},
});
if (isError) {
if (isError && !data) {
return (
<p className="text-muted-foreground text-sm" data-slot="zeitplan-history">
{t("medications.detail.zeitplan.history.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="zeitplan-history"
message={t("medications.detail.zeitplan.history.loadError")}
onRetry={() => void refetch()}
/>
);
}
if (!data) return null;
@@ -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({
/>
</div>
) : isError ? (
<p
className="text-destructive text-sm"
role="alert"
data-slot="phase-config-error"
>
{t("common.loadFailed")}
</p>
<QueryErrorRow
inset
error={error}
slot="phase-config-error"
onRetry={() => void refetch()}
/>
) : data ? (
<PhaseConfigForm
key={snapshotKey(data)}
@@ -27,6 +27,7 @@ import {
} from "@/lib/mental-health/instruments";
import type { AssessmentDetail, InstrumentId } from "./types";
import { QueryErrorRow } from "@/components/ui/query-error-row";
export function AssessmentItemBreakdown({
assessmentId,
@@ -38,7 +39,7 @@ export function AssessmentItemBreakdown({
const { t } = useTranslations();
const def = INSTRUMENTS[instrument];
const { data, isPending, isError } = useQuery({
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId),
queryFn: () =>
apiGet<{ assessment: AssessmentDetail }>(
@@ -62,13 +63,13 @@ export function AssessmentItemBreakdown({
if (isError || !data) {
return (
<p
className="text-destructive text-sm"
role="alert"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.error")}
</p>
<QueryErrorRow
inset
error={error}
slot="assessment-item-breakdown"
message={t("mentalHealth.error")}
onRetry={() => void refetch()}
/>
);
}
@@ -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 | number>) => 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");
}
@@ -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(
@@ -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";
}
@@ -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);
@@ -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}` });
@@ -80,6 +80,7 @@ export function CoachConversationsMemoryCard({
/>
{history.isError ? (
<QueryErrorRow
inset
error={history.error}
onRetry={() => void history.refetch()}
/>
@@ -326,6 +326,7 @@ export function CoachMemorySection({
{query.isError && (
<QueryErrorRow
inset
error={query.error}
message={t("settings.ai.coachMemory.loadError")}
onRetry={() => query.refetch()}
@@ -188,6 +188,7 @@ export function CoachRemindersSection({
{query.isError && (
<QueryErrorRow
inset
error={query.error}
message={t("settings.ai.coachReminders.loadError")}
onRetry={() => query.refetch()}
@@ -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<HealthKitStatus>("/api/integrations/healthkit"),
@@ -384,9 +387,12 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
}
>
{isError ? (
<p role="alert" className="text-destructive text-sm">
{t("settings.appleHealth.status.unavailable")}
</p>
<QueryErrorRow
error={error}
slot="apple-health-status-error"
message={t("settings.appleHealth.status.unavailable")}
onRetry={() => void refetch()}
/>
) : null}
</IntegrationCard>
);
@@ -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")}
</Label>
{places.isError ? (
<p role="alert" className="text-destructive text-sm">
{t(errorKeyOf(places.error))}
</p>
<QueryErrorRow
inset
error={places.error}
slot="dawarich-places-error"
message={t(errorKeyOf(places.error))}
onRetry={() => void places.refetch()}
/>
) : (
<NativeSelect
id="dawarich-home-place"
@@ -30,6 +30,7 @@ import {
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import { translateCode } from "@/lib/i18n/translate-code";
export interface ProviderSync {
syncing: boolean;
@@ -151,11 +152,12 @@ export function ActionResultLines({
const { t } = useTranslations();
function describeError(errorCode: string): string {
const key = `settings.testConnection.errors.${errorCode}`;
const translated = t(key);
return translated === key
? t("settings.testConnection.errors.generic")
: translated;
return translateCode(
t,
"settings.testConnection.errors",
errorCode,
"settings.testConnection.errors.generic",
);
}
const busy = sync?.syncing || test?.testing;
+7 -6
View File
@@ -22,6 +22,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
interface NtfySettings {
enabled: boolean;
@@ -80,13 +81,13 @@ export function NtfyCard({ 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")),
);
}
},
+12 -1
View File
@@ -37,6 +37,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface PrivacySummary {
retention: {
@@ -73,6 +74,8 @@ export function PrivacySection() {
data: summary,
isLoading: summaryLoading,
isError: summaryError,
error: summaryFailure,
refetch: refetchSummary,
} = useQuery({
queryKey: queryKeys.privacySummary(),
queryFn: () => apiGet<PrivacySummary>("/api/settings/privacy-summary"),
@@ -141,7 +144,15 @@ export function PrivacySection() {
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : summaryError || !summary ? (
) : summaryError && !summary ? (
<QueryErrorRow
inset
error={summaryFailure}
slot="privacy-retention-error"
message={t("settings.privacy.retention.unavailable")}
onRetry={() => void refetchSummary()}
/>
) : !summary ? (
<p className="text-muted-foreground text-sm">
{t("settings.privacy.retention.unavailable")}
</p>
@@ -445,6 +445,7 @@ export function ScoreSection() {
{analyticsQuery.isError ? (
<QueryErrorRow
inset
error={analyticsQuery.error}
slot="score-config-eligibility-error"
message={t("settings.sections.score.eligibilityFailed")}
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more