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