From d1fa0d9d527bb5f72398a981e5eeaea01cd25f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 08:34:53 +0200 Subject: [PATCH 1/3] Name the mood sliders for what they ask, not how many they are The capture section and the insights card called the five sliders "Five scales", a count where a name belongs. They now carry the state-of-mind wording in all six languages, with Spanish diverging from the first slider's own label so the block and the mood slider stay distinguishable. --- messages/de.json | 6 +++--- messages/en.json | 6 +++--- messages/es.json | 6 +++--- messages/fr.json | 6 +++--- messages/it.json | 6 +++--- messages/pl.json | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/messages/de.json b/messages/de.json index 329c13522..be74a8301 100644 --- a/messages/de.json +++ b/messages/de.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", "quickTagsExpand": "Weitere", "loadError": "Stimmungseinträge konnten nicht geladen werden.", - "sectionLevelA": "Fünf Skalen", + "sectionLevelA": "Gefühlslage", "sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.", "dimensionNotAnswered": "nicht angegeben", "dimensionClear": "leeren", @@ -3646,8 +3646,8 @@ }, "inTargetTitle": "Im Zielbereich", "dimensions": { - "title": "Fünf Skalen", - "none": "Für diese fünf Skalen ist noch nichts erfasst.", + "title": "Gefühlslage", + "none": "Zur Gefühlslage ist noch nichts erfasst.", "window": "{days} Tage", "noneInWindow": "nichts im Zeitraum", "higherIsMore": "höher = {anchor}", diff --git a/messages/en.json b/messages/en.json index 76cc7a265..2c16de62f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Your frequent tags — one tap", "quickTagsExpand": "More", "loadError": "Could not load your mood entries.", - "sectionLevelA": "Five scales", + "sectionLevelA": "State of mind", "sectionLevelAHelp": "Optional. Anything you leave alone stays empty.", "dimensionNotAnswered": "not recorded", "dimensionClear": "clear", @@ -3646,8 +3646,8 @@ }, "inTargetTitle": "In your range", "dimensions": { - "title": "Five scales", - "none": "Nothing recorded for these five scales yet.", + "title": "State of mind", + "none": "Nothing recorded on your state of mind yet.", "window": "{days} days", "noneInWindow": "nothing in this range", "higherIsMore": "higher = {anchor}", diff --git a/messages/es.json b/messages/es.json index 9dadf056b..ad0681b29 100644 --- a/messages/es.json +++ b/messages/es.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Tus etiquetas frecuentes: un toque", "quickTagsExpand": "Más", "loadError": "No se pudieron cargar tus registros de estado de ánimo.", - "sectionLevelA": "Cinco escalas", + "sectionLevelA": "Estado emocional", "sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.", "dimensionNotAnswered": "sin indicar", "dimensionClear": "borrar", @@ -3646,8 +3646,8 @@ }, "inTargetTitle": "En tu rango", "dimensions": { - "title": "Cinco escalas", - "none": "Todavía no hay nada registrado en estas cinco escalas.", + "title": "Estado emocional", + "none": "Todavía no hay nada registrado sobre tu estado emocional.", "window": "{days} días", "noneInWindow": "nada en este periodo", "higherIsMore": "más alto = {anchor}", diff --git a/messages/fr.json b/messages/fr.json index 553da5fe7..a57c6dea6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Vos tags fréquents — en un geste", "quickTagsExpand": "Plus", "loadError": "Impossible de charger vos entrées d'humeur.", - "sectionLevelA": "Cinq échelles", + "sectionLevelA": "État d'esprit", "sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.", "dimensionNotAnswered": "non renseigné", "dimensionClear": "effacer", @@ -3646,8 +3646,8 @@ }, "inTargetTitle": "Dans votre plage", "dimensions": { - "title": "Cinq échelles", - "none": "Rien n'est encore enregistré pour ces cinq échelles.", + "title": "État d'esprit", + "none": "Rien n'est encore enregistré pour l'état d'esprit.", "window": "{days} jours", "noneInWindow": "rien sur cette période", "higherIsMore": "plus haut = {anchor}", diff --git a/messages/it.json b/messages/it.json index 792bb59bf..c352fc949 100644 --- a/messages/it.json +++ b/messages/it.json @@ -891,7 +891,7 @@ "quickTagsHelp": "I tuoi tag frequenti: un tocco", "quickTagsExpand": "Altro", "loadError": "Impossibile caricare le voci dell'umore.", - "sectionLevelA": "Cinque scale", + "sectionLevelA": "Stato d'animo", "sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.", "dimensionNotAnswered": "non indicato", "dimensionClear": "cancella", @@ -3646,8 +3646,8 @@ }, "inTargetTitle": "Nel tuo intervallo", "dimensions": { - "title": "Cinque scale", - "none": "Per queste cinque scale non c'è ancora nulla.", + "title": "Stato d'animo", + "none": "Non c'è ancora nulla sullo stato d'animo.", "window": "{days} giorni", "noneInWindow": "nulla in questo periodo", "higherIsMore": "più alto = {anchor}", diff --git a/messages/pl.json b/messages/pl.json index 0c8475bde..4a9febb41 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -891,7 +891,7 @@ "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", "quickTagsExpand": "Więcej", "loadError": "Nie udało się załadować wpisów nastroju.", - "sectionLevelA": "Pięć skal", + "sectionLevelA": "Samopoczucie", "sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.", "dimensionNotAnswered": "nie podano", "dimensionClear": "wyczyść", @@ -3646,8 +3646,8 @@ }, "inTargetTitle": "W Twoim zakresie", "dimensions": { - "title": "Pięć skal", - "none": "Dla tych pięciu skal nie ma jeszcze żadnych danych.", + "title": "Samopoczucie", + "none": "Nie zapisano jeszcze żadnych danych o samopoczuciu.", "window": "{days} dni", "noneInWindow": "brak w tym okresie", "higherIsMore": "wyżej = {anchor}", From b61b53263e9b14cb3ae7455dc00d92641360c69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 09:07:38 +0200 Subject: [PATCH 2/3] Drop the coach launch button from the workout detail The page ended on a "Why?" button that opened the coach with a prefilled question about the session. It leaves on the maintainer's call; the coach remains reachable from its own surfaces. The two strings it carried leave all six locales with it. --- messages/de.json | 2 -- messages/en.json | 2 -- messages/es.json | 2 -- messages/fr.json | 2 -- messages/it.json | 2 -- messages/pl.json | 2 -- src/app/insights/workouts/[id]/page.tsx | 19 +++---------------- 7 files changed, 3 insertions(+), 28 deletions(-) diff --git a/messages/de.json b/messages/de.json index 329c13522..9691f3967 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3120,8 +3120,6 @@ "navRecovery": "Erholung", "workouts": { "title": "Workouts", - "askWhy": "Warum?", - "askWhyPrompt": "Warum ist diese Einheit so ausgefallen? Erkläre mir meine eigenen Werte dazu.", "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", "emptyState": { "title": "Noch keine Workouts", diff --git a/messages/en.json b/messages/en.json index 76cc7a265..27991f9f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3120,8 +3120,6 @@ "navRecovery": "Recovery", "workouts": { "title": "Workouts", - "askWhy": "Ask why", - "askWhyPrompt": "Why did this session read the way it did? Walk me through my own numbers from it.", "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", "emptyState": { "title": "No workouts yet", diff --git a/messages/es.json b/messages/es.json index 9dadf056b..81c135001 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3120,8 +3120,6 @@ "navRecovery": "Recuperación", "workouts": { "title": "Entrenamientos", - "askWhy": "Por qué", - "askWhyPrompt": "¿Por qué salió así esta sesión? Explícame mis propios datos de ella.", "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", "emptyState": { "title": "Aún no hay entrenamientos", diff --git a/messages/fr.json b/messages/fr.json index 553da5fe7..3b7d774fc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3120,8 +3120,6 @@ "navRecovery": "Récupération", "workouts": { "title": "Entraînements", - "askWhy": "Pourquoi", - "askWhyPrompt": "Pourquoi cette séance s'est-elle passée ainsi ? Explique-moi mes propres chiffres.", "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", "emptyState": { "title": "Aucun entraînement", diff --git a/messages/it.json b/messages/it.json index 792bb59bf..e77e18e20 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3120,8 +3120,6 @@ "navRecovery": "Recupero", "workouts": { "title": "Allenamenti", - "askWhy": "Perché", - "askWhyPrompt": "Perché questa sessione è andata così? Spiegami i miei dati di questa sessione.", "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", "emptyState": { "title": "Nessun allenamento", diff --git a/messages/pl.json b/messages/pl.json index 0c8475bde..dbff42745 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3120,8 +3120,6 @@ "navRecovery": "Regeneracja", "workouts": { "title": "Treningi", - "askWhy": "Dlaczego", - "askWhyPrompt": "Dlaczego ta sesja wypadła w ten sposób? Wyjaśnij mi moje własne wyniki.", "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", "emptyState": { "title": "Brak treningów", diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 2780caf84..c242b1dce 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -9,7 +9,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { useWorkoutDetail } from "@/hooks/use-workouts"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { BackLink } from "@/components/ui/back-link"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; @@ -31,7 +30,7 @@ import { * Layout, top to bottom (mobile-first single column): * hero header → reserved Activity-Insight seam (renders nothing today) * → stats grid + sport-average line → HR curve → effort zones → GPS - * route → per-km splits → "that day" → coach launch. + * route → per-km splits → "that day". * * "That day" answers the last question the page is for: what the rest of * the day looked like around the session. It renders the day's own pulse @@ -40,8 +39,8 @@ import { * * Every data-less section returns `null` (hide, don't render empty), so * an aggregates-only workout (a Strava ride with no wearable, a manual - * entry) reads as hero + stats + "that day" + coach — compact but - * honest, no empty shells. + * entry) reads as hero + stats + "that day" — compact but honest, no + * empty shells. * * Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67 * enrichment fields). The `canonicalId` pointer still resolves a @@ -120,18 +119,6 @@ export default function InsightsWorkoutDetailPage({ - {/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the - snapshot the first coach turn reads. v1.31.0 — `workoutId` pins - THIS session's own numbers as one additional snapshot section, so - the answer is about this workout rather than about training in - general, and the opener auto-sends so it lands directly. */} - )} From f3c84018f971b9bd7cafc781e81332f13a49ee55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 09:38:30 +0200 Subject: [PATCH 3/3] Record a backfilled dose at its slot's own time Marking an older missed dose as taken from the Verlauf pinned the right slot but stamped the intake with the current moment. The body builder is a pure helper now, anchored on the slot instant, matching both the optimistic paint and the skipped-to-taken flip that already behaved this way. --- CHANGELOG.md | 9 ++++++++ public/sw.js | 2 +- .../dose-history-ledger-compute.test.ts | 21 +++++++++++++++++++ .../dose-history-ledger-compute.ts | 15 +++++++++++++ .../medications/dose-history-ledger.tsx | 16 ++++++-------- 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f81deb5d..d7ef0ce74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Fixed + +- Marking an older missed dose as taken from the intake history now records + the dose at the slot's own date and time. Marking Tuesday 09:00 from a + Thursday evening used to land on the right slot but stamp the intake with + the current moment; the recorded time now matches the claim being made. The + optimistic paint had promised this all along, and the skipped-to-taken flip + in the same view already behaved this way. + ## [1.37.1] — 2026-08-08 ### Added diff --git a/public/sw.js b/public/sw.js index d2632934b..d62c81e76 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.0"; + /* @sw-version-fallback */ "v1.37.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts index 420d73a1b..496be10f6 100644 --- a/src/components/medications/__tests__/dose-history-ledger-compute.test.ts +++ b/src/components/medications/__tests__/dose-history-ledger-compute.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyOptimisticSlotMark, + slotMarkRequestBody, complianceFromLedger, formatSlotDelta, groupLedgerByDay, @@ -195,3 +196,23 @@ describe("applyOptimisticSlotMark", () => { expect(next.rows[0].status).toBe("taken_late"); }); }); + +describe("slotMarkRequestBody", () => { + const row = { at: "2026-08-04T07:00:00.000Z" }; + + it("anchors takenAt on the slot instant, not on now", () => { + const body = slotMarkRequestBody(row, "taken"); + expect(body).toEqual({ + skipped: false, + scheduledFor: "2026-08-04T07:00:00.000Z", + takenAt: "2026-08-04T07:00:00.000Z", + }); + }); + + it("keeps the skipped arm free of takenAt", () => { + expect(slotMarkRequestBody(row, "skipped")).toEqual({ + skipped: true, + scheduledFor: "2026-08-04T07:00:00.000Z", + }); + }); +}); diff --git a/src/components/medications/dose-history-ledger-compute.ts b/src/components/medications/dose-history-ledger-compute.ts index 06f51e084..9bb009e35 100644 --- a/src/components/medications/dose-history-ledger-compute.ts +++ b/src/components/medications/dose-history-ledger-compute.ts @@ -204,3 +204,18 @@ export function applyOptimisticSlotMark( }); return { ...payload, rows }; } + +/** + * Request body for marking a ledger slot from the Verlauf. The taken arm + * anchors `takenAt` on the slot instant: marking Tuesday 09:00 from a + * Thursday evening records a dose taken Tuesday 09:00, which is the claim + * the person is making. The optimistic paint above already promised this. + */ +export function slotMarkRequestBody( + row: Pick, + action: "taken" | "skipped", +): Record { + return action === "skipped" + ? { skipped: true, scheduledFor: row.at } + : { skipped: false, scheduledFor: row.at, takenAt: row.at }; +} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 9204ed83d..82b6ae50e 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -106,6 +106,7 @@ import { formatSlotDelta, groupLedgerByDay, isSlotActionable, + slotMarkRequestBody, type LedgerPayload, type LedgerRow, } from "@/components/medications/dose-history-ledger-compute"; @@ -273,18 +274,13 @@ export function DoseHistoryLedger({ try { // Pin the displayed slot so the server marks THAT dose (the canonical - // slot upsert), not a now-snap to the nearest one. The POST returns - // the created event; its id drives the Undo affordance (the same - // soft-delete route the cards use). + // slot upsert), not a now-snap to the nearest one — and anchor + // `takenAt` on the same instant, matching the optimistic paint. The + // POST returns the created event; its id drives the Undo affordance + // (the same soft-delete route the cards use). const created = await apiPost<{ id?: string } | undefined>( `/api/medications/${medicationId}/intake`, - action === "skipped" - ? { skipped: true, scheduledFor: row.at } - : { - skipped: false, - scheduledFor: row.at, - takenAt: new Date().toISOString(), - }, + slotMarkRequestBody(row, action), ); const eventId: string | undefined = created?.id; toast.success(