From 189202f4c663be0d92af46b5df8441233bcd4944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 00:22:29 +0200 Subject: [PATCH] fix(insights,medications): reconcile review findings on the v1.14.0 surfaces - Medication cycle status: re-hydrate `nextDueAt` (it arrives as an ISO string over the wire) before the Berlin day-bucketing, so "next dose in N days" no longer drifts by a day near midnight or off Berlin. Covered by a new string-input test. - Wellness rings: drive the conic sheen off a fixed white so it always lightens (a near-black light-mode foreground under overlay was darkening the tile into a grey band); give the ring a transform transition so it scales smoothly with the tile lift instead of snapping; cap the green-band glow at a few breaths instead of an infinite drop-shadow repaint; deepen the light-mode stress and recovery end-stops for non-text contrast; track the sm/md column count to the active-score count so two or four scores never leave an empty cell. - Mood factor crosstab: bound the snapshot's cross-metric read to the 365-day window and exclude soft-deleted rows, so the factor deltas the model cites match the visible board over the same canonical row set (no recency-biased truncation, no soft-delete skew); thread the user's source priority so the per-day canonical pick agrees with the screen; render the delta neutrally rather than green/red, since a higher vital is good for steps but bad for resting heart rate and the board is observational. - Log-intake: surface an error when a pinned slot can't resolve to an instant instead of silently logging the dose through the unscheduled path. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 5 ++-- messages/en.json | 5 ++-- messages/es.json | 5 ++-- messages/fr.json | 5 ++-- messages/it.json | 5 ++-- messages/pl.json | 5 ++-- src/app/globals.css | 19 +++++++++++---- .../insights/derived/wellness-scores.tsx | 11 ++++++++- .../mood/mood-factor-metric-crosstab.tsx | 14 +++++------ .../medications/__tests__/card-parts.test.tsx | 13 ++++++++++ .../card-parts/medication-cycle-status.tsx | 6 ++++- .../medications/log-intake-dialog.tsx | 15 ++++++++++-- .../insights/__tests__/mood-status.test.ts | 6 +++++ src/lib/insights/mood-status.ts | 24 ++++++++++++++++++- 14 files changed, 110 insertions(+), 28 deletions(-) diff --git a/messages/de.json b/messages/de.json index bc1b7c786..8497c0d13 100644 --- a/messages/de.json +++ b/messages/de.json @@ -605,7 +605,8 @@ "skippedLabel": "Als ausgelassen erfassen", "noMedications": "Lege zuerst ein Medikament an, dann kannst du Einnahmen dafür erfassen.", "cancel": "Abbrechen", - "submit": "Einnahme erfassen" + "submit": "Einnahme erfassen", + "slotError": "Der gewählte Slot konnte keiner Uhrzeit zugeordnet werden. Bitte wähle einen anderen Slot oder „Kein bestimmter Slot“." }, "emptyTitle": "Noch keine Medikamente", "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um Compliance zu erfassen.", @@ -4867,4 +4868,4 @@ }, "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker." } -} \ No newline at end of file +} diff --git a/messages/en.json b/messages/en.json index 8503c9d99..6fb5b62e9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -605,7 +605,8 @@ "skippedLabel": "Logged as skipped", "noMedications": "Add a medication first, then you can log intakes against it.", "cancel": "Cancel", - "submit": "Log intake" + "submit": "Log intake", + "slotError": "The selected slot couldn’t be matched to a time. Pick another slot or “No specific slot”." }, "emptyTitle": "No medications yet", "emptyDescription": "Add medications, dosages, and reminder windows to track compliance.", @@ -4867,4 +4868,4 @@ }, "footer": "Shared from HealthLog — a self-hosted personal health tracker." } -} \ No newline at end of file +} diff --git a/messages/es.json b/messages/es.json index d5b8544cd..513ca1b2e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -605,7 +605,8 @@ "skippedLabel": "Registrar como omitida", "noMedications": "Añade primero un medicamento y luego podrás registrar tomas para él.", "cancel": "Cancelar", - "submit": "Registrar toma" + "submit": "Registrar toma", + "slotError": "No se pudo asignar una hora al intervalo seleccionado. Elige otro intervalo o «Sin intervalo específico»." }, "emptyTitle": "Aún no hay medicamentos", "emptyDescription": "Crea medicamentos, dosis y franjas horarias para registrar el cumplimiento.", @@ -4867,4 +4868,4 @@ }, "footer": "Compartido desde HealthLog — un registro de salud autoalojado." } -} \ No newline at end of file +} diff --git a/messages/fr.json b/messages/fr.json index ab2f5ceeb..b2543a04a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -605,7 +605,8 @@ "skippedLabel": "Enregistrer comme ignorée", "noMedications": "Ajoutez d’abord un médicament, puis vous pourrez enregistrer des prises pour celui-ci.", "cancel": "Annuler", - "submit": "Enregistrer la prise" + "submit": "Enregistrer la prise", + "slotError": "Le créneau sélectionné n’a pas pu être associé à une heure. Choisissez un autre créneau ou « Aucun créneau précis »." }, "emptyTitle": "Aucun médicament pour l’instant", "emptyDescription": "Crée des médicaments, des doses et des fenêtres horaires pour suivre l’observance.", @@ -4867,4 +4868,4 @@ }, "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé." } -} \ No newline at end of file +} diff --git a/messages/it.json b/messages/it.json index 9997c4aa9..e94caef04 100644 --- a/messages/it.json +++ b/messages/it.json @@ -605,7 +605,8 @@ "skippedLabel": "Registra come saltata", "noMedications": "Aggiungi prima un farmaco, poi potrai registrarne le assunzioni.", "cancel": "Annulla", - "submit": "Registra assunzione" + "submit": "Registra assunzione", + "slotError": "Non è stato possibile associare un orario alla fascia selezionata. Scegli un’altra fascia o «Nessuna fascia specifica»." }, "emptyTitle": "Ancora nessun farmaco", "emptyDescription": "Crea farmaci, dosi e fasce orarie per registrare l’aderenza.", @@ -4867,4 +4868,4 @@ }, "footer": "Condiviso da HealthLog — un tracker della salute self-hosted." } -} \ No newline at end of file +} diff --git a/messages/pl.json b/messages/pl.json index 935bea4fb..6e87401a3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -605,7 +605,8 @@ "skippedLabel": "Zarejestruj jako pominięte", "noMedications": "Najpierw dodaj lek, a potem będziesz mógł rejestrować jego przyjęcia.", "cancel": "Anuluj", - "submit": "Zarejestruj przyjęcie" + "submit": "Zarejestruj przyjęcie", + "slotError": "Nie udało się przypisać godziny do wybranego slotu. Wybierz inny slot lub „Brak konkretnego slotu”." }, "emptyTitle": "Brak leków", "emptyDescription": "Utwórz leki, dawki i okna czasowe, aby śledzić przestrzeganie.", @@ -4867,4 +4868,4 @@ }, "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia." } -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index 9f840bfa8..4806a0bed 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -294,13 +294,13 @@ --ring-readiness-to: #18a34e; --tile-readiness: #14720a; --ring-recovery-from: #07857c; - --ring-recovery-to: #12b3a3; + --ring-recovery-to: #0e9c8f; --tile-recovery: #0a8f86; --ring-sleep-from: #2d5fc4; --ring-sleep-to: #4f7ae0; --tile-sleep: #2d5fc4; --ring-stress-from: #b06a16; - --ring-stress-to: #d68a2a; + --ring-stress-to: #c47a1e; --tile-stress: #a34d14; --ring-strain-from: #6b4fd0; --ring-strain-to: #8a6ff0; @@ -503,6 +503,10 @@ .wellness-tile:active { transform: translateY(0) scale(0.985); } +/* So the ring scales smoothly with the tile lift instead of snapping. */ +.wellness-tile [data-slot="score-ring"] { + transition: transform 200ms var(--ring-ease); +} /* * v1.14.0 — the wellness-strip "signature reveal" motion system. @@ -554,9 +558,12 @@ inset: 6%; border-radius: 50%; opacity: 0; + /* A specular highlight must always LIGHTEN. Under mix-blend overlay a + near-black light-mode `--card-foreground` would darken the tile into a + grey band, so the sheen rides a fixed white in both themes. */ background: radial-gradient( circle at 50% 50%, - color-mix(in srgb, var(--card-foreground) 70%, transparent) 0%, + rgba(255, 255, 255, 0.72) 0%, transparent 70% ); mix-blend-mode: overlay; @@ -606,7 +613,10 @@ filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)); } .wellness-ring-arc--pulse { - animation: ringGlow 3.4s ease-in-out 2s infinite; + /* Breathe a few times to celebrate a strong score, then settle — an + infinite loop would repaint the (non-compositable) drop-shadow filter + forever. Motion happens, then the surface goes calm. */ + animation: ringGlow 3.4s ease-in-out 2s 3; } @keyframes ringGlow { 0%, @@ -622,6 +632,7 @@ .wellness-tile, .wellness-tile:hover, .wellness-tile:active, + .wellness-tile [data-slot="score-ring"], .wellness-tile:hover [data-slot="score-ring"] { transform: none; transition: none; diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 0b946afc7..d7e147803 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -326,6 +326,15 @@ export function WellnessScores({ 5: "lg:grid-cols-5", }; const lgCols = LG_COLS[Math.min(tiles.length, 5)] ?? "lg:grid-cols-5"; + // The sm/md row caps at 3 columns; track the actual tile count there too so + // two or four active scores don't leave an empty cell that reads as a + // missing tile (the same fill rule the lg row already follows). + const SM_COLS: Record = { + 1: "sm:grid-cols-1", + 2: "sm:grid-cols-2", + 3: "sm:grid-cols-3", + }; + const smCols = SM_COLS[Math.min(tiles.length, 3)] ?? "sm:grid-cols-3"; return (
{tiles} diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx index 9b9101d01..bec66ed6f 100644 --- a/src/components/insights/mood/mood-factor-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -106,11 +106,14 @@ export function MoodFactorMetricCrosstab({ METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, ); const unit = t(UNIT_KEY[row.display]); - // `delta` = lowAvg − highAvg: negative = vital runs lower on the - // low-factor (worse) days. The read-out is colored by sign. + // `delta` = lowAvg − highAvg: positive = the vital runs higher on the + // low-factor (worse) days. The number stays NEUTRAL — a higher value + // is good for steps but bad for resting HR, and this board is + // observational ("associations, not causes"), so colouring it + // green/red would imply a health verdict the data doesn't support. + // The sign prefix + `data-direction` carry the direction. const up = row.delta >= 0; const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; - const deltaColor = up ? "var(--success)" : "var(--destructive)"; return (
  • - + {deltaText} { expect(html).toContain("lucide-circle-check"); }); + it("on_track reads the same day count when nextDueAt arrives as an ISO string", () => { + // Over the wire `nextDueAt` is JSON, so it reaches the card as a string + // even though the type says `Date`. The component must re-hydrate it for + // the Berlin day-bucketing — a raw string would silently drift the count. + const iso = inDays(4).toISOString() as unknown as Date; + const html = render( + , + ); + expect(html).toContain("Next dose in 4 days"); + }); + it("on_track one day out reads tomorrow", () => { const html = render(