mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
37 files changed
+1059
-113
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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]} — `);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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() },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user