Merge pull request #253 from MBombeck/release/v1.11.2

release/v1.11.2 — Coach-memory controls, privacy hardening, more pinnable metrics
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-04 13:40:35 +02:00
commit e07527e680
37 files changed
+1059 -113

No files matched your search

+16
View File
@@ -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.
+1 -1
View File
@@ -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.
+24
View File
@@ -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.",
+24
View File
@@ -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.",
+24
View File
@@ -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.",
+24
View File
@@ -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.",
+24
View File
@@ -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.",
+24
View File
@@ -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.",
+1 -1
View File
@@ -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",
+5 -4
View File
@@ -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.
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachMemorySection isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
}
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");
});
});
@@ -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("<DashboardLayoutSection> — tile + chart split", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
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("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("paints a drag handle for every widget row", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
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("<DashboardLayoutSection> — drag-and-drop reorder", () => {
// to care about attribute ordering inside the rendered <button>.
const handleButtons =
html.match(/<button[^>]*data-slot="widget-drag-handle"[^>]*>/g) ?? [];
expect(handleButtons).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
expect(handleButtons).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// Every handle declares aria-describedby and they all share the
// same id (one hint paragraph below the list — single source of
// truth for screen readers).
@@ -139,8 +148,8 @@ describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
// strings — counted once per row.
const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length;
const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length;
expect(moveUpCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
expect(moveDownCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
expect(moveUpCount).toBe(WEB_RENDERABLE_ROW_COUNT);
expect(moveDownCount).toBe(WEB_RENDERABLE_ROW_COUNT);
});
it("hint string localises to German", () => {
@@ -229,10 +238,11 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// One row (= one tile switch) per WEB-known widget — the iOS-only
// ids are skipped, not rendered as raw-id rows.
// One row (= one tile switch) per WEB-renderable widget — the iOS-only
// ids (and the iOS-pin-only writable ids) are skipped, not rendered as
// raw-id rows.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// No iOS-only raw id leaks into the markup as a row label.
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
@@ -258,3 +268,43 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
expect(html).toContain("dashboard-layout");
});
});
/**
* v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the
* iOS pin PUT validates them) but have NO web render path, so the web
* Settings list must NOT offer a dead toggle for them.
*/
describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2)", () => {
it("renders one fewer row per iOS-pin-only id than the writable id count", () => {
// Default layout carries all 24 writable widgets incl. the 8 pin-only.
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// Sanity: WEB_RENDERABLE_ROW_COUNT == writable − pin-only.
expect(WEB_RENDERABLE_ROW_COUNT).toBe(
DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length,
);
});
it("does not paint a row whose aria-label matches an iOS-pin-only widget label", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The pin-only ids reuse `measurements.type*` labels that resolve to
// distinctive English strings; none should appear as a toggle aria-label.
const pinOnlyLabels: Record<(typeof IOS_PIN_ONLY_WIDGET_IDS)[number], string> =
{
cardioRecovery: "Cardio recovery",
sixMinuteWalk: "Six-minute walk distance",
stairAscentSpeed: "Stair ascent speed",
stairDescentSpeed: "Stair descent speed",
breathingDisturbances: "Breathing disturbances",
wristTemperature: "Wrist temperature",
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
// The widget label drives the switch aria-label; if the row were
// rendered the localised label would show up in the markup.
expect(html).not.toContain(`${pinOnlyLabels[id]} — `);
}
});
});
+12 -1
View File
@@ -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 (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
@@ -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. */}
<DisableCoachCard isAuthenticated={isAuthenticated} />
{/* v1.11.2 — "What the Coach remembers": durable-fact review +
forget controls. Gated on the Coach being enabled. */}
{coachEnabled && (
<CoachMemorySection isAuthenticated={isAuthenticated} />
)}
</section>
);
}
@@ -0,0 +1,289 @@
"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 <when>" 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<FactCategory, string> = {
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<CoachFact[]> {
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 (
<section
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="bg-card border-border space-y-4 rounded-xl border p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="pr-2">
<h2
id="settings-ai-coach-memory-title"
className="text-base font-semibold"
>
{t("settings.ai.coachMemory.title")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.description")}
</p>
</div>
{facts.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
onClick={() => forgetAll.mutate()}
>
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{query.isError && (
<p role="status" aria-live="polite" className="text-destructive text-xs">
{t("settings.ai.coachMemory.loadError")}
</p>
)}
{!query.isError && facts.length === 0 ? (
<p
data-testid="settings-coach-memory-empty"
className="text-muted-foreground text-sm"
>
{t("settings.ai.coachMemory.empty")}
</p>
) : (
<div className="space-y-5">
{grouped.map((group) => (
<div key={group.category} className="space-y-2">
<h3
data-testid={`settings-coach-memory-group-${group.category}`}
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t(CATEGORY_LABEL_KEY[group.category])}
</h3>
<ul className="space-y-2">
{group.items.map((fact) => (
<li
key={fact.id}
data-testid="settings-coach-memory-fact"
className="border-border bg-background flex items-start justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0">
<p className="text-sm break-words">{fact.text}</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.learnedPrefix", {
when: formatDateOrRelative(fact.createdAt, t),
})}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="settings-coach-memory-forget"
aria-label={t("settings.ai.coachMemory.forgetAria")}
// v1.11.2 — per-id pending: only the row being
// deleted disables/spins. `forgetOne.variables` holds
// the id passed to the in-flight `mutate()`, so a
// single shared mutation no longer greys out every
// other row's forget button during one delete.
disabled={
!isAuthenticated ||
(forgetOne.isPending && forgetOne.variables === fact.id)
}
onClick={() => forgetOne.mutate(fact.id)}
>
{forgetOne.isPending &&
forgetOne.variables === fact.id ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forget")}
</Button>
</li>
))}
</ul>
</div>
))}
</div>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachMemory.summaryNote")}
</p>
</section>
);
}
@@ -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<DashboardWidgetId, string> = {
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<string>(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<string>(IOS_PIN_ONLY_WIDGET_IDS);
const webWidgetIds = new Set<string>(
DASHBOARD_WIDGET_IDS.filter((wid) => !iosPinOnly.has(wid)),
);
const sortedWidgets = [...layout.widgets]
.filter((w): w is typeof w & { id: DashboardWidgetId } =>
webWidgetIds.has(w.id),
+56 -9
View File
@@ -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<string>(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<string>(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<string>(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);
}
@@ -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/);
});
});
+3 -1
View File
@@ -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) {
-1
View File
@@ -58,7 +58,6 @@ type RunCompletionFn = typeof runStatusCompletion;
type PrismaLike = Pick<typeof prisma, "coachFact" | "coachConversation">;
interface ExtractOpts {
now?: Date;
runCompletion?: RunCompletionFn;
prisma?: PrismaLike;
locale?: string;
+4 -1
View File
@@ -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) {
@@ -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,
),
]);
+7 -3
View File
@@ -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) {
+1 -21
View File
@@ -348,27 +348,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
) 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,
+57
View File
@@ -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 },
],
};
+6 -6
View File
@@ -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");
+4 -1
View File
@@ -300,7 +300,10 @@ async function lookupIpLocationOnline(ip: string): Promise<string | null> {
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;
@@ -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() },
},
}));
+16 -2
View File
@@ -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";
@@ -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. */
@@ -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;
+1 -10
View File
@@ -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
@@ -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<PrismaClient, "medication" | "medicationIntakeEvent">;
/** 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 {
+24
View File
@@ -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"
);
}
+1
View File
@@ -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
@@ -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.
+3 -2
View File
@@ -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.
+29 -5
View File
@@ -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.