From fcb41b87804fe82d4bac545aa652f3e40d0648a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:16:26 +0200 Subject: [PATCH 01/15] feat(coach): settings UI to review and forget what the Coach remembers Add a 'What the Coach remembers' settings section: lists the user's durable facts grouped by category with a learned-when stamp, forgets a single fact or clears all (behind a confirm), and notes the rolling conversation summary. Consumes the v1.11.1 CoachFact routes via the query-key factory; gated on the Coach feature; localised in all six locales. Closes the API-only gap from v1.11.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 24 ++ messages/en.json | 24 ++ messages/es.json | 24 ++ messages/fr.json | 24 ++ messages/it.json | 24 ++ messages/pl.json | 24 ++ .../__tests__/coach-memory-section.test.tsx | 153 ++++++++++ src/components/settings/ai-section.tsx | 13 +- .../settings/coach-memory-section.tsx | 270 ++++++++++++++++++ src/lib/query-keys.ts | 1 + 10 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/__tests__/coach-memory-section.test.tsx create mode 100644 src/components/settings/coach-memory-section.tsx diff --git a/messages/de.json b/messages/de.json index 8637a8d75..fbd360987 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3336,6 +3336,30 @@ "testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.", "testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.", "testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.", + "coachMemory": { + "title": "Woran sich der Coach erinnert", + "description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast. Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.", + "empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst – deine Ziele, Beschwerden, Vorlieben – und du kannst sie hier jederzeit löschen.", + "learnedPrefix": "gelernt {when}", + "categoryPreference": "Vorlieben", + "categoryCondition": "Beschwerden", + "categoryGoal": "Ziele", + "categoryConstraint": "Einschränkungen", + "categoryContext": "Kontext", + "forget": "Vergessen", + "forgetAria": "Diese Angabe vergessen", + "forgetAll": "Alles vergessen", + "forgetAllConfirmTitle": "Alles vergessen, woran sich der Coach erinnert?", + "forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.", + "forgetAllConfirmAction": "Alles vergessen", + "cancel": "Abbrechen", + "summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung – wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.", + "loadError": "Konnte nicht laden, woran sich der Coach erinnert.", + "forgotToast": "Angabe vergessen.", + "forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.", + "clearedToast": "{count} gespeicherte Angaben vergessen.", + "clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen." + }, "disableCoach": { "title": "Coach ausblenden", "description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", diff --git a/messages/en.json b/messages/en.json index 6a8b15084..f82aea721 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3336,6 +3336,30 @@ "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", "testReasonServerError": "The AI provider returned a server error.", "testReasonUnreachable": "Could not reach the AI provider.", + "coachMemory": { + "title": "What the Coach remembers", + "description": "Durable facts you have told the Coach. It uses these to keep its replies relevant. Remove anything you would rather it forgot.", + "empty": "The Coach has not stored any facts yet. As you chat, it remembers durable details you share — your goals, conditions, preferences — and you can clear them here at any time.", + "learnedPrefix": "learned {when}", + "categoryPreference": "Preferences", + "categoryCondition": "Conditions", + "categoryGoal": "Goals", + "categoryConstraint": "Constraints", + "categoryContext": "Context", + "forget": "Forget", + "forgetAria": "Forget this fact", + "forgetAll": "Forget everything", + "forgetAllConfirmTitle": "Forget everything the Coach remembers?", + "forgetAllConfirmBody": "This removes every stored fact. The Coach will start fresh the next time you chat. This cannot be undone.", + "forgetAllConfirmAction": "Forget everything", + "cancel": "Cancel", + "summaryNote": "The Coach may also keep a short rolling summary of each conversation. There is no per-item control for it — deleting a conversation removes its summary.", + "loadError": "Could not load what the Coach remembers.", + "forgotToast": "Fact forgotten.", + "forgotError": "Could not forget that fact. Please try again.", + "clearedToast": "Forgot {count} stored facts.", + "clearError": "Could not clear the stored facts. Please try again." + }, "disableCoach": { "title": "Hide Coach", "description": "Hides the Coach button and drawer everywhere.", diff --git a/messages/es.json b/messages/es.json index 9e4bd7cbf..a45cc6212 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3336,6 +3336,30 @@ "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", "testReasonServerError": "The AI provider returned a server error.", "testReasonUnreachable": "Could not reach the AI provider.", + "coachMemory": { + "title": "Lo que el Coach recuerda", + "description": "Datos duraderos que le has contado al Coach. Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.", + "empty": "El Coach aún no ha guardado ningún dato. A medida que conversas, recuerda detalles duraderos que compartes: tus objetivos, afecciones y preferencias, y puedes borrarlos aquí cuando quieras.", + "learnedPrefix": "aprendido {when}", + "categoryPreference": "Preferencias", + "categoryCondition": "Afecciones", + "categoryGoal": "Objetivos", + "categoryConstraint": "Restricciones", + "categoryContext": "Contexto", + "forget": "Olvidar", + "forgetAria": "Olvidar este dato", + "forgetAll": "Olvidar todo", + "forgetAllConfirmTitle": "¿Olvidar todo lo que recuerda el Coach?", + "forgetAllConfirmBody": "Esto elimina todos los datos guardados. El Coach empezará de cero la próxima vez que converses. No se puede deshacer.", + "forgetAllConfirmAction": "Olvidar todo", + "cancel": "Cancelar", + "summaryNote": "El Coach también puede mantener un breve resumen continuo de cada conversación. No hay un control individual para él: eliminar una conversación elimina su resumen.", + "loadError": "No se pudo cargar lo que recuerda el Coach.", + "forgotToast": "Dato olvidado.", + "forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.", + "clearedToast": "Se olvidaron {count} datos guardados.", + "clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo." + }, "disableCoach": { "title": "Ocultar el Coach", "description": "Oculta el botón del Coach y el panel en todas las páginas.", diff --git a/messages/fr.json b/messages/fr.json index 2cba90fc8..d79ae8d4d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3336,6 +3336,30 @@ "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", "testReasonServerError": "The AI provider returned a server error.", "testReasonUnreachable": "Could not reach the AI provider.", + "coachMemory": { + "title": "Ce dont le Coach se souvient", + "description": "Les informations durables que vous avez confiées au Coach. Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.", + "empty": "Le Coach n'a encore enregistré aucune information. Au fil de vos échanges, il retient les détails durables que vous partagez — vos objectifs, vos affections, vos préférences — et vous pouvez les effacer ici à tout moment.", + "learnedPrefix": "appris {when}", + "categoryPreference": "Préférences", + "categoryCondition": "Affections", + "categoryGoal": "Objectifs", + "categoryConstraint": "Contraintes", + "categoryContext": "Contexte", + "forget": "Oublier", + "forgetAria": "Oublier cette information", + "forgetAll": "Tout oublier", + "forgetAllConfirmTitle": "Oublier tout ce dont le Coach se souvient ?", + "forgetAllConfirmBody": "Cela supprime toutes les informations enregistrées. Le Coach repartira de zéro lors de votre prochaine conversation. Cette action est irréversible.", + "forgetAllConfirmAction": "Tout oublier", + "cancel": "Annuler", + "summaryNote": "Le Coach peut aussi conserver un bref résumé évolutif de chaque conversation. Il n'existe pas de commande individuelle pour cela — supprimer une conversation supprime son résumé.", + "loadError": "Impossible de charger ce dont le Coach se souvient.", + "forgotToast": "Information oubliée.", + "forgotError": "Impossible d'oublier cette information. Veuillez réessayer.", + "clearedToast": "{count} informations enregistrées oubliées.", + "clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer." + }, "disableCoach": { "title": "Masquer le Coach", "description": "Masque le bouton et le panneau du Coach sur toutes les pages.", diff --git a/messages/it.json b/messages/it.json index 7e6f967ae..e25ee1368 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3336,6 +3336,30 @@ "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", "testReasonServerError": "The AI provider returned a server error.", "testReasonUnreachable": "Could not reach the AI provider.", + "coachMemory": { + "title": "Ciò che il Coach ricorda", + "description": "Informazioni durature che hai comunicato al Coach. Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.", + "empty": "Il Coach non ha ancora memorizzato alcuna informazione. Mentre chatti, ricorda i dettagli duraturi che condividi — i tuoi obiettivi, le condizioni, le preferenze — e puoi cancellarli qui in qualsiasi momento.", + "learnedPrefix": "appreso {when}", + "categoryPreference": "Preferenze", + "categoryCondition": "Condizioni", + "categoryGoal": "Obiettivi", + "categoryConstraint": "Vincoli", + "categoryContext": "Contesto", + "forget": "Dimentica", + "forgetAria": "Dimentica questa informazione", + "forgetAll": "Dimentica tutto", + "forgetAllConfirmTitle": "Dimenticare tutto ciò che il Coach ricorda?", + "forgetAllConfirmBody": "Questo rimuove tutte le informazioni memorizzate. Il Coach ripartirà da zero alla prossima chat. L'operazione non può essere annullata.", + "forgetAllConfirmAction": "Dimentica tutto", + "cancel": "Annulla", + "summaryNote": "Il Coach può anche conservare un breve riassunto progressivo di ogni conversazione. Non esiste un controllo per ogni voce — eliminando una conversazione si elimina anche il suo riassunto.", + "loadError": "Impossibile caricare ciò che il Coach ricorda.", + "forgotToast": "Informazione dimenticata.", + "forgotError": "Impossibile dimenticare questa informazione. Riprova.", + "clearedToast": "Dimenticate {count} informazioni memorizzate.", + "clearError": "Impossibile cancellare le informazioni memorizzate. Riprova." + }, "disableCoach": { "title": "Nascondi il Coach", "description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.", diff --git a/messages/pl.json b/messages/pl.json index 89f4460a9..39097727f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3336,6 +3336,30 @@ "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", "testReasonServerError": "The AI provider returned a server error.", "testReasonUnreachable": "Could not reach the AI provider.", + "coachMemory": { + "title": "Co Coach pamięta", + "description": "Trwałe informacje, które przekazałeś Coachowi. Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.", + "empty": "Coach nie zapisał jeszcze żadnych informacji. Podczas rozmowy zapamiętuje trwałe szczegóły, którymi się dzielisz — Twoje cele, dolegliwości, preferencje — i możesz je tutaj w każdej chwili usunąć.", + "learnedPrefix": "zapamiętano {when}", + "categoryPreference": "Preferencje", + "categoryCondition": "Dolegliwości", + "categoryGoal": "Cele", + "categoryConstraint": "Ograniczenia", + "categoryContext": "Kontekst", + "forget": "Zapomnij", + "forgetAria": "Zapomnij tę informację", + "forgetAll": "Zapomnij wszystko", + "forgetAllConfirmTitle": "Zapomnieć wszystko, co Coach pamięta?", + "forgetAllConfirmBody": "Spowoduje to usunięcie wszystkich zapisanych informacji. Coach zacznie od nowa przy następnej rozmowie. Tej operacji nie można cofnąć.", + "forgetAllConfirmAction": "Zapomnij wszystko", + "cancel": "Anuluj", + "summaryNote": "Coach może też przechowywać krótkie, bieżące podsumowanie każdej rozmowy. Nie ma dla niego osobnej kontrolki — usunięcie rozmowy usuwa jej podsumowanie.", + "loadError": "Nie udało się wczytać tego, co Coach pamięta.", + "forgotToast": "Informacja zapomniana.", + "forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.", + "clearedToast": "Zapomniano {count} zapisanych informacji.", + "clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie." + }, "disableCoach": { "title": "Ukryj Coacha", "description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.", diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx new file mode 100644 index 000000000..2ce1a0feb --- /dev/null +++ b/src/components/settings/__tests__/coach-memory-section.test.tsx @@ -0,0 +1,153 @@ +/** + * v1.11.2 — Settings → AI "What the Coach remembers" panel contract. + * + * Project convention is SSR-only tests (no `@testing-library/react`; + * the Vitest environment is `node`). We pre-seed the `coachFacts` + * query in a real `QueryClient` so `useQuery` resolves synchronously + * during the `renderToStaticMarkup` pass, then assert the rendered + * shape: facts grouped + labelled by category, the empty state, and + * the per-fact + bulk "forget" controls that drive the DELETE + * mutations. + * + * The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts` + * → `queryKeys.coachFacts()` invalidation + toast) is pinned by the + * route-shape contract in the component plus the typecheck gate; this + * suite guards the render contract the way the sibling + * `disable-coach-card.test.tsx` does for the toggle. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { CoachMemorySection } from "../coach-memory-section"; + +interface SeedFact { + id: string; + category: "preference" | "condition" | "goal" | "constraint" | "context"; + text: string; + confidence: number; + createdAt: string; +} + +const NOW = "2026-06-04T12:00:00.000Z"; + +function buildFacts(): SeedFact[] { + return [ + { + id: "fact-goal-1", + category: "goal", + text: "Wants to reach 80 kg by autumn", + confidence: 0.9, + createdAt: NOW, + }, + { + id: "fact-pref-1", + category: "preference", + text: "Prefers morning workouts", + confidence: 0.8, + createdAt: NOW, + }, + { + id: "fact-cond-1", + category: "condition", + text: "Takes ramipril for blood pressure", + confidence: 0.95, + createdAt: NOW, + }, + ]; +} + +beforeEach(() => { + // The component never fetches when the query is pre-seeded fresh, but + // stub fetch so any background refetch can't escape to the network. + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ data: { facts: [] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function render(facts: SeedFact[] | "skip"): string { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: 0, staleTime: Infinity, gcTime: Infinity }, + }, + }); + if (facts !== "skip") { + client.setQueryData(queryKeys.coachFacts(), facts); + } + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Settings — CoachMemorySection", () => { + it("renders the facts grouped by category", () => { + const html = render(buildFacts()); + // Each populated category surfaces its group heading... + expect(html).toContain( + 'data-testid="settings-coach-memory-group-preference"', + ); + expect(html).toContain('data-testid="settings-coach-memory-group-goal"'); + expect(html).toContain( + 'data-testid="settings-coach-memory-group-condition"', + ); + // ...and the localized category labels render. + expect(html).toContain("Preferences"); + expect(html).toContain("Goals"); + expect(html).toContain("Conditions"); + // The fact text appears verbatim. + expect(html).toContain("Prefers morning workouts"); + expect(html).toContain("Wants to reach 80 kg by autumn"); + // No group rows for categories that have no facts. + expect(html).not.toContain( + 'data-testid="settings-coach-memory-group-constraint"', + ); + }); + + it("renders a per-fact forget control and the bulk forget-all action", () => { + const html = render(buildFacts()); + expect(html).toContain('data-testid="settings-coach-memory-forget"'); + expect(html).toContain('data-testid="settings-coach-memory-forget-all"'); + // One forget button per fact (3 seeded). + const forgetButtons = html.match( + /data-testid="settings-coach-memory-forget"/g, + ); + expect(forgetButtons).toHaveLength(3); + }); + + it("renders the empty state with the explainer when there are no facts", () => { + const html = render([]); + expect(html).toContain('data-testid="settings-coach-memory-empty"'); + // The bulk action hides when there is nothing to forget. + expect(html).not.toContain( + 'data-testid="settings-coach-memory-forget-all"', + ); + // No fact rows. + expect(html).not.toContain('data-testid="settings-coach-memory-fact"'); + }); + + it("always shows the rolling-summary note", () => { + const withFacts = render(buildFacts()); + const empty = render([]); + // The summary explainer is present in both states (it has no + // per-row control — deleting a conversation removes its summary). + expect(withFacts).toContain("rolling summary"); + expect(empty).toContain("rolling summary"); + }); +}); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index cc235c30a..4e1cfb54e 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -34,6 +34,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { PasswordInput } from "@/components/ui/password-input"; +import { CoachMemorySection } from "@/components/settings/coach-memory-section"; import { useAuth } from "@/hooks/use-auth"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; @@ -170,7 +171,11 @@ function localiseTestReason( export function AiSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); + // v1.11.2 — the Coach-memory panel rides the same gate as the rest of + // the Coach surface: when the user has hidden the Coach there is no + // assistant to remember anything, so the memory controls hide too. + const coachEnabled = !user?.disableCoach; return (
@@ -192,6 +197,12 @@ export function AiSection() { card so users who want the surface gone never have to scroll through provider configuration before finding the toggle. */} + + {/* v1.11.2 — "What the Coach remembers": durable-fact review + + forget controls. Gated on the Coach being enabled. */} + {coachEnabled && ( + + )}
); } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx new file mode 100644 index 000000000..2d52f59f5 --- /dev/null +++ b/src/components/settings/coach-memory-section.tsx @@ -0,0 +1,270 @@ +"use client"; + +/** + * v1.11.2 — Settings → AI "What the Coach remembers" panel. + * + * Surfaces the durable `CoachFact` rows the assistant has stored about + * the user (the v1.11.1 routes already ship the data), grouped by + * category, each with a relative "learned " stamp and a "forget" + * control. A bulk "forget everything" action clears the lot. + * + * GET /api/insights/coach/facts → { data: { facts: [...] } } + * DELETE /api/insights/coach/facts/{id} → { data: { deleted } } + * DELETE /api/insights/coach/facts → { data: { cleared } } + * + * Reads unwrap `(await res.json()).data` per the envelope convention; + * every read/write routes its key through `queryKeys.coachFacts()` so + * a forget invalidates the list. Gated on `!user.disableCoach` so the + * panel mirrors the rest of the Coach surface — hiding the Coach hides + * its memory controls too. + */ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { formatDateOrRelative } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { toast } from "sonner"; + +/** Closed enum mirrored from the server `CoachFact.category` column. */ +const FACT_CATEGORIES = [ + "preference", + "condition", + "goal", + "constraint", + "context", +] as const; +type FactCategory = (typeof FACT_CATEGORIES)[number]; + +interface CoachFact { + id: string; + category: FactCategory; + text: string; + confidence: number; + createdAt: string; +} + +const CATEGORY_LABEL_KEY: Record = { + preference: "settings.ai.coachMemory.categoryPreference", + condition: "settings.ai.coachMemory.categoryCondition", + goal: "settings.ai.coachMemory.categoryGoal", + constraint: "settings.ai.coachMemory.categoryConstraint", + context: "settings.ai.coachMemory.categoryContext", +}; + +async function fetchFacts(): Promise { + const res = await fetch("/api/insights/coach/facts"); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = (await res.json()) as { data?: { facts?: CoachFact[] } }; + return json.data?.facts ?? []; +} + +export function CoachMemorySection({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: queryKeys.coachFacts(), + queryFn: fetchFacts, + enabled: isAuthenticated, + }); + + const forgetOne = useMutation({ + mutationKey: queryKeys.coachFacts(), + mutationFn: async (id: string) => { + const res = await fetch( + `/api/insights/coach/facts/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return id; + }, + onSuccess: () => { + toast.success(t("settings.ai.coachMemory.forgotToast")); + queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); + }, + onError: () => { + toast.error(t("settings.ai.coachMemory.forgotError")); + }, + }); + + const forgetAll = useMutation({ + mutationKey: queryKeys.coachFacts(), + mutationFn: async () => { + const res = await fetch("/api/insights/coach/facts", { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const json = (await res.json()) as { data?: { cleared?: number } }; + return json.data?.cleared ?? 0; + }, + onSuccess: (cleared) => { + toast.success( + t("settings.ai.coachMemory.clearedToast", { count: cleared }), + ); + queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); + }, + onError: () => { + toast.error(t("settings.ai.coachMemory.clearError")); + }, + }); + + const facts = useMemo(() => query.data ?? [], [query.data]); + + // Group by category in the canonical category order so the panel + // reads the same way every render regardless of insertion order. + const grouped = useMemo(() => { + return FACT_CATEGORIES.map((category) => ({ + category, + items: facts.filter((f) => f.category === category), + })).filter((g) => g.items.length > 0); + }, [facts]); + + return ( +
+
+
+

+ {t("settings.ai.coachMemory.title")} +

+

+ {t("settings.ai.coachMemory.description")} +

+
+ {facts.length > 0 && ( + + + + + + + + {t("settings.ai.coachMemory.forgetAllConfirmTitle")} + + + {t("settings.ai.coachMemory.forgetAllConfirmBody")} + + + + + {t("settings.ai.coachMemory.cancel")} + + forgetAll.mutate()} + > + {t("settings.ai.coachMemory.forgetAllConfirmAction")} + + + + + )} +
+ + {query.isError && ( +

+ {t("settings.ai.coachMemory.loadError")} +

+ )} + + {!query.isError && facts.length === 0 ? ( +

+ {t("settings.ai.coachMemory.empty")} +

+ ) : ( +
+ {grouped.map((group) => ( +
+

+ {t(CATEGORY_LABEL_KEY[group.category])} +

+
    + {group.items.map((fact) => ( +
  • +
    +

    {fact.text}

    +

    + {t("settings.ai.coachMemory.learnedPrefix", { + when: formatDateOrRelative(fact.createdAt, t), + })} +

    +
    + +
  • + ))} +
+
+ ))} +
+ )} + +

+ {t("settings.ai.coachMemory.summaryNote")} +

+
+ ); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index ed241217d..eae5f478e 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -299,6 +299,7 @@ export const queryKeys = { featureFlags: () => ["feature-flags"] as const, coachPrefs: () => ["coach-prefs"] as const, + coachFacts: () => ["coach-facts"] as const, /** * v1.4.41 — admin surfaces. Pre-fix every admin section declared its From bd613743511ae12e8e48a89a3b7cf048b30637cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:20:23 +0200 Subject: [PATCH 02/15] fix(security): pin DNS-rebinding check on user/operator outbound hosts Add requirePublicHost to the three outbound safeFetch sites whose host is user- or operator-controlled and lacked the connect-time pin: the OpenAI and Anthropic clients (BYO base-URL override) and the IP-geo lookup (IP_GEO_LOOKUP_URL env). A base URL resolving to a private/metadata address is now rejected. The LOCAL AI client intentionally stays conditional (LAN escape hatch). Redirect-follow is already closed by the safeFetch defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/ai/anthropic-client.ts | 4 +++- src/lib/ai/openai-client.ts | 5 ++++- src/lib/geo.ts | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 903e3c88a..4228fd322 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -54,7 +54,9 @@ export class AnthropicClient implements AIProvider { }), }, // 60 s ceiling — see openai-client.ts for the rationale. - { timeoutMs: 60_000 }, + // v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS + // check so a private/metadata address is rejected (SSRF/rebinding). + { timeoutMs: 60_000, requirePublicHost: true }, ); if (!res.ok) { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 06a3b3a00..a898acaed 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -52,7 +52,10 @@ export class OpenAIClient implements AIProvider { }, // 60 s ceiling so a tar-pit upstream cannot pin a worker // indefinitely. Real completions land well inside this budget. - { timeoutMs: 60_000 }, + // v1.11.2 — the base URL is user/admin-overridable (BYO gateway), so pin + // the connect-time DNS check: a base URL resolving to a private/metadata + // address is rejected, closing the SSRF/rebinding surface. + { timeoutMs: 60_000, requirePublicHost: true }, ); if (!res.ok) { diff --git a/src/lib/geo.ts b/src/lib/geo.ts index e9b5b4358..c3bd9e623 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -300,7 +300,10 @@ async function lookupIpLocationOnline(ip: string): Promise { Accept: "application/json", }, }, - { timeoutMs: 3_000 }, + // v1.11.2 — the lookup URL comes from the operator IP_GEO_LOOKUP_URL env; + // pin the connect-time DNS check so it can't be pointed at a private / + // metadata address (SSRF/rebinding). + { timeoutMs: 3_000, requirePublicHost: true }, ); if (!res.ok) return null; From 38ef581e7afcf0eb0cc9b5c987154ad5e7cfd49e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:23:43 +0200 Subject: [PATCH 03/15] test(security): inventory guard for the SSRF requirePublicHost pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-grep guard asserting the openai/anthropic/geo outbound sites keep the unconditional DNS-rebinding pin and the local AI client keeps its conditional LAN escape hatch — so a future edit can't silently re-open the SSRF surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/__tests__/ssrf-pin-inventory.test.ts | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/lib/__tests__/ssrf-pin-inventory.test.ts diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts new file mode 100644 index 000000000..0b2dce059 --- /dev/null +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -0,0 +1,46 @@ +/** + * v1.11.2 — SSRF DNS-rebinding pin inventory. + * + * Source-text guard (same approach as the queue-registration + coach-gate + * inventory tests): the outbound `safeFetch` sites whose host is user- or + * operator-controlled MUST pass `requirePublicHost: true`, which wires both + * the input-time `isPublicUrl` check and the connect-time DNS-rebinding pin. + * A future edit that drops the pin (re-opening the SSRF surface) fails here + * instead of shipping silently. + * + * The LOCAL AI client is the deliberate exception: it stays CONDITIONAL + * (`requirePublicHost: !allowPrivate`) so an operator can opt into LAN hosts + * via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`. We assert it keeps that conditional + * shape — never an unconditional `true` (which would break LAN local models) + * and never absent (which would re-open it for the default case). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); +const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + +describe("SSRF requirePublicHost pin inventory", () => { + it("openai-client pins the BYO base-URL outbound unconditionally", () => { + expect(read("ai/openai-client.ts")).toMatch(/requirePublicHost:\s*true/); + }); + + it("anthropic-client pins the BYO base-URL outbound unconditionally", () => { + expect(read("ai/anthropic-client.ts")).toMatch(/requirePublicHost:\s*true/); + }); + + it("geo lookup pins the operator IP_GEO_LOOKUP_URL outbound", () => { + expect(read("geo.ts")).toMatch(/requirePublicHost:\s*true/); + }); + + it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => { + const src = read("ai/local-client.ts"); + // Must carry the conditional form … + expect(src).toMatch(/requirePublicHost:\s*!?\s*allowPrivate/); + // … and must NOT be hardened to an unconditional true (that would break + // a deliberately-private LAN local model). + expect(src).not.toMatch(/requirePublicHost:\s*true/); + }); +}); From b45ca232fafc1e954fd8fb3e11a8dda902980fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:27:56 +0200 Subject: [PATCH 04/15] perf(analytics): thread source-priority once through correlations fast path Load the source-priority blob once and pass it into the three readRollupBuckets calls instead of letting each re-query the user. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/analytics/correlations-fast-path.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index f2ecde171..e5fc0e07a 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -46,6 +46,7 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { probeRollupCoverage, type RollupCoverageMap, @@ -168,10 +169,13 @@ export async function computeCorrelationHypothesesFastPath( // could mint per-user-tz buckets if the discrepancy materialises // on a non-Berlin account; the n >= 20 surface gate absorbs the // single-day phase shift either way. + // v1.11.2 — load the source-priority blob once and thread it into every + // reader so the per-type fan-out doesn't re-query the user 3×. + const priority = await loadUserSourcePriority(userId); const [sysBuckets, pulseBuckets, weightBuckets] = await Promise.all([ - readRollupBuckets(userId, "BLOOD_PRESSURE_SYS", "DAY", since, now), - readRollupBuckets(userId, "PULSE", "DAY", since, now), - readRollupBuckets(userId, "WEIGHT", "DAY", since, now), + readRollupBuckets(userId, "BLOOD_PRESSURE_SYS", "DAY", since, now, priority), + readRollupBuckets(userId, "PULSE", "DAY", since, now, priority), + readRollupBuckets(userId, "WEIGHT", "DAY", since, now, priority), ]); dailySysMean = new Map(); for (const b of sysBuckets) { From b132abc78977b5618838fa609f90f3ffbb4de7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:34:13 +0200 Subject: [PATCH 05/15] perf(analytics): thread source-priority once through bp-in-target fast path Load the source-priority blob once and pass it into both BP readers instead of letting each re-query the user. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/analytics/bp-in-target-fast-path.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index 2d42884d8..cd6147477 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -53,6 +53,7 @@ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { probeRollupCoverage, type RollupCoverageMap, @@ -197,6 +198,9 @@ async function computeFromRollups( return d; })(); + // v1.11.2 — load the source-priority blob once and thread it into both + // reads so the sys/dia fan-out doesn't re-query the user twice. + const priority = await loadUserSourcePriority(userId); const [sysBuckets, diaBuckets] = await Promise.all([ readRollupBuckets( userId, @@ -204,6 +208,7 @@ async function computeFromRollups( "DAY", readSince, now, + priority, ), readRollupBuckets( userId, @@ -211,6 +216,7 @@ async function computeFromRollups( "DAY", readSince, now, + priority, ), ]); From 1f3b0ff5b0a01baa17d423633295a34c900532e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:38:25 +0200 Subject: [PATCH 06/15] perf(insights): thread source-priority once through graded-series readers Load the source-priority blob once and pass it into both readBestGranularityRollups calls. Completes the B3 fast-path threading. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/insights/graded-series.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index ff97961cd..19bbf3e54 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -53,6 +53,7 @@ import { readBestGranularityRollups, type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { toBerlinYmd } from "@/lib/tz/resolver"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -350,10 +351,23 @@ export async function buildGradedSeriesWithRollups( now, ); + // v1.11.2 — load the source-priority blob once and thread it into both + // granularity reads so they don't re-query the user. + const priority = await loadUserSourcePriority(userId); // Monthly slice: route a ~1-year window through the tier. - const monthlyRouted = await readBestGranularityRollups(userId, type, 365); + const monthlyRouted = await readBestGranularityRollups( + userId, + type, + 365, + priority, + ); // Yearly slice: route a multi-year window through the tier. - const yearlyRouted = await readBestGranularityRollups(userId, type, 1095); + const yearlyRouted = await readBestGranularityRollups( + userId, + type, + 1095, + priority, + ); const monthlyCovered = monthlyRouted !== null && monthlyRouted.granularity === "MONTH"; From 0949fadfb03a594beb1fab464c1a1d456118d30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:53:38 +0200 Subject: [PATCH 07/15] feat(dashboard): make the v1.10 additive metrics pinnable to Home Add eight widget ids (cardio recovery, six-minute walk, stair ascent/descent speed, breathing disturbances, wrist temperature, falls, walking steadiness) to the dashboard widget catalogue + default layout (hidden by default) so they can be pinned via the layout PUT. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../settings/dashboard-layout-section.tsx | 10 +++++++ src/lib/__tests__/dashboard-layout.test.ts | 20 +++++++------ src/lib/dashboard-layout.ts | 28 +++++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 5785fd739..60cde6dcd 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -132,6 +132,16 @@ const WIDGET_LABEL_KEYS: Record = { vo2Max: "dashboard.vo2Max", // v1.4.32 — Recent workouts tile (default-on). recentWorkouts: "dashboard.recentWorkouts.title", + // v1.11.2 B5 — v1.10 additive HealthKit signals, now pinnable. Each + // reuses the existing measurement-type label key. + cardioRecovery: "measurements.typeCardioRecovery", + sixMinuteWalk: "measurements.typeSixMinuteWalkDistance", + stairAscentSpeed: "measurements.typeStairAscentSpeed", + stairDescentSpeed: "measurements.typeStairDescentSpeed", + breathingDisturbances: "measurements.typeBreathingDisturbances", + wristTemperature: "measurements.typeWristTemperature", + falls: "measurements.typeFallCount", + walkingSteadiness: "measurements.typeWalkingSteadiness", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index c972eb2e0..5ac048573 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -382,11 +382,13 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => { * iOS-only ids extend it without touching the writable PUT enum. */ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { - it("carries exactly 27 distinct ids (16 server-known + 11 iOS-only)", () => { - expect(DASHBOARD_WIDGET_IDS).toHaveLength(16); + it("carries exactly 35 distinct ids (24 server-known + 11 iOS-only)", () => { + // v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so + // the server-known set grew 16 → 24 and the catalogue 27 → 35. + expect(DASHBOARD_WIDGET_IDS).toHaveLength(24); expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(11); - expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(27); - expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(27); + expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(35); + expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(35); }); it("is a superset of the server-known ids in declaration order", () => { @@ -483,17 +485,17 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { expect(ids).not.toContain("totally-made-up"); }); - it("keeps the default layout at the 16 web tiles (no iOS-only seeded)", () => { + it("keeps the default layout at the 24 web tiles (no iOS-only seeded)", () => { const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id); - expect(ids).toHaveLength(16); + expect(ids).toHaveLength(24); for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) { expect(ids).not.toContain(iosId); } }); it("does NOT auto-append iOS-only ids when a web-only layout is read", () => { - // A web account that saved only `weight` must auto-upgrade to the 16 - // web defaults — never to the 27 catalogue. iOS-only ids appear only + // A web account that saved only `weight` must auto-upgrade to the 24 + // web defaults — never to the 35 catalogue. iOS-only ids appear only // once a native client has explicitly sent them. const partial = { version: 1, @@ -501,7 +503,7 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => { }; const resolved = resolveDashboardLayout(partial); const ids = resolved.widgets.map((w) => w.id); - expect(ids).toHaveLength(16); + expect(ids).toHaveLength(24); for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) { expect(ids).not.toContain(iosId); } diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 139814d2f..6e3b5e8ec 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -42,6 +42,18 @@ export const DASHBOARD_WIDGET_IDS = [ // cue is discoverable from the dashboard rather than hidden // behind Settings. "recentWorkouts", + // v1.11.2 B5 — the v1.10 additive HealthKit signals become pinnable. + // Each maps 1:1 to a MeasurementType the server already stores; + // default-invisible so existing dashboards stay unchanged until the + // user opts in via Settings → Dashboard. + "cardioRecovery", + "sixMinuteWalk", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", + "wristTemperature", + "falls", + "walkingSteadiness", ] as const; export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; @@ -323,6 +335,22 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // workouts list and renders an Apple-Health onboarding hint // otherwise. { id: "recentWorkouts", visible: true, tileVisible: true, order: 15 }, + // v1.11.2 B5 — v1.10 additive HealthKit signals, pinnable but + // default-invisible on both surfaces. The user opts in via + // Settings → Dashboard; each tile self-gates on having any sample. + { id: "cardioRecovery", visible: false, tileVisible: false, order: 16 }, + { id: "sixMinuteWalk", visible: false, tileVisible: false, order: 17 }, + { id: "stairAscentSpeed", visible: false, tileVisible: false, order: 18 }, + { id: "stairDescentSpeed", visible: false, tileVisible: false, order: 19 }, + { + id: "breathingDisturbances", + visible: false, + tileVisible: false, + order: 20, + }, + { id: "wristTemperature", visible: false, tileVisible: false, order: 21 }, + { id: "falls", visible: false, tileVisible: false, order: 22 }, + { id: "walkingSteadiness", visible: false, tileVisible: false, order: 23 }, ], }; From 7923d75afe0256b1dadd8da5b2a9616bcc0f6241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 12:59:38 +0200 Subject: [PATCH 08/15] chore: clear v1.11.2 QA-low cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the unused `now` field from the coach `ExtractOpts` type; fact extraction never read it (conversation-summary keeps its own `now`). - Correct the no-ladder tiebreak docstring in measurement-read: the collapse picks the alphabetically smallest source name, matching the live-SQL `ORDER BY … source` tiebreak, not max count. - Replace the hand-inlined canonical-source subquery in the summaries rollup-path narrows query with `canonicalMeasurementsFrom(rank, "90 days")` for live/rollup parity with its siblings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/ai/coach/facts.ts | 1 - src/lib/analytics/summaries-slice.ts | 22 +--------------------- src/lib/rollups/measurement-read.ts | 5 +++-- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index 52c1d0b4e..a1045d112 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -58,7 +58,6 @@ type RunCompletionFn = typeof runStatusCompletion; type PrismaLike = Pick; interface ExtractOpts { - now?: Date; runCompletion?: RunCompletionFn; prisma?: PrismaLike; locale?: string; diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 1c5f16dc6..08ce29746 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -348,27 +348,7 @@ async function computeFromRollups(userId: string): Promise { ) FILTER ( WHERE m."measured_at" >= NOW() - INTERVAL '90 days' )::double precision AS r2_90 - FROM ( - SELECT mm.* - FROM measurements mm - JOIN ( - SELECT DISTINCT ON ("type", date_trunc('day', "measured_at")) - "type" AS t, - date_trunc('day', "measured_at") AS d, - "source" AS canon - FROM measurements - WHERE "user_id" = $1 - AND "deleted_at" IS NULL - AND "measured_at" >= NOW() - INTERVAL '90 days' - ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source" - ) c - ON c.t = mm."type" - AND c.d = date_trunc('day', mm."measured_at") - AND c.canon = mm."source" - WHERE mm."user_id" = $1 - AND mm."deleted_at" IS NULL - AND mm."measured_at" >= NOW() - INTERVAL '90 days' - ) m + FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")} GROUP BY m."type" `, userId, diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index 67d657470..c43bd7386 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -73,8 +73,9 @@ export interface SourcedBucketRow { * Resolution per bucket: * 1. the first source in the metric's ladder that is present → canonical; * 2. no ladder match (an unlisted source, or a type with no ladder) → the - * row with the most readings (max `count`), tie-broken by source name, - * so the bucket neither doubles nor goes dark. + * row with the alphabetically smallest source name, so the bucket + * neither doubles nor goes dark AND the pick matches the live-SQL + * paths' `ORDER BY … source` tiebreak. * * Input order is preserved (buckets emit in first-seen order). A single-source * day (the common case) short-circuits to the row unchanged. From 07c444f9387a38bc3407289a0ac460890c178778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 13:01:54 +0200 Subject: [PATCH 09/15] fix(rollups): treat a concurrent same-partition P2002 as benign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synchronous DAY rollup hook carries no pg-boss singleton, so two measurement writes landing on the same (user, type, day) can interleave their delete-then-insert and the losing createMany trips the per-source unique index. The peer rewrote the identical source-collapsed partition — the aggregate is a pure function of the same underlying rows — so the rollup is already correct. Catch the P2002 in the single-chunk transactional path, annotate it, and return rather than failing the recompute. Any other Prisma error still bubbles. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/measurement-rollups.test.ts | 68 +++++++++++++++++++ src/lib/rollups/measurement-rollups.ts | 43 ++++++++++-- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/lib/rollups/__tests__/measurement-rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts index f96c0018e..23e86443d 100644 --- a/src/lib/rollups/__tests__/measurement-rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -212,6 +212,74 @@ describe("recomputeBucketsForMeasurement", () => { } }); + it("swallows a P2002 from a concurrent same-partition recompute", async () => { + // Two writes on the same (user, type, day) can interleave the + // delete-then-insert (no pg-boss singleton on the sync DAY hook), so + // the losing createMany trips the per-source unique index. The peer + // wrote the identical source-collapsed partition, so the rollup is + // already correct — persistRollupRows must treat the P2002 as benign + // rather than failing the recompute. + queryRawUnsafe.mockResolvedValueOnce([ + { + type: "WEIGHT", + source: "MANUAL", + bucket_start: new Date("2026-05-10T00:00:00.000Z"), + count: BigInt(1), + mean: 80.0, + min_value: 80.0, + max_value: 80.0, + sum_value: 80.0, + sd: 0, + slope: 0, + r2: 0, + }, + ]); + getGlobalBossMock.mockReturnValue({ send: bossSend }); + bossSend.mockResolvedValue("job-id"); + // The DAY transaction loses the race and throws a unique violation. + transaction.mockRejectedValueOnce({ code: "P2002" }); + + await expect( + recomputeBucketsForMeasurement( + "user-1", + "WEIGHT", + new Date("2026-05-10T14:30:00.000Z"), + ), + ).resolves.not.toThrow(); + + // The downstream WEEK/MONTH/YEAR enqueues still fire — the benign DAY + // race does not abort the rest of the recompute. + expect(bossSend).toHaveBeenCalledTimes(3); + }); + + it("rethrows a non-P2002 transaction error", async () => { + queryRawUnsafe.mockResolvedValueOnce([ + { + type: "WEIGHT", + source: "MANUAL", + bucket_start: new Date("2026-05-10T00:00:00.000Z"), + count: BigInt(1), + mean: 80.0, + min_value: 80.0, + max_value: 80.0, + sum_value: 80.0, + sd: 0, + slope: 0, + r2: 0, + }, + ]); + getGlobalBossMock.mockReturnValue({ send: bossSend }); + transaction.mockRejectedValueOnce({ code: "P2010" }); + + await expect( + recomputeBucketsForMeasurement( + "user-1", + "WEIGHT", + new Date("2026-05-10T14:30:00.000Z"), + ), + ).rejects.toMatchObject({ code: "P2010" }); + }); + it("writes sum_value for cumulative ACTIVITY_STEPS buckets", async () => { // 5 step samples in a day summing to 12480: emulates an iOS // batch of HealthKit slices reaching the rollup aggregator. diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index be973b5aa..6369483b6 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -437,6 +437,16 @@ async function runRollupAggregate(input: { * with `ON CONFLICT DO UPDATE` so the round-trip count is bounded * regardless of fan-out width. */ +/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ +function isP2002(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: unknown }).code === "P2002" + ); +} + async function persistRollupRows( userId: string, granularity: RollupGranularity, @@ -494,12 +504,35 @@ async function persistRollupRows( // Common path — the entire write fits one chunk (every incremental hook // and most bucket-span recomputes). Atomic delete-then-insert so a // concurrent read never sees a half-rewritten partition. + // + // The synchronous DAY hook carries no pg-boss singleton, so two writes + // landing on the same (user, type, day) can interleave: T1.delete, + // T2.delete, T1.create, T2.create — and T2's createMany then trips the + // (userId, type, granularity, bucketStart, source) unique index. Treat + // that P2002 as benign: the peer rewrote the identical source-collapsed + // partition (the aggregate is a pure function of the same underlying + // rows), so the rollup is already correct. Annotate and return 0 written + // rather than bubbling a recompute failure. if (rows.length <= CHUNK) { - const [, created] = await prisma.$transaction([ - prisma.measurementRollup.deleteMany({ where: deleteWhere }), - prisma.measurementRollup.createMany({ data: rows.map(toData) }), - ]); - return created.count; + try { + const [, created] = await prisma.$transaction([ + prisma.measurementRollup.deleteMany({ where: deleteWhere }), + prisma.measurementRollup.createMany({ data: rows.map(toData) }), + ]); + return created.count; + } catch (err) { + if (isP2002(err)) { + annotate({ + meta: { + rollup_persist_race: true, + rollup_persist_granularity: granularity, + rollup_persist_types: types.length, + }, + }); + return 0; + } + throw err; + } } // Large backfill — delete the partition range once, then insert in chunks. From 2994954138d1a9b09cef680adfa37029866863d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 13:12:18 +0200 Subject: [PATCH 10/15] test+a11y: fix mocks/expectations for source-priority + dashboard ids; pair coach-memory spinner with motion-reduce Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/dashboard/widgets/route.ts | 9 +++++---- src/components/settings/coach-memory-section.tsx | 5 ++++- src/lib/dashboard/__tests__/snapshot.test.ts | 12 ++++++------ .../insights/__tests__/graded-series-rollups.test.ts | 3 +++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 341787b3a..5ab6e51d7 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -70,10 +70,11 @@ const layoutSchema = z.object({ }), ) .min(1) - // v1.7.0 W1 — raised from 20 to 30 so the full 27-id catalogue PUT - // (web 16 + iOS-only 11) fits with headroom; the enum still bounds - // each id to one of the 27. - .max(30), + // v1.11.2 B5 — raised to 40 so the full catalogue PUT (24 writable + + // 11 iOS-only = 35) fits with headroom after the v1.10 additive + // HealthKit signals became pinnable; the enum still bounds each id to + // one of the catalogue ids. + .max(40), // v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides // on the layout blob per research §7 Q3 (no Prisma migration). Optional // so v1.4.15 clients that don't know the field can still PUT. diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 2d52f59f5..81f7a2f1a 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -172,7 +172,10 @@ export function CoachMemorySection({ disabled={!isAuthenticated || forgetAll.isPending} > {forgetAll.isPending ? ( - + ) : ( )} diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index a99a5e65b..ff331f27f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -364,17 +364,17 @@ describe("buildDashboardSnapshot — metricStates (iOS cold-launch seed)", () => }); }); -describe("buildDashboardSnapshot — layoutCatalogue (27-id round-trip)", () => { +describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () => { beforeEach(() => { probeRollupCoverage.mockResolvedValue(new Map()); isFullyCovered.mockReturnValue(false); }); - it("emits all 27 catalogue ids with visibility + order", async () => { + it("emits all 35 catalogue ids with visibility + order", async () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); - expect(snap.layoutCatalogue).toHaveLength(27); + expect(snap.layoutCatalogue).toHaveLength(35); const ids = new Set(snap.layoutCatalogue.map((w) => w.id)); - expect(ids.size).toBe(27); + expect(ids.size).toBe(35); // iOS-only ids appended default-invisible. const hrv = snap.layoutCatalogue.find((w) => w.id === "hrv"); expect(hrv).toBeDefined(); @@ -436,11 +436,11 @@ describe("buildDashboardSnapshot — additive proof", () => { expect(snap).toHaveProperty("layoutCatalogue"); // The web-consumed `layout` block is the resolved per-user layout — - // NOT replaced by the 27-id catalogue. + // NOT replaced by the 35-id catalogue. expect(snap.layout).toHaveProperty("version"); expect(Array.isArray(snap.layout.widgets)).toBe(true); // Resolved layout still carries only server-known widget ids. - expect(snap.layout.widgets.length).toBeLessThanOrEqual(16); + expect(snap.layout.widgets.length).toBeLessThanOrEqual(24); // tiles shape unchanged. expect(snap.tiles).toHaveProperty("summaries"); diff --git a/src/lib/insights/__tests__/graded-series-rollups.test.ts b/src/lib/insights/__tests__/graded-series-rollups.test.ts index 37009d705..ced555190 100644 --- a/src/lib/insights/__tests__/graded-series-rollups.test.ts +++ b/src/lib/insights/__tests__/graded-series-rollups.test.ts @@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: (...args: unknown[]) => findMany(...args), }, + // loadUserSourcePriority lazy-loads the source-priority blob; null + // (default findUnique) falls back to the default ladders. + user: { findUnique: vi.fn() }, }, })); From 2763a64b83c6a6ebd0b7a61c5915d60cff453a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 13:25:55 +0200 Subject: [PATCH 11/15] fix(dashboard): hide iOS-pin-only widgets from the web settings list The eight v1.10 additive HealthKit signals (cardio recovery, six-minute walk, stair ascent/descent speed, breathing disturbances, wrist temperature, falls, walking steadiness) are writable dashboard widget ids so the native client's Home-pin PUT validates them, but the web dashboard renders strip tiles via hardcoded per-id blocks and has no render path for them. Left as-is, the web Settings -> Dashboard list would offer eight toggles that do nothing on web. Name them in a new IOS_PIN_ONLY_WIDGET_IDS set (writable-but-not- web-rendered) and filter that set out of the rendered settings list, so the toggles never appear on web while the ids stay accepted by the widgets PUT enum for the native client. Add tests asserting the eight remain writable (catalogue membership) yet are absent from the web list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard-layout-section.test.tsx | 72 ++++++++++++++++--- .../settings/dashboard-layout-section.tsx | 14 +++- src/lib/__tests__/dashboard-layout.test.ts | 45 ++++++++++++ src/lib/dashboard-layout.ts | 29 ++++++++ 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 3ea8f1d2b..81edb4ead 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -4,9 +4,18 @@ import { renderToStaticMarkup } from "react-dom/server"; import { DEFAULT_DASHBOARD_LAYOUT, DASHBOARD_IOS_ONLY_WIDGET_IDS, + DASHBOARD_WIDGET_IDS, + IOS_PIN_ONLY_WIDGET_IDS, type DashboardLayout, } from "@/lib/dashboard-layout"; +// v1.11.2 HIGH-1 — the web Settings list renders one row per WRITABLE id +// (`DASHBOARD_WIDGET_IDS`) MINUS the `IOS_PIN_ONLY_WIDGET_IDS` (writable so +// the iOS pin PUT validates, but with no web render path). The default +// layout still carries all 24 writable widgets; only this subset renders. +const WEB_RENDERABLE_ROW_COUNT = + DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length; + // Mutable holder so individual tests can inject a layout (e.g. one that // carries iOS-only ids) into the mocked `useQuery` without re-mocking // the module. Defaults to the 16-tile web default layout. @@ -88,10 +97,10 @@ describe(" — tile + chart split", () => { const html = render(); const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? []; const chartSwitches = html.match(/data-slot="widget-chart-switch"/g) ?? []; - // Default layout has 13 widgets — both switch counts must match - // exactly so the split control covers every widget. - expect(tileSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length); - expect(chartSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length); + // One tile + one chart switch per WEB-renderable widget (the + // iOS-pin-only ids are filtered out — they have no web render path). + expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT); + expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT); }); }); @@ -108,7 +117,7 @@ describe(" — drag-and-drop reorder", () => { it("paints a drag handle for every widget row", () => { const html = render(); const handles = html.match(/data-slot="widget-drag-handle"/g) ?? []; - expect(handles).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length); + expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT); }); it("each drag handle has an aria-describedby pointing to a shared hint", () => { @@ -117,7 +126,7 @@ describe(" — drag-and-drop reorder", () => { // to care about attribute ordering inside the rendered From ba4349eda0315be2095556d9a41552c91ab82d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 13:26:07 +0200 Subject: [PATCH 13/15] refactor(db): extract the P2002 predicate into one shared helper The Prisma unique-constraint (P2002) narrowing predicate had been hand-copied into five call sites. Fold them into a single isP2002 in src/lib/prisma-errors.ts and import it everywhere (the two step-drain sites keep their local isUniqueConstraintViolation name via an import alias). Behaviour-identical; one place to chase a future P2002-semantics change instead of five. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../measurements/consolidate-legacy-steps.ts | 10 +------- .../measurements/dense-intraday-retention.ts | 14 +---------- src/lib/medications/intake-slot-dedup.ts | 11 +-------- src/lib/medications/scheduling/slot-upsert.ts | 10 +------- src/lib/prisma-errors.ts | 24 +++++++++++++++++++ src/lib/rollups/measurement-rollups.ts | 11 +-------- 6 files changed, 29 insertions(+), 51 deletions(-) create mode 100644 src/lib/prisma-errors.ts diff --git a/src/lib/measurements/consolidate-legacy-steps.ts b/src/lib/measurements/consolidate-legacy-steps.ts index e027bdabe..4a17a0d13 100644 --- a/src/lib/measurements/consolidate-legacy-steps.ts +++ b/src/lib/measurements/consolidate-legacy-steps.ts @@ -38,6 +38,7 @@ * `rollup-full-backfill` boot-time converging-backfill pattern. */ import type { PrismaClient } from "@/generated/prisma/client"; +import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; import { dailyStatsExternalId } from "./apple-health-mapping"; import { @@ -100,15 +101,6 @@ export interface StepConsolidationSummary { }; } -/** True for a Prisma unique-constraint violation (P2002). */ -function isUniqueConstraintViolation(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "P2002" - ); -} export interface StepConsolidationOptions { /** Limit the pass to a single user. Default = every user. */ diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index fd072a1f4..7b9fa25aa 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -41,6 +41,7 @@ * production standalone image strips `tsx`, so this can never be a CLI. */ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; import { dailyStatsExternalId, @@ -77,19 +78,6 @@ export const DENSE_INTRADAY_RETENTION_DAYS = 14; /** The daily-stats externalId prefix marks an already-collapsed row. */ const DAILY_STATS_PREFIX = "stats:"; -/** - * True for a Prisma unique-constraint violation (P2002). Mirrors the - * `consolidate-legacy-steps.ts` precedent verbatim so the two drains - * classify the same error the same way. - */ -function isUniqueConstraintViolation(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "P2002" - ); -} export interface DenseIntradayRetentionSummary { dryRun: boolean; diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 2c69498e0..fab3673be 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -41,6 +41,7 @@ * unregistered queue silently never drains. */ import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -53,16 +54,6 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication- export const INTAKE_SLOT_DEDUP_QUEUE = "intake-slot-dedup"; -/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ -function isP2002(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code: unknown }).code === "P2002" - ); -} - /** * Serial concurrency — the populator walks a user's intake events and * writes per slot; concurrency-1 keeps it from crowding the request diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 09d21612b..55cb3d6a8 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -17,21 +17,13 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; type PrismaLike = Pick; -/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ -function isP2002(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code: unknown }).code === "P2002" - ); -} /** The minimal intake-row shape the slot upsert reads + returns. */ export interface SlotIntakeRow { diff --git a/src/lib/prisma-errors.ts b/src/lib/prisma-errors.ts new file mode 100644 index 000000000..bf6096656 --- /dev/null +++ b/src/lib/prisma-errors.ts @@ -0,0 +1,24 @@ +/** + * Shared Prisma error narrowing helpers. + * + * The P2002 (unique-constraint violation) predicate was hand-copied into + * five call sites (rollup persist, intake-slot dedup, slot upsert, legacy + * step consolidation, dense-intraday retention). v1.11.2 folds them into + * this single helper so a future change to the P2002 semantics has one + * place to live instead of five. + * + * Deliberately structural (`typeof === "object"` + `"code" in err` + + * `code === "P2002"`) rather than `instanceof Prisma.PrismaClientKnownRequestError` + * so it survives client-bundling quirks while staying specific to the + * unique-constraint code. + */ + +/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ +export function isP2002(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: unknown }).code === "P2002" + ); +} diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 6369483b6..012bada89 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -30,6 +30,7 @@ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; +import { isP2002 } from "@/lib/prisma-errors"; import { collapseRollupRowsBySource, loadUserSourcePriority, @@ -437,16 +438,6 @@ async function runRollupAggregate(input: { * with `ON CONFLICT DO UPDATE` so the round-trip count is bounded * regardless of fan-out width. */ -/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ -function isP2002(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code: unknown }).code === "P2002" - ); -} - async function persistRollupRows( userId: string, granularity: RollupGranularity, From dd3ac3510fcae5ae8586f2a1cf35216c0978a389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 13:26:19 +0200 Subject: [PATCH 14/15] test(ssrf): lock the pre-existing moodLog and ntfy host pins Extend the SSRF pin inventory to also assert the requirePublicHost pins already present on the user/operator-supplied moodLog push/sync webhooks and the ntfy sender, so the full set of overridable-host egress points is CI-guarded and a future edit that drops one reds here. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/__tests__/ssrf-pin-inventory.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index 0b2dce059..edc8498c4 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -35,6 +35,23 @@ describe("SSRF requirePublicHost pin inventory", () => { expect(read("geo.ts")).toMatch(/requirePublicHost:\s*true/); }); + // v1.11.2 product-lead M2 (optional) — lock the PRE-EXISTING user/operator + // webhook pins too, so the full inventory is CI-guarded and a future edit + // that drops one of these reds here rather than shipping silently. + it("moodLog push pins the user-supplied webhook outbound", () => { + expect(read("moodlog/push.ts")).toMatch(/requirePublicHost:\s*true/); + }); + + it("moodLog sync pins the user-supplied webhook outbound", () => { + expect(read("moodlog/sync.ts")).toMatch(/requirePublicHost:\s*true/); + }); + + it("ntfy sender pins the user/operator-supplied server outbound", () => { + expect(read("notifications/senders/ntfy.ts")).toMatch( + /requirePublicHost:\s*true/, + ); + }); + it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => { const src = read("ai/local-client.ts"); // Must carry the conditional form … From 074e3bbdf133f540b645adb12b91e85edfad4e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 13:31:25 +0200 Subject: [PATCH 15/15] =?UTF-8?q?chore(release):=20v1.11.2=20=E2=80=94=20C?= =?UTF-8?q?oach-memory=20controls,=20SSRF=20hardening,=20more=20pinnable?= =?UTF-8?q?=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coach-memory management UI (review/forget durable facts), connect-time DNS-rebinding pins on user/operator-overridable outbound hosts, eight additional pinnable dashboard metrics, plus a rollup write-race fix and fast-path source-priority amortisation. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 ++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d6fb2b9..7174405eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.11.2] — 2026-06-04 — Coach-memory controls, privacy hardening, more pinnable metrics + +### Added + +- **Review and clear what the Coach remembers.** A new "What the Coach remembers" settings section lists the durable facts the assistant has learned about you, grouped by category, and lets you forget a single one or clear all of them. Encrypted at rest; available wherever the Coach is enabled. +- **More dashboard metrics you can pin.** Cardio recovery, six-minute walk distance, stair ascent/descent speed, breathing disturbances, wrist temperature, fall count, and walking steadiness can now be pinned to your Home layout. + +### Security + +- **Outbound request hardening.** The AI provider clients (when pointed at a custom base URL) and the optional IP-geo lookup now reject any address that resolves to a private or internal host, closing a server-side request-forgery / DNS-rebinding gap. + +### Fixed + +- A rare write race when two updates recompute the same day's rollup at once is now handled cleanly instead of surfacing a transient error. +- Analytics fast paths resolve your source-priority preference once per request instead of repeatedly. + ## [1.11.1] — 2026-06-04 — source-aware vitals + Coach long-term memory Closes the three follow-ups left open by v1.11.0: cross-source vital de-duplication, the Coach's conversation memory, and durable personal facts. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 39ef54640..19c3d242a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.11.1 + version: 1.11.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 955eee106..3dff3e73c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.11.1", + "version": "1.11.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",