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 `