mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
commit
86eb743a69
134 files changed
+1567
-594
No files matched your search
+25
-23
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
Reference in new issue
Block a user