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/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/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", 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/__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/__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 + + + + + {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/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 5785fd739..213d5baf7 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -39,6 +39,7 @@ import { COMPARISON_BASELINES, DEFAULT_DASHBOARD_LAYOUT, DASHBOARD_WIDGET_IDS, + IOS_PIN_ONLY_WIDGET_IDS, } from "@/lib/dashboard-layout"; import { Select, @@ -132,6 +133,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 }) { @@ -412,12 +423,21 @@ export function DashboardLayoutSection({ id }: { id: string }) { // v1.7.0 — the stored layout now round-trips the 11 iOS-only // widget ids so the native client can drop its local merge // workarounds. The web Settings list has no tile/chart - // surface for them, so skip any id outside the 16 web-known + // surface for them, so skip any id outside the web-known // ids rather than render an unlabelled row with dead toggles. // The skipped ids stay untouched in the persisted layout // because the Save mutation PUTs `layout.widgets` whole and // the server retains every catalogue id. - const webWidgetIds = new Set(DASHBOARD_WIDGET_IDS); + // + // v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in + // `DASHBOARD_WIDGET_IDS` so the iOS pin PUT validates) but + // have no web render path either, so exclude + // `IOS_PIN_ONLY_WIDGET_IDS` too: a web toggle for them would + // be a silent no-op on the web dashboard. + const iosPinOnly = new Set(IOS_PIN_ONLY_WIDGET_IDS); + const webWidgetIds = new Set( + DASHBOARD_WIDGET_IDS.filter((wid) => !iosPinOnly.has(wid)), + ); const sortedWidgets = [...layout.widgets] .filter((w): w is typeof w & { id: DashboardWidgetId } => webWidgetIds.has(w.id), diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index c972eb2e0..29f2fc330 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -6,6 +6,7 @@ import { DASHBOARD_WIDGET_IDS, DASHBOARD_IOS_ONLY_WIDGET_IDS, DASHBOARD_WIDGET_CATALOGUE_IDS, + IOS_PIN_ONLY_WIDGET_IDS, type DashboardLayout, } from "@/lib/dashboard-layout"; @@ -382,11 +383,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", () => { @@ -419,6 +422,50 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { }); }); +/** + * v1.11.2 HIGH-1 — the 8 B5 metrics are WRITABLE (members of + * `DASHBOARD_WIDGET_IDS`, so the widgets PUT enum — derived from the + * catalogue — accepts them, which the iOS Home-pin request requires) but + * have NO web render path. `IOS_PIN_ONLY_WIDGET_IDS` names exactly that + * set so the web Settings list can filter them out (asserted in + * `dashboard-layout-section.test.tsx`). + */ +describe("IOS_PIN_ONLY_WIDGET_IDS — writable but not web-rendered", () => { + it("is the 8 B5 ids verbatim", () => { + expect([...IOS_PIN_ONLY_WIDGET_IDS]).toEqual([ + "cardioRecovery", + "sixMinuteWalk", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", + "wristTemperature", + "falls", + "walkingSteadiness", + ]); + }); + + it("every pin-only id is WRITABLE (in DASHBOARD_WIDGET_IDS so the PUT enum accepts it)", () => { + const writable = new Set(DASHBOARD_WIDGET_IDS); + for (const id of IOS_PIN_ONLY_WIDGET_IDS) { + expect(writable.has(id)).toBe(true); + } + }); + + it("every pin-only id is in the catalogue the widgets PUT Zod enum derives from", () => { + const catalogue = new Set(DASHBOARD_WIDGET_CATALOGUE_IDS); + for (const id of IOS_PIN_ONLY_WIDGET_IDS) { + expect(catalogue.has(id)).toBe(true); + } + }); + + it("does not overlap the iOS-only (non-writable) catalogue ids", () => { + const iosOnly = new Set(DASHBOARD_IOS_ONLY_WIDGET_IDS); + for (const id of IOS_PIN_ONLY_WIDGET_IDS) { + expect(iosOnly.has(id)).toBe(false); + } + }); +}); + /** * v1.7.0 W1 — iOS-only ids round-trip through the stored layout so the * native client can drop its local merge workarounds @@ -483,17 +530,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 +548,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/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts new file mode 100644 index 000000000..edc8498c4 --- /dev/null +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -0,0 +1,63 @@ +/** + * 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/); + }); + + // 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 … + 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/); + }); +}); 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/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/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/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, ), ]); 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) { 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/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 139814d2f..5405acd99 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -42,10 +42,51 @@ 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]; +/** + * v1.11.2 B5 / HIGH-1 — widget ids that are WRITABLE (members of + * `DASHBOARD_WIDGET_IDS`, so the iOS pin PUT validates them) but have NO + * web render path: `src/app/page.tsx` renders strip tiles via hardcoded + * per-id blocks and never iterates an id→component map, so these ids draw + * nothing on the web dashboard. They are surfaced and pinned by the native + * iOS client only. + * + * They MUST stay in `DASHBOARD_WIDGET_IDS` (the iOS v0.14 Home-pin request + * needs the widgets PUT enum to accept them), but the web Settings → + * Dashboard list filters them out (see `dashboard-layout-section.tsx`) so a + * web user is never offered a toggle that silently does nothing on web. + * + * The 11 `DASHBOARD_IOS_ONLY_WIDGET_IDS` below are a separate concern: those + * are NOT writable (not in `DASHBOARD_WIDGET_IDS`) so they never reached the + * web Settings list in the first place. This set is specifically the + * writable-but-not-web-rendered ids that B5 introduced. + */ +export const IOS_PIN_ONLY_WIDGET_IDS = [ + "cardioRecovery", + "sixMinuteWalk", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", + "wristTemperature", + "falls", + "walkingSteadiness", +] as const satisfies readonly DashboardWidgetId[]; + /** * v1.7.0 — iOS-only widget ids the native client materialises in its * own default layout but the server PUT enum does NOT yet accept. These @@ -323,6 +364,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 }, ], }; 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/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; 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() }, }, })); 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"; 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/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 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-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. diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index be973b5aa..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, @@ -494,12 +495,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.