From b217535b62868db57a2685c7272e876777b459ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 27 Jun 2026 16:29:58 +0200
Subject: [PATCH] chore(v1.22): clear the QA punch-list across Coach, insights,
settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Code-review and design follow-ups before the v1.22 cut. No behaviour change
beyond the items below.
Coach / insights:
- Coach SSE route: a non-provider error raised inside the open producer (e.g. a
DB failure persisting the reply) now records on the wide event and forwards to
GlitchTip instead of masquerading as a provider outage; it still surfaces an
error frame since the stream is open.
- Lab ↔ outcome correlations: hedge faint-tier pairs ("a faint hint, if
anything … too small to lean on") so the narrated confidence never outruns the
effect, matching the daily engine.
- Reminder sweep: mint the plan-review reminder and clear the plan's reviewDate
in one transaction so a half-applied write can't duplicate the reminder.
- Fold the two raw provider-fallback runners onto one shared chain walker.
Design / accessibility:
- Lift sub-44px tap targets to the 44px mobile floor (suggested-action card,
Coach bubble affordances), brighten the resting timestamp clock for non-text
contrast, and route the lab chart height through the shared constant.
- Replace ad-hoc arbitrary type sizes with the scale token and static size-11
icon buttons with the named icon-lg variant.
- Settings: add the missing About card-header descriptions, normalise the
low-stock numeric widths, and relax the notification-status rows from nested
cards to a divided list.
Housekeeping:
- Drop three unused exported types (and the now-orphaned source const), re-stamp
v1.21.5 comments to v1.22, and register the live-judge eval harness as a knip
entry so the script-only module resolves.
---
knip.json | 1 +
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 4 +-
src/app/api/insights/chat/route.ts | 56 ++++++-
src/app/coach/conversations/page.tsx | 4 +-
src/components/data-list/delete-button.tsx | 4 +-
.../__tests__/daily-briefing.test.tsx | 12 +-
.../insights/__tests__/hero-strip.test.tsx | 19 ++-
.../__tests__/insight-status-card.test.tsx | 12 +-
.../insights/coach-panel/coach-drawer.tsx | 8 +-
.../insights/coach-panel/message-thread.tsx | 11 +-
.../coach-panel/suggested-action-card.tsx | 4 +-
src/components/insights/correlation-card.tsx | 4 +-
src/components/insights/daily-briefing.tsx | 11 +-
.../insights/derived/provenance-explainer.tsx | 5 +-
src/components/insights/health-score-card.tsx | 22 +--
.../insights/health-score-delta-explainer.tsx | 5 +-
src/components/insights/hero-strip.tsx | 13 +-
.../insights/insight-status-card.tsx | 18 ++-
.../insights/period-narrative-card.tsx | 22 ++-
.../insights/recommendation-card.tsx | 2 +-
src/components/labs/lab-biomarker-chart.tsx | 5 +-
src/components/labs/reference-range-badge.tsx | 2 +-
.../measurements/measurement-list.tsx | 32 ++--
src/components/settings/about-section.tsx | 8 +-
.../account-section/passkey-list-section.tsx | 8 +-
src/components/settings/ai/shared.ts | 1 -
src/components/settings/low-stock-card.tsx | 4 +-
.../settings/notification-status-card.tsx | 4 +-
src/lib/ai/coach/reminders.ts | 10 --
src/lib/ai/local-client.ts | 2 +-
src/lib/ai/provider-runner.ts | 143 ++++++++----------
src/lib/ai/types.ts | 4 +-
src/lib/insights/correlation-discovery.ts | 22 ++-
.../__tests__/coach-reminder-sweep.test.ts | 7 +
src/lib/jobs/coach-reminder-sweep.ts | 48 +++---
40 files changed, 330 insertions(+), 227 deletions(-)
diff --git a/knip.json b/knip.json
index baae9fd35..f5d9ec71d 100644
--- a/knip.json
+++ b/knip.json
@@ -20,6 +20,7 @@
"src/proxy.ts",
"src/instrumentation.ts",
"src/lib/jobs/**/*.ts",
+ "src/lib/ai/coach/eval/judge.ts",
"scripts/**/*.ts",
"scripts/**/*.mjs",
"e2e/**/*.spec.ts",
diff --git a/messages/de.json b/messages/de.json
index 2e4707abb..8a3a5fb4c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1946,7 +1946,6 @@
"confidenceMittel": "Mittel",
"confidenceGering": "Gering",
"loadError": "Auswertungen konnten nicht geladen werden",
- "heroGenerated": "Erstellt {time}",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerate": "Neu generieren",
"heroRegenerating": "Wird generiert",
@@ -1958,7 +1957,6 @@
"narrativeMonth": "Dieser Monat",
"narrativePreparing": "Deine Zusammenfassung wird erstellt…",
"narrativeUpdating": "Wird aktualisiert…",
- "narrativeUpdated": "Aktualisiert {time}",
"narrativeProvenanceLabel": "Wie das zustande kam",
"narrativeProvenanceMethod": "Eine Zusammenfassung des Zeitraums in einfacher Sprache, ausschließlich aus deinen erfassten Werten und den Zusammenhängen, die die statistische Mehrfachvergleichskorrektur überstanden haben. Beschreibend, nie eine Ursache.",
"narrativeProvenanceMetrics": "Basierend auf: {metrics}",
@@ -4738,6 +4736,8 @@
"changelog": "Changelog",
"docs": "Dokumentation",
"linksHeading": "Quellen & Dokumentation",
+ "identityDescription": "Version, Lizenz und Build-Details dieser Instanz.",
+ "linksDescription": "Quellcode, Änderungsprotokoll und Dokumentation.",
"newerAvailable": "Neue Version: {tag}"
},
"testConnection": {
diff --git a/messages/en.json b/messages/en.json
index 03e44452f..06b38c3e8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1946,7 +1946,6 @@
"confidenceMittel": "Medium",
"confidenceGering": "Low",
"loadError": "Failed to load insights",
- "heroGenerated": "Generated {time}",
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerate": "Regenerate",
"heroRegenerating": "Regenerating",
@@ -1958,7 +1957,6 @@
"narrativeMonth": "This month",
"narrativePreparing": "Preparing your summary…",
"narrativeUpdating": "Updating…",
- "narrativeUpdated": "Updated {time}",
"narrativeProvenanceLabel": "How this was put together",
"narrativeProvenanceMethod": "A plain-language summary of the period, drawn only from your logged numbers and the associations that survived statistical multiple-comparison control. Descriptive, never a cause.",
"narrativeProvenanceMetrics": "Based on: {metrics}",
@@ -4738,6 +4736,8 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
+ "identityDescription": "Version, license, and build details for this instance.",
+ "linksDescription": "Source code, changelog, and documentation.",
"newerAvailable": "Newer version: {tag}"
},
"testConnection": {
diff --git a/messages/es.json b/messages/es.json
index f9641ab19..0b49cd985 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1946,7 +1946,6 @@
"confidenceMittel": "Media",
"confidenceGering": "Baja",
"loadError": "No se han podido cargar los análisis",
- "heroGenerated": "Generado {time}",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerate": "Regenerar",
"heroRegenerating": "Generando",
@@ -1958,7 +1957,6 @@
"narrativeMonth": "Este mes",
"narrativePreparing": "Preparando tu resumen…",
"narrativeUpdating": "Actualizando…",
- "narrativeUpdated": "Actualizado {time}",
"narrativeProvenanceLabel": "Cómo se elaboró esto",
"narrativeProvenanceMethod": "Un resumen del período en lenguaje sencillo, basado únicamente en tus valores registrados y en las asociaciones que superaron el control estadístico de comparaciones múltiples. Descriptivo, nunca una causa.",
"narrativeProvenanceMetrics": "Basado en: {metrics}",
@@ -4738,6 +4736,8 @@
"changelog": "Changelog",
"docs": "Documentación",
"linksHeading": "Fuentes y documentación",
+ "identityDescription": "Versión, licencia y detalles de compilación de esta instancia.",
+ "linksDescription": "Código fuente, registro de cambios y documentación.",
"newerAvailable": "Nueva versión: {tag}"
},
"testConnection": {
diff --git a/messages/fr.json b/messages/fr.json
index 890f9e6d4..971a1bbd8 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1946,7 +1946,6 @@
"confidenceMittel": "Moyenne",
"confidenceGering": "Faible",
"loadError": "Impossible de charger les analyses",
- "heroGenerated": "Généré {time}",
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
"heroRegenerate": "Régénérer",
"heroRegenerating": "Génération en cours",
@@ -1958,7 +1957,6 @@
"narrativeMonth": "Ce mois-ci",
"narrativePreparing": "Préparation de votre résumé…",
"narrativeUpdating": "Mise à jour…",
- "narrativeUpdated": "Mis à jour {time}",
"narrativeProvenanceLabel": "Comment cela a été établi",
"narrativeProvenanceMethod": "Un résumé de la période en langage clair, établi uniquement à partir de vos valeurs enregistrées et des associations ayant survécu au contrôle statistique des comparaisons multiples. Descriptif, jamais une cause.",
"narrativeProvenanceMetrics": "Basé sur : {metrics}",
@@ -4738,6 +4736,8 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources et documentation",
+ "identityDescription": "Version, licence et détails de build de cette instance.",
+ "linksDescription": "Code source, journal des modifications et documentation.",
"newerAvailable": "Nouvelle version : {tag}"
},
"testConnection": {
diff --git a/messages/it.json b/messages/it.json
index 2347e559a..4b762cd6b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1946,7 +1946,6 @@
"confidenceMittel": "Media",
"confidenceGering": "Bassa",
"loadError": "Impossibile caricare le analisi",
- "heroGenerated": "Generato {time}",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerate": "Rigenera",
"heroRegenerating": "Generazione in corso",
@@ -1958,7 +1957,6 @@
"narrativeMonth": "Questo mese",
"narrativePreparing": "Preparazione del riepilogo…",
"narrativeUpdating": "Aggiornamento…",
- "narrativeUpdated": "Aggiornato {time}",
"narrativeProvenanceLabel": "Come è stato realizzato",
"narrativeProvenanceMethod": "Un riepilogo del periodo in linguaggio semplice, basato esclusivamente sui valori registrati e sulle associazioni che hanno superato il controllo statistico dei confronti multipli. Descrittivo, mai una causa.",
"narrativeProvenanceMetrics": "Basato su: {metrics}",
@@ -4738,6 +4736,8 @@
"changelog": "Changelog",
"docs": "Documentazione",
"linksHeading": "Fonti e documentazione",
+ "identityDescription": "Versione, licenza e dettagli di build di questa istanza.",
+ "linksDescription": "Codice sorgente, registro delle modifiche e documentazione.",
"newerAvailable": "Nuova versione: {tag}"
},
"testConnection": {
diff --git a/messages/pl.json b/messages/pl.json
index f0787958d..664af0d8d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1946,7 +1946,6 @@
"confidenceMittel": "Średnia",
"confidenceGering": "Niska",
"loadError": "Nie udało się załadować analiz",
- "heroGenerated": "Wygenerowano {time}",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerate": "Wygeneruj ponownie",
"heroRegenerating": "Generowanie",
@@ -1958,7 +1957,6 @@
"narrativeMonth": "Ten miesiąc",
"narrativePreparing": "Przygotowywanie podsumowania…",
"narrativeUpdating": "Aktualizowanie…",
- "narrativeUpdated": "Zaktualizowano {time}",
"narrativeProvenanceLabel": "Jak to powstało",
"narrativeProvenanceMethod": "Podsumowanie okresu w prostym języku, oparte wyłącznie na zarejestrowanych wartościach i powiązaniach, które przeszły statystyczną korektę dla porównań wielokrotnych. Opisowe, nigdy nie przyczynowe.",
"narrativeProvenanceMetrics": "Na podstawie: {metrics}",
@@ -4738,6 +4736,8 @@
"changelog": "Changelog",
"docs": "Dokumentacja",
"linksHeading": "Źródła i dokumentacja",
+ "identityDescription": "Wersja, licencja i szczegóły kompilacji tej instancji.",
+ "linksDescription": "Kod źródłowy, dziennik zmian i dokumentacja.",
"newerAvailable": "Nowa wersja: {tag}"
},
"testConnection": {
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index d6a9b79b8..4efedf7ea 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -30,7 +30,8 @@ import { NextResponse, type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
-import { annotate } from "@/lib/logging/context";
+import { annotate, getEvent } from "@/lib/logging/context";
+import { redactOptional, redactSecrets } from "@/lib/logging/redact";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -1169,10 +1170,23 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
// surfaces a graceful `coach.provider.*` frame; anything else degrades to
// a generic unavailable frame. A genuine defect is still annotated.
const providerError = classifyBubblingProviderError(err);
+ if (!providerError) {
+ // Not a tagged provider failure — a genuine server defect raised
+ // inside the open producer (e.g. a DB error in the post-stream
+ // `appendMessage` persistence). Record it on the wide event and
+ // forward it to GlitchTip so it stays visible instead of reading
+ // as a provider outage; the stream is open, so we still emit an
+ // error frame below rather than a 500.
+ getEvent()?.setError(
+ err instanceof Error ? err : new Error(String(err)),
+ );
+ void reportCoachStreamDefect(err);
+ }
annotate({
action: { name: "insights.coach.streamError" },
meta: {
unwrapped: Boolean(providerError),
+ reported: !providerError,
firstStatus: providerError?.httpStatus ?? null,
},
});
@@ -1327,6 +1341,46 @@ function classifyBubblingProviderError(err: unknown): {
return { code: "unavailable", httpStatus: status };
}
+/**
+ * Fire-and-forget GlitchTip forward for a genuine server defect that
+ * surfaces INSIDE the open SSE producer — e.g. a Prisma failure in the
+ * post-stream `appendMessage` persistence, which runs after the provider
+ * call and therefore outside its try/catch. Once the stream is open the
+ * route can no longer return a 500, so without this such a defect would
+ * read as a generic provider outage in error tracking rather than the
+ * server bug it is. Mirrors the api-handler / worker forwarders: dynamic
+ * import (no cycle, no startup cost), redacted message + stack, and it
+ * NEVER throws so a sink failure cannot mask the original error.
+ */
+async function reportCoachStreamDefect(err: unknown): Promise {
+ const e =
+ err instanceof Error ? err : new Error("Unknown coach stream error");
+ const message = redactSecrets(`[insights.coach.stream] ${e.message}`);
+ console.error("[coach-stream]", message, e);
+ try {
+ const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] =
+ await Promise.all([
+ import("@/lib/monitoring-settings"),
+ import("@/lib/monitoring/glitchtip"),
+ ]);
+ const settings = await getGlitchtipSettings();
+ if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return;
+ await sendGlitchtipEvent({
+ dsn: settings.glitchtipDsn,
+ input: {
+ environment: settings.glitchtipEnvironment || "production",
+ message,
+ level: "error",
+ type: e.name || "Error",
+ stack: redactOptional(e.stack),
+ sourceTag: "healthlog-api-handler",
+ },
+ });
+ } catch {
+ /* the reporter must never throw */
+ }
+}
+
function streamProviderError(args: { code: string }): Response {
const stream = createSseStream((controller) => {
controller.enqueue(
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 0855a364b..3d68a8fbf 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -211,7 +211,7 @@ function CoachConversationsBody() {
diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx
index 97fe98148..1f3c105c6 100644
--- a/src/components/insights/correlation-card.tsx
+++ b/src/components/insights/correlation-card.tsx
@@ -126,7 +126,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
data-slot="correlation-card-confidence"
variant="outline"
className={cn(
- "shrink-0 text-[10px]",
+ "shrink-0 text-xs",
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
)}
>
@@ -153,7 +153,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
{t("insights.correlationRow.sourceChip", {
n: result.n,
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index 2b454c5de..91b6142bf 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -29,6 +29,7 @@ import { AskCoachAction } from "@/components/insights/ask-coach-action";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
+import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import type {
DailyBriefing as DailyBriefingPayload,
@@ -353,6 +354,7 @@ export function DailyBriefing({
}: DailyBriefingProps) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const { user } = useAuth();
return (
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
@@ -431,7 +433,7 @@ export function DailyBriefing({
{t("insights.dailyBriefing.signalsTitle")}
@@ -449,7 +451,7 @@ export function DailyBriefing({
{t("insights.dailyBriefing.keyFindingsTitle")}
@@ -471,13 +473,14 @@ export function DailyBriefing({
{updatedAt && (
- {/* v1.4.27 MB7 / CF-76 — bump the metadata
- badges from `text-[10px]` to `text-[11px]`
- so the legibility floor (12 px is the
- mobile baseline; 11 px is the lowest
- tolerated value for non-primary chrome)
- holds across the row. */}
+ {/* v1.22 — metadata badges sit on the scale
+ token `text-xs` (12 px, the mobile legibility
+ baseline) instead of an arbitrary per-site
+ size, so the row tracks the type scale. */}
{(m.type === "BLOOD_PRESSURE_SYS" ||
m.type === "BLOOD_PRESSURE_DIA") && (
{t(TYPE_LABEL_KEYS[m.type])}
@@ -1198,7 +1196,7 @@ export function MeasurementList({
) : (
isGrouped && (
-
+
{t("measurements.dailyTotalCaption", {
count: fmt.integer(m.sampleCount as number),
})}
@@ -1217,7 +1215,7 @@ export function MeasurementList({
{formatMeasurementSource(m.source, t)}
@@ -1234,8 +1232,7 @@ export function MeasurementList({
{isGrouped ? (
toggleExpand(m.dayKey as string)}
aria-expanded={isExpanded}
@@ -1254,15 +1251,13 @@ export function MeasurementList({
<>
startEdit(m)}
aria-label={t("common.edit")}
>
deleteMutation.mutate(m.id)}
title={t("measurements.deleteConfirmTitle")}
@@ -1317,8 +1312,7 @@ export function MeasurementList({
goToPage((p) => p - 1)}
aria-label={t("measurements.previousPage")}
@@ -1327,8 +1321,7 @@ export function MeasurementList({
= totalPages}
onClick={() => goToPage((p) => p + 1)}
aria-label={t("measurements.nextPage")}
@@ -1430,8 +1423,7 @@ export function MeasurementList({
-
+
{isLoading || !version ? (
@@ -266,6 +271,7 @@ export function AboutSection() {
diff --git a/src/components/settings/account-section/passkey-list-section.tsx b/src/components/settings/account-section/passkey-list-section.tsx
index c28c5a274..cc9e4ebf3 100644
--- a/src/components/settings/account-section/passkey-list-section.tsx
+++ b/src/components/settings/account-section/passkey-list-section.tsx
@@ -130,7 +130,7 @@ export function PasskeyListSection({
{pk.credentialBackedUp
? t("settings.backedUp")
@@ -209,14 +209,14 @@ export function PasskeyListSection({
"Single-device / Multi-device" column. Sits on the
same chip row as the backup status and date so all
metadata reads as a single horizontal stride. */}
-