From a8149e0bdf7c8a096141ce7f7cb3f0ae434b8947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 03:30:18 +0200 Subject: [PATCH] fix(ux): honest deletes, stable hydration and tidy numbers after release review Server and first client render now agree everywhere query state used to leak into the chrome, checkboxes no longer nest inside buttons, delete confirmations admit the undo they offer, raw step counts drop their decimals, the truncated unit label wraps instead, streaks know their singular, the medication header speaks the user's date format, a failed coach turn keeps the user's message and says what went wrong, settings status notes follow a language switch, the locale only flips once its bundle actually loaded, and the last raw api fetches in hooks move onto the shared client. --- .../healthlog/api-fetch-required.js | 6 +- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- .../dashboard-tile-strip-skeleton.test.ts | 5 +- src/app/insights/medications/page.tsx | 4 +- src/app/measurements/page.tsx | 7 +- src/app/page.tsx | 16 +++- .../insights/coach-panel/message-thread.tsx | 8 +- .../insights/coach-panel/use-coach.ts | 23 +++-- .../insights/derived/sparkline-delta-tile.tsx | 7 +- .../insights/insights-layout-shell.tsx | 8 +- .../measurements/measurement-list.tsx | 47 +++++----- .../card-parts/medication-compliance-bars.tsx | 5 +- .../medications/medication-detail-summary.tsx | 2 +- .../medications/wizard/steps/Step8Summary.tsx | 5 +- .../medications/wizard/wizard-payload.ts | 9 +- src/components/mood/mood-list.tsx | 33 +++---- .../settings/__tests__/sections.test.tsx | 7 ++ src/components/settings/account-section.tsx | 94 +++++++++++++------ src/components/settings/settings-shell.tsx | 10 +- src/hooks/use-auth.ts | 24 +++-- src/hooks/use-chart-overlay-prefs.ts | 19 ++-- src/hooks/use-coach-prefs.ts | 23 ++--- src/hooks/use-feature-flags.ts | 5 +- src/hooks/use-insight-status.ts | 11 +-- src/hooks/use-insights-layout-prefs.ts | 8 +- src/hooks/use-insights-layout.ts | 8 +- src/hooks/use-mounted.ts | 38 ++++++++ src/hooks/use-workouts.ts | 17 +--- src/lib/i18n/context.tsx | 27 ++++-- src/lib/measurements/display-transform.ts | 26 +++++ 35 files changed, 347 insertions(+), 191 deletions(-) create mode 100644 src/hooks/use-mounted.ts diff --git a/eslint-plugins/healthlog/api-fetch-required.js b/eslint-plugins/healthlog/api-fetch-required.js index 4b6828070..028bb33ce 100644 --- a/eslint-plugins/healthlog/api-fetch-required.js +++ b/eslint-plugins/healthlog/api-fetch-required.js @@ -41,8 +41,10 @@ const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"]; // Enforce across the client-facing source roots. `src/lib/` stays out: // server-side lib code never self-calls `/api/` routes, and the -// safe-fetch rule already covers its outbound calls. -const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +// safe-fetch rule already covers its outbound calls. `src/hooks/` is +// client-only by construction (v1.16.4) — its `/api/` reads route +// through the wrapper like every component. +const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"]; function toPosix(filename) { return filename.replace(/\\/g, "/"); diff --git a/messages/de.json b/messages/de.json index f914ea15f..5131c9de3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -468,7 +468,7 @@ "comment": "Kommentar", "actions": "Aktionen", "deleteConfirmTitle": "Messwert löschen?", - "deleteConfirmDescription": "Dieser Messwert wird unwiderruflich gelöscht.", + "deleteConfirmDescription": "Dieser Messwert wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.", "pageInfo": "Seite {page} von {total}", "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", @@ -556,7 +556,7 @@ "restoreError": "Wiederherstellen fehlgeschlagen", "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", "deleteConfirmTitle": "Eintrag löschen?", - "deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.", + "deleteConfirmDescription": "Dieser Stimmungseintrag wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.", "pageInfo": "Seite {page} von {total}", "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", @@ -740,6 +740,7 @@ "nextIntake": "Nächste Einnahme:", "nextIntakeOverdue": "Überfällig · {time} — noch nachholbar", "dayStreak": "Tage Serie", + "dayStreakOne": "Tag Serie", "cycleNextDoseToday": "Nächste Dosis heute", "cycleNextDoseTomorrow": "Nächste Dosis morgen", "cycleNextDoseInDays": "Nächste Dosis in {days} Tagen", @@ -2433,6 +2434,7 @@ "correlations": "Korrelationen", "medicationCompliance": "Therapietreue", "dayStreak": "{count} Tage Serie", + "dayStreakOne": "{count} Tag Serie", "compliance7d": "Therapietreue (7 Tage)", "compliance30d": "Therapietreue (30 Tage)", "taken": "genommen", diff --git a/messages/en.json b/messages/en.json index d2dbe17c9..75aa016ef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -468,7 +468,7 @@ "comment": "Comment", "actions": "Actions", "deleteConfirmTitle": "Delete measurement?", - "deleteConfirmDescription": "This measurement will be permanently deleted.", + "deleteConfirmDescription": "This measurement will be deleted. You can undo this for a short moment afterwards.", "pageInfo": "Page {page} of {total}", "previousPage": "Previous page", "nextPage": "Next page", @@ -556,7 +556,7 @@ "restoreError": "Restore failed", "invalidTimestamp": "Please select a valid time.", "deleteConfirmTitle": "Delete entry?", - "deleteConfirmDescription": "This mood entry will be permanently deleted.", + "deleteConfirmDescription": "This mood entry will be deleted. You can undo this for a short moment afterwards.", "pageInfo": "Page {page} of {total}", "previousPage": "Previous page", "nextPage": "Next page", @@ -740,6 +740,7 @@ "nextIntake": "Next intake:", "nextIntakeOverdue": "Overdue · {time} — can still be taken", "dayStreak": "day streak", + "dayStreakOne": "day streak", "cycleNextDoseToday": "Next dose today", "cycleNextDoseTomorrow": "Next dose tomorrow", "cycleNextDoseInDays": "Next dose in {days} days", @@ -2433,6 +2434,7 @@ "correlations": "Correlations", "medicationCompliance": "Medication adherence", "dayStreak": "{count} day streak", + "dayStreakOne": "{count} day streak", "compliance7d": "Adherence (7 days)", "compliance30d": "Adherence (30 days)", "taken": "taken", diff --git a/messages/es.json b/messages/es.json index 06e2b4047..4fe64b082 100644 --- a/messages/es.json +++ b/messages/es.json @@ -468,7 +468,7 @@ "comment": "Comentario", "actions": "Acciones", "deleteConfirmTitle": "¿Eliminar medición?", - "deleteConfirmDescription": "Esta medición se eliminará de forma permanente.", + "deleteConfirmDescription": "Esta medición se eliminará. Justo después podrás deshacerlo durante un breve momento.", "pageInfo": "Página {page} de {total}", "previousPage": "Página anterior", "nextPage": "Página siguiente", @@ -556,7 +556,7 @@ "restoreError": "Error al restaurar", "invalidTimestamp": "Selecciona una fecha y hora válidas.", "deleteConfirmTitle": "¿Eliminar entrada?", - "deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.", + "deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará. Justo después podrás deshacerlo durante un breve momento.", "pageInfo": "Página {page} de {total}", "previousPage": "Página anterior", "nextPage": "Página siguiente", @@ -740,6 +740,7 @@ "nextIntake": "Próxima toma:", "nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar", "dayStreak": "días de racha", + "dayStreakOne": "día de racha", "cycleNextDoseToday": "Próxima dosis hoy", "cycleNextDoseTomorrow": "Próxima dosis mañana", "cycleNextDoseInDays": "Próxima dosis en {days} días", @@ -2433,6 +2434,7 @@ "correlations": "Correlaciones", "medicationCompliance": "Cumplimiento de medicación", "dayStreak": "{count} días de racha", + "dayStreakOne": "{count} día de racha", "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", "taken": "Tomada", diff --git a/messages/fr.json b/messages/fr.json index ea5d00ba2..bf128ed8b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -468,7 +468,7 @@ "comment": "Commentaire", "actions": "Actions", "deleteConfirmTitle": "Supprimer la mesure ?", - "deleteConfirmDescription": "Cette mesure sera supprimée définitivement.", + "deleteConfirmDescription": "Cette mesure sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.", "pageInfo": "Page {page} sur {total}", "previousPage": "Page précédente", "nextPage": "Page suivante", @@ -556,7 +556,7 @@ "restoreError": "Échec de la restauration", "invalidTimestamp": "Choisis une date et une heure valides.", "deleteConfirmTitle": "Supprimer l’entrée ?", - "deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.", + "deleteConfirmDescription": "Cette entrée d’humeur sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.", "pageInfo": "Page {page} sur {total}", "previousPage": "Page précédente", "nextPage": "Page suivante", @@ -740,6 +740,7 @@ "nextIntake": "Prochaine prise :", "nextIntakeOverdue": "En retard · {time} — encore rattrapable", "dayStreak": "jours de série", + "dayStreakOne": "jour de série", "cycleNextDoseToday": "Prochaine dose aujourd'hui", "cycleNextDoseTomorrow": "Prochaine dose demain", "cycleNextDoseInDays": "Prochaine dose dans {days} jours", @@ -2433,6 +2434,7 @@ "correlations": "Corrélations", "medicationCompliance": "Observance des médicaments", "dayStreak": "{count} jours de série", + "dayStreakOne": "{count} jour de série", "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", "taken": "Pris", diff --git a/messages/it.json b/messages/it.json index d7bdb4689..b47026e16 100644 --- a/messages/it.json +++ b/messages/it.json @@ -468,7 +468,7 @@ "comment": "Commento", "actions": "Azioni", "deleteConfirmTitle": "Eliminare la misurazione?", - "deleteConfirmDescription": "Questa misurazione sarà eliminata in modo definitivo.", + "deleteConfirmDescription": "Questa misurazione sarà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.", "pageInfo": "Pagina {page} di {total}", "previousPage": "Pagina precedente", "nextPage": "Pagina successiva", @@ -556,7 +556,7 @@ "restoreError": "Ripristino non riuscito", "invalidTimestamp": "Scegli una data e un’ora valide.", "deleteConfirmTitle": "Eliminare la voce?", - "deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.", + "deleteConfirmDescription": "Questa voce dell’umore sarà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.", "pageInfo": "Pagina {page} di {total}", "previousPage": "Pagina precedente", "nextPage": "Pagina successiva", @@ -740,6 +740,7 @@ "nextIntake": "Prossima assunzione:", "nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile", "dayStreak": "giorni di serie", + "dayStreakOne": "giorno di serie", "cycleNextDoseToday": "Prossima dose oggi", "cycleNextDoseTomorrow": "Prossima dose domani", "cycleNextDoseInDays": "Prossima dose tra {days} giorni", @@ -2433,6 +2434,7 @@ "correlations": "Correlazioni", "medicationCompliance": "Aderenza ai farmaci", "dayStreak": "{count} giorni di serie", + "dayStreakOne": "{count} giorno di serie", "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", "taken": "Assunta", diff --git a/messages/pl.json b/messages/pl.json index a64d238ab..2cca67983 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -468,7 +468,7 @@ "comment": "Komentarz", "actions": "Akcje", "deleteConfirmTitle": "Usunąć pomiar?", - "deleteConfirmDescription": "Ten pomiar zostanie nieodwracalnie usunięty.", + "deleteConfirmDescription": "Ten pomiar zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.", "pageInfo": "Strona {page} z {total}", "previousPage": "Poprzednia strona", "nextPage": "Następna strona", @@ -556,7 +556,7 @@ "restoreError": "Przywracanie nie powiodło się", "invalidTimestamp": "Wybierz poprawną datę i godzinę.", "deleteConfirmTitle": "Usunąć wpis?", - "deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.", + "deleteConfirmDescription": "Ten wpis nastroju zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.", "pageInfo": "Strona {page} z {total}", "previousPage": "Poprzednia strona", "nextPage": "Następna strona", @@ -740,6 +740,7 @@ "nextIntake": "Następne przyjęcie:", "nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć", "dayStreak": "dni serii", + "dayStreakOne": "dzień serii", "cycleNextDoseToday": "Następna dawka dzisiaj", "cycleNextDoseTomorrow": "Następna dawka jutro", "cycleNextDoseInDays": "Następna dawka za {days} dni", @@ -2433,6 +2434,7 @@ "correlations": "Korelacje", "medicationCompliance": "Przestrzeganie leków", "dayStreak": "{count} dni serii", + "dayStreakOne": "{count} dzień serii", "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", "taken": "Przyjęte", diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 3bf6a6583..8bb77e152 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -104,8 +104,11 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { it("derives `primaryLoading` from the snapshot vs slim query", () => { const src = readFileSync(PAGE_PATH, "utf8"); + // v1.16.4 — `!mounted` pins SSR + the hydration render to the + // skeleton branch so a late-hydrating boundary can't disagree with + // the server HTML (React #418); see `useMounted`. expect(src).toMatch( - /const\s+primaryLoading\s*=\s*snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading;/, + /const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*\(snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading\);/, ); }); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index de9400130..3ef05f645 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -218,7 +218,9 @@ export default function InsightsMedikamentePage() { {categoryLabel} {med.streak > 0 ? ( - {t("insights.dayStreak", { count: med.streak })} + {med.streak === 1 + ? t("insights.dayStreakOne", { count: med.streak }) + : t("insights.dayStreak", { count: med.streak })} ) : null} diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 306379e47..497b52d85 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { MeasurementForm, @@ -18,6 +19,7 @@ import { useTranslations } from "@/lib/i18n/context"; export default function MeasurementsPage() { const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); const searchParams = useSearchParams(); @@ -73,7 +75,10 @@ export default function MeasurementsPage() { } }, [isLoading, isAuthenticated, router]); - if (isLoading) { + // v1.16.4 — `!mounted` keeps the hydration render identical to the + // SSR HTML when this boundary hydrates after `/api/auth/me` settled + // (React #418 family); see `useMounted`. + if (!mounted || isLoading) { return (
diff --git a/src/app/page.tsx b/src/app/page.tsx index e2aba1d5f..020a340e3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import React, { Suspense, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { Activity, Droplet, @@ -128,6 +129,7 @@ export function resolveDashboardFirstPaintGate(input: { export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); + const mounted = useMounted(); const { t } = useTranslations(); const fmt = useFormatters(); const [quickEntryDialog, setQuickEntryDialog] = @@ -1310,9 +1312,17 @@ export default function DashboardPage() { // never fires under snapshot mode, so the empty-state branch // below flashes for the whole snapshot fetch. Key the loading // flag off whichever query is actually driving the tiles. - const primaryLoading = snapshotEnabled - ? snapshotQuery.isLoading - : analyticsSlimQuery.isLoading; + // v1.16.4 — `!mounted` pins the SSR pass AND the hydration + // render to the skeleton branch. Server-side the data queries + // are `enabled: false` (no auth) → `isLoading: false`, which + // used to select the empty state; a late-hydrating boundary + // then saw `isAuthenticated` already resolved and rendered the + // skeleton instead — React #418. See `useMounted`. + const primaryLoading = + !mounted || + (snapshotEnabled + ? snapshotQuery.isLoading + : analyticsSlimQuery.isLoading); const { showTileStripSkeleton, showEmptyState } = resolveDashboardFirstPaintGate({ trendCardCount: trendCards.length, diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index d70fbdb5b..c9b9e550c 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -459,15 +459,21 @@ function ChatBubble({ a classic typing indicator (three pulsing dots) instead of the old whole-bubble pulse + "Thinking…" prose. The prose stays as the screen-reader announcement. */} + {/* v1.16.4 — a failed turn with no streamed prose renders the + error copy INSIDE the bubble instead of an empty bubble + with a caption below it. With partial prose the prose + keeps the bubble and the error stays a caption. */} {content ? ( stripChartTokens(content) ) : inProgress ? ( + ) : safeError ? ( + {safeError} ) : ( "" )}
- {safeError && ( + {safeError && content && (

{safeError}

)} {/* v1.12.0 — collapse the whole provenance block ("what was diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 5e787edbc..c14bcb039 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -400,10 +400,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { messageId: null, errorCode: structured ?? `coach.http.${response.status}`, }); - // Drop the optimistic user bubble on the error path too so - // the next refetch — which still has the persisted user - // message — doesn't render the same bubble twice. - setOptimisticUser(null); + // v1.16.4 — KEEP the optimistic user bubble: a rejected turn + // (budget gate, 4xx) has no persisted twin coming, and dropping + // the bubble left the error standing next to an empty thread. + // The content-equality dedupe in still + // suppresses the optimistic copy if a persisted twin lands. return; } @@ -484,10 +485,16 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { optsRef.current.onDone?.(resolvedConversationId); } // v1.4.25 W5 — drop the optimistic user bubble; the persisted - // twin is on its way via the invalidate-refetch above. Drop on - // error too so the user message isn't doubled when the next - // refetch sees the server-persisted row. - setOptimisticUser(null); + // twin is on its way via the invalidate-refetch above. + // v1.16.4 — but only when a twin IS coming (the turn resolved a + // conversation) or the turn succeeded outright. An errored turn + // without a conversation id persisted nothing; dropping the + // bubble then erased the user's message next to the error bubble. + // The content-equality dedupe in still suppresses + // the optimistic copy if a persisted twin does land later. + if (resolvedConversationId || !lastError) { + setOptimisticUser(null); + } }, [queryClient], ); diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 6589120bf..cc0a27213 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -151,7 +151,7 @@ export function SparklineDeltaTile({
{value !== null ? fmt.number(value, precision) : "—"} - + {/* v1.16.4 — the unit is short, load-bearing metadata ("mmHg", + "kg") and must never ellipsis ("m…"); on a too-narrow tile + the row wraps instead (hence `flex-wrap` above). */} + {unit} {isGrouped ? fmt.integer(m.value) - : fmt.number(m.value)}{" "} + : fmt.number( + m.value, + rawDisplayFractionDigits(m.type), + )}{" "} {m.unit} {isGrouped && ( @@ -1017,30 +1021,24 @@ export function MeasurementList({ {/* v1.15.13 — multi-select checkbox in a 44px tap target; absent for synthetic grouped rows. */} {!isGrouped && ( - // v1.15.13 MEDIUM-1 — a Radix Checkbox renders a - // 16px ` +
)} {Icon && (
{isGrouped ? fmt.integer(m.value) - : fmt.number(m.value)}{" "} + : fmt.number( + m.value, + rawDisplayFractionDigits(m.type), + )}{" "} {m.unit} )} diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index 630bf52a3..993e7c14b 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -92,7 +92,10 @@ export function MedicationComplianceBars({
- {streak} {t("medications.dayStreak")} + {streak}{" "} + {streak === 1 + ? t("medications.dayStreakOne") + : t("medications.dayStreak")}
)} diff --git a/src/components/medications/medication-detail-summary.tsx b/src/components/medications/medication-detail-summary.tsx index 2cae2fdb6..9ceb3f407 100644 --- a/src/components/medications/medication-detail-summary.tsx +++ b/src/components/medications/medication-detail-summary.tsx @@ -83,7 +83,7 @@ export function MedicationDetailSummary({ date: formatters.dateTime(startsOn), }) : t("medications.detail.cadence.oneShotPending") - : summariseCadence(hydrateWizardPayload(payload), t); + : summariseCadence(hydrateWizardPayload(payload), t, formatters.date); return (
diff --git a/src/components/medications/wizard/steps/Step8Summary.tsx b/src/components/medications/wizard/steps/Step8Summary.tsx index a0b6355bc..c082b5c70 100644 --- a/src/components/medications/wizard/steps/Step8Summary.tsx +++ b/src/components/medications/wizard/steps/Step8Summary.tsx @@ -7,7 +7,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -56,7 +56,8 @@ export function Step8Summary({ onAddSchedule, }: Step8SummaryProps) { const { t } = useTranslations(); - const topSummary = summariseCadence(payload, t); + const formatters = useFormatters(); + const topSummary = summariseCadence(payload, t, formatters.date); const canRemove = payload.schedules.length > 1; // Reminders need at least one delivery channel. Fetch the channel diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 956e3430f..c9f3bf8a5 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -833,6 +833,11 @@ function cadenceLineFor( export function summariseCadence( payload: WizardPayload, t: (key: string, params?: Record) => string, + // v1.16.4 — UI callers pass `useFormatters().date` so the course-window + // dates render in the active locale ("11.06.2026" / "Jun 11, 2026") + // instead of the raw ISO string. The ISO default keeps the helper pure + // for non-React callers. + formatDate: (d: Date) => string = dateToIsoString, ): string { const cadenceLine = cadenceLineFor(payload, t); const timesPhrase = @@ -843,7 +848,7 @@ export function summariseCadence( : ""; const startPhrase = payload.startsOn ? t("medications.wizard.summary.startsOn", { - date: dateToIsoString(payload.startsOn), + date: formatDate(payload.startsOn), }) : ""; const endPhrase = @@ -851,7 +856,7 @@ export function summariseCadence( ? "" : payload.endsOn ? t("medications.wizard.summary.endsOn", { - date: dateToIsoString(payload.endsOn), + date: formatDate(payload.endsOn), }) : t("medications.wizard.summary.noEndDate"); return [cadenceLine, timesPhrase, startPhrase, endPhrase] diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 9967d0105..e550e326b 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -670,28 +670,23 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3" >
- {/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px - ` +
({ })); // Stub the auth hook — sections check `isAuthenticated` to enable queries. +// v1.16.4 — AccountSection gates its content on `useMounted()` so the +// SSR pass and the hydration render agree (React #418 family). The +// smoke test asserts the post-mount content, so pin the flag to true. +vi.mock("@/hooks/use-mounted", () => ({ + useMounted: () => true, +})); + // We return `isAuthenticated: true` + a minimal user so the loading-spinner // branch in AccountSection paints the real form. vi.mock("@/hooks/use-auth", () => ({ diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 4e4f1259b..36e36ecb9 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -42,6 +42,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; @@ -110,9 +111,30 @@ export function resolveInitialTimezone( return shouldAutoSeed ? detectedBrowserTimezone : stored; } +/** + * v1.16.4 — settings status hints store the i18n KEY (+ params), not + * the translated string: a locale switch re-renders the hint in the + * new language instead of freezing the old-language snapshot. Server- + * provided error text (which has no key) rides `text` verbatim. + */ +type StatusMessage = + | { key: string; params?: Record } + | { text: string }; + +function statusText( + msg: StatusMessage, + t: (key: string, params?: Record) => string, +): string { + return "key" in msg ? t(msg.key, msg.params) : msg.text; +} + export function AccountSection() { const { t, locale, setLocale, pendingLocale } = useTranslations(); const { user, isLoading, isAuthenticated, refetch } = useAuth(); + // v1.16.4 — see `useMounted`: keeps the hydration render identical to + // the SSR HTML when this boundary hydrates after `/api/auth/me` + // settled (React #418 family). + const mounted = useMounted(); const router = useRouter(); const [email, setEmail] = useState(""); @@ -125,7 +147,7 @@ export function AccountSection() { const [insurerName, setInsurerName] = useState(""); const [insuranceNumber, setInsuranceNumber] = useState(""); const [saving, setSaving] = useState(false); - const [saveMsg, setSaveMsg] = useState(null); + const [saveMsg, setSaveMsg] = useState(null); const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( null, ); @@ -135,7 +157,7 @@ export function AccountSection() { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordMsg, setPasswordMsg] = useState(null); + const [passwordMsg, setPasswordMsg] = useState(null); const [passwordMsgType, setPasswordMsgType] = useState< "success" | "error" | null >(null); @@ -153,13 +175,13 @@ export function AccountSection() { // replay button so the surface stays mirrored). const [tourRestarting, setTourRestarting] = useState(false); const [tourFeedback, setTourFeedback] = useState<{ - text: string; + key: string; type: "success" | "error"; } | null>(null); // Passkey registration state. const [passkeyLoading, setPasskeyLoading] = useState(false); - const [passkeyMsg, setPasskeyMsg] = useState(null); + const [passkeyMsg, setPasskeyMsg] = useState(null); const [passkeyMsgType, setPasskeyMsgType] = useState< "success" | "error" | null >(null); @@ -228,7 +250,7 @@ export function AccountSection() { ]); if (profileRes.ok && tzRes.ok) { - setSaveMsg(t("settings.profileSaved")); + setSaveMsg({ key: "settings.profileSaved" }); setSaveMsgType("success"); await refetch(); } else if (!tzRes.ok) { @@ -238,14 +260,20 @@ export function AccountSection() { // failure copy. try { const json = (await (tzRes as Response).json()) as { error?: string }; - setSaveMsg(json.error || t("settings.timezoneInvalid")); + setSaveMsg( + json.error + ? { text: json.error } + : { key: "settings.timezoneInvalid" }, + ); } catch { - setSaveMsg(t("settings.timezoneInvalid")); + setSaveMsg({ key: "settings.timezoneInvalid" }); } setSaveMsgType("error"); } else { const json = await profileRes.json(); - setSaveMsg(json.error || t("settings.savingError")); + setSaveMsg( + json.error ? { text: json.error } : { key: "settings.savingError" }, + ); setSaveMsgType("error"); } setSaving(false); @@ -262,7 +290,7 @@ export function AccountSection() { }); if (!optRes.ok) { - setPasskeyMsg(t("settings.passkeyOptionsError")); + setPasskeyMsg({ key: "settings.passkeyOptionsError" }); setPasskeyMsgType("error"); setPasskeyLoading(false); return; @@ -281,18 +309,20 @@ export function AccountSection() { }); if (verifyRes.ok) { - setPasskeyMsg(t("settings.passkeyAdded")); + setPasskeyMsg({ key: "settings.passkeyAdded" }); setPasskeyMsgType("success"); } else { const verifyJson = await verifyRes.json(); setPasskeyMsg( - verifyJson.error || t("settings.passkeyRegistrationFailed"), + verifyJson.error + ? { text: verifyJson.error } + : { key: "settings.passkeyRegistrationFailed" }, ); setPasskeyMsgType("error"); } } catch (err) { const { key, params } = describePasskeyError(err); - setPasskeyMsg(t(key, params)); + setPasskeyMsg({ key, params }); setPasskeyMsgType("error"); } finally { setPasskeyLoading(false); @@ -313,11 +343,11 @@ export function AccountSection() { if (result.ok) { await refetch(); setTourFeedback({ - text: t("onboarding.tour.restartConfirmation"), + key: "onboarding.tour.restartConfirmation", type: "success", }); } else { - setTourFeedback({ text: t(result.messageKey), type: "error" }); + setTourFeedback({ key: result.messageKey, type: "error" }); } setTourRestarting(false); } @@ -329,7 +359,7 @@ export function AccountSection() { setPasswordMsgType(null); if (newPassword !== confirmPassword) { - setPasswordMsg(t("settings.passwordMismatch")); + setPasswordMsg({ key: "settings.passwordMismatch" }); setPasswordMsgType("error"); setPasswordSaving(false); return; @@ -348,25 +378,27 @@ export function AccountSection() { const json = await res.json(); if (!res.ok) { - setPasswordMsg(json.error || t("settings.savingError")); + setPasswordMsg( + json.error ? { text: json.error } : { key: "settings.savingError" }, + ); setPasswordMsgType("error"); return; } - setPasswordMsg(t("settings.passwordUpdated")); + setPasswordMsg({ key: "settings.passwordUpdated" }); setPasswordMsgType("success"); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch { - setPasswordMsg(t("common.networkError")); + setPasswordMsg({ key: "common.networkError" }); setPasswordMsgType("error"); } finally { setPasswordSaving(false); } } - if (isLoading) { + if (!mounted || isLoading) { return (
@@ -587,7 +619,7 @@ export function AccountSection() { saveMsgType === "success" ? "text-success" : "text-destructive" }`} > - {saveMsg} + {statusText(saveMsg, t)}

)} @@ -646,7 +678,7 @@ export function AccountSection() { passkeyMsgType === "success" ? "text-success" : "text-destructive" }`} > - {passkeyMsg} + {statusText(passkeyMsg, t)}

)}
@@ -728,7 +760,7 @@ export function AccountSection() { : "text-destructive" }`} > - {tourFeedback.text} + {t(tourFeedback.key)}

)}
@@ -797,7 +829,7 @@ export function AccountSection() { : "text-destructive" }`} > - {passwordMsg} + {statusText(passwordMsg, t)}

)} @@ -1084,7 +1116,7 @@ function AvatarSection() { const { t } = useTranslations(); const { user, refetch } = useAuth(); const fileInputRef = useRef(null); - const [msg, setMsg] = useState(null); + const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const upload = useMutation({ @@ -1105,12 +1137,12 @@ function AvatarSection() { }, onSuccess: async () => { setMsgType("success"); - setMsg(t("settings.avatar.uploaded")); + setMsg({ key: "settings.avatar.uploaded" }); await refetch(); }, onError: (err: Error) => { setMsgType("error"); - setMsg(err.message); + setMsg({ text: err.message }); }, }); @@ -1124,12 +1156,12 @@ function AvatarSection() { }, onSuccess: async () => { setMsgType("success"); - setMsg(t("settings.avatar.removed")); + setMsg({ key: "settings.avatar.removed" }); await refetch(); }, onError: (err: Error) => { setMsgType("error"); - setMsg(err.message); + setMsg({ text: err.message }); }, }); @@ -1142,12 +1174,12 @@ function AvatarSection() { setMsgType(null); if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) { setMsgType("error"); - setMsg(t("settings.avatar.invalidType")); + setMsg({ key: "settings.avatar.invalidType" }); return; } if (file.size > AVATAR_MAX_UPLOAD_BYTES) { setMsgType("error"); - setMsg(t("settings.avatar.tooLarge")); + setMsg({ key: "settings.avatar.tooLarge" }); return; } upload.mutate(file); @@ -1227,7 +1259,7 @@ function AvatarSection() { msgType === "success" ? "text-success" : "text-destructive" }`} > - {msg} + {statusText(msg, t)}

)}
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 249e04aa6..1ca71948b 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -289,10 +289,14 @@ export function SettingsShell({ active, children }: SettingsShellProps) { `/settings/account` (Geschlecht / Gender) and `/settings/ai` (Aktiver Provider) is not eaten by the floating mobile bottom-nav. Desktop reverts to the parent's - own padding because the bottom-nav doesn't render on `md+`. */} -
+ own padding because the bottom-nav doesn't render on `md+`. + + v1.16.4 — a `
`, not `
`: the surrounding AuthShell + already provides the page's single `
` landmark and a + nested second one is an a11y violation. */} +
{children} -
+
); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 0ac74cee1..dab7eb333 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; @@ -81,18 +82,21 @@ export interface AuthUser { } async function fetchMe(): Promise { - const res = await fetch("/api/auth/me"); - if (!res.ok) throw new Error("Not authenticated"); - const json = await res.json(); + // v1.16.4 — routed through the typed wrapper; a non-OK /me (401) + // throws `ApiError`, which `useAuth` treats as "not authenticated" + // exactly like the old hand-rolled throw. + // // v1.4.47 W3 — coerce `disableCoach` against `undefined` so a stale // /me payload from a partial-deploy rollback (older server image // without the field) keeps the Coach surface visible by default. - const data = json.data as Partial & { - id: string; - username: string; - role: string; - timezone: string; - }; + const data = await apiGet< + Partial & { + id: string; + username: string; + role: string; + timezone: string; + } + >("/api/auth/me"); // Hour-cycle preference: coerce against a stale /me payload, then mirror // into localStorage so `useFormatters()` and the legacy format helpers // (which cannot reach the query cache) render the same clock. @@ -143,7 +147,7 @@ export function useLogout() { return useMutation({ mutationFn: async () => { - await fetch("/api/auth/logout", { method: "POST" }); + await apiFetchRaw("/api/auth/logout", { method: "POST" }); }, onSuccess: () => { queryClient.setQueryData(queryKeys.authMe(), null); diff --git a/src/hooks/use-chart-overlay-prefs.ts b/src/hooks/use-chart-overlay-prefs.ts index 295befdab..462d4a9ab 100644 --- a/src/hooks/use-chart-overlay-prefs.ts +++ b/src/hooks/use-chart-overlay-prefs.ts @@ -11,6 +11,7 @@ import { type DashboardLayout, } from "@/lib/dashboard-layout"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; /** * v1.4.18 — TanStack Query hook that returns a single chart's overlay @@ -43,12 +44,8 @@ export function useChartOverlayPrefs( const { data: layout } = useQuery({ queryKey: queryKeys.dashboardWidgets(), - queryFn: async (): Promise => { - const res = await fetch("/api/dashboard/widgets"); - if (!res.ok) throw new Error("Failed to load dashboard layout"); - const json = await res.json(); - return json.data as DashboardLayout; - }, + queryFn: async (): Promise => + apiGet("/api/dashboard/widgets"), // Keep the cache warm — the dashboard page mounts a query against // the same key on first paint, so this hook just piggy-backs. staleTime: 60_000, @@ -68,14 +65,10 @@ export function useChartOverlayPrefs( // No-op when no chartKey — the setter is wired up but the hook // is in "ad-hoc render" mode where overlays don't persist. if (!chartKey) return; - const res = await fetch("/api/dashboard/chart-overlay-prefs", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ chartKey, prefs: next }), + await apiPut("/api/dashboard/chart-overlay-prefs", { + chartKey, + prefs: next, }); - if (!res.ok) { - throw new Error("Failed to save chart overlay prefs"); - } }, onMutate: async (next) => { if (!chartKey) return { previous: undefined }; diff --git a/src/hooks/use-coach-prefs.ts b/src/hooks/use-coach-prefs.ts index 09d650fa6..d360e335f 100644 --- a/src/hooks/use-coach-prefs.ts +++ b/src/hooks/use-coach-prefs.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch"; import { DEFAULT_COACH_PREFS, type CoachPrefs, @@ -27,7 +28,9 @@ export function useCoachPrefs(opts?: { enabled?: boolean }) { return useQuery({ queryKey: queryKeys.coachPrefs(), queryFn: async () => { - const res = await fetch("/api/auth/me/coach-prefs"); + // `apiFetchRaw` (no .ok throw) — a non-OK read soft-fails to the + // defaults instead of surfacing an error state. + const res = await apiFetchRaw("/api/auth/me/coach-prefs"); if (!res.ok) return DEFAULT_COACH_PREFS; const env = (await res.json()) as { data: CoachPrefs }; return env.data; @@ -49,20 +52,10 @@ export function useSaveCoachPrefs(opts?: { onSuccess?: () => void }) { const queryClient = useQueryClient(); return useMutation({ mutationKey: queryKeys.coachPrefs(), - mutationFn: async (next: CoachPrefs) => { - const res = await fetch("/api/auth/me/coach-prefs", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(next), - }); - if (!res.ok) throw new Error("coach-prefs.save_failed"); - return (await res.json()) as { data: CoachPrefs }; - }, - onSuccess: (envelope) => { - queryClient.setQueryData( - queryKeys.coachPrefs(), - envelope.data, - ); + mutationFn: async (next: CoachPrefs) => + apiPut("/api/auth/me/coach-prefs", next), + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.coachPrefs(), data); opts?.onSuccess?.(); }, }); diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts index 494267244..1f1dd4e2e 100644 --- a/src/hooks/use-feature-flags.ts +++ b/src/hooks/use-feature-flags.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; /** @@ -47,7 +48,9 @@ export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({ }); async function fetchFeatureFlags(): Promise { - const res = await fetch("/api/feature-flags"); + // `apiFetchRaw` (no .ok throw) — this read soft-fails to the all-on + // default below instead of surfacing an error state. + const res = await apiFetchRaw("/api/feature-flags"); if (!res.ok) { // Soft-fail: any HTTP error returns the all-on default so the // surface continues to render. The endpoint is a non-critical diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 43f93d702..d340cd270 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; /** * Shape returned by every `/api/insights/-status` endpoint. @@ -151,13 +152,10 @@ export function useInsightStatus(metric: InsightStatusMetric) { STATUS_TIMEOUT_MS, ); try { - const res = await fetch( + return await apiGet( `/api/insights/${metric}-status?locale=${locale}`, { signal: controller.signal }, ); - if (!res.ok) throw new Error("Failed"); - const json = (await res.json()) as { data: InsightStatusData }; - return json.data; } finally { clearTimeout(timeoutHandle); } @@ -222,15 +220,12 @@ export function useInsightMetricStatus( STATUS_TIMEOUT_MS, ); try { - const res = await fetch( + return await apiGet( `/api/insights/metric-status?metric=${encodeURIComponent( metric, )}&locale=${locale}`, { signal: controller.signal }, ); - if (!res.ok) throw new Error("Failed"); - const json = (await res.json()) as { data: InsightStatusData }; - return json.data; } finally { clearTimeout(timeoutHandle); } diff --git a/src/hooks/use-insights-layout-prefs.ts b/src/hooks/use-insights-layout-prefs.ts index af37ce719..e7890ecc4 100644 --- a/src/hooks/use-insights-layout-prefs.ts +++ b/src/hooks/use-insights-layout-prefs.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; import { resolveDashboardLayout, type DashboardLayout, @@ -28,12 +29,7 @@ import { export function useInsightsLayoutPrefs(enabled: boolean) { const { data } = useQuery({ queryKey: queryKeys.dashboardWidgets(), - queryFn: async () => { - const res = await fetch("/api/dashboard/widgets"); - if (!res.ok) throw new Error("Failed to load dashboard layout"); - const json = await res.json(); - return json.data as DashboardLayout; - }, + queryFn: async () => apiGet("/api/dashboard/widgets"), enabled, }); const compareBaseline = diff --git a/src/hooks/use-insights-layout.ts b/src/hooks/use-insights-layout.ts index 707092c7f..196ba3619 100644 --- a/src/hooks/use-insights-layout.ts +++ b/src/hooks/use-insights-layout.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; import { DEFAULT_INSIGHTS_LAYOUT, resolveInsightsLayout, @@ -39,11 +40,8 @@ export function useInsightsLayoutQuery(enabled: boolean): { } { const { data, isLoading, isSuccess } = useQuery({ queryKey: queryKeys.insightsLayout(), - queryFn: async () => { - const res = await fetch("/api/insights/layout"); - if (!res.ok) throw new Error("Failed to load insights layout"); - return resolveInsightsLayout((await res.json()).data); - }, + queryFn: async () => + resolveInsightsLayout(await apiGet("/api/insights/layout")), enabled, }); return { diff --git a/src/hooks/use-mounted.ts b/src/hooks/use-mounted.ts new file mode 100644 index 000000000..dcc4e1f50 --- /dev/null +++ b/src/hooks/use-mounted.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +// Stable no-op subscription — the "store" never changes; only the +// server/client snapshot split below does the work. +const emptySubscribe = () => () => {}; + +/** + * v1.16.4 — SSR/CSR-consistent mount flag for query-dependent chrome. + * + * React hydrates streamed Suspense boundaries late, so a client + * component can hydrate AFTER a TanStack query already resolved. + * Any first render that branches on query state (`isLoading`, + * `isAuthenticated`, cached data) then disagrees with the + * server-rendered HTML and React logs hydration error #418. + * + * `useMounted()` returns `false` during SSR AND during the hydration + * render (React always uses the server snapshot of a + * `useSyncExternalStore` while hydrating, regardless of when the + * boundary hydrates), then `true` from the first client re-render. + * Gate query-derived branches on it: + * + * const mounted = useMounted(); + * if (!mounted || query.isLoading) return ; + * + * Unlike the classic `useEffect(() => setMounted(true), [])` pattern + * this needs no state setter inside an effect (the repo's + * `react-hooks/set-state-in-effect` rule rejects that) and stays a + * single re-render. + */ +export function useMounted(): boolean { + return useSyncExternalStore( + emptySubscribe, + () => true, + () => false, + ); +} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 2da2d46f9..6d2c49ace 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; /** * v1.4.32 — TanStack Query wrapper for `GET /api/workouts`. @@ -73,12 +74,7 @@ async function fetchWorkouts( const query = params.toString(); const url = query ? `/api/workouts?${query}` : "/api/workouts"; - const res = await fetch(url); - if (!res.ok) { - throw new Error(`Failed to load workouts (${res.status})`); - } - const json = (await res.json()) as { data: WorkoutListPayload }; - return json.data; + return apiGet(url); } export interface UseWorkoutsResult { @@ -132,12 +128,9 @@ export interface WorkoutDetailPayload extends WorkoutListEntry { } async function fetchWorkoutDetail(id: string): Promise { - const res = await fetch(`/api/workouts/${encodeURIComponent(id)}`); - if (!res.ok) { - throw new Error(`Failed to load workout (${res.status})`); - } - const json = (await res.json()) as { data: WorkoutDetailPayload }; - return json.data; + return apiGet( + `/api/workouts/${encodeURIComponent(id)}`, + ); } export interface UseWorkoutDetailResult { diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 59076d4db..2ad0929b0 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -77,6 +77,19 @@ function getSavedLocale(): Locale | null { return null; } +// Persist a locale choice everywhere the next render / request reads +// it: localStorage (client preference), cookie (SSR handoff — layout + +// metadata render in the user's language), . Called ONLY +// alongside the `setActive` flip so a failed bundle load never leaves +// the persisted locale pointing at strings the UI isn't showing. +function persistLocale(newLocale: Locale) { + localStorage.setItem("healthlog-locale", newLocale); + // 1-year expiry, Lax samesite, not HttpOnly so the client continues + // to own it. + document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; + document.documentElement.lang = newLocale; +} + export function I18nProvider({ children, initialLocale, @@ -142,17 +155,12 @@ export function I18nProvider({ const setLocale = useCallback((newLocale: Locale) => { if (!(locales as readonly string[]).includes(newLocale)) return; - localStorage.setItem("healthlog-locale", newLocale); - // Also mirror to cookie so SSR (layout, metadata) renders in the - // user's language. 1-year expiry, Lax samesite, not HttpOnly so the - // client continues to own it. - document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; - document.documentElement.lang = newLocale; const cached = getCachedMessages(newLocale); if (cached) { requestedLocaleRef.current = null; setPendingLocale(null); + persistLocale(newLocale); setActive({ locale: newLocale, messages: cached }); return; } @@ -164,11 +172,16 @@ export function I18nProvider({ if (requestedLocaleRef.current !== newLocale) return; requestedLocaleRef.current = null; setPendingLocale(null); + // Persist atomically with the flip — never before the bundle is + // in hand, so cookie / / localStorage can't drift + // onto a locale the UI failed to load. + persistLocale(newLocale); setActive({ locale: newLocale, messages: loaded }); }) .catch(() => { // Bundle fetch failed (offline mid-session, …) — stay on the - // current locale rather than rendering the new one in EN. + // current locale rather than rendering the new one in EN, and + // leave cookie / / localStorage untouched. if (requestedLocaleRef.current !== newLocale) return; requestedLocaleRef.current = null; setPendingLocale(null); diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 207ee0a8f..1bc059952 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -90,6 +90,32 @@ export function getDisplayTransform( return perType[preference] ?? perType.metric; } +/** + * v1.16.4 — count metrics are integers by definition; any fractional + * part on a stored sample is source noise (HK chunk splitting, device + * estimation), so the raw-value surfaces render them with 0 decimals + * ("9.689 steps", never "9.688,595 steps"). Every other type keeps the + * formatter's default precision (≤3 digits, trailing zeros dropped). + */ +const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ + "ACTIVITY_STEPS", + "FLIGHTS_CLIMBED", + "FALL_COUNT", + // kcal + daylight minutes — whole units are the meaningful display + // grain for these day-cumulative counts. + "ACTIVE_ENERGY_BURNED", + "TIME_IN_DAYLIGHT", +]); + +/** + * Display fraction digits for a RAW (untransformed) value of `type`. + * `0` for integer count metrics, `undefined` (= formatter default) + * otherwise — pass the result straight into `Formatters.number`. + */ +export function rawDisplayFractionDigits(type: string): number | undefined { + return INTEGER_DISPLAY_TYPES.has(type) ? 0 : undefined; +} + /** * Apply a transform's factor to a raw canonical value. Pure helper — * keeps the multiply in one place so the chart, list cell, and tooltip