mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/v1436-ui' into release/v1.43.6
This commit is contained in:
commit
0e6e50fc7b
33 files changed
+1892
-747
No files matched your search
+8
-16
@@ -4198,7 +4198,14 @@ paths:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: List measurements
|
||||
description: Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.
|
||||
description: >-
|
||||
Filter by type + date range. Response is paged via offset/limit; default limit 100.
|
||||
|
||||
|
||||
With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over
|
||||
those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way
|
||||
over nights. On both, `meta.total` counts the days or nights in the filtered window, and a page holds at most
|
||||
1000 of them (`meta.limit` echoes the value applied).
|
||||
parameters:
|
||||
- in: query
|
||||
name: type
|
||||
@@ -46242,9 +46249,6 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
partial:
|
||||
description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
@@ -47069,9 +47073,6 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
partial:
|
||||
description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
@@ -47140,12 +47141,6 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
windowFrom:
|
||||
description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant
|
||||
the read started from. The window is a year ending at `to` (or now); page further back by moving `to`."
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- total
|
||||
- limit
|
||||
@@ -47259,9 +47254,6 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
partial:
|
||||
description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.
|
||||
type: boolean
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
+12
-6
@@ -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",
|
||||
@@ -9211,7 +9214,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",
|
||||
@@ -11897,7 +11901,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",
|
||||
@@ -12989,7 +12994,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": {
|
||||
|
||||
+12
-6
@@ -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",
|
||||
@@ -9211,7 +9214,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",
|
||||
@@ -11897,7 +11901,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",
|
||||
@@ -12989,7 +12994,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": {
|
||||
|
||||
+12
-6
@@ -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",
|
||||
@@ -9211,7 +9214,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",
|
||||
@@ -11897,7 +11901,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",
|
||||
@@ -12989,7 +12994,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": {
|
||||
|
||||
+12
-6
@@ -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",
|
||||
@@ -9211,7 +9214,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",
|
||||
@@ -11897,7 +11901,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",
|
||||
@@ -12989,7 +12994,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": {
|
||||
|
||||
+12
-6
@@ -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",
|
||||
@@ -9211,7 +9214,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",
|
||||
@@ -11897,7 +11901,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",
|
||||
@@ -12989,7 +12994,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": {
|
||||
|
||||
+12
-6
@@ -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": "기분 패턴",
|
||||
@@ -9211,7 +9214,8 @@
|
||||
"viewDetails": "주기 인사이트 보기"
|
||||
},
|
||||
"wellnessRing": {
|
||||
"label": "주기"
|
||||
"label": "주기",
|
||||
"loadError": "주기를 불러오지 못했어요."
|
||||
},
|
||||
"disclaimer": "이 예측은 기록한 데이터로 추정한 설명일 뿐, 피임 방법도 의학적 조언도 아니에요. 피임 없는 성관계가 안전하다는 뜻은 절대 아니에요. 피임이나 의학적 결정은 의료 전문가와 상의해 주세요.",
|
||||
"title": "내 주기",
|
||||
@@ -11897,7 +11901,8 @@
|
||||
"referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)"
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)"
|
||||
"ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)",
|
||||
"loadError": "카페인을 불러오지 못했어요."
|
||||
},
|
||||
"micronutrients": {
|
||||
"title": "비타민·미네랄",
|
||||
@@ -12989,7 +12994,8 @@
|
||||
"allOther": "{count}개 모두 보기",
|
||||
"fewer": "줄여 보기",
|
||||
"note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.",
|
||||
"report": "{date}부터 오늘까지 진료 보고서"
|
||||
"report": "{date}부터 오늘까지 진료 보고서",
|
||||
"loadError": "지난 방문 이후의 기록을 불러오지 못했어요."
|
||||
},
|
||||
"travel": "여행 중",
|
||||
"event": {
|
||||
|
||||
+12
-6
@@ -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",
|
||||
@@ -9211,7 +9214,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",
|
||||
@@ -11897,7 +11901,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",
|
||||
@@ -12989,7 +12994,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": {
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
* a `useInfiniteQuery` in the file), or is listed below with the reason the
|
||||
* cap cannot cut anything off.
|
||||
*
|
||||
* Only code counts as a pager. A comment that says "offset" sends nothing,
|
||||
* and an offset pinned to a literal `0` is the opposite of paging: it asks
|
||||
* for the first page and nothing else. The day-grouped measurement list
|
||||
* passed this guard in exactly that shape (#1245), `limit` 5000 between a
|
||||
* comment about pagination and `offset` "0", while it drew every day of a
|
||||
* long history on one page and lost the days past the cap.
|
||||
*
|
||||
* What it matches: `limit=<digits>` inside a URL string, and the two
|
||||
* `URLSearchParams` spellings, `.set("limit", "<digits>")` and
|
||||
* `limit: "<digits>"`. What it does not: a limit routed through a named
|
||||
@@ -98,6 +105,36 @@ const LIMIT_PATTERNS: readonly RegExp[] = [
|
||||
const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/;
|
||||
const PAGER_LINES = 3;
|
||||
|
||||
/** The line with every block comment that opens and closes on it removed. */
|
||||
function withoutInlineBlocks(text: string): string {
|
||||
return text.replace(/\/\*.*?\*\//g, "");
|
||||
}
|
||||
|
||||
/** A whole-line comment: it describes code, it sends nothing. */
|
||||
function isCommentLine(text: string): boolean {
|
||||
const trimmed = withoutInlineBlocks(text).trim();
|
||||
return (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("/*") ||
|
||||
trimmed.startsWith("*")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The line as code a pager could live in: a trailing `// …` comment cut off
|
||||
* (only after whitespace, so a `https://` inside a string survives), and
|
||||
* every offset pinned to a literal zero removed, in the URL spelling and
|
||||
* both `URLSearchParams` spellings.
|
||||
*/
|
||||
function pagerCode(text: string): string {
|
||||
if (isCommentLine(text)) return "";
|
||||
return withoutInlineBlocks(text)
|
||||
.replace(/\s\/\/.*$/, "")
|
||||
.replace(/[?&]offset=0(?!\d)/g, "")
|
||||
.replace(/\.set\(\s*["']offset["']\s*,\s*["']0["']\s*\)/g, "")
|
||||
.replace(/\boffset:\s*["']?0["']?(?![\d.])/g, "");
|
||||
}
|
||||
|
||||
function clientFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
@@ -126,12 +163,12 @@ function findLimitHits(file: string, source: string): Hit[] {
|
||||
const hits: Hit[] = [];
|
||||
lines.forEach((text, index) => {
|
||||
// Comments describe old shapes; they send nothing.
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
|
||||
if (isCommentLine(text)) return;
|
||||
for (const pattern of LIMIT_PATTERNS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const near = lines
|
||||
.slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1)
|
||||
.map(pagerCode)
|
||||
.join("\n");
|
||||
hits.push({
|
||||
file,
|
||||
@@ -196,6 +233,55 @@ describe("client fetch limits", () => {
|
||||
expect(isAllowed(hits[0])).toBe(false);
|
||||
});
|
||||
|
||||
// #1245 — the day-grouped measurement list as it stood: a wide limit, a
|
||||
// comment that talks about the offset, and the offset pinned to zero.
|
||||
// Neither of the two is a pager.
|
||||
it("flags the shape the day-grouped measurement list had", () => {
|
||||
const hits = findLimitHits(
|
||||
"components/measurements/measurement-list.tsx",
|
||||
[
|
||||
" if (isCumulativeFilter) {",
|
||||
' params.set("groupBy", "day");',
|
||||
" // pagination/offset don't apply on the server side.",
|
||||
' params.set("limit", "5000");',
|
||||
" // Reset the offset so a sort flip stays on the first page.",
|
||||
' params.set("offset", "0");',
|
||||
" }",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].limit).toBe(5000);
|
||||
expect(hits[0].paged).toBe(false);
|
||||
});
|
||||
|
||||
it("counts neither a comment nor a zero offset as a pager, in any spelling", () => {
|
||||
const shapes = [
|
||||
'apiGet("/api/x?limit=500&offset=0")',
|
||||
'apiGet("/api/x?limit=500") // the offset is fixed',
|
||||
'/* offset */ apiGet("/api/x?limit=500")',
|
||||
'new URLSearchParams({ limit: "500", offset: "0" })',
|
||||
'new URLSearchParams({ limit: "500", offset: 0 })',
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const hits = findLimitHits("components/example.tsx", shape);
|
||||
expect(hits, shape).toHaveLength(1);
|
||||
expect(hits[0].paged, shape).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts an offset that is a page, beside a zero elsewhere", () => {
|
||||
const shapes = [
|
||||
"fetch(`/api/x?limit=100&offset=${(page - 1) * 100}`)",
|
||||
'params.set("limit", "100");\nparams.set("offset", String(offset));',
|
||||
'apiGet("/api/x?limit=100&offset=10")',
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const hits = findLimitHits("components/example.tsx", shape);
|
||||
expect(hits, shape).toHaveLength(1);
|
||||
expect(hits[0].paged, shape).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a limit that travels with an offset", () => {
|
||||
const hits = findLimitHits(
|
||||
"components/example.tsx",
|
||||
|
||||
@@ -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 && <C />}`, `{x ? <C /> : 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<string, string> = {
|
||||
"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<string> {
|
||||
const names = new Set<string>();
|
||||
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 && <C />}` or `{x ? <C /> : 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>,
|
||||
): 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 <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>;
|
||||
}
|
||||
export function Visits() {
|
||||
return (
|
||||
<Card className="gap-3">
|
||||
<CardContent>
|
||||
<Since rows={[]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
`;
|
||||
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 (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>{can && <Button>Start</Button>}</CardContent>
|
||||
</Card>
|
||||
<div className="rounded-lg border p-3">
|
||||
{rows.length > 0 ? <ul>{rows}</ul> : null}
|
||||
</div>
|
||||
<div className="border p-3">
|
||||
{can ? <span>Yes</span> : <span>No</span>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
`;
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>Title</CardHeader>
|
||||
{can && <CardContent><Button>Start</Button></CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
`;
|
||||
expect(findings([{ rel: "planted.tsx", source: planted }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,67 @@ function getRequest(query: string): NextRequest {
|
||||
});
|
||||
}
|
||||
|
||||
interface Group {
|
||||
day_key: string;
|
||||
source: string;
|
||||
device_type: string | null;
|
||||
unit: string;
|
||||
total: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
/** One SQL fold row: a day's readings of one source, device and unit. */
|
||||
function group(
|
||||
day_key: string,
|
||||
total: number,
|
||||
n: number,
|
||||
over: Partial<Group> = {},
|
||||
): Group {
|
||||
return {
|
||||
day_key,
|
||||
source: "APPLE_HEALTH",
|
||||
device_type: null,
|
||||
unit: "steps",
|
||||
total,
|
||||
n,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The day-grouped branch answers from SQL alone: one query picks the page's
|
||||
* days, each row carrying the distinct-day total from a window count; a
|
||||
* second folds those days into source groups. A page past the end has no row
|
||||
* to carry the total, so only then is it counted on its own. Answer each
|
||||
* query by its text.
|
||||
*/
|
||||
function mockDayPage(
|
||||
days: string[],
|
||||
total = days.length,
|
||||
groups: Group[] = [],
|
||||
) {
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation(((
|
||||
strings: TemplateStringsArray,
|
||||
) => {
|
||||
const text = strings.join("");
|
||||
if (text.includes("COUNT(DISTINCT")) return Promise.resolve([{ total }]);
|
||||
if (text.includes("COUNT(*) OVER ()")) {
|
||||
return Promise.resolve(days.map((day_key) => ({ day_key, total })));
|
||||
}
|
||||
if (text.includes("SUM(")) return Promise.resolve(groups);
|
||||
return Promise.resolve([]);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
/** The `$queryRaw` calls whose text contains `needle`. */
|
||||
function rawCallsWith(needle: string) {
|
||||
return vi
|
||||
.mocked(prisma.$queryRaw)
|
||||
.mock.calls.filter(([strings]) =>
|
||||
(strings as unknown as string[]).join("").includes(needle),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
@@ -91,55 +152,12 @@ beforeEach(() => {
|
||||
|
||||
describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
|
||||
it("collapses per-sample step rows into one row per user-TZ day with sampleCount", async () => {
|
||||
// Three Apple-Watch chunks on 2026-05-15 (Europe/Berlin) +
|
||||
// two on 2026-05-16 — the route should sum each day's bucket.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-2",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-3",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T16:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-4",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 2500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T07:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-5",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 4500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
] as never);
|
||||
// Three Apple-Watch chunks on 2026-05-15 (Europe/Berlin) summing to 5400
|
||||
// and two on 2026-05-16 summing to 7000, as SQL folds them.
|
||||
mockDayPage(["2026-05-16", "2026-05-15"], 2, [
|
||||
group("2026-05-15", 5400, 3),
|
||||
group("2026-05-16", 7000, 2),
|
||||
]);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
@@ -177,21 +195,42 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
|
||||
expect(day15.measuredAt).toContain("2026-05-15T10:00:00");
|
||||
});
|
||||
|
||||
// v1.29.6 — the raw-row scan must read the MOST RECENT window before
|
||||
// bucketing, mirroring the workouts-list ordering fix (05f80ad37): the
|
||||
// canonical/day-bucket picker doesn't reorder its input, but the
|
||||
// underlying `orderBy: asc` + `take: limit` sliced the OLDEST page of
|
||||
// rows before the day-bucketing loop ran — on a long history the most
|
||||
// recent days never appeared at all.
|
||||
it("scans the raw rows newest-first before bucketing", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// #1245 — the fold covers exactly the page's days: from the start of the
|
||||
// oldest page day to the end of the newest, in the user's zone, on top of
|
||||
// the request's own filters. Nothing outside the page is scanned, and no
|
||||
// raw row is read into the process at all.
|
||||
it("folds only the page's days, in SQL", async () => {
|
||||
mockDayPage(["2026-05-16", "2026-05-15"], 40);
|
||||
|
||||
await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
|
||||
|
||||
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0] as {
|
||||
orderBy?: { measuredAt?: "asc" | "desc" };
|
||||
const [fold] = rawCallsWith("SUM(");
|
||||
const instants = (fold ?? [])
|
||||
.slice(1)
|
||||
.filter((v): v is Date => v instanceof Date)
|
||||
.map((d) => d.toISOString());
|
||||
// Europe/Berlin is UTC+2 in May.
|
||||
expect(instants).toEqual([
|
||||
"2026-05-14T22:00:00.000Z",
|
||||
"2026-05-16T22:00:00.000Z",
|
||||
]);
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("folds nothing when the page holds no day", async () => {
|
||||
mockDayPage([], 3);
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: unknown[]; meta: { total: number } };
|
||||
};
|
||||
expect(call.orderBy).toEqual({ measuredAt: "desc" });
|
||||
expect(json.data.measurements).toEqual([]);
|
||||
expect(json.data.meta.total).toBe(3);
|
||||
expect(rawCallsWith("SUM(")).toHaveLength(0);
|
||||
// Past the end, and only there, the total is counted on its own.
|
||||
expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// v1.29.6 — a day with both an Apple Health AND a Withings write for the
|
||||
@@ -201,28 +240,10 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
|
||||
// Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 —
|
||||
// a number the tile never showed.
|
||||
it("collapses cross-source rows to the ladder-canonical source before summing", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-apple",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 9000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
|
||||
notes: null,
|
||||
deviceType: null,
|
||||
},
|
||||
{
|
||||
id: "m-withings",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8800,
|
||||
unit: "steps",
|
||||
source: "WITHINGS",
|
||||
measuredAt: new Date("2026-05-15T09:00:00.000Z"),
|
||||
notes: null,
|
||||
deviceType: null,
|
||||
},
|
||||
] as never);
|
||||
mockDayPage(["2026-05-15"], 1, [
|
||||
group("2026-05-15", 8800, 1, { source: "WITHINGS" }),
|
||||
group("2026-05-15", 9000, 1),
|
||||
]);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
@@ -251,97 +272,54 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
|
||||
expect(json.data.meta.droppedDuplicates).toBe(1);
|
||||
});
|
||||
|
||||
// v1.30.3 (QA F6) — a capped read's oldest bucket sits mid-day: every
|
||||
// sample on that calendar day BEFORE the cutoff row was excluded, so the
|
||||
// bucket's SUM understates the real day total. The row must carry a
|
||||
// `partial: true` marker instead of rendering as if it were complete.
|
||||
it("flags the oldest bucket partial when the raw-row read hits the cap", async () => {
|
||||
// limit=3: the DESC-ordered read returns exactly 3 rows (the cap), so
|
||||
// the oldest of the three (2026-05-14) is a truncated day — its bucket
|
||||
// could be missing earlier same-day samples the cap cut off.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-2",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 2500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T07:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-3",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-14T16:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
] as never);
|
||||
// #1245 — `limit` / `offset` page over days and reach the SQL that picks
|
||||
// them; `meta.total` is the distinct-day count, not the page length, and
|
||||
// no row is marked partial because every page day is read whole.
|
||||
it("pages over days and reports the distinct-day total", async () => {
|
||||
mockDayPage(["2026-04-20"], 40, [group("2026-04-20", 3400, 1)]);
|
||||
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=3"),
|
||||
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=25&offset=25"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{
|
||||
dayKey: string;
|
||||
value: number;
|
||||
partial?: boolean;
|
||||
}>;
|
||||
meta: { capped?: boolean };
|
||||
};
|
||||
};
|
||||
const day15 = json.data.measurements.find((m) => m.dayKey === "2026-05-15");
|
||||
const day14 = json.data.measurements.find((m) => m.dayKey === "2026-05-14");
|
||||
expect(day15?.partial).toBeUndefined();
|
||||
expect(day14?.partial).toBe(true);
|
||||
});
|
||||
|
||||
it("flags no bucket partial when the read comes in under the cap (whole window covered)", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "m-3",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-14T16:00:00.000Z"),
|
||||
notes: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
// Default limit (100) is nowhere near hit by 2 rows.
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ dayKey: string; partial?: boolean }>;
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
};
|
||||
};
|
||||
expect(json.data.measurements.every((m) => m.partial === undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(json.data.meta).toMatchObject({ total: 40, limit: 25, offset: 25 });
|
||||
expect(json.data.measurements.map((m) => m.dayKey)).toEqual(["2026-04-20"]);
|
||||
expect(json.data.measurements[0].partial).toBeUndefined();
|
||||
const [pageCall] = rawCallsWith("COUNT(*) OVER ()");
|
||||
expect(pageCall?.slice(1)).toEqual(expect.arrayContaining([25]));
|
||||
expect(pageCall?.slice(1).filter((v) => v === 25)).toHaveLength(2);
|
||||
// The total rode on the page's own rows; there was no second pass over
|
||||
// the history, only the fold of the page's one day.
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(rawCallsWith("COUNT(DISTINCT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A device split inside the winning source keeps the top device's
|
||||
// groups only, the same narrowing the picker applies to raw rows.
|
||||
it("keeps the top-ranked device's groups within the winning source", async () => {
|
||||
mockDayPage(["2026-05-15"], 1, [
|
||||
group("2026-05-15", 6000, 40, { device_type: "watch" }),
|
||||
group("2026-05-15", 5500, 12, { device_type: "phone" }),
|
||||
]);
|
||||
const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ value: number; sampleCount: number }>;
|
||||
meta: { droppedDuplicates: number };
|
||||
};
|
||||
};
|
||||
expect(json.data.measurements).toHaveLength(1);
|
||||
expect(json.data.measurements[0]).toMatchObject({
|
||||
value: 6000,
|
||||
sampleCount: 40,
|
||||
});
|
||||
expect(json.data.meta.droppedDuplicates).toBe(12);
|
||||
});
|
||||
|
||||
it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => {
|
||||
@@ -480,17 +458,14 @@ describe("GET /api/measurements — dayKey drill-down (W7c)", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/measurements — schema rejections (W10 reconcile)", () => {
|
||||
// v1.4.37 W10 H1 — the groupBy=day collapsed path runs the collapse
|
||||
// AFTER the per-sample scan, so it can't honour a non-zero offset
|
||||
// without a Postgres-side date_trunc grouping that's a v1.4.38
|
||||
// backlog item. The validator must reject the combination instead
|
||||
// of silently rendering "showing 1-25 of N" with the wrong N.
|
||||
it("rejects offset>0 when groupBy=day is set", async () => {
|
||||
// #1245 — the day-grouped mode pages over days, so an offset is a real
|
||||
// page request now rather than a refused combination.
|
||||
it("accepts offset>0 when groupBy=day is set", async () => {
|
||||
mockDayPage([], 0);
|
||||
const res = await GET(
|
||||
getRequest("type=ACTIVITY_STEPS&groupBy=day&offset=25"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// v1.4.37 W10 H1 — same restriction for the drill-down branch; it
|
||||
|
||||
+454
-254
@@ -53,6 +53,7 @@ import {
|
||||
import {
|
||||
reconstructSleepSessions,
|
||||
pickMainNightAndNaps,
|
||||
SESSION_GAP_MS,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
@@ -71,12 +72,14 @@ import {
|
||||
oppositeMergeSource,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
GlucoseContext,
|
||||
import {
|
||||
Prisma,
|
||||
type MeasurementType,
|
||||
type MeasurementSource,
|
||||
type GlucoseContext,
|
||||
type SleepStage,
|
||||
} from "@/generated/prisma/client";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
@@ -234,161 +237,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.4.37 W7c — collapsed "one row per day" mode for cumulative
|
||||
// types. The list page renders the day's SUM as a single synthesised
|
||||
// row with a `sampleCount` chevron that drills back into the
|
||||
// per-sample rows via the `dayKey=…` branch above.
|
||||
// per-sample rows via the `dayKey=…` branch above. `limit` / `offset`
|
||||
// page over DAYS, not raw rows (#1245).
|
||||
if (
|
||||
groupBy === "day" &&
|
||||
type &&
|
||||
CUMULATIVE_HK_TYPES.has(type as MeasurementType)
|
||||
) {
|
||||
const tz =
|
||||
user.timezone && user.timezone.length > 0
|
||||
? user.timezone
|
||||
: DEFAULT_TIMEZONE;
|
||||
// v1.29.6 — DESC + take so a long history returns the MOST RECENT
|
||||
// window of raw samples for bucketing. The previous `orderBy: asc`
|
||||
// + `take: limit` sliced the OLDEST page of rows before the
|
||||
// day-bucketing loop ran below — on an account with more than
|
||||
// `limit` cumulative samples in its history, the buckets were built
|
||||
// entirely from year-old rows and the recent days silently never
|
||||
// appeared. Mirrors the workouts-list ordering fix (05f80ad37):
|
||||
// re-establish "most recent" as the page the caller actually gets.
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
// Per-sample row scan within the requested window — bounded by
|
||||
// the existing `limit` cap (default 100, max 5000) so a wide
|
||||
// multi-year window can still walk the whole range when the
|
||||
// chart explicitly asks for it.
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
source: true,
|
||||
measuredAt: true,
|
||||
notes: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.29.6 — collapse overlapping sources to the ladder-canonical
|
||||
// reading per day BEFORE summing. Without this, a day with both an
|
||||
// Apple Health AND a Withings write for the same cumulative metric
|
||||
// (e.g. steps) summed BOTH sources into one inflated total — a day
|
||||
// the dashboard tile (which reads through the source-collapsed
|
||||
// rollup, `collapseRollupRowsBySource` in
|
||||
// `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the
|
||||
// same `metricKeyForType` ladder lookup the rollup collapse uses, so
|
||||
// this list and the tile agree on which source wins. A type with no
|
||||
// ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse
|
||||
// against — every row passes through unchanged, matching the
|
||||
// pass-through fallback used at the other `pickCanonicalSourceRows`
|
||||
// call sites.
|
||||
const metricKey = metricKeyForType(type as MeasurementType);
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
const canonicalRows = metricKey
|
||||
? pickCanonicalSourceRows(
|
||||
rows.map((r) => ({ ...r, type: r.type as MeasurementType })),
|
||||
metricKey,
|
||||
priorityJson,
|
||||
(d) => dayKeyForUserTz(d, tz),
|
||||
).canonicalRows
|
||||
: rows;
|
||||
|
||||
// Group by the user's calendar day, sum values, keep a representative
|
||||
// unit/source from the bucket's first row so the rendered row carries
|
||||
// a sensible label even when the bucket mixes sources.
|
||||
type Bucket = {
|
||||
dayKey: string;
|
||||
total: number;
|
||||
sampleCount: number;
|
||||
latest: Date;
|
||||
unit: string;
|
||||
source: string;
|
||||
};
|
||||
const buckets = new Map<string, Bucket>();
|
||||
for (const row of canonicalRows) {
|
||||
const key = dayKeyForUserTz(row.measuredAt, tz);
|
||||
const slot = buckets.get(key);
|
||||
if (!slot) {
|
||||
buckets.set(key, {
|
||||
dayKey: key,
|
||||
total: row.value,
|
||||
sampleCount: 1,
|
||||
latest: row.measuredAt,
|
||||
unit: row.unit,
|
||||
source: row.source,
|
||||
});
|
||||
} else {
|
||||
slot.total += row.value;
|
||||
slot.sampleCount += 1;
|
||||
if (row.measuredAt > slot.latest) slot.latest = row.measuredAt;
|
||||
}
|
||||
}
|
||||
|
||||
// v1.30.3 (QA F6) — the read above is DESC + take(limit), so once a
|
||||
// dense account's in-window row count crosses the cap, the OLDEST
|
||||
// returned row sits mid-day: every earlier sample on that same
|
||||
// calendar day was cut off, and its bucket's SUM understates the real
|
||||
// day total with no marker. Flag exactly that one bucket — the day
|
||||
// containing the cutoff row (the minimum dayKey among the buckets
|
||||
// actually built) — `partial: true` only when the cap was truly hit;
|
||||
// a read that came in under the cap covers its whole window and no
|
||||
// day is a truncation artifact.
|
||||
const cappedRead = rows.length === limit;
|
||||
let oldestDayKey: string | null = null;
|
||||
if (cappedRead) {
|
||||
for (const key of buckets.keys()) {
|
||||
if (oldestDayKey === null || key < oldestDayKey) oldestDayKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
const measurements = Array.from(buckets.values())
|
||||
.map((b) => ({
|
||||
// Synthetic id so the list-row key stays stable across pages
|
||||
// and the row never collides with a real `Measurement.id`.
|
||||
id: `day:${type}:${b.dayKey}`,
|
||||
type,
|
||||
value: b.total,
|
||||
unit: b.unit,
|
||||
source: b.source,
|
||||
// Anchor the row's timestamp to local noon so the row sorts
|
||||
// cleanly between same-day spot samples — matches the
|
||||
// `canonicalDailyTimestamp` convention used by the drain.
|
||||
measuredAt: canonicalDailyTimestamp(b.dayKey, tz).toISOString(),
|
||||
notes: null,
|
||||
dayKey: b.dayKey,
|
||||
sampleCount: b.sampleCount,
|
||||
...(b.dayKey === oldestDayKey ? { partial: true } : {}),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
type,
|
||||
groupBy: "day",
|
||||
rowsScanned: rows.length,
|
||||
droppedDuplicates: rows.length - canonicalRows.length,
|
||||
// QA F6 — surfaces when the oldest bucket in this page is a
|
||||
// partial-day sum (the read hit the cap).
|
||||
capped: cappedRead,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
limit,
|
||||
offset: 0,
|
||||
groupBy: "day",
|
||||
droppedDuplicates: rows.length - canonicalRows.length,
|
||||
},
|
||||
return dayGroupedListResponse(user, {
|
||||
type: type as MeasurementType,
|
||||
limit,
|
||||
offset,
|
||||
sortDir,
|
||||
sourceEq,
|
||||
valueMin,
|
||||
valueMax,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -506,6 +371,237 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The day-grouped list for a cumulative type, paged over local days.
|
||||
*
|
||||
* Everything heavy runs in Postgres. The first query picks the page: the
|
||||
* distinct local days that hold a reading, sorted and cut by `limit` /
|
||||
* `offset`, each row carrying the distinct-day count of the whole filtered
|
||||
* set as `meta.total` from a window count. The second folds only the page's
|
||||
* days, one row per day, source, device and unit with its sum and sample
|
||||
* count, and the source ladder picks among those groups exactly as it would
|
||||
* among the raw rows (it keeps or drops whole source and device groups per
|
||||
* day, never single rows). So a page costs its own days whatever the length
|
||||
* of the history, every day is complete, and no raw row reaches the heap.
|
||||
*
|
||||
* Until v1.43.6 the branch read the newest `limit` raw rows (the web asked
|
||||
* for 5000), bucketed them and returned every bucket. A long history came
|
||||
* back as thousands of rows in one response, and every day older than the
|
||||
* 5000th raw row was missing with nothing to say so.
|
||||
*
|
||||
* A day exists for the page when any reading of the type falls on it; the
|
||||
* source collapse narrows a day's groups but never empties one, so the count
|
||||
* and the folded rows agree.
|
||||
*/
|
||||
async function dayGroupedListResponse(
|
||||
user: { id: string; timezone?: string | null },
|
||||
opts: {
|
||||
type: MeasurementType;
|
||||
limit: number;
|
||||
offset: number;
|
||||
sortDir: "asc" | "desc";
|
||||
sourceEq?: MeasurementSource;
|
||||
valueMin?: number;
|
||||
valueMax?: number;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
},
|
||||
) {
|
||||
const { type, limit, offset, sortDir } = opts;
|
||||
const stored =
|
||||
user.timezone && user.timezone.length > 0
|
||||
? user.timezone
|
||||
: DEFAULT_TIMEZONE;
|
||||
// A zone Postgres does not know would 500 the read; the drill-down cuts
|
||||
// days in the same zone, so both must agree on it.
|
||||
const tz = isValidTimezone(stored) ? stored : DEFAULT_TIMEZONE;
|
||||
|
||||
// The request's filters, as SQL: the same ones the plain list applies.
|
||||
const filters = Prisma.sql`
|
||||
m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
${opts.sourceEq ? Prisma.sql`AND m."source" = ${opts.sourceEq}::measurement_source` : Prisma.empty}
|
||||
${opts.valueMin != null ? Prisma.sql`AND m."value" >= ${opts.valueMin}` : Prisma.empty}
|
||||
${opts.valueMax != null ? Prisma.sql`AND m."value" <= ${opts.valueMax}` : Prisma.empty}
|
||||
${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty}
|
||||
${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty}
|
||||
`;
|
||||
const localDay = Prisma.sql`to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD')`;
|
||||
// Direction comes from the Zod enum, never from raw input. The window
|
||||
// count runs before LIMIT, so every row of the page carries the
|
||||
// distinct-day total of the whole filtered set.
|
||||
const direction = Prisma.raw(sortDir === "asc" ? "ASC" : "DESC");
|
||||
const pageDays = await prisma.$queryRaw<
|
||||
Array<{ day_key: string; total: number }>
|
||||
>`
|
||||
SELECT d.day_key, (COUNT(*) OVER ())::int AS total
|
||||
FROM (
|
||||
SELECT ${localDay} AS day_key
|
||||
FROM measurements m
|
||||
WHERE ${filters}
|
||||
GROUP BY 1
|
||||
) d
|
||||
ORDER BY d.day_key ${direction}
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
// A page past the end has no row to carry the total; only then is it
|
||||
// counted on its own.
|
||||
const total =
|
||||
pageDays.length > 0
|
||||
? Number(pageDays[0].total)
|
||||
: offset > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ total: number }>>`
|
||||
SELECT COUNT(DISTINCT ${localDay})::int AS total
|
||||
FROM measurements m
|
||||
WHERE ${filters}
|
||||
`
|
||||
)[0]?.total ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const dayKeys = pageDays.map((d) => d.day_key);
|
||||
|
||||
// The page's days are consecutive in the sorted day set, so the span from
|
||||
// the first one's start to the last one's end holds exactly their rows.
|
||||
let groups: Array<{
|
||||
day_key: string;
|
||||
source: MeasurementSource;
|
||||
device_type: string | null;
|
||||
unit: string;
|
||||
total: number;
|
||||
n: number;
|
||||
}> = [];
|
||||
if (dayKeys.length > 0) {
|
||||
const sorted = [...dayKeys].sort();
|
||||
const spanStart = localDayWindow(sorted[0], tz).dayStart;
|
||||
const spanEnd = localDayWindow(sorted[sorted.length - 1], tz).dayEnd;
|
||||
// Newest group first within a day, so the day's label comes from its
|
||||
// latest reading, as it did when raw rows were read newest first.
|
||||
groups = await prisma.$queryRaw`
|
||||
SELECT
|
||||
${localDay} AS day_key,
|
||||
m."source"::text AS source,
|
||||
m."device_type" AS device_type,
|
||||
m."unit" AS unit,
|
||||
SUM(m."value")::float8 AS total,
|
||||
COUNT(*)::int AS n
|
||||
FROM measurements m
|
||||
WHERE ${filters}
|
||||
AND m."measured_at" >= ${spanStart}
|
||||
AND m."measured_at" < ${spanEnd}
|
||||
GROUP BY 1, 2, 3, 4
|
||||
ORDER BY 1, MAX(m."measured_at") DESC, 2, 3, 4
|
||||
`;
|
||||
}
|
||||
|
||||
// v1.29.6 — collapse overlapping sources to the ladder-canonical
|
||||
// reading per day BEFORE summing. Without this, a day with both an
|
||||
// Apple Health AND a Withings write for the same cumulative metric
|
||||
// (e.g. steps) summed BOTH sources into one inflated total — a day
|
||||
// the dashboard tile (which reads through the source-collapsed
|
||||
// rollup, `collapseRollupRowsBySource` in
|
||||
// `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the
|
||||
// same `metricKeyForType` ladder lookup the rollup collapse uses, so
|
||||
// this list and the tile agree on which source wins. A type with no
|
||||
// ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse
|
||||
// against — every group passes through unchanged. Each group stands at
|
||||
// its day's local noon, so the picker files it under the day SQL cut.
|
||||
const groupRows = groups.map((g) => ({
|
||||
...g,
|
||||
type,
|
||||
deviceType: g.device_type,
|
||||
measuredAt: canonicalDailyTimestamp(g.day_key, tz),
|
||||
}));
|
||||
const metricKey = metricKeyForType(type);
|
||||
const priorityJson = metricKey ? await loadUserSourcePriority(user.id) : null;
|
||||
const canonicalGroups = metricKey
|
||||
? pickCanonicalSourceRows(groupRows, metricKey, priorityJson, (d) =>
|
||||
dayKeyForUserTz(d, tz),
|
||||
).canonicalRows
|
||||
: groupRows;
|
||||
|
||||
// Sum per local day; unit and source come from the day's first kept group
|
||||
// so the rendered row carries a sensible label even when sources mix.
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ total: number; sampleCount: number; unit: string; source: string }
|
||||
>();
|
||||
for (const g of canonicalGroups) {
|
||||
const slot = buckets.get(g.day_key);
|
||||
if (!slot) {
|
||||
buckets.set(g.day_key, {
|
||||
total: g.total,
|
||||
sampleCount: g.n,
|
||||
unit: g.unit,
|
||||
source: g.source,
|
||||
});
|
||||
} else {
|
||||
slot.total += g.total;
|
||||
slot.sampleCount += g.n;
|
||||
}
|
||||
}
|
||||
const rowsScanned = groups.reduce((sum, g) => sum + g.n, 0);
|
||||
const rowsKept = canonicalGroups.reduce((sum, g) => sum + g.n, 0);
|
||||
|
||||
// Rows follow the page's day order from SQL.
|
||||
const measurements = dayKeys.flatMap((dayKey) => {
|
||||
const b = buckets.get(dayKey);
|
||||
if (!b) return [];
|
||||
return [
|
||||
{
|
||||
// Synthetic id so the list-row key stays stable across pages
|
||||
// and the row never collides with a real `Measurement.id`.
|
||||
id: `day:${type}:${dayKey}`,
|
||||
type,
|
||||
value: b.total,
|
||||
unit: b.unit,
|
||||
source: b.source,
|
||||
// Anchor the row's timestamp to local noon so the row sorts
|
||||
// cleanly between same-day spot samples — matches the
|
||||
// `canonicalDailyTimestamp` convention used by the drain.
|
||||
measuredAt: canonicalDailyTimestamp(dayKey, tz).toISOString(),
|
||||
notes: null,
|
||||
dayKey,
|
||||
sampleCount: b.sampleCount,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total,
|
||||
type,
|
||||
groupBy: "day",
|
||||
days: measurements.length,
|
||||
rowsScanned,
|
||||
droppedDuplicates: rowsScanned - rowsKept,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
groupBy: "day",
|
||||
droppedDuplicates: rowsScanned - rowsKept,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A sleep page holds at most this many nights. The nights' stage rows are
|
||||
* read and reconstructed in the process, so the bound sits on the nights;
|
||||
* the applied value comes back as `meta.limit`, and `meta.total` still counts
|
||||
* every night, so a caller asking for more pages on rather than losing any.
|
||||
* Until v1.43.6 the list read no more than a year of nights however large
|
||||
* the limit, so this is a widening, never a new cut.
|
||||
*/
|
||||
const SLEEP_NIGHT_PAGE_MAX = 1000;
|
||||
|
||||
/**
|
||||
* v1.11.5 — per-night collapse for the SLEEP_DURATION list. Sleep is
|
||||
* stored one row per STAGE per night (minutes); a plain list leaks every
|
||||
@@ -521,11 +617,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
* nap count, and awakenings in the row so the UI can caption the night
|
||||
* without a second fetch.
|
||||
*
|
||||
* The read is bounded to the last year (matching the series route's sleep
|
||||
* read) so an unbounded list never walks a multi-year per-stage row set.
|
||||
* The nights are paged in Postgres (`sleepNightPage`), like the days of the
|
||||
* cumulative list, and only the stage rows of the page's nights are read.
|
||||
* Until v1.43.6 the read stopped at a year before `to` and the list ended
|
||||
* there without saying so to the client.
|
||||
*/
|
||||
const SLEEP_LIST_MAX_DAYS = 365;
|
||||
|
||||
async function sleepListResponse(
|
||||
user: { id: string },
|
||||
opts: {
|
||||
@@ -537,53 +633,74 @@ async function sleepListResponse(
|
||||
dayKey?: string;
|
||||
},
|
||||
) {
|
||||
const { from, to, limit, offset, sortDir, dayKey } = opts;
|
||||
const { from, to, offset, sortDir, dayKey } = opts;
|
||||
const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX);
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
|
||||
// Bound the read window to a year ending at `to` (or now). An explicit
|
||||
// `from` narrows it but never widens it: an early `from` used to walk every
|
||||
// per-stage row back to that date, which on a multi-year, multi-source
|
||||
// account is the unbounded read this cap exists to stop. Older nights are
|
||||
// reached by moving `to` back, and `meta.windowFrom` says where a cut
|
||||
// window started so a client can tell a cut from an empty history.
|
||||
const windowEnd = to ?? new Date();
|
||||
const floor = new Date(
|
||||
windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000,
|
||||
);
|
||||
const since = from && from > floor ? from : floor;
|
||||
const windowCut = from !== undefined && from < floor;
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
measuredAt: {
|
||||
gte: since,
|
||||
...(to ? { lte: to } : {}),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
const page = await sleepNightPage(user.id, tz, {
|
||||
from,
|
||||
to,
|
||||
sortDir,
|
||||
...(dayKey ? { night: dayKey, limit: 1, offset: 0 } : { limit, offset }),
|
||||
});
|
||||
const nightKeys = page.nights.map((n) => n.night);
|
||||
const wanted = new Set(nightKeys);
|
||||
|
||||
// The page's sessions lie between the first one's start and the last one's
|
||||
// end, and every other session is more than the session gap away from that
|
||||
// span, so this read holds the page's sessions whole and no part of any
|
||||
// other.
|
||||
let rows: Array<{
|
||||
id: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: SleepStage | null;
|
||||
source: MeasurementSource;
|
||||
deviceType: string | null;
|
||||
}> = [];
|
||||
if (page.nights.length > 0) {
|
||||
let spanStart = page.nights[0].spanStart;
|
||||
let spanEnd = page.nights[0].spanEnd;
|
||||
for (const n of page.nights) {
|
||||
if (n.spanStart < spanStart) spanStart = n.spanStart;
|
||||
if (n.spanEnd > spanEnd) spanEnd = n.spanEnd;
|
||||
}
|
||||
rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
AND: [
|
||||
{ measuredAt: { gte: spanStart, lte: spanEnd } },
|
||||
...(from ? [{ measuredAt: { gte: from } }] : []),
|
||||
...(to ? [{ measuredAt: { lte: to } }] : []),
|
||||
],
|
||||
},
|
||||
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter(
|
||||
(s) => wanted.has(s.night),
|
||||
);
|
||||
|
||||
// Drill-down — return the requested night's canonical stage segments.
|
||||
if (dayKey) {
|
||||
const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter(
|
||||
(s) => s.night === dayKey,
|
||||
const { main, naps } = pickMainNightAndNaps(
|
||||
sessions.filter((s) => s.night === dayKey),
|
||||
);
|
||||
const { main, naps } = pickMainNightAndNaps(sessions);
|
||||
const dayUnit = getUnitForType("SLEEP_DURATION");
|
||||
// One row per stage segment of the main night + each nap, so the
|
||||
// drill-down reveals exactly the rows that summed into the headline.
|
||||
@@ -624,9 +741,7 @@ async function sleepListResponse(
|
||||
// Per-night collapse — one synthetic row per wake day. The headline
|
||||
// value is the MAIN night's TIME ASLEEP (the longest session that day),
|
||||
// per the nap convention: naps are surfaced as a count, never folded
|
||||
// into the main night's headline. Reconstruct sessions and pick the
|
||||
// main + naps per wake day.
|
||||
const sessions = reconstructSleepSessions(rows, tz, priorityJson);
|
||||
// into the main night's headline.
|
||||
const byDay = new Map<string, typeof sessions>();
|
||||
for (const s of sessions) {
|
||||
const list = byDay.get(s.night) ?? [];
|
||||
@@ -635,80 +750,165 @@ async function sleepListResponse(
|
||||
}
|
||||
|
||||
const dayUnit = getUnitForType("SLEEP_DURATION");
|
||||
const nightRows: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
source: string;
|
||||
measuredAt: string;
|
||||
notes: null;
|
||||
dayKey: string;
|
||||
sampleCount: number;
|
||||
sleepStages: Record<string, number>;
|
||||
inBedMinutes: number | null;
|
||||
awakeMinutes: number | null;
|
||||
napCount: number;
|
||||
napAsleepMinutes: number;
|
||||
awakenings: number;
|
||||
}> = [];
|
||||
for (const [day, daySessions] of byDay) {
|
||||
const { main, naps } = pickMainNightAndNaps(daySessions);
|
||||
if (!main) continue;
|
||||
nightRows.push({
|
||||
// Synthetic id mirrors the series route's `sleep:<night>` shape.
|
||||
id: `sleep:${day}`,
|
||||
type: "SLEEP_DURATION",
|
||||
value: Math.round(main.asleepMinutes),
|
||||
unit: dayUnit,
|
||||
source: main.source ?? "MANUAL",
|
||||
measuredAt: main.end.toISOString(),
|
||||
notes: null,
|
||||
dayKey: day,
|
||||
// Drives the drill-down chevron — number of stage segments behind
|
||||
// the night, matching the cumulative-type `sampleCount` contract.
|
||||
sampleCount: main.segments.length,
|
||||
sleepStages: main.stages as Record<string, number>,
|
||||
inBedMinutes: main.inBedMinutes,
|
||||
awakeMinutes: main.awakeMinutes,
|
||||
napCount: naps.length,
|
||||
napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0),
|
||||
awakenings: main.awakenings,
|
||||
});
|
||||
}
|
||||
|
||||
// Honour offset + limit against the full night set, and report the true
|
||||
// pre-slice night count as `meta.total` — matching the non-sleep list
|
||||
// branch's pagination contract so a client can page through nights.
|
||||
const totalNights = nightRows.length;
|
||||
const measurements = nightRows
|
||||
.sort((a, b) => {
|
||||
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
})
|
||||
.slice(offset, offset + limit);
|
||||
// Rows follow the page's night order from SQL.
|
||||
const measurements = nightKeys.flatMap((day) => {
|
||||
const { main, naps } = pickMainNightAndNaps(byDay.get(day) ?? []);
|
||||
if (!main) return [];
|
||||
return [
|
||||
{
|
||||
// Synthetic id mirrors the series route's `sleep:<night>` shape.
|
||||
id: `sleep:${day}`,
|
||||
type: "SLEEP_DURATION",
|
||||
value: Math.round(main.asleepMinutes),
|
||||
unit: dayUnit,
|
||||
source: main.source ?? "MANUAL",
|
||||
measuredAt: main.end.toISOString(),
|
||||
notes: null,
|
||||
dayKey: day,
|
||||
// Drives the drill-down chevron — number of stage segments behind
|
||||
// the night, matching the cumulative-type `sampleCount` contract.
|
||||
sampleCount: main.segments.length,
|
||||
sleepStages: main.stages as Record<string, number>,
|
||||
inBedMinutes: main.inBedMinutes,
|
||||
awakeMinutes: main.awakeMinutes,
|
||||
napCount: naps.length,
|
||||
napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0),
|
||||
awakenings: main.awakenings,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total: totalNights,
|
||||
total: page.total,
|
||||
type: "SLEEP_DURATION",
|
||||
mode: "sleep-per-night",
|
||||
nights: measurements.length,
|
||||
rowsScanned: rows.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: {
|
||||
total: totalNights,
|
||||
total: page.total,
|
||||
limit,
|
||||
offset,
|
||||
groupBy: "night",
|
||||
...(windowCut ? { windowFrom: since.toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of sleep nights, chosen in Postgres.
|
||||
*
|
||||
* The stage rows are cut into sessions the way `reconstructSleepSessions`
|
||||
* cuts them: ordered by start (`measured_at` minus the minutes), a new
|
||||
* session wherever a row starts more than `SESSION_GAP_MS` after every
|
||||
* earlier row has ended. A session counts when it holds an asleep row (a
|
||||
* bare or granular asleep stage, or a stage-less one, with minutes), and its
|
||||
* night is the local day its last row ends on. Nights are grouped, sorted
|
||||
* and cut by `limit` / `offset`; `total` is the night count of the whole
|
||||
* filtered set from the same query. `spanStart` / `spanEnd` bound the
|
||||
* night's counting sessions, so the caller reads exactly their rows.
|
||||
*
|
||||
* The session's night here is taken over every writer, while the JS fold
|
||||
* takes it over the winning writer only. They part only when a losing writer
|
||||
* runs past midnight and the winner does not; that night then sits on the
|
||||
* page and comes back without a row, and `total` counts it.
|
||||
*/
|
||||
async function sleepNightPage(
|
||||
userId: string,
|
||||
tz: string,
|
||||
opts: {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
sortDir: "asc" | "desc";
|
||||
limit: number;
|
||||
offset: number;
|
||||
night?: string;
|
||||
},
|
||||
): Promise<{
|
||||
nights: Array<{ night: string; spanStart: Date; spanEnd: Date }>;
|
||||
total: number;
|
||||
}> {
|
||||
const zone = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE;
|
||||
const gapSeconds = SESSION_GAP_MS / 1000;
|
||||
// Direction comes from the Zod enum, never from raw input.
|
||||
const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC");
|
||||
const nightsCte = Prisma.sql`
|
||||
WITH r AS (
|
||||
SELECT
|
||||
m."measured_at" AS e,
|
||||
m."measured_at" - m."value" * interval '1 minute' AS s,
|
||||
(m."value" > 0 AND (m."sleep_stage" IS NULL
|
||||
OR m."sleep_stage" IN ('ASLEEP', 'REM', 'CORE', 'DEEP'))) AS asleep
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = 'SLEEP_DURATION'::measurement_type
|
||||
AND m."deleted_at" IS NULL
|
||||
${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty}
|
||||
${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty}
|
||||
), o AS (
|
||||
SELECT r.*, MAX(e) OVER (
|
||||
ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||
) AS prev_end
|
||||
FROM r
|
||||
), g AS (
|
||||
SELECT o.*, SUM(
|
||||
CASE WHEN prev_end IS NULL
|
||||
OR s - prev_end > make_interval(secs => ${gapSeconds}::double precision) THEN 1 ELSE 0 END
|
||||
) OVER (ORDER BY s, e ROWS UNBOUNDED PRECEDING) AS sid
|
||||
FROM o
|
||||
), sess AS (
|
||||
SELECT MIN(s) AS s, MAX(e) AS e
|
||||
FROM g
|
||||
GROUP BY sid
|
||||
HAVING BOOL_OR(asleep)
|
||||
), nights AS (
|
||||
SELECT
|
||||
to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${zone}, 'YYYY-MM-DD') AS night,
|
||||
MIN(s) AS span_start,
|
||||
MAX(e) AS span_end
|
||||
FROM sess
|
||||
GROUP BY 1
|
||||
)
|
||||
`;
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ night: string; span_start: Date; span_end: Date; total: number }>
|
||||
>`
|
||||
${nightsCte}
|
||||
SELECT night, span_start, span_end, (COUNT(*) OVER ())::int AS total
|
||||
FROM nights
|
||||
${opts.night ? Prisma.sql`WHERE night = ${opts.night}` : Prisma.empty}
|
||||
ORDER BY night ${direction}
|
||||
LIMIT ${opts.limit} OFFSET ${opts.offset}
|
||||
`;
|
||||
// A page past the end has no row to carry the total; only then is it
|
||||
// counted on its own.
|
||||
const total =
|
||||
rows.length > 0
|
||||
? Number(rows[0].total)
|
||||
: opts.offset > 0
|
||||
? Number(
|
||||
(
|
||||
await prisma.$queryRaw<Array<{ total: number }>>`
|
||||
${nightsCte}
|
||||
SELECT COUNT(*)::int AS total FROM nights
|
||||
`
|
||||
)[0]?.total ?? 0,
|
||||
)
|
||||
: 0;
|
||||
return {
|
||||
nights: rows.map((r) => ({
|
||||
night: r.night,
|
||||
spanStart: r.span_start,
|
||||
spanEnd: r.span_end,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this body attribute a source to any reading — any source at all?
|
||||
*
|
||||
|
||||
@@ -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 ? <CycleRingTile /> : undefined}
|
||||
extraTile={cycleRingCell ? <CycleRingTile /> : 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
|
||||
|
||||
@@ -152,13 +152,19 @@ describe("<CycleRingTile>", () => {
|
||||
expect(render(<CycleRingTile />)).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(<CycleRingTile />)).toBe("");
|
||||
const html = render(<CycleRingTile />);
|
||||
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", () => {
|
||||
|
||||
@@ -26,16 +26,35 @@ function window(): [string, string] {
|
||||
}
|
||||
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
const { dial, pending } = useCycleRingDial(enabled);
|
||||
return <span>{`${dial}:${pending}`}</span>;
|
||||
const { dial, pending, failed } = useCycleRingDial(enabled);
|
||||
return <span>{`${dial}:${pending}${failed ? ":failed" : ""}`}</span>;
|
||||
}
|
||||
|
||||
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 \? <CycleRingTile \/> : undefined\}/,
|
||||
);
|
||||
expect(source).toMatch(/hideStrain=\{cycleRingCell\}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the scores strip waits for the ring's decision", () => {
|
||||
|
||||
@@ -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 (
|
||||
<QueryErrorRow
|
||||
slot="wellness-cycle-tile-error"
|
||||
message={t("cycle.wellnessRing.loadError")}
|
||||
onRetry={() => void calendar.refetch()}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (verdict?.phase == null || verdict.dayOfCycle == null) return null;
|
||||
|
||||
const hue = PHASE_HUE[verdict.phase];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<DayEventKind, LucideIcon>> = {
|
||||
@@ -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 (
|
||||
<QueryErrorRow
|
||||
slot="since-last-visit-error"
|
||||
message={t("day.sinceLastVisit.loadError")}
|
||||
onRetry={() => 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 (
|
||||
<section
|
||||
data-slot="since-last-visit"
|
||||
aria-labelledby={`since-last-visit-${next.id}`}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 id={`since-last-visit-${next.id}`} className={DAY_SECTION_LABEL}>
|
||||
{t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
</h3>
|
||||
{rows.length > 0 ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="space-y-2" aria-hidden="true">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
|
||||
{visible.map((row) => (
|
||||
<li
|
||||
key={row.key}
|
||||
data-slot="since-last-visit-row"
|
||||
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 py-2.5 sm:grid-cols-[7.5rem_1.25rem_minmax(0,1fr)]"
|
||||
<Card className="gap-3 py-3 md:gap-3 md:py-4">
|
||||
<CardContent>
|
||||
<section
|
||||
data-slot="since-last-visit"
|
||||
aria-labelledby={`since-last-visit-${next.id}`}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3
|
||||
id={`since-last-visit-${next.id}`}
|
||||
className={DAY_SECTION_LABEL}
|
||||
>
|
||||
<span className="col-start-2 text-sm sm:col-start-1 sm:row-start-1">
|
||||
<DayLink date={row.date}>
|
||||
{fmt.dateShortSmartCalendar(row.date)}
|
||||
</DayLink>
|
||||
</span>
|
||||
<row.Icon
|
||||
className="text-muted-foreground row-span-2 mt-0.5 size-4 sm:row-span-1 sm:row-start-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="col-start-2 min-w-0 sm:col-start-3 sm:row-start-1">
|
||||
{row.title ? <p className="text-sm">{row.title}</p> : null}
|
||||
{row.meta ? (
|
||||
<p className="text-muted-foreground text-xs">{row.meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{rows.length > VISIBLE_ROWS ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAll}
|
||||
onClick={() => setShowAll((value) => !value)}
|
||||
className="focus-visible:ring-ring/50 min-h-11 rounded-md text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
{showAll
|
||||
? t("day.sinceLastVisit.fewer")
|
||||
: tCount("day.sinceLastVisit.all", rows.length)}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("day.sinceLastVisit.note")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
data-slot="since-last-visit-report"
|
||||
>
|
||||
<FileText className="size-4" aria-hidden="true" />
|
||||
{t("day.sinceLastVisit.report", { date: lastLabel })}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
{t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
</h3>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
|
||||
{visible.map((row) => (
|
||||
<li
|
||||
key={row.key}
|
||||
data-slot="since-last-visit-row"
|
||||
className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3 gap-y-0.5 py-2.5 sm:grid-cols-[7.5rem_1.25rem_minmax(0,1fr)]"
|
||||
>
|
||||
<span className="col-start-2 text-sm sm:col-start-1 sm:row-start-1">
|
||||
<DayLink date={row.date}>
|
||||
{fmt.dateShortSmartCalendar(row.date)}
|
||||
</DayLink>
|
||||
</span>
|
||||
<row.Icon
|
||||
className="text-muted-foreground row-span-2 mt-0.5 size-4 sm:row-span-1 sm:row-start-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="col-start-2 min-w-0 sm:col-start-3 sm:row-start-1">
|
||||
{row.title ? <p className="text-sm">{row.title}</p> : null}
|
||||
{row.meta ? (
|
||||
<p className="text-muted-foreground text-xs">{row.meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{rows.length > VISIBLE_ROWS ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAll}
|
||||
onClick={() => setShowAll((value) => !value)}
|
||||
className="focus-visible:ring-ring/50 min-h-11 rounded-md text-sm font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
{showAll
|
||||
? t("day.sinceLastVisit.fewer")
|
||||
: tCount("day.sinceLastVisit.all", rows.length)}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("day.sinceLastVisit.note")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
data-slot="since-last-visit-report"
|
||||
>
|
||||
<FileText className="size-4" aria-hidden="true" />
|
||||
{t("day.sinceLastVisit.report", { date: lastLabel })}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
@@ -69,10 +72,14 @@ function visit(over: Partial<EncounterDTO> & { 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("<VisitsSection>", () => {
|
||||
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("<VisitsSection> 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);
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<Card className="gap-3 py-3 md:gap-3 md:py-4">
|
||||
<CardContent>
|
||||
<SinceLastVisit next={encounter} last={lastDone} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SinceLastVisit next={encounter} last={lastDone} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -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<CoachReadStripData>(
|
||||
@@ -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 (
|
||||
<QueryErrorRow
|
||||
slot="coach-read-strip-error"
|
||||
message={t("insights.coach.readStrip.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
data-slot="coach-read-strip"
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Compass } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
@@ -52,18 +52,31 @@ function formatValue(value: number, locale: string): string {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The card over its read. Nothing is drawn while the read is on its way:
|
||||
* below the first rung the card draws nothing at all, so a loading
|
||||
* placeholder was a frame that often collapsed again. A failed read says so
|
||||
* with a retry rather than passing for "nothing to say yet".
|
||||
*/
|
||||
export function MoodPrognosisCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodPrognosis(),
|
||||
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="min-h-24 w-full rounded-xl" />;
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
slot="mood-prognosis-error"
|
||||
message={t("insights.mood.prognosis.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
return <MoodPrognosisView reading={data} />;
|
||||
|
||||
@@ -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<NutrientDailySeries>(
|
||||
`/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 (
|
||||
<Card data-slot="nutrients-caffeine-card">
|
||||
<CardContent className="py-6" aria-hidden="true">
|
||||
<Skeleton className="h-[280px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<QueryErrorRow
|
||||
slot="nutrients-caffeine-error"
|
||||
message={t("nutrients.caffeine.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -424,7 +425,7 @@ export function MeasurementList({
|
||||
// never a history entry per keystroke); an external URL change (back
|
||||
// button, deep link) re-seeds the draft via the render-phase derived-state
|
||||
// adjustment below, exactly like the vault's search field.
|
||||
const urlValueRange = `${filters.valueMin ?? ""} | ||||