From 38c106e358cf64b0a63803cfc1d64e07d10c8987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 16:43:52 +0200 Subject: [PATCH] fix(ui): draw no empty frame and say when a read failed "Since the last visit" was wrapped in a card by the visits section, so the card stayed on screen empty while the window loaded, when it held nothing and when the read failed. The component now owns its card and draws it only with a row; a failed read shows a compact error row. The same shapes elsewhere: the caffeine card and the mood forecast no longer open a loading card that most often collapses, and a failed read on them, the mood tag catalogue, the usual-range strip, the cycle ring and the measurement drill-down now says so with a retry instead of passing for no data. The scores strip keeps the ring's cell on a failed calendar read. The mental-health card no longer draws an empty action slot for a viewer without the right to start. The frame guard now also flags a frame whose only content sits behind a condition. Refs #1241 --- messages/de.json | 18 +- messages/en.json | 18 +- messages/es.json | 18 +- messages/fr.json | 18 +- messages/it.json | 18 +- messages/ko.json | 18 +- messages/pl.json | 18 +- .../self-gating-child-frame-guard.test.ts | 322 ++++++++++++++++++ src/app/insights/page-client.tsx | 7 +- .../cycle/__tests__/cycle-ring-tile.test.tsx | 10 +- .../use-cycle-ring-has-dial.test.tsx | 52 ++- src/components/cycle/cycle-ring-tile.tsx | 27 +- src/components/cycle/use-cycle.ts | 7 +- src/components/day/since-last-visit.tsx | 180 +++++----- .../__tests__/visits-section.test.tsx | 128 ++++++- src/components/encounters/visits-section.tsx | 11 +- .../insights/derived/coach-read-strip.tsx | 18 +- .../insights/mood/mood-prognosis-card.tsx | 21 +- .../insights/nutrients/caffeine-card.tsx | 39 ++- .../measurements/measurement-list.tsx | 12 +- .../mental-health/instrument-card.tsx | 16 +- src/components/mood/mood-quick-tags.tsx | 17 +- src/components/mood/mood-tag-picker.tsx | 16 +- 23 files changed, 816 insertions(+), 193 deletions(-) create mode 100644 src/__tests__/self-gating-child-frame-guard.test.ts diff --git a/messages/de.json b/messages/de.json index 1e7d0ea10..260f50100 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "Für diesen Tag ist im Krankheitstagebuch nichts erfasst", "openIllness": "Öffnen" }, - "sourceNotification": "Benachrichtigung" + "sourceNotification": "Benachrichtigung", + "tagCatalogLoadError": "Die Tags konnten nicht geladen werden." }, "medications": { "title": "Medikamente", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.", "steadyLatestBelow": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.", "insufficient": "Lerne noch deinen üblichen Bereich. Protokolliere noch ein paar Tage weiter.", - "driver": "{note}" + "driver": "{note}", + "loadError": "Dein üblicher Bereich konnte nicht geladen werden." }, "scope": { "prefix": "Der Coach ist schon bei {metric}" @@ -4290,7 +4292,8 @@ "caveat": "Das ist ein Vergleich mit deinen eigenen bisherigen Tagen, keine Aussage darüber, wie ein Tag zustande gekommen ist.", "learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.", "noPattern": "In den bisher erfassten {entries} Tagen ist noch nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wenn du auch den Kontext eines Tages festhältst, wird mehr davon vergleichbar.", - "featureValue": "{field}: {value}" + "featureValue": "{field}: {value}", + "loadError": "Der auf Basis deiner Muster erwartete Wert konnte nicht geladen werden." }, "patterns": { "title": "Muster in deiner Stimmung", @@ -9204,7 +9207,8 @@ "viewDetails": "Zyklus-Erkenntnisse ansehen" }, "wellnessRing": { - "label": "Zyklus" + "label": "Zyklus", + "loadError": "Dein Zyklus konnte nicht geladen werden." }, "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.", "title": "Dein Zyklus", @@ -11890,7 +11894,8 @@ "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)" }, "caffeine": { - "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)" + "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)", + "loadError": "Koffein konnte nicht geladen werden." }, "micronutrients": { "title": "Vitamine & Mineralstoffe", @@ -12982,7 +12987,8 @@ "allOther": "Alle {count} anzeigen", "fewer": "Weniger anzeigen", "note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.", - "report": "Arztbericht {date} bis heute" + "report": "Arztbericht {date} bis heute", + "loadError": "Was seit dem letzten Besuch war, konnte nicht geladen werden." }, "travel": "Unterwegs", "event": { diff --git a/messages/en.json b/messages/en.json index 5e59955ce..6ac777648 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "Nothing is logged in the illness journal for this day", "openIllness": "Open" }, - "sourceNotification": "Notification" + "sourceNotification": "Notification", + "tagCatalogLoadError": "Could not load the tags." }, "medications": { "title": "Medications", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, above that.", "steadyLatestBelow": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, below that.", "insufficient": "Still learning your usual range. Keep logging for a few more days.", - "driver": "{note}" + "driver": "{note}", + "loadError": "Could not load your usual range." }, "scope": { "prefix": "The Coach is already on {metric}" @@ -4290,7 +4292,8 @@ "caveat": "This is a comparison with your own past days, not a statement about how a day came to go the way it did.", "learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.", "noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. When you also capture a day's context, more of it becomes comparable.", - "featureValue": "{field}: {value}" + "featureValue": "{field}: {value}", + "loadError": "Could not load the value expected on your own pattern." }, "patterns": { "title": "Patterns in your mood", @@ -9204,7 +9207,8 @@ "viewDetails": "View cycle insights" }, "wellnessRing": { - "label": "Cycle" + "label": "Cycle", + "loadError": "Could not load your cycle." }, "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.", "title": "Your cycle", @@ -11890,7 +11894,8 @@ "referenceMeta": "Reference intake {value} {unit}/day (EFSA)" }, "caffeine": { - "ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)" + "ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)", + "loadError": "Could not load caffeine." }, "micronutrients": { "title": "Vitamins & minerals", @@ -12982,7 +12987,8 @@ "allOther": "Show all {count}", "fewer": "Show fewer", "note": "Compiled from your entries, without judgement. Every date opens the whole day.", - "report": "Doctor report from {date} to today" + "report": "Doctor report from {date} to today", + "loadError": "Could not load what happened since the last visit." }, "travel": "Away", "event": { diff --git a/messages/es.json b/messages/es.json index 1c799ce67..4a05b5498 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "No hay nada registrado en el diario de enfermedad para este día", "openIllness": "Abrir" }, - "sourceNotification": "Notificación" + "sourceNotification": "Notificación", + "tagCatalogLoadError": "No se pudieron cargar las etiquetas." }, "medications": { "title": "Medicamentos", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por encima.", "steadyLatestBelow": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por debajo.", "insufficient": "Todavía estoy aprendiendo tu rango habitual. Sigue registrando unos días más.", - "driver": "{note}" + "driver": "{note}", + "loadError": "No se pudo cargar tu rango habitual." }, "scope": { "prefix": "El Coach ya está en {metric}" @@ -4290,7 +4292,8 @@ "caveat": "Es una comparación con tus propios días anteriores, no una afirmación sobre cómo llegó a transcurrir un día.", "learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.", "noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Cuando anotas también el contexto del día, se puede comparar una parte mayor.", - "featureValue": "{field}: {value}" + "featureValue": "{field}: {value}", + "loadError": "No se pudo cargar el valor esperado según tus propios patrones." }, "patterns": { "title": "Patrones en tu estado de ánimo", @@ -9204,7 +9207,8 @@ "viewDetails": "Ver análisis del ciclo" }, "wellnessRing": { - "label": "Ciclo" + "label": "Ciclo", + "loadError": "No se pudo cargar tu ciclo." }, "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.", "title": "Tu ciclo", @@ -11890,7 +11894,8 @@ "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)" }, "caffeine": { - "ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)" + "ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)", + "loadError": "No se pudo cargar la cafeína." }, "micronutrients": { "title": "Vitaminas y minerales", @@ -12982,7 +12987,8 @@ "allOther": "Mostrar las {count}", "fewer": "Mostrar menos", "note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.", - "report": "Informe médico del {date} hasta hoy" + "report": "Informe médico del {date} hasta hoy", + "loadError": "No se pudo cargar lo ocurrido desde la última visita." }, "travel": "De viaje", "event": { diff --git a/messages/fr.json b/messages/fr.json index 230f4a60a..31edfead6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "Rien n'est consigné dans le journal de maladie pour cette journée", "openIllness": "Ouvrir" }, - "sourceNotification": "Notification" + "sourceNotification": "Notification", + "tagCatalogLoadError": "Impossible de charger les étiquettes." }, "medications": { "title": "Médicaments", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.", "steadyLatestBelow": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.", "insufficient": "J'apprends encore ta plage habituelle. Continue à enregistrer quelques jours de plus.", - "driver": "{note}" + "driver": "{note}", + "loadError": "Impossible de charger ta plage habituelle." }, "scope": { "prefix": "Le Coach est déjà sur {metric}" @@ -4290,7 +4292,8 @@ "caveat": "C'est une comparaison avec tes propres journées passées, pas une affirmation sur la manière dont une journée s'est déroulée.", "learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.", "noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Quand tu notes aussi le contexte d'une journée, tu en rends davantage comparable.", - "featureValue": "{field} : {value}" + "featureValue": "{field} : {value}", + "loadError": "Impossible de charger la valeur attendue selon tes propres habitudes." }, "patterns": { "title": "Tendances de votre humeur", @@ -9204,7 +9207,8 @@ "viewDetails": "Voir les analyses du cycle" }, "wellnessRing": { - "label": "Cycle" + "label": "Cycle", + "loadError": "Impossible de charger ton cycle." }, "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.", "title": "Ton cycle", @@ -11890,7 +11894,8 @@ "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)" }, "caffeine": { - "ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)" + "ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)", + "loadError": "Impossible de charger la caféine." }, "micronutrients": { "title": "Vitamines et minéraux", @@ -12982,7 +12987,8 @@ "allOther": "Afficher les {count}", "fewer": "Afficher moins", "note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.", - "report": "Rapport médical du {date} à aujourd’hui" + "report": "Rapport médical du {date} à aujourd’hui", + "loadError": "Impossible de charger ce qui s’est passé depuis la dernière visite." }, "travel": "En déplacement", "event": { diff --git a/messages/it.json b/messages/it.json index 5446a3205..21210a200 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "Per questa giornata non c'è nulla nel diario di malattia", "openIllness": "Apri" }, - "sourceNotification": "Notifica" + "sourceNotification": "Notifica", + "tagCatalogLoadError": "Impossibile caricare le etichette." }, "medications": { "title": "Farmaci", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.", "steadyLatestBelow": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.", "insufficient": "Sto ancora imparando il tuo intervallo abituale. Continua a registrare per qualche giorno in più.", - "driver": "{note}" + "driver": "{note}", + "loadError": "Impossibile caricare il tuo intervallo abituale." }, "scope": { "prefix": "Il Coach è già su {metric}" @@ -4290,7 +4292,8 @@ "caveat": "È un confronto con le tue giornate passate, non un'affermazione su come una giornata sia andata a finire così.", "learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.", "noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Quando registri anche il contesto della giornata, ne rendi confrontabile una parte maggiore.", - "featureValue": "{field}: {value}" + "featureValue": "{field}: {value}", + "loadError": "Impossibile caricare il valore atteso in base alle tue abitudini." }, "patterns": { "title": "Schemi nel tuo umore", @@ -9204,7 +9207,8 @@ "viewDetails": "Vedi le analisi del ciclo" }, "wellnessRing": { - "label": "Ciclo" + "label": "Ciclo", + "loadError": "Impossibile caricare il tuo ciclo." }, "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.", "title": "Il tuo ciclo", @@ -11890,7 +11894,8 @@ "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)" }, "caffeine": { - "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)" + "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)", + "loadError": "Impossibile caricare la caffeina." }, "micronutrients": { "title": "Vitamine e minerali", @@ -12982,7 +12987,8 @@ "allOther": "Mostra tutte le {count}", "fewer": "Mostra meno", "note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.", - "report": "Referto medico dal {date} a oggi" + "report": "Referto medico dal {date} a oggi", + "loadError": "Impossibile caricare ciò che è successo dall’ultima visita." }, "travel": "In viaggio", "event": { diff --git a/messages/ko.json b/messages/ko.json index 753810a79..8c8370cfe 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "이 날은 아픈 날 일지에 기록된 게 없어요", "openIllness": "열기" }, - "sourceNotification": "알림" + "sourceNotification": "알림", + "tagCatalogLoadError": "태그를 불러오지 못했어요." }, "medications": { "title": "복약", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value}, 평소보다 높았어요.", "steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value}, 평소보다 낮았어요.", "insufficient": "아직 평소 범위를 배우는 중이에요. 며칠 더 기록해 주세요.", - "driver": "{note}" + "driver": "{note}", + "loadError": "평소 범위를 불러오지 못했어요." }, "scope": { "prefix": "코치가 이미 보고 있어요: {metric}" @@ -4290,7 +4292,8 @@ "caveat": "이건 내 지난 날들과의 비교일 뿐, 그날이 왜 그렇게 흘러갔는지에 대한 설명은 아니에요.", "learning": "학습 중이에요: {threshold}일 중 {entries}일 기록했어요. 충분히 모이면 비교가 나타나요.", "noPattern": "지금까지 기록한 {entries}일 중에는 하루를 견줄 만큼 일정한 패턴이 없어요. 하루의 맥락까지 함께 남기면 비교할 수 있는 게 늘어나요.", - "featureValue": "{field}: {value}" + "featureValue": "{field}: {value}", + "loadError": "내 패턴상 예상값을 불러오지 못했어요." }, "patterns": { "title": "기분 패턴", @@ -9204,7 +9207,8 @@ "viewDetails": "주기 인사이트 보기" }, "wellnessRing": { - "label": "주기" + "label": "주기", + "loadError": "주기를 불러오지 못했어요." }, "disclaimer": "이 예측은 기록한 데이터로 추정한 설명일 뿐, 피임 방법도 의학적 조언도 아니에요. 피임 없는 성관계가 안전하다는 뜻은 절대 아니에요. 피임이나 의학적 결정은 의료 전문가와 상의해 주세요.", "title": "내 주기", @@ -11890,7 +11894,8 @@ "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)" }, "caffeine": { - "ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)" + "ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)", + "loadError": "카페인을 불러오지 못했어요." }, "micronutrients": { "title": "비타민·미네랄", @@ -12982,7 +12987,8 @@ "allOther": "{count}개 모두 보기", "fewer": "줄여 보기", "note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.", - "report": "{date}부터 오늘까지 진료 보고서" + "report": "{date}부터 오늘까지 진료 보고서", + "loadError": "지난 방문 이후의 기록을 불러오지 못했어요." }, "travel": "여행 중", "event": { diff --git a/messages/pl.json b/messages/pl.json index 3046dc530..9abaabaca 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1272,7 +1272,8 @@ "bodyNotLogged": "Na ten dzień nic nie zapisano w dzienniku choroby", "openIllness": "Otwórz" }, - "sourceNotification": "Powiadomienie" + "sourceNotification": "Powiadomienie", + "tagCatalogLoadError": "Nie udało się załadować tagów." }, "medications": { "title": "Leki", @@ -3173,7 +3174,8 @@ "steadyLatestAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był wyższy.", "steadyLatestBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był niższy.", "insufficient": "Wciąż poznaję twój zwykły zakres. Rejestruj jeszcze przez kilka dni.", - "driver": "{note}" + "driver": "{note}", + "loadError": "Nie udało się załadować twojego typowego zakresu." }, "scope": { "prefix": "Coach jest już przy {metric}" @@ -4290,7 +4292,8 @@ "caveat": "To porównanie z twoimi wcześniejszymi dniami, nie stwierdzenie, jak doszło do takiego przebiegu dnia.", "learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.", "noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z nimi dzień. Gdy zapisujesz też kontekst dnia, więcej da się porównać.", - "featureValue": "{field}: {value}" + "featureValue": "{field}: {value}", + "loadError": "Nie udało się załadować wartości oczekiwanej na podstawie twoich wzorców." }, "patterns": { "title": "Wzorce w twoim nastroju", @@ -9204,7 +9207,8 @@ "viewDetails": "Zobacz analizy cyklu" }, "wellnessRing": { - "label": "Cykl" + "label": "Cykl", + "loadError": "Nie udało się załadować twojego cyklu." }, "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.", "title": "Twój cykl", @@ -11890,7 +11894,8 @@ "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)" }, "caffeine": { - "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)" + "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)", + "loadError": "Nie udało się załadować kofeiny." }, "micronutrients": { "title": "Witaminy i minerały", @@ -12982,7 +12987,8 @@ "allOther": "Pokaż wszystkie {count}", "fewer": "Pokaż mniej", "note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.", - "report": "Raport lekarski od {date} do dziś" + "report": "Raport lekarski od {date} do dziś", + "loadError": "Nie udało się załadować tego, co wydarzyło się od ostatniej wizyty." }, "travel": "W podróży", "event": { diff --git a/src/__tests__/self-gating-child-frame-guard.test.ts b/src/__tests__/self-gating-child-frame-guard.test.ts new file mode 100644 index 000000000..7fe2e623f --- /dev/null +++ b/src/__tests__/self-gating-child-frame-guard.test.ts @@ -0,0 +1,322 @@ +/** + * A component that can draw nothing owns its own frame. + * + * "Since the last visit" returns null when it has nothing to list, and the + * visits section drew the card around it. Every time the component stood + * down (still loading, a quiet window, a failed read) an empty bordered card + * stayed on screen under the next appointment (#1241). The fix moved the card + * into the component; this guard keeps the shape from coming back elsewhere. + * + * Scope: every `.tsx` under `src/components` and `src/app` (tests excluded). + * A finding is a frame (`Card`, `CardContent`, `CardFooter`, or an intrinsic + * element whose class draws a border, a card background, a rounded box, a + * shadow or padding) whose only content, through nested frames, is either + * one component that returns null at its own top level, or one element + * behind a condition (`{x && }`, `{x ? : null}`). Such a frame is + * empty whenever the child stands down or the condition fails. The second + * shape is reported as `Frame > {cond} > C`, whatever C is: the condition + * alone is enough to empty the frame. + * + * The cases below allow a finding only when the parent already refuses to + * render the frame on the same condition the child gates on; the reason says + * which. The list cannot rot quietly: a new finding fails the second case, + * an entry that no longer matches fails the third, and the fourth runs the + * detector over a planted example so a sweep that finds nothing cannot pass + * by accident. + * + * The matching is by component name, so two components sharing a name are + * one entry here. It is a review aid with a structural floor, not a proof. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const SCOPES = ["components/", "app/"]; + +const FRAME_COMPONENT = /^(Card|CardContent|CardFooter)$/; +const FRAME_CLASS = + /\b(border|bg-card|rounded-(?:lg|xl|2xl)|shadow(?:-\w+)?|p-\d+|px-\d+|py-\d+)\b/; + +/** `file > Frame > Child` → why the frame can never be drawn empty. */ +const ALLOWED: Record = { + "components/insights/mood/mood-insights-sections.tsx > CardContent > MoodBetterDays": + "The section returns null when `betterDays` is empty before drawing the card, the same condition the child gates on.", + "components/insights/metric-stat-strip.tsx > Card > SeriesBlock": + "The strip returns null on `!summary || summary.count <= 0` before drawing the card, the same condition the child gates on.", + "components/insights/metric-stat-strip.tsx > CardContent > SeriesBlock": + "Same card as the entry above.", + "components/medications/detail/medication-detail-tabs.tsx > div > PhasesRow": + "The padded row renders only under `isGlp1`, the condition the child returns null on.", + "components/settings/ai/ai-usage-card.tsx > span > {cond} > span": + "The bordered ring is the radio control itself and the dot inside marks the checked option; an empty ring is the unchecked look, not an empty frame.", +}; + +function returnsNullAtTopLevel(body: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node) => { + if (found) return; + // A nested function's `return null` is that function's, not the + // component's. + if (node !== body && ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node) && node.expression) { + const e = node.expression; + if (e.kind === ts.SyntaxKind.NullKeyword) found = true; + if ( + ts.isConditionalExpression(e) && + (e.whenTrue.kind === ts.SyntaxKind.NullKeyword || + e.whenFalse.kind === ts.SyntaxKind.NullKeyword) + ) { + found = true; + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return found; +} + +/** Component names (capitalised) whose own body can return null. */ +function selfGatingComponents(sources: ts.SourceFile[]): Set { + const names = new Set(); + for (const sf of sources) { + ts.forEachChild(sf, (node) => { + if (ts.isFunctionDeclaration(node) && node.name && node.body) { + if (/^[A-Z]/.test(node.name.text) && returnsNullAtTopLevel(node.body)) + names.add(node.name.text); + } + if (ts.isVariableStatement(node)) { + for (const d of node.declarationList.declarations) { + const init = d.initializer; + if ( + ts.isIdentifier(d.name) && + /^[A-Z]/.test(d.name.text) && + init && + (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) && + ts.isBlock(init.body) && + returnsNullAtTopLevel(init.body) + ) { + names.add(d.name.text); + } + } + } + }); + } + return names; +} + +type JsxNode = ts.JsxElement | ts.JsxSelfClosingElement; + +function tagOf(node: JsxNode): string { + return ( + ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName + ).getText(); +} + +function classOf(node: JsxNode): string { + const attrs = (ts.isJsxElement(node) ? node.openingElement : node).attributes + .properties; + for (const a of attrs) { + if (ts.isJsxAttribute(a) && a.name.getText() === "className") { + return a.initializer?.getText() ?? ""; + } + } + return ""; +} + +function isFrame(node: JsxNode): boolean { + const tag = tagOf(node); + if (FRAME_COMPONENT.test(tag)) return true; + return /^[a-z]/.test(tag) && FRAME_CLASS.test(classOf(node)); +} + +function unparenthesised(e: ts.Expression): ts.Expression { + return ts.isParenthesizedExpression(e) ? unparenthesised(e.expression) : e; +} + +function asJsx(e: ts.Expression): JsxNode | null { + const inner = unparenthesised(e); + return ts.isJsxElement(inner) || ts.isJsxSelfClosingElement(inner) + ? inner + : null; +} + +/** The element behind `{x && }` or `{x ? : null}`, or null. */ +function conditionalElement(e: ts.Expression): JsxNode | null { + const inner = unparenthesised(e); + if ( + ts.isBinaryExpression(inner) && + inner.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + ) { + return asJsx(inner.right); + } + if (ts.isConditionalExpression(inner)) { + const isNull = (x: ts.Expression) => + unparenthesised(x).kind === ts.SyntaxKind.NullKeyword; + if (isNull(inner.whenFalse)) return asJsx(inner.whenTrue); + if (isNull(inner.whenTrue)) return asJsx(inner.whenFalse); + } + return null; +} + +interface Content { + node: JsxNode; + /** Reached through a condition that, when false, leaves nothing. */ + conditional: boolean; +} + +/** The one element a frame holds, through nested frames, or null. */ +function soleContent(node: ts.JsxElement, conditional = false): Content | null { + const kids = node.children.filter( + (c) => + !(ts.isJsxText(c) && c.getText().trim() === "") && + !(ts.isJsxExpression(c) && !c.expression), + ); + if (kids.length !== 1) return null; + const [kid] = kids; + if (ts.isJsxSelfClosingElement(kid)) return { node: kid, conditional }; + if (ts.isJsxElement(kid)) { + return isFrame(kid) + ? soleContent(kid, conditional) + : { node: kid, conditional }; + } + if (ts.isJsxExpression(kid) && kid.expression) { + const el = conditionalElement(kid.expression); + if (!el) return null; + if (ts.isJsxElement(el) && isFrame(el)) { + // A frame behind the condition is gone with it; look inside it for + // what it frames. + return soleContent(el, true) ?? { node: el, conditional: true }; + } + return { node: el, conditional: true }; + } + return null; +} + +function findings( + files: Array<{ rel: string; source: string }>, + gating?: Set, +): string[] { + const sources = files.map((f) => + ts.createSourceFile( + f.rel, + f.source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ), + ); + const nullers = gating ?? selfGatingComponents(sources); + const out: string[] = []; + for (const sf of sources) { + const visit = (node: ts.Node) => { + if (ts.isJsxElement(node) && isFrame(node)) { + const inner = soleContent(node); + if (inner?.conditional) { + out.push( + `${sf.fileName} > ${tagOf(node)} > {cond} > ${tagOf(inner.node)}`, + ); + } else if (inner && nullers.has(tagOf(inner.node))) { + out.push(`${sf.fileName} > ${tagOf(node)} > ${tagOf(inner.node)}`); + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + return [...new Set(out)].sort(); +} + +function sweep(): string[] { + const files = walkSourceFiles(SRC, { floor: 1200, extensions: [".tsx"] }) + .filter((rel) => SCOPES.some((s) => rel.startsWith(s))) + .filter((rel) => !rel.includes("__tests__/")) + .map((rel) => ({ rel, source: readFileSync(join(SRC, rel), "utf8") })); + expect(files.length).toBeGreaterThan(800); + return findings(files); +} + +describe("self-gating child frame guard", () => { + const found = sweep(); + + it("finds the allowed frames, so the sweep reads the tree it claims to", () => { + expect(found.length).toBeGreaterThanOrEqual(Object.keys(ALLOWED).length); + }); + + it("draws no frame around a component that can draw nothing", () => { + const unlisted = found.filter((f) => !(f in ALLOWED)); + expect( + unlisted, + "Move the frame into the child (it renders its own card, and none when it has nothing), or guard the frame on the child's own condition and list it in ALLOWED with the reason.", + ).toEqual([]); + }); + + it("has no stale allowance", () => { + const stale = Object.keys(ALLOWED).filter((key) => !found.includes(key)); + expect(stale).toEqual([]); + }); + + it("catches the shape #1241 had", () => { + const planted = ` + function Since({ rows }: { rows: string[] }) { + if (rows.length === 0) return null; + return
    {rows.map((r) =>
  • {r}
  • )}
; + } + export function Visits() { + return ( + + + + + + ); + } + `; + expect(findings([{ rel: "planted.tsx", source: planted }])).toEqual([ + "planted.tsx > Card > Since", + "planted.tsx > CardContent > Since", + ]); + }); + + it("catches a frame whose only content sits behind a condition", () => { + const planted = ` + export function Tile({ can, rows }: { can: boolean; rows: string[] }) { + return ( + <> + + {can && } + +
+ {rows.length > 0 ?
    {rows}
: null} +
+
+ {can ? Yes : No} +
+ + ); + } + `; + expect(findings([{ rel: "planted.tsx", source: planted }])).toEqual([ + "planted.tsx > Card > {cond} > Button", + "planted.tsx > CardContent > {cond} > Button", + "planted.tsx > div > {cond} > ul", + ]); + }); + + it("does not count a frame that holds the condition beside other content", () => { + const planted = ` + export function Tile({ can }: { can: boolean }) { + return ( + + Title + {can && } + + ); + } + `; + expect(findings([{ rel: "planted.tsx", source: planted }])).toEqual([]); + }); +}); diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index f37981b91..c57503a45 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -391,7 +391,8 @@ export default function InsightsPageClient() { // Until that read answers the strip keeps its skeleton, so it never paints // Strain first and swaps it for the ring when the calendar lands. const cycleRing = useCycleRingDial(isAuthenticated && cycleRingVisible); - const cycleRingDial = cycleRing.dial; + // A failed read keeps the cell too: the tile then says so, with a retry. + const cycleRingCell = cycleRing.dial || cycleRing.failed; // A failed comprehensive read no longer replaces the page. The payload only // decides the "no data yet" empty state below; every section on the page @@ -492,11 +493,11 @@ export default function InsightsPageClient() { // AND the operator server-wide kill-switch) so an operator-off // instance never renders the ring nor fires its calendar read. // v1.39 — and only once the ring has a dial to draw (see above). - extraTile={cycleRingDial ? : undefined} + extraTile={cycleRingCell ? : undefined} // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: // hide Strain so the strip stays compact instead of growing a sixth // tile. Strain stays visible for non-cycle accounts. - hideStrain={cycleRingDial} + hideStrain={cycleRingCell} /> ), // Hidden, never an error card, while the briefing is unavailable for any diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx index 2085f7904..60c159c9e 100644 --- a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -152,13 +152,19 @@ describe("", () => { expect(render()).toBe(""); }); - it("renders nothing on a hard calendar read error", () => { + // A failed read is not a day without a cycle: the tile says it failed and + // offers a retry, instead of passing the failure off as nothing to show. + it("says a hard calendar read error failed, with a retry", () => { calendarState.current = { data: undefined, isLoading: false, isError: true, }; - expect(render()).toBe(""); + const html = render(); + expect(html).toContain('data-slot="wellness-cycle-tile-error"'); + expect(html).toContain('role="alert"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(html).not.toContain('data-slot="wellness-cycle-tile"'); }); it("renders nothing when there is no active cycle today", () => { diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx index 8d1e24b97..6d7653583 100644 --- a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -26,16 +26,35 @@ function window(): [string, string] { } function Probe({ enabled }: { enabled: boolean }) { - const { dial, pending } = useCycleRingDial(enabled); - return {`${dial}:${pending}`}; + const { dial, pending, failed } = useCycleRingDial(enabled); + return {`${dial}:${pending}${failed ? ":failed" : ""}`}; } -function probe(verdict: unknown | undefined, enabled = true): string { +/** Marks the calendar read as failed, with no data behind it. */ +const FAILED = Symbol("failed"); + +function probe( + verdict: unknown | undefined | typeof FAILED, + enabled = true, +): string { const client = new QueryClient({ - defaultOptions: { queries: { staleTime: Infinity, retry: 0 } }, + // `retryOnMount: false` keeps a seeded failure a failure on mount, the + // state the strip sees once the real read has given up. + defaultOptions: { + queries: { staleTime: Infinity, retry: 0, retryOnMount: false }, + }, }); const [from, to] = window(); - if (verdict !== undefined) { + if (verdict === FAILED) { + client + .getQueryCache() + .build(client, { queryKey: queryKeys.cycleCalendar(from, to) }) + .setState({ + status: "error", + error: new Error("calendar read failed"), + fetchStatus: "idle", + }); + } else if (verdict !== undefined) { client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict }); } const html = renderToStaticMarkup( @@ -66,6 +85,29 @@ describe("useCycleRingDial", () => { it("is pending until the calendar read lands", () => { expect(probe(undefined)).toBe("false:true"); }); + + // A failed read is not a day without a cycle: the strip keeps the ring's + // cell so the tile can say the read failed. + it("reports a failed read instead of settling it to no dial", () => { + expect(probe(FAILED)).toBe("false:false:failed"); + expect(probe(FAILED, false)).toBe("false:false"); + }); +}); + +describe("the scores strip keeps the ring's cell on a failed read", () => { + it("mounts the tile on a dial or a failure, and only then drops Strain", () => { + const source = readFileSync( + join(process.cwd(), "src/app/insights/page-client.tsx"), + "utf8", + ); + expect(source).toMatch( + /cycleRingCell = cycleRing\.dial \|\| cycleRing\.failed/, + ); + expect(source).toMatch( + /extraTile=\{cycleRingCell \? : undefined\}/, + ); + expect(source).toMatch(/hideStrain=\{cycleRingCell\}/); + }); }); describe("the scores strip waits for the ring's decision", () => { diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 486f06578..0096a0184 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { Droplets } from "lucide-react"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; @@ -31,9 +32,12 @@ import { useCycleRingCalendar } from "./use-cycle"; * `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the * sidebar-nav entry + the summary card gate on), so the cycle read never fires * for an account without the feature. The tile additionally renders nothing - * while the calendar read resolves, on a hard error, and when there is no - * active cycle phase today — never an empty-framed or half-painted dial in the - * strip (no gap, no placeholder). + * while the calendar read resolves and when there is no active cycle phase + * today — never an empty-framed or half-painted dial in the strip (no gap, no + * placeholder). A failed read takes the tile's place with a notice and a + * retry: silence there read as "no cycle phase today", and the summary card + * further down stays out on the same failure, so the tile is the one place + * the page says it. */ export function CycleRingTile({ className }: { className?: string }) { @@ -61,11 +65,20 @@ export function CycleRingTile({ className }: { className?: string }) { } }); - // Stay silent until the read resolves, on a hard error, and when today has - // no phase (no active cycle) — the strip must never show a half-painted or - // empty-framed cycle dial. The full cycle page owns the empty-state CTA. + // Stay silent until the read resolves and when today has no phase (no + // active cycle) — the strip must never show a half-painted or empty-framed + // cycle dial. The full cycle page owns the empty-state CTA. if (calendar.isLoading && !calendar.data) return null; - if (calendar.isError && !calendar.data) return null; + if (calendar.isError && !calendar.data) { + return ( + void calendar.refetch()} + className={className} + /> + ); + } if (verdict?.phase == null || verdict.dayOfCycle == null) return null; const hue = PHASE_HUE[verdict.phase]; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index dd2f902f7..285c5d7b6 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -136,17 +136,22 @@ export function useCycleRingCalendar(enabled = true) { * takes Strain's place for it): the ring renders nothing without an active * cycle, and a cell reserved for it would sit empty in the row. While the * calendar read is `pending` the strip holds its skeleton, so it never paints - * Strain and then swaps it for the ring. A failed read settles to no dial. + * Strain and then swaps it for the ring. A failed read draws no dial but is + * `failed`: the strip still gives the ring its cell, where the tile says the + * read failed and offers a retry. Settling it to "no dial" passed a failure + * off as a day without an active cycle. */ export function useCycleRingDial(enabled: boolean): { dial: boolean; pending: boolean; + failed: boolean; } { const calendar = useCycleRingCalendar(enabled); const verdict = calendar.data?.verdict; return { dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null, pending: enabled && calendar.isPending, + failed: enabled && calendar.isError && !calendar.data, }; } diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 51253ea91..34982d4d1 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -17,7 +17,8 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import type { EncounterDTO } from "@/lib/encounters/dto"; import { apiGet } from "@/lib/api/api-fetch"; import type { @@ -52,6 +53,15 @@ import { useTodayKey } from "./use-today-key"; * today: the server lists every context change in it (a dose change always, * however quiet the day around it) and every notable observation. Nothing is * recomputed here; this only spells the rows. + * + * The component owns its card and draws nothing until it has a row to show: + * not while the window loads, not when it holds nothing worth listing. A card + * frame drawn by the caller around it stayed on screen empty in each of those + * cases (#1241), and a loading skeleton would open a card for a window that + * most often turns out empty, then collapse it again. Arriving late costs + * only the visit list below a shift, once per window; the read is cached for + * five minutes. A failed read is not a quiet window, so it says so in a + * compact row with a retry. */ const EVENT_ICON: Partial> = { @@ -181,94 +191,98 @@ export function SinceLastVisit({ }) : []; - const loading = window.isPending && window.fetchStatus !== "idle"; - const failed = window.isError; - if (!loading && !failed && rows.length === 0) return null; - // A preparation that could not be read is left out rather than shown half: - // the visit card stands on its own, and the day links stay everywhere else. - if (failed && rows.length === 0) return null; + if (window.isError && rows.length === 0) { + return ( + void window.refetch()} + /> + ); + } + // Nothing to show is nothing drawn: still loading, or a quiet window. The + // visit card stands on its own, and the day links stay everywhere else. + 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 ( -
-
-

- {t("day.sinceLastVisit.title", { date: lastLabel })} -

- {rows.length > 0 ? ( - - {tCount("day.sinceLastVisit.count", rows.length)} - - ) : null} -
- {loading && rows.length === 0 ? ( - - ) : ( -
    - {visible.map((row) => ( -
  • + +
    +
    +

    - - - {fmt.dateShortSmartCalendar(row.date)} - - -

  • - ))} -
- )} - {rows.length > VISIBLE_ROWS ? ( - - ) : null} -

- {t("day.sinceLastVisit.note")} -

- -
+ {t("day.sinceLastVisit.title", { date: lastLabel })} + + + {tCount("day.sinceLastVisit.count", rows.length)} + + +
    + {visible.map((row) => ( +
  • + + + {fmt.dateShortSmartCalendar(row.date)} + + +
  • + ))} +
+ {rows.length > VISIBLE_ROWS ? ( + + ) : null} +

+ {t("day.sinceLastVisit.note")} +

+ + + + ); } diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 19f3ab62b..c945dbe1d 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -47,9 +47,12 @@ vi.mock("@/lib/api/api-fetch", async () => { }; }); +import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url"; +import type { DayNotableWindowResponse } from "@/lib/day/contract"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO } from "@/lib/encounters/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { VisitsSection } from "../visits-section"; function visit(over: Partial & { id: string }): EncounterDTO { @@ -69,10 +72,14 @@ function visit(over: Partial & { id: string }): EncounterDTO { }; } -function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) { +function render( + list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }, + seed?: (queryClient: QueryClient) => void, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + seed?.(queryClient); queryClient.setQueryData( queryKeys.encounterList(null, null, undefined, undefined), list, @@ -160,3 +167,120 @@ describe("", () => { expect(html).toContain('href="/checkups/practitioners"'); }); }); + +/** + * #1241 — "since the last visit" under the next appointment. The caller used + * to wrap it in a card, so whenever it had nothing to list (still loading, a + * quiet window, a failed read) an empty bordered card sat under the visit. + * It now draws its own card, and only once it has a row. + */ +describe(" since the last visit", () => { + const next = visit({ + id: "next", + status: "PLANNED", + occurredAt: "2026-09-20T09:00:00.000Z", + }); + const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" }); + + /** Cards painted inside the upcoming list. */ + function upcomingCards(html: string): number { + const start = html.indexOf('data-slot="visits-upcoming"'); + const end = html.indexOf('data-slot="visits-past"'); + expect(start).toBeGreaterThan(-1); + return html.slice(start, end).split('data-slot="card"').length - 1; + } + + function seedWindow(window: DayNotableWindowResponse | null) { + return (queryClient: QueryClient) => { + if (!window) return; + const tz = DEFAULT_TIMEZONE; + queryClient.setQueryData( + queryKeys.dayNotable( + dateKeyOfInstant(last.occurredAt, tz), + todayKeyInZone(tz), + ), + window, + ); + }; + } + + const emptyWindow = { + observations: [], + changes: [], + } as unknown as DayNotableWindowResponse; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-15T12:00:00.000Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("draws no card while the window is still loading", () => { + const withLast = render({ upcoming: [next], past: [last] }); + const withoutLast = render({ upcoming: [next], past: [] }); + expect(withLast).not.toContain('data-slot="since-last-visit"'); + expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast)); + }); + + it("draws no card when the window holds nothing worth listing", () => { + const html = render( + { upcoming: [next], past: [last] }, + seedWindow(emptyWindow), + ); + const baseline = render({ upcoming: [next], past: [] }); + expect(html).not.toContain('data-slot="since-last-visit"'); + expect(upcomingCards(html)).toBe(upcomingCards(baseline)); + }); + + it("says a failed read failed, rather than passing it off as a quiet window", () => { + const html = render({ upcoming: [next], past: [last] }, (queryClient) => { + const tz = DEFAULT_TIMEZONE; + const queryKey = queryKeys.dayNotable( + dateKeyOfInstant(last.occurredAt, tz), + todayKeyInZone(tz), + ); + // The state the section sees once the real read has given up: a mount + // does not retry it. + queryClient.setQueryDefaults(queryKey, { retryOnMount: false }); + queryClient + .getQueryCache() + .build(queryClient, { queryKey }) + .setState({ + status: "error", + error: new Error("window read failed"), + fetchStatus: "idle", + }); + }); + const baseline = render({ upcoming: [next], past: [] }); + 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)); + }); + + it("draws one card with the rows once there is something to list", () => { + const html = render( + { upcoming: [next], past: [last] }, + seedWindow({ + ...emptyWindow, + changes: [ + { + date: "2026-08-03", + kind: "doseChange", + section: "medications", + id: "d1", + title: "Example 5 mg", + count: 1, + href: null, + }, + ], + } as unknown as DayNotableWindowResponse), + ); + const baseline = render({ upcoming: [next], past: [] }); + expect(html).toContain('data-slot="since-last-visit"'); + expect(html).toContain('data-slot="since-last-visit-row"'); + expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); + }); +}); diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 9e3ee1a7c..7544c3539 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -18,7 +18,6 @@ import { Fragment, useState } from "react"; import { SinceLastVisit } from "@/components/day/since-last-visit"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; @@ -142,13 +141,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { onOpen={canManageVisits ? openSheet : undefined} /> {/* v1.42 — the next appointment carries what happened - since the last one, each date opening its day. */} + since the last one, each date opening its day. The + component draws its own card, and none when it has + nothing to list. */} {i === 0 && lastDone ? ( - - - - - + ) : null} ))} diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 844ba9528..6bb30c8d3 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { apiGet } from "@/lib/api/api-fetch"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; @@ -79,7 +80,7 @@ export function CoachReadStrip({ const transformed = unitDisplay.isTransformed(metricType); const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; - const { data, isError } = useQuery({ + const { data, isError, refetch } = useQuery({ queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( @@ -94,9 +95,20 @@ export function CoachReadStrip({ // shape: it lands above the chart, and popping in later pushed the chart // and everything under it down the page. A landed read always paints (the // baseline line degrades to the "learning" copy), so the placeholder only - // ever gives way to a card of its own height; a failed read drops it. + // ever gives way to a card of its own height. A failed read gives way to a + // notice with a retry: dropping the strip read as "nothing to say about + // today", which is not what happened. if (!mounted || !data) { - if (!isAuthenticated || isError) return null; + if (!isAuthenticated) return null; + if (isError) { + return ( + void refetch()} + /> + ); + } return ( apiGet("/api/mood/prognosis"), enabled: isAuthenticated, staleTime: 60_000, }); - if (isLoading) { - return ; + if (isError && !data) { + return ( + void refetch()} + /> + ); } if (!data) return null; return ; diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx index e35e065a2..399053bbe 100644 --- a/src/components/insights/nutrients/caffeine-card.tsx +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Coffee } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -16,39 +16,38 @@ const WINDOW_DAYS = 30; /** * v1.29 — caffeine card on `/insights/nutrients`. Rendered ONLY when the - * window carries at least one logged day (a query-error also collapses - * to nothing rather than a card the user can do nothing with — the - * hydration card above already carries the surface's error affordance). - * The EFSA safe-level value is phrased as a ceiling, never "limit - * exceeded" colouring: a day above the dashed line is just a bar above - * a dashed line. + * window carries at least one logged day. The EFSA safe-level value is + * phrased as a ceiling, never "limit exceeded" colouring: a day above the + * dashed line is just a bar above a dashed line. + * + * Nothing is drawn while the read is on its way. Most accounts never log + * caffeine, so a loading card was most often a card that collapsed again a + * moment later. A failed read says so with a retry, instead of passing for + * a month without caffeine; it is its own read, so the hydration card's + * notice does not cover it. */ export function CaffeineCard() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading } = useQuery({ + const { data, isError, refetch } = useQuery({ queryKey: queryKeys.nutrientDaily("caffeine", WINDOW_DAYS), queryFn: () => apiGet( `/api/nutrients/daily?nutrient=caffeine&days=${WINDOW_DAYS}`, ), - // v1.30 UI audit (M7) — this card unmounts entirely on a no-data window - // (`hasData` gate below), so a self-hoster who never logs caffeine saw - // the 280px skeleton collapse on every `/insights/nutrients` visit. A - // 60s `staleTime` (the "route swing within a minute is a free cache - // hit" convention used across the insights queries) keeps the flash to - // once per session rather than once per mount. + // The "route swing within a minute is a free cache hit" convention used + // across the insights queries. staleTime: 60_000, }); - if (isLoading) { + if (isError && !data) { return ( - - - + void refetch()} + /> ); } diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 9f0f8ba91..1e5e4f795 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { ListRow } from "@/components/ui/list-row"; import { @@ -1894,7 +1895,7 @@ function DayDrillDown({ }) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); - const { data, isLoading, error } = useQuery({ + const { data, isLoading, error, refetch } = useQuery({ queryKey: queryKeys.measurementDrilldown(type, dayKey), queryFn: async () => { const params = new URLSearchParams(); @@ -1923,9 +1924,12 @@ function DayDrillDown({ } if (error) { return ( -
- {t("measurements.loadError")} -
+ void refetch()} + className={layout === "desktop" ? "m-2" : "mt-2"} + /> ); } const rows = data?.measurements ?? []; diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx index 7176055c6..df4008a6f 100644 --- a/src/components/mental-health/instrument-card.tsx +++ b/src/components/mental-health/instrument-card.tsx @@ -175,18 +175,20 @@ export function InstrumentCard({ )} - - {/* Bottom-pinned single primary action — the med-/Vorsorge card slot. - Nothing sits below it now: the attribution moved to the header's - info control, so the Start button stands alone with clean space. */} - {canManageMind && ( + {/* Bottom-pinned single primary action — the med-/Vorsorge card slot. + Nothing sits below it now: the attribution moved to the header's + info control, so the Start button stands alone with clean space. + Without the right to start, the slot is not drawn at all: an empty + one still took the card's gap at its foot. */} + {canManageMind && ( +
- )} -
+
+ )}
); } diff --git a/src/components/mood/mood-quick-tags.tsx b/src/components/mood/mood-quick-tags.tsx index 1e26faa2f..ccb21e07f 100644 --- a/src/components/mood/mood-quick-tags.tsx +++ b/src/components/mood/mood-quick-tags.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -21,7 +22,10 @@ import { selectQuickTagKeys } from "./recent-tags"; * * Reads the same `/api/mood/tags` catalogue as `MoodTagPicker` (shared query * key → one network round-trip). Renders nothing while the catalogue is - * empty so a cleared taxonomy degrades gracefully. + * empty so a cleared taxonomy degrades gracefully. The loading placeholder + * stays because the catalogue ships seeded and the row nearly always fills + * it; a failed read says so with a retry rather than leaving the sheet + * without its tags and without a word. */ interface CatalogTag { @@ -59,7 +63,7 @@ export function MoodQuickTags({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodTagCatalog(), queryFn: async () => apiGet("/api/mood/tags"), enabled: isAuthenticated, @@ -69,6 +73,15 @@ export function MoodQuickTags({ if (isLoading) { return ; } + if (isError && !data) { + return ( + void refetch()} + /> + ); + } if (!data || data.categories.length === 0) return null; // Flatten the catalogue to binary tags only; the Quick row never carries diff --git a/src/components/mood/mood-tag-picker.tsx b/src/components/mood/mood-tag-picker.tsx index 23c6f067e..900a64fbc 100644 --- a/src/components/mood/mood-tag-picker.tsx +++ b/src/components/mood/mood-tag-picker.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Plus, Tag } from "lucide-react"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -104,7 +105,7 @@ export function MoodTagPicker({ // tile belongs to, or null when closed. const [createGroupKey, setCreateGroupKey] = useState(null); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodTagCatalog(), queryFn: async () => { return apiGet("/api/mood/tags"); @@ -114,10 +115,23 @@ export function MoodTagPicker({ staleTime: 60 * 60 * 1000, }); + // The placeholder stays: the catalogue ships seeded, so the picker nearly + // always fills it. A failed read says so with a retry; it used to leave the + // field without its tags and without a word. if (isLoading) { return ; } + if (isError && !data) { + return ( + void refetch()} + /> + ); + } + if (!data || data.categories.length === 0) { return null; }