Merge branch 'main' into fix/cycle-correctness

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-07 18:24:37 +02:00
commit 626ec203ac
34 files changed
+1305 -383

No files matched your search

+18
View File
@@ -2,6 +2,24 @@
## [Unreleased]
### Security
- The PDF renderer behind the document vault moves to a release that closes a
script-execution flaw. A crafted PDF could get JavaScript to run inside the
renderer, and the vault feeds it every PDF that is uploaded. Two things
limited what that reached, and neither is a substitute for the fix: the
renderer runs server-side only, because a PDF is displayed through the
browser's own viewer rather than this library, and it already ran with font
evaluation turned off. A second copy of the same library, pulled in by a
development dependency, is pinned past the flaw as well, since the runtime
image ships every copy it finds rather than only the one that gets used.
- The container image now runs the version of the PDF renderer the project
pins. The build had been taking whichever copy the filesystem listed first,
and more than one copy reaches the image, so the pinned version was not
reliably the one that ran. It is selected by name now, and a copy that never
arrived fails the build instead of quietly leaving another one in its place.
## [1.37.0] — 2026-08-07
### Added
+30 -6
View File
@@ -48,6 +48,13 @@ ENV NEXT_TELEMETRY_DISABLED=1
# starts fresh and therefore does not inherit this setting.
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Record which pdfjs-dist the top-level dependency actually resolves to. pnpm
# links a direct dependency at `node_modules/<name>`, so this reads the exact
# pinned copy rather than guessing. The runner stage hoists that version by
# name; see the note there for why guessing was wrong.
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
> /app/.pdfjs-version
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
@@ -116,13 +123,30 @@ RUN set -e; \
# path breaks in the standalone image), so the server does a runtime bare
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
#
# Hoist the version the app pins, by name. More than one pdfjs-dist reaches the
# store — `pdf-parse` carries its own, older copy for the text-extraction path —
# and this step used to take whatever `find | head -1` returned first. That is
# readdir order, so it could hoist the OLDER copy and the version named in
# package.json would never run in the image: the renderer resolving one version
# while every local check exercised another. Name the version and fail the build
# when it is absent, instead of degrading to whichever copy comes back first.
COPY --from=builder /app/.pdfjs-version /tmp/.pdfjs-version
RUN set -e; \
PDFJS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null | head -1)"; \
if [ -n "$PDFJS_DIR" ]; then \
ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \
chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \
echo "pdfjs-dist hoisted: $PDFJS_DIR"; \
else echo "WARN: pdfjs-dist not found; PDF rasterization will degrade to local text"; fi
PDFJS_VERSION="$(cat /tmp/.pdfjs-version)"; \
PDFJS_DIR="/app/node_modules/.pnpm/pdfjs-dist@${PDFJS_VERSION}/node_modules/pdfjs-dist"; \
if [ ! -d "$PDFJS_DIR" ]; then \
echo "ERROR: pinned pdfjs-dist@${PDFJS_VERSION} did not reach the traced image."; \
echo "Present instead:"; \
find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null; \
exit 1; \
fi; \
ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \
chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \
rm -f /tmp/.pdfjs-version; \
RESOLVED="$(node -p "require('/app/node_modules/pdfjs-dist/package.json').version")"; \
[ "$RESOLVED" = "$PDFJS_VERSION" ] || { echo "ERROR: hoisted pdfjs-dist is $RESOLVED, expected $PDFJS_VERSION"; exit 1; }; \
echo "pdfjs-dist hoisted: $PDFJS_DIR (resolves as $RESOLVED)"
# Copy Prisma for migrations (schema, migration SQL, config, engines)
COPY --from=builder /app/prisma ./prisma
+268
View File
@@ -0,0 +1,268 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* An add / edit dialog must not pan sideways, and nothing you have to operate
* may sit outside its visible box.
*
* A report named four surfaces — measurements add, mood add, the medication
* intake editor, the cycle log sheet — and four surfaces sharing one symptom
* is one defect in something shared, not four. Two shared mechanisms were
* behind it, and this spec pins the outcome of both rather than either
* implementation:
*
* 1. `<DateField>` / `<TimeField>` wrap a text `<input>`. A flex item's
* `min-width: auto` resolves to a content-based minimum, and for a text
* input that minimum is its intrinsic `size` (20 characters, ~180 px).
* The date + time row therefore demanded ~470 px inside a 400 px dialog
* body and put the clock button ~58 px past the right edge — the picker
* was not merely ugly, it was unreachable without scrolling sideways.
*
* 2. The body scrolls (`overflow-y-auto`), and CSS promotes the OTHER axis
* from `visible` to `auto`. So every intentional outward bleed in the
* tree — `<SheetSection>`'s trigger row, `<Switch>`'s `inset-[-13px]`
* hit-target pseudo — turned the whole form into a horizontally
* scrollable surface. That is why the desktop dialog showed two
* scrollbars while the phone sheet, whose body has padding, looked fine.
*
* The two assertions map onto the two harms and neither is a restatement of a
* class name:
*
* - `expectNoSidewaysScroll` — the surface is not a horizontal scroll
* container. Fails against mechanism 2 (the axis was promoted to `auto`).
* - `expectControlsInsideBox` — every focusable control is within the
* surface's visible content box. Fails against mechanism 1 (the clock
* button was outside it), and keeps failing if a future change clips a
* control out of sight instead of fitting it.
*
* Both widths run: the defect was desktop-only for the date/time row, and the
* phone sheet is the branch where a regression would be least visible.
*
* NOT covered here: the medication intake editor and the cycle log sheet, the
* other two reported surfaces. Both need account state the shared fixture does
* not carry (a medication with a logged dose; a profile with cycle tracking
* on), and a spec that silently measures an empty page proves nothing. They
* share the primitives asserted below — `<ResponsiveSheet>` + `<DateTimeField>`
* for the editor, `<ResponsiveSheet>` + `<SheetSection>` for the log sheet —
* so a regression in either mechanism surfaces here first.
*/
const SURFACE =
'[data-slot="responsive-sheet-content"], [data-slot="dialog-content"]';
const BODY = '[data-slot="responsive-sheet-body"]';
interface DialogCase {
name: string;
path: string;
/** Accessible name of the control that opens the dialog. */
trigger: RegExp;
/** Reach the state the report describes, when the form does not open in it. */
prepare?: (page: import("@playwright/test").Page) => Promise<void>;
/** Something inside the dialog that proves it rendered its form, not a shell. */
ready: string;
}
const DIALOGS: DialogCase[] = [
{
name: "measurements add",
path: "/measurements",
trigger: /^add$/i,
ready: '[data-slot="date-time-field"]',
},
{
name: "mood add",
path: "/mood",
trigger: /^add$/i,
// The mood sheet opens on the five-face quick check; the timestamp row
// (and everything else) appears once a face is picked. Measuring the
// quick check alone would measure a sheet that has almost nothing in it.
prepare: async (page) => {
await page.locator('[data-slot="mood-face"]').first().click();
await page
.locator('[data-slot="mood-annotate-panel"]')
.first()
.waitFor({ state: "visible" });
},
ready: '[data-slot="date-time-field"]',
},
{
name: "labs add",
path: "/labs",
trigger: /^add$/i,
ready: '[data-slot="date-time-field"]',
},
];
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
{ label: "phone 390", width: 390, height: 844 },
];
/**
* The surface and its scrolling body must not be horizontal scroll
* containers. Read from the COMPUTED style, because the promotion that caused
* the defect happens in the cascade and is invisible in the class list.
*/
async function expectNoSidewaysScroll(
page: import("@playwright/test").Page,
label: string,
): Promise<void> {
const boxes = await page.evaluate(
([surfaceSel, bodySel]) => {
const nodes = [
...document.querySelectorAll<HTMLElement>(surfaceSel),
...document.querySelectorAll<HTMLElement>(bodySel),
];
return nodes.map((el) => ({
slot: el.dataset.slot ?? el.tagName.toLowerCase(),
overflowX: getComputedStyle(el).overflowX,
}));
},
[SURFACE, BODY] as const,
);
expect(
boxes.length,
`${label}: no dialog surface found to measure`,
).toBeGreaterThan(0);
for (const box of boxes) {
expect(
["auto", "scroll"].includes(box.overflowX),
`${label}: ${box.slot} computes overflow-x:${box.overflowX} — the surface can be panned sideways`,
).toBe(false);
}
}
/**
* No focusable control may lie outside the surface's visible content box.
* This is the reachability claim: a picker pushed past the edge (or clipped
* out of sight by a later "fix") fails here regardless of how the layout got
* there.
*/
async function expectControlsInsideBox(
page: import("@playwright/test").Page,
label: string,
): Promise<void> {
const result = await page.evaluate((surfaceSel) => {
const surface = document.querySelector<HTMLElement>(surfaceSel);
if (!surface) return null;
const box = surface.getBoundingClientRect();
// The visible content box: `clientWidth` excludes a vertical scrollbar,
// so a control hidden behind one counts as outside.
const visibleRight = box.left + surface.clientWidth;
const controls = [
...surface.querySelectorAll<HTMLElement>(
"button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])",
),
];
const outside = controls
.map((el) => {
const r = el.getBoundingClientRect();
return { el, r };
})
.filter(({ r }) => r.width > 0 && r.height > 0)
.filter(({ r }) => r.right > visibleRight + 1 || r.left < box.left - 1)
.map(({ el, r }) => ({
tag: el.tagName.toLowerCase(),
slot: el.dataset.slot ?? null,
label:
el.getAttribute("aria-label") ??
el.textContent?.trim().slice(0, 30) ??
"",
left: Math.round(r.left),
right: Math.round(r.right),
}));
return {
controlCount: controls.length,
surfaceLeft: Math.round(box.left),
visibleRight: Math.round(visibleRight),
outside,
};
}, SURFACE);
expect(result, `${label}: dialog surface not found`).not.toBeNull();
// Guard the guard: a dialog with no controls would pass vacuously.
expect(
result!.controlCount,
`${label}: the dialog rendered no focusable controls, so this assertion proves nothing`,
).toBeGreaterThan(2);
expect(
result!.outside,
`${label}: control(s) outside the dialog's visible box [${result!.surfaceLeft}..${result!.visibleRight}]`,
).toEqual([]);
}
test.describe("add/edit dialogs do not scroll sideways", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const vp of VIEWPORTS) {
for (const dialog of DIALOGS) {
test(`${dialog.name} fits its surface at ${vp.label}`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(dialog.path, { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: dialog.trigger })
.first()
.click();
const surface = page.locator(SURFACE).first();
await expect(surface).toBeVisible();
await dialog.prepare?.(page);
// Gate on the form's own content, not the shell: the surface mounts
// before the fields do, and measuring the empty frame would pass
// against exactly the layout this spec exists to catch.
await expect(surface.locator(dialog.ready).first()).toBeVisible();
const label = `${dialog.name} @${vp.label}`;
await expectNoSidewaysScroll(page, label);
await expectControlsInsideBox(page, label);
});
}
}
/**
* The reachability assertion has to be able to FAIL, or the six tests above
* are decoration. Inject a control that deliberately sits past the right
* edge and assert the helper reports it — the same shape the time-field
* button had before the fix.
*/
test("the reachability check detects a control pushed past the edge", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /^add$/i }).first().click();
const surface = page.locator(SURFACE).first();
await expect(surface).toBeVisible();
await expect(
surface.locator('[data-slot="date-time-field"]').first(),
).toBeVisible();
await page.evaluate((surfaceSel) => {
const el = document.querySelector<HTMLElement>(surfaceSel)!;
const probe = document.createElement("button");
probe.id = "reach-canary";
probe.textContent = "canary";
probe.style.position = "absolute";
probe.style.left = `${el.clientWidth + 40}px`;
probe.style.top = "0";
probe.style.width = "30px";
probe.style.height = "30px";
el.appendChild(probe);
}, SURFACE);
await expect(
page.locator(`${SURFACE} #reach-canary`).first(),
"the canary is not inside the dialog — the fixture failed, not the helper",
).toBeAttached();
await expect(expectControlsInsideBox(page, "canary probe")).rejects.toThrow(
/outside the dialog's visible box/,
);
});
});
+61 -9
View File
@@ -79,6 +79,48 @@ async function openSwitcher(page: Page) {
).toBeVisible();
}
/**
* Run `act` and wait for the document it replaces to be gone.
*
* Entering or leaving a record is a server write followed by
* `window.location.assign`, so the click that starts it returns long before the
* document it produces exists. Everything a test does next races the app's own
* navigation, and both ways of losing that race are silent:
*
* - a `page.goto` issued into the gap is cancelled when the assign commits,
* which Chromium reports as `net::ERR_ABORTED`;
* - or the goto wins, and the page it lands on belongs to the CALLER'S OWN
* record, where a route that is ungranted in somebody else's record is
* simply granted. Measured: `/coach` opened in that gap issued all four of
* the protected reads the test asserts never happen. The assertion was
* reading the wrong record, and it could as easily have passed there.
*
* Neither is visible in the DOM the click leaves behind, which is why waiting
* on the banner cannot settle it — the pre-navigation document already shows
* whatever the next assertion looks for, so the wait returns immediately and
* proves nothing. A marker on `window` can settle it, because a document
* replacement is the one thing that destroys it, and no amount of re-rendering
* inside the old document will.
*
* If the navigation never happens the wait fails, which is the right outcome:
* a switch that did not land is a failure and not something to paper over.
*/
async function withDocumentReplacement(
page: Page,
act: () => Promise<void>,
): Promise<void> {
await page.evaluate(() => {
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
});
await act();
await page.waitForFunction(
() =>
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
undefined,
{ timeout: 30_000 },
);
}
async function openRecord(page: Page, username: string) {
await page.goto("/");
await openSwitcher(page);
@@ -93,14 +135,26 @@ async function openRecord(page: Page, username: string) {
}
const accessLevel = await entry.getAttribute("data-access-level");
const recordKind = await entry.getAttribute("data-record-kind");
await entry.click();
await withDocumentReplacement(page, () => entry.click());
// The switch landed AND the record it landed in is the one on screen. Callers
// may navigate away immediately after this, and until both are true there is
// no record to navigate away from.
await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({
timeout: 30_000,
});
return { accountId, accessLevel, recordKind };
}
async function leaveRecord(page: Page) {
await page.locator('[data-slot="shared-record-banner-exit"]').click();
// Leaving is the same act pointed the other way, and it has the same gap.
// The banner being gone is satisfied by the document that is on its way out,
// so on its own it is not a wait at all.
await withDocumentReplacement(page, () =>
page.locator('[data-slot="shared-record-banner-exit"]').click(),
);
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
0,
{ timeout: 30_000 },
);
}
@@ -636,13 +690,11 @@ test.describe.serial("managed profile browser journey", () => {
// And back out, with the record still on the list.
//
// Leaving ends in a full document reload, and a `goto` issued while that
// reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`,
// reproducibly at CI's worker count and not at one. The banner being gone
// is satisfied by the pre-reload DOM, so it cannot be the thing waited on.
// Retrying the navigation is the honest fix: the claim is that the record
// survives the round trip, not that the first navigation attempt wins a
// race with the app's own.
// `leaveRecord` now waits for the app's own reload to land before it
// returns, so the `goto` below is no longer issued into that gap — see
// `withDocumentReplacement`. The retry stays: the claim here is that the
// record survives the round trip, not that one navigation attempt wins,
// and a loop that has nothing left to absorb costs a single pass.
await leaveRecord(page);
await expect(async () => {
await page.goto("/settings/access");
+6 -3
View File
@@ -5744,7 +5744,7 @@
"notificationStatus": {
"title": "Zustellstatus",
"description": "Pro Kanal aktueller Status; \"Automatisch deaktiviert\" stoppt nach dauerhaften Fehlern.",
"emptyDescription": "Noch keine Kanäle konfiguriert. Lege unten einen Kanal an, um die Zustellung zu überwachen.",
"emptyDescription": "Noch keine Kanäle konfiguriert. Richte unter Zustellkanäle einen Kanal ein, um die Zustellung zu verfolgen.",
"stateActive": "Aktiv",
"stateAutoDisabled": "Automatisch deaktiviert",
"stateSendingPaused": "Versand pausiert",
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Einige deiner Nächte waren diese Woche recht kurz. Wenn du willst, suchen wir gemeinsam nach einem Muster.",
"measurementGapBody": "Dein Protokoll ist seit etwa einer Woche still. Wann immer es dir passt, hilft ein kurzer Austausch beim Wiedereinstieg.",
"focusNote": "Das passt auch zu dem, was du selbst im Blick behalten möchtest.",
"reminderSurfacedTitle": "Deine Erinnerung",
"reminderSurfacedLeadOne": "Du wolltest heute daran erinnert werden:",
"reminderSurfacedLeadMany": "Du wolltest heute an {count} Dinge erinnert werden:",
"questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?",
"questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?",
"questionAllergies": "Hast du Allergien oder Unverträglichkeiten?",
@@ -8595,8 +8598,7 @@
},
"mentalHealth": {
"pageTitle": "Seelisches Wohlbefinden",
"pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
"moodLink": "Deinen Stimmungsverlauf ansehen",
"pageDescription": "Vier validierte, freiwillige Selbsttests — aussagekräftig ist der Verlauf, nicht ein einzelner Wert.",
"attributionLabel": "Quelle",
"attributionShow": "Quelle und Lizenz für {instrument}",
"choosePrompt": "Check-in auswählen",
@@ -8851,6 +8853,7 @@
"saveError": "Lebensstil-Kontext konnte nicht gespeichert werden",
"loadError": "Lebensstil-Kontext konnte nicht geladen werden",
"remove": "{kind} entfernen",
"removeShort": "Entfernen",
"removeConfirmTitle": "{kind} entfernen?",
"removeConfirmDescription": "Der aktuelle Wert gilt danach als nicht erfasst. Der datierte Verlauf bleibt erhalten.",
"removedToast": "{kind} aus dem aktuellen Lebensstil-Kontext entfernt",
+6 -3
View File
@@ -5744,7 +5744,7 @@
"notificationStatus": {
"title": "Delivery status",
"description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.",
"emptyDescription": "No channels configured yet. Add a channel below to start tracking its delivery health.",
"emptyDescription": "No channels configured yet. Set one up under Delivery channels to start tracking delivery.",
"stateActive": "Active",
"stateAutoDisabled": "Auto-disabled",
"stateSendingPaused": "Sending paused",
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Several of your nights came up short this week. If you want, we can look for a pattern together.",
"measurementGapBody": "Your log has been quiet for about a week. Whenever it suits you, a quick check-in helps you pick things back up.",
"focusNote": "It also ties into what you said you wanted to keep an eye on.",
"reminderSurfacedTitle": "The reminder you asked for",
"reminderSurfacedLeadOne": "You asked me to bring this back to you today:",
"reminderSurfacedLeadMany": "You asked me to bring {count} things back to you today:",
"questionConditions": "Do you have any chronic conditions the Coach should know about?",
"questionFocus": "Is there anything specific the Coach should pay attention to?",
"questionAllergies": "Do you have any allergies or intolerances?",
@@ -8595,8 +8598,7 @@
},
"mentalHealth": {
"pageTitle": "Mental wellbeing",
"pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
"moodLink": "View your mood history",
"pageDescription": "Four validated, voluntary self-checks — the trend over time says more than any single score.",
"attributionLabel": "Attribution",
"attributionShow": "Source and licence for {instrument}",
"choosePrompt": "Choose a check-in",
@@ -8851,6 +8853,7 @@
"saveError": "Could not save lifestyle context",
"loadError": "Could not load lifestyle context",
"remove": "Remove {kind}",
"removeShort": "Remove",
"removeConfirmTitle": "Remove {kind}?",
"removeConfirmDescription": "The current value will become not recorded. Its dated history will be retained.",
"removedToast": "{kind} removed from current lifestyle context",
+8 -5
View File
@@ -5742,9 +5742,9 @@
}
},
"notificationStatus": {
"title": "Channel reliability",
"description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.",
"emptyDescription": "Aún no hay canales configurados. Añade un canal abajo para empezar a controlar su estado de entrega.",
"title": "Fiabilidad de los canales",
"description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.",
"emptyDescription": "Aún no hay canales configurados. Configura uno en Canales de entrega para seguir la entrega.",
"stateActive": "Activo",
"stateAutoDisabled": "Desactivado automáticamente",
"stateSendingPaused": "Envío en pausa",
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Varias de tus noches se quedaron cortas esta semana. Si te apetece, buscamos un patrón juntos.",
"measurementGapBody": "Tu registro lleva una semana en silencio. Cuando te venga bien, una breve puesta al día ayuda a retomarlo.",
"focusNote": "También tiene que ver con eso que querías vigilar.",
"reminderSurfacedTitle": "Tu recordatorio",
"reminderSurfacedLeadOne": "Me pediste que hoy te recordara esto:",
"reminderSurfacedLeadMany": "Me pediste que hoy te recordara {count} cosas:",
"questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?",
"questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?",
"questionAllergies": "¿Tienes alergias o intolerancias?",
@@ -8595,8 +8598,7 @@
},
"mentalHealth": {
"pageTitle": "Bienestar mental",
"pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
"moodLink": "Ver tu historial de estado de ánimo",
"pageDescription": "Cuatro autoevaluaciones validadas y voluntarias: cuenta la evolución, no un valor aislado.",
"attributionLabel": "Fuente",
"attributionShow": "Fuente y licencia de {instrument}",
"choosePrompt": "Elige una revisión",
@@ -8851,6 +8853,7 @@
"saveError": "No se pudo guardar el contexto del estilo de vida",
"loadError": "No se pudo cargar el contexto del estilo de vida",
"remove": "Eliminar {kind}",
"removeShort": "Quitar",
"removeConfirmTitle": "¿Eliminar {kind}?",
"removeConfirmDescription": "El valor actual pasará a constar como no registrado. Se conservará su historial fechado.",
"removedToast": "{kind} eliminado del contexto actual del estilo de vida",
+8 -5
View File
@@ -5742,9 +5742,9 @@
}
},
"notificationStatus": {
"title": "Channel reliability",
"description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.",
"emptyDescription": "Aucun canal configuré pour l’instant. Ajoutez un canal ci-dessous pour commencer à suivre l’état de sa livraison.",
"title": "Fiabilité des canaux",
"description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.",
"emptyDescription": "Aucun canal configuré pour l’instant. Ajoutez-en un dans Canaux de diffusion pour suivre la distribution.",
"stateActive": "Actif",
"stateAutoDisabled": "Désactivé automatiquement",
"stateSendingPaused": "Envoi en pause",
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Plusieurs de vos nuits ont été un peu courtes cette semaine. Si vous voulez, on cherche une régularité ensemble.",
"measurementGapBody": "Votre journal est silencieux depuis environ une semaine. Quand cela vous convient, un petit point aide à reprendre.",
"focusNote": "Cela rejoint aussi ce que vous vouliez garder à l'œil.",
"reminderSurfacedTitle": "Ton rappel",
"reminderSurfacedLeadOne": "Tu m'as demandé de te rappeler ceci aujourd'hui :",
"reminderSurfacedLeadMany": "Tu m'as demandé de te rappeler {count} choses aujourd'hui :",
"questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?",
"questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?",
"questionAllergies": "Avez-vous des allergies ou des intolérances ?",
@@ -8595,8 +8598,7 @@
},
"mentalHealth": {
"pageTitle": "Bien-être mental",
"pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
"moodLink": "Voir ton historique d'humeur",
"pageDescription": "Quatre auto-évaluations validées et facultatives : l’évolution compte plus qu’un score isolé.",
"attributionLabel": "Source",
"attributionShow": "Source et licence pour {instrument}",
"choosePrompt": "Choisis un bilan",
@@ -8851,6 +8853,7 @@
"saveError": "Impossible d'enregistrer le contexte du mode de vie",
"loadError": "Impossible de charger le contexte du mode de vie",
"remove": "Supprimer : {kind}",
"removeShort": "Supprimer",
"removeConfirmTitle": "Supprimer : {kind} ?",
"removeConfirmDescription": "La valeur actuelle sera considérée comme non renseignée. Son historique daté sera conservé.",
"removedToast": "{kind} supprimé du contexte actuel du mode de vie",
+8 -5
View File
@@ -5742,9 +5742,9 @@
}
},
"notificationStatus": {
"title": "Channel reliability",
"description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.",
"emptyDescription": "Nessun canale ancora configurato. Aggiungi un canale qui sotto per iniziare a monitorarne la consegna.",
"title": "Affidabilità dei canali",
"description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.",
"emptyDescription": "Nessun canale ancora configurato. Aggiungine uno in Canali di recapito per monitorarne la consegna.",
"stateActive": "Attivo",
"stateAutoDisabled": "Disattivato automaticamente",
"stateSendingPaused": "Invio in pausa",
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Diverse delle tue notti sono state un po' corte questa settimana. Se ti va, cerchiamo insieme uno schema.",
"measurementGapBody": "Il tuo diario è in silenzio da circa una settimana. Quando ti è comodo, due parole aiutano a riprendere.",
"focusNote": "Si lega anche a ciò che volevi tenere d'occhio.",
"reminderSurfacedTitle": "Il tuo promemoria",
"reminderSurfacedLeadOne": "Mi avevi chiesto di ricordarti questo oggi:",
"reminderSurfacedLeadMany": "Mi avevi chiesto di ricordarti {count} cose oggi:",
"questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?",
"questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?",
"questionAllergies": "Hai allergie o intolleranze?",
@@ -8595,8 +8598,7 @@
},
"mentalHealth": {
"pageTitle": "Benessere mentale",
"pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
"moodLink": "Vedi il tuo andamento dell'umore",
"pageDescription": "Quattro autovalutazioni validate e facoltative: conta l’andamento, non il singolo punteggio.",
"attributionLabel": "Fonte",
"attributionShow": "Fonte e licenza di {instrument}",
"choosePrompt": "Scegli un check-in",
@@ -8851,6 +8853,7 @@
"saveError": "Impossibile salvare il contesto dello stile di vita",
"loadError": "Impossibile caricare il contesto dello stile di vita",
"remove": "Rimuovi {kind}",
"removeShort": "Rimuovi",
"removeConfirmTitle": "Rimuovere {kind}?",
"removeConfirmDescription": "Il valore attuale risulterà non registrato. La cronologia datata verrà conservata.",
"removedToast": "{kind} rimosso dal contesto attuale dello stile di vita",
+8 -5
View File
@@ -5742,9 +5742,9 @@
}
},
"notificationStatus": {
"title": "Channel reliability",
"description": "Live status of every notification channel — Auto-disabled means HealthLog stopped retrying after a permanent error.",
"emptyDescription": "Nie skonfigurowano jeszcze żadnych kanałów. Dodaj kanał poniżej, aby zacząć śledzić jego dostarczalność.",
"title": "Niezawodność kanałów",
"description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.",
"emptyDescription": "Nie skonfigurowano jeszcze żadnych kanałów. Dodaj kanał w sekcji Kanały dostarczania, aby śledzić dostarczalność.",
"stateActive": "Aktywne",
"stateAutoDisabled": "Automatycznie wyłączone",
"stateSendingPaused": "Wysyłanie wstrzymane",
@@ -7417,6 +7417,9 @@
"sleepDebtBody": "Kilka Twoich nocy było w tym tygodniu krótkich. Jeśli masz ochotę, poszukajmy razem wzorca.",
"measurementGapBody": "Twój dziennik milczy od około tygodnia. Kiedy Ci pasuje, krótka rozmowa pomoże wrócić do rutyny.",
"focusNote": "Wiąże się to też z tym, na co chciałeś/chciałaś zwracać uwagę.",
"reminderSurfacedTitle": "Twoje przypomnienie",
"reminderSurfacedLeadOne": "Prosiłeś, żeby przypomnieć ci o tym dzisiaj:",
"reminderSurfacedLeadMany": "Prosiłeś, żeby przypomnieć ci dzisiaj o {count} sprawach:",
"questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?",
"questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?",
"questionAllergies": "Czy masz alergie lub nietolerancje?",
@@ -8595,8 +8598,7 @@
},
"mentalHealth": {
"pageTitle": "Dobrostan psychiczny",
"pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
"moodLink": "Zobacz historię swojego nastroju",
"pageDescription": "Cztery zwalidowane, dobrowolne testy — liczy się przebieg w czasie, nie pojedynczy wynik.",
"attributionLabel": "Źródło",
"attributionShow": "Źródło i licencja: {instrument}",
"choosePrompt": "Wybierz sprawdzenie",
@@ -8851,6 +8853,7 @@
"saveError": "Nie udało się zapisać kontekstu stylu życia",
"loadError": "Nie udało się wczytać kontekstu stylu życia",
"remove": "Usuń: {kind}",
"removeShort": "Usuń",
"removeConfirmTitle": "Usunąć: {kind}?",
"removeConfirmDescription": "Bieżąca wartość zmieni się na brak danych. Historia z datami zostanie zachowana.",
"removedToast": "Usunięto {kind} z bieżącego kontekstu stylu życia",
+1 -1
View File
@@ -126,7 +126,7 @@
"knip": "^6.16.1",
"lefthook": "^2.1.10",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "6.1.200",
"pdfjs-dist": "6.2.108",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.1",
+7 -144
View File
@@ -14,6 +14,7 @@ overrides:
fast-uri@<3.1.5: ^3.1.5
ip-address@<10.3.1: ^10.3.1
sharp@<0.35.0: ^0.35.3
pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108
importers:
@@ -234,8 +235,8 @@ importers:
specifier: ^2.4.5
version: 2.4.5
pdfjs-dist:
specifier: 6.1.200
version: 6.1.200
specifier: 6.2.108
version: 6.2.108
pg:
specifier: ^8.22.0
version: 8.22.0
@@ -1223,12 +1224,6 @@ packages:
resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
engines: {node: '>=18'}
'@napi-rs/canvas-android-arm64@0.1.100':
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-android-arm64@0.1.80':
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
engines: {node: '>= 10'}
@@ -1241,12 +1236,6 @@ packages:
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.100':
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-arm64@0.1.80':
resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==}
engines: {node: '>= 10'}
@@ -1259,12 +1248,6 @@ packages:
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.100':
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.80':
resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==}
engines: {node: '>= 10'}
@@ -1277,12 +1260,6 @@ packages:
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==}
engines: {node: '>= 10'}
@@ -1295,13 +1272,6 @@ packages:
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==}
engines: {node: '>= 10'}
@@ -1316,13 +1286,6 @@ packages:
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
engines: {node: '>= 10'}
@@ -1337,13 +1300,6 @@ packages:
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
engines: {node: '>= 10'}
@@ -1358,13 +1314,6 @@ packages:
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
engines: {node: '>= 10'}
@@ -1379,13 +1328,6 @@ packages:
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-x64-musl@0.1.80':
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
engines: {node: '>= 10'}
@@ -1400,24 +1342,12 @@ packages:
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
engines: {node: '>= 10'}
@@ -1430,10 +1360,6 @@ packages:
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.100':
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
engines: {node: '>= 10'}
'@napi-rs/canvas@0.1.80':
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
engines: {node: '>= 10'}
@@ -5696,9 +5622,6 @@ packages:
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
engines: {node: '>= 6.13.0'}
node-readable-to-web-readable-stream@0.4.2:
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
node-releases@2.0.51:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
@@ -5918,12 +5841,8 @@ packages:
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
pdfjs-dist@5.6.205:
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
pdfjs-dist@6.1.200:
resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==}
pdfjs-dist@6.2.108:
resolution: {integrity: sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==}
engines: {node: '>=22.13.0 || >=24'}
pend@1.2.0:
@@ -8354,117 +8273,69 @@ snapshots:
strict-event-emitter: 0.5.1
optional: true
'@napi-rs/canvas-android-arm64@0.1.100':
optional: true
'@napi-rs/canvas-android-arm64@0.1.80':
optional: true
'@napi-rs/canvas-android-arm64@1.0.2':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.80':
optional: true
'@napi-rs/canvas-darwin-arm64@1.0.2':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.80':
optional: true
'@napi-rs/canvas-darwin-x64@1.0.2':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.2':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@1.0.2':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm64-musl@1.0.2':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-x64-gnu@1.0.2':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.80':
optional: true
'@napi-rs/canvas-linux-x64-musl@1.0.2':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@1.0.2':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
optional: true
'@napi-rs/canvas-win32-x64-msvc@1.0.2':
optional: true
'@napi-rs/canvas@0.1.100':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.100
'@napi-rs/canvas-darwin-arm64': 0.1.100
'@napi-rs/canvas-darwin-x64': 0.1.100
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-musl': 0.1.100
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
optional: true
'@napi-rs/canvas@0.1.80':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.80
@@ -8707,7 +8578,7 @@ snapshots:
fflate: 0.8.2
incur: 0.4.13
papaparse: 5.5.3
pdfjs-dist: 5.6.205
pdfjs-dist: 6.2.108
smol-toml: 1.6.1
transitivePeerDependencies:
- '@types/node'
@@ -12819,9 +12690,6 @@ snapshots:
node-forge@1.4.0: {}
node-readable-to-web-readable-stream@0.4.2:
optional: true
node-releases@2.0.51: {}
nodemailer@9.0.3: {}
@@ -13084,12 +12952,7 @@ snapshots:
optionalDependencies:
'@napi-rs/canvas': 0.1.80
pdfjs-dist@5.6.205:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
node-readable-to-web-readable-stream: 0.4.2
pdfjs-dist@6.1.200:
pdfjs-dist@6.2.108:
optionalDependencies:
'@napi-rs/canvas': 1.0.2
+8
View File
@@ -12,6 +12,14 @@ overrides:
# rate limiter where nothing of ours screens it.
"ip-address@<10.3.1": "^10.3.1"
"sharp@<0.35.0": "^0.35.3"
# A crafted PDF could run arbitrary JavaScript through the renderer. Our own
# copy is pinned past it in `devDependencies`, but a second copy arrives
# transitively, and `outputFileTracingIncludes` ships EVERY `pdfjs-dist@*` in
# the store into the standalone image — so an unused transitive copy is still
# a copy in the runtime image. The range is bounded on both ends deliberately:
# `pdf-parse` resolves 5.4.296, which predates the flaw and drives the local
# text-extraction path, so it must not be dragged onto a new major.
"pdfjs-dist@>=5.6.83 <6.2.108": "6.2.108"
allowBuilds:
"@prisma/engines": true
+11 -3
View File
@@ -6,8 +6,10 @@
* Groups the user's live numeric lab results by panel date, pairs the two
* most-recent panels, and reports the per-analyte delta + reference-band
* standing (`summariseLabChanges`). Absent when there are fewer than two
* panels or no analyte is shared. Neutral framing only — a delta is not a
* diagnosis.
* panels, no analyte is shared, or the newest panel is older than the
* recency window — the card's "since your last panel" wording is a claim
* about recent news, and it stops being made rather than being made about a
* years-old draw. Neutral framing only — a delta is not a diagnosis.
*
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
* module gate, the shared analytics-read budget. `userId` is narrowed from the
@@ -62,7 +64,13 @@ export const GET = apiHandler(async () => {
annotate({
action: { name: "insights.labs-changes.read" },
meta: { present: summary.present, changes: summary.changes.length },
meta: {
present: summary.present,
changes: summary.changes.length,
// Distinguishes "no comparable panels" from "the comparison aged out",
// so an absent card is diagnosable without reaching for the database.
lab_rows: rows.length,
},
});
return apiSuccess({ ...summary, generatedAt: new Date().toISOString() });
@@ -267,11 +267,15 @@ describe("<CoachInput>", () => {
expect(controls?.[0]).not.toMatch(/\bjustify-between\b/);
expect(controls?.[0]).toMatch(/\bshrink-0\b/);
// And the row itself is a row at every width, bottom-aligned so the send
// button stays beside the last line as the textarea grows.
// And the row itself is a row at every width. The textarea is centred in
// it — bottom-aligning a one-line textarea against a 44 px tap target left
// the placeholder sitting visibly low in the pill — while the controls
// keep their own bottom alignment, so the send button stays beside the
// LAST line as the textarea grows rather than drifting to the middle.
const shell = html.match(/<div[^>]*class="[^"]*\bflex-row\b[^"]*"[^>]*>/);
expect(shell?.[0]).toMatch(/\bitems-end\b/);
expect(shell?.[0]).toMatch(/\bitems-center\b/);
expect(shell?.[0]).not.toMatch(/\bflex-col\b/);
expect(controls?.[0]).toMatch(/\bself-end\b/);
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
@@ -427,9 +427,15 @@ export function CoachInput({
// hold: focus follows DOM order, and the DOM order (textarea,
// actions, Send) is identical in both layouts. Nothing jumps.
//
// `items-end` keeps the controls beside the LAST line as the textarea
// grows, so the send button stays where the thumb left it.
"flex flex-row items-end gap-1.5 p-1.5",
// `items-center` with the controls pinned `self-end` below. The row
// used to be `items-end` throughout, which bottom-aligned an empty
// one-line textarea against a 44 px touch target: the placeholder sat
// roughly 6 px low in the pill, reading as if the text had slipped.
// Centring the textarea fixes the empty and single-line cases, and
// `self-end` on the controls preserves the reason `items-end` was
// there — as the textarea grows, the send button stays beside the
// LAST line rather than drifting to the middle.
"flex flex-row items-center gap-1.5 p-1.5",
)}
>
<textarea
@@ -455,7 +461,7 @@ export function CoachInput({
/>
<div
data-slot="coach-input-controls"
className="order-2 flex shrink-0 items-center gap-1.5"
className="order-2 flex shrink-0 items-center gap-1.5 self-end"
>
<div
data-slot="coach-input-leading"
@@ -97,8 +97,16 @@ export function LabsChangesCard({
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
{/* The delta is CONTENT — the number the row exists to report —
so it takes the analyte's tier, not the meta tier of the
line under it (UI-STANDARDS §3). At `text-xs` muted it wore
the sub-line's typography and optically attached itself to
that lower line, which is what made a row centred to the
pixel read as bottom-aligned. `self-center` states the row's
alignment on the cell itself so it survives a future change
to the row's own `items-*`. */}
<p
className="text-muted-foreground shrink-0 text-xs tabular-nums"
className="text-foreground shrink-0 self-center text-sm tabular-nums"
data-slot="labs-change-delta"
>
{signedDelta}
+12 -19
View File
@@ -122,27 +122,20 @@ export function LayoutCoachFab() {
},
});
// v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive:
// the dot also lights when the daily sweep has flipped a "remind me" reminder
// to due, independent of the nudge seen-stamp (resolving the reminder in the
// ledger clears it). Same cheap, gated, stale-while-revalidate read shape.
const { data: dueReminders } = useQuery({
queryKey: queryKeys.coachReminders("due,surfaced"),
queryFn: async () => {
const data = await apiGet<{ reminders?: unknown[] } | undefined>(
"/api/coach/reminders?status=due,surfaced",
);
return data?.reminders ?? [];
},
enabled: coachAvailable && !onCoachPage,
staleTime: 5 * 60 * 1000,
});
const hasDueReminders = (dueReminders?.length ?? 0) > 0;
const nudgedAt = status?.nudgedAt ?? null;
// ONE source for the dot: an assistant message the user has not opened yet.
//
// v1.22 added a second arm — a separate "any due reminder?" query — and that
// is the arm that produced a badge over an empty Coach. The sweep flipped a
// reminder to `due` without writing anything into a conversation, so the dot
// promised a message that did not exist; opening the Coach showed the empty
// state and did not clear the dot either, because only resolving the reminder
// in Settings could. The sweep now writes the reminder into the thread as a
// real assistant message (`coach-reminder-sweep.ts`), which the nudge status
// already reports — so the reminder still lights the dot, through the message
// it actually wrote, and opening the Coach clears it like any other.
const unread = isNudgeUnread(status, seenStamp);
// The visual dot lights for either an unread nudge OR a due reminder.
const showDot = unread || hasDueReminders;
const showDot = unread;
// The unread dot is visual-only (`aria-hidden`) and the swapped
// `aria-label` is not announced on mutation — a screen-reader user
@@ -28,9 +28,7 @@
* the page someone arrives at to take stock.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -179,18 +177,11 @@ export function MentalWellbeing() {
)}
</section>
{/* 2026-07-17 UX/IA audit M9 — mood tracking, this screener surface,
and the mood insights page form one mental-health domain but used
to be three unconnected islands. A quiet pointer to mood history,
mirroring the one added on `/mood` pointing back here. */}
<Link
href="/mood"
data-slot="mental-wellbeing-mood-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 self-start text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mentalHealth.moodLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{/* The pointer to mood history that used to sit here is gone. The
cross-link on `/mood` still points at this page, which is the
direction that earns its keep — someone logging a mood may not
know the screeners exist, while someone who came here for a
screener already knows where their mood diary is. */}
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
@@ -114,7 +114,12 @@ describe("health-profile fact removal control", () => {
.filter(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
// The Save control is the entry's PRIMARY action, so it carries the
// default variant. Matching on `variant === "outline"` used to be the
// discriminator and silently stopped matching anything when the
// action row was rebuilt — the button was still there, the selector
// was not.
props.variant === undefined &&
typeof props.onClick === "function",
)
.map((props) => {
@@ -132,12 +137,18 @@ describe("health-profile fact removal control", () => {
const { html } = render();
expect(confirmButtons).toHaveLength(1);
// The visible label is short — three entries side by side each carrying
// their own field name read as one run of words — but the ACCESSIBLE name
// still says which entry it removes, and so does the dialog it opens.
expect(confirmButtons[0]).toMatchObject({
slot: "health-profile-fact-remove-SMOKING_STATUS",
label: "Remove Smoking status",
label: "Remove",
ariaLabel: "Remove Smoking status",
confirmLabel: "Remove Smoking status",
title: "Remove Smoking status?",
body: "The current value will become not recorded. Its dated history will be retained.",
});
expect(html).toContain("Remove Smoking status");
expect(html).toContain("Remove");
expect(apiDelete).not.toHaveBeenCalled();
});
@@ -173,7 +184,7 @@ describe("health-profile fact removal control", () => {
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
@@ -207,7 +218,7 @@ describe("health-profile fact removal control", () => {
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
@@ -228,26 +228,21 @@ export function HealthProfileFactsManager() {
))}
</SelectContent>
</Select>
<div className="space-y-2">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 w-full sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
{/* One action row per entry (UI-STANDARDS §11 + the action-row
rule): right-aligned, primary last, the quiet destructive
beside it. Two full-width stacked blocks — a grey disabled
slab over a red-outlined one — carried the same visual weight
as the field they belong to and ran into each other when
scanned, which is what "Save" directly above "Remove smoking
status" read as. */}
<div className="flex items-center justify-end gap-2 pt-1">
{current ? (
<ConfirmButton
label={t("records.profileFacts.remove", {
// Short label inside the row; the full phrase stays the
// accessible name, because "Remove" on its own is
// ambiguous once three of them are on screen.
label={t("records.profileFacts.removeShort")}
ariaLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
title={t("records.profileFacts.removeConfirmTitle", {
@@ -262,12 +257,28 @@ export function HealthProfileFactsManager() {
remove.isPending && remove.variables?.id === current.id
}
disabled={disabled}
variant="ghost"
size="sm"
className="text-destructive min-h-11 w-full sm:min-h-9"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</div>
</div>
);
@@ -113,8 +113,13 @@ export function AvatarSection() {
title={t("settings.avatar.title")}
className="mb-4"
/>
{/* The accepted formats and limits belong to the sentence that explains
what this card does, not to a caption of their own under the buttons.
Two strings rather than one merged key: the same wording is the
upload's own constraint copy, and a translator keeps the shorter unit
either way. */}
<p className="text-muted-foreground mb-4 pl-7 text-sm">
{t("settings.avatar.description")}
{t("settings.avatar.description")} {t("settings.avatar.hint")}
</p>
<div className="flex items-center gap-4 pl-7">
<Avatar className="size-16">
@@ -167,9 +172,6 @@ export function AvatarSection() {
)}
</div>
</div>
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.avatar.hint")}
</p>
{msg && (
<p
role="alert"
+14 -2
View File
@@ -48,8 +48,16 @@ import {
// only applies to the native value text — here no native input is rendered).
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// `min-w-0` is load-bearing, not cosmetic. A field is almost always a flex item
// (the date/time row, a form grid cell), and a flex item's `min-width: auto`
// resolves to its content-based minimum. The content here contains a text
// `<input>`, whose intrinsic width comes from the `size` attribute's default of
// 20 characters (~180 px) — so without this the field refuses to render below
// ~230 px no matter what width the parent hands it, and the surplus spills out
// of whatever dialog or sheet it sits in. See the matching note on the overlay
// input below; both ends are needed, one alone still floors the box.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
export interface DateFieldProps {
id?: string;
@@ -182,7 +190,11 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
// `min-w-0` releases the input's own automatic minimum size (the
// 20-character intrinsic width of a text input). Without it the
// input is the widest thing in the field and the field cannot
// shrink to the width its parent offers.
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
+11 -7
View File
@@ -110,13 +110,17 @@ export const DateTimeField = React.forwardRef<
<div
className={cn(
// v1.25.10 — stack the date and time halves on a narrow viewport and
// only sit them side-by-side from `sm:` up. A native `<input
// type="date">` keeps its intrinsic width on mobile WebKit and won't
// shrink below it, so the `date + w-32 time` row overran a phone-width
// sheet (the time field + clock icon spilled ~70px past the edge,
// forcing a sideways scroll on every add form). Stacked, each half
// takes the full sheet width and nothing overflows.
"flex flex-col gap-2 sm:flex-row sm:items-stretch",
// only sit them side-by-side from `sm:` up. Stacked, each half takes
// the full sheet width, which is what a phone-width sheet can give.
//
// The side-by-side row above `sm:` needs `min-w-0` on both halves as
// well, and that lives in the field primitives themselves: each field
// wraps a text `<input>` whose 20-character intrinsic width was the
// field's automatic minimum size, so `date + w-32 time` demanded
// ~470 px inside a 400 px dialog body and spilled past the edge on
// every add / edit form. `min-w-0` here lets the row itself shrink
// inside whatever grid or flex cell hosts it.
"flex min-w-0 flex-col gap-2 sm:flex-row sm:items-stretch",
className,
)}
data-slot="date-time-field"
+18 -1
View File
@@ -69,7 +69,24 @@ function DialogContent({
// viewport on short / soft-keyboard-collapsed mobile screens.
// Consumers that need a different ceiling can override via
// `className`.
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
//
// `grid-cols-[minmax(0,1fr)]` pins the implicit column. A bare
// `grid` gives its single column an `auto` track, which sizes to the
// widest child's max-content and grows the box past the dialog's own
// `max-w-*` — and because `overflow-y:auto` makes `overflow-x`
// compute to `auto`, that surplus becomes a horizontal scrollbar
// rather than a visible spill. `minmax(0,1fr)` caps the track at the
// dialog width so an over-wide child is contained instead of
// dragging the surface out with it.
//
// `overflow-x-clip` pairs with it: `overflow-y-auto` alone promotes
// the other axis from `visible` to `auto`, so a single over-wide
// child made the whole dialog pannable sideways. A dialog scrolls
// vertically; anything that genuinely needs sideways scroll brings
// its own `overflow-x-auto` wrapper. Consumers that set `overflow-*`
// themselves still win — `<ResponsiveSheet>` passes
// `overflow-hidden` and scrolls its body instead.
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] grid-cols-[minmax(0,1fr)] gap-4 overflow-x-clip overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
+18 -2
View File
@@ -220,7 +220,10 @@ export function ResponsiveSheet({
<div
data-slot="responsive-sheet-body"
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
// Vertical-only scroll port — see the note on the Dialog
// branch's body below for why `overflow-y-auto` alone does not
// say that.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto p-4",
bodyClassName,
)}
>
@@ -304,7 +307,20 @@ export function ResponsiveSheet({
<div
data-slot="responsive-sheet-body"
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto",
// `overflow-x-clip` states the invariant the surrounding comment
// already claims: this body scrolls VERTICALLY. `overflow-y-auto`
// alone does not say that — CSS promotes the other axis from
// `visible` to `auto`, so any child wider than the port turned the
// whole form into a sideways-pannable surface. That is how the
// date/time row and `<SheetSection>`'s deliberate 4 px hover bleed
// (`-mx-1 w-[calc(100%+0.5rem)]`) both produced a horizontal
// scrollbar on the desktop dialog. `clip` is not `visible`, so the
// promotion does not happen and the vertical scroll is kept.
//
// Content that genuinely needs to scroll sideways (a wide table)
// owns its own `overflow-x-auto` wrapper — that is the correct
// place for it, and it still works inside a clipped port.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto",
bodyClassName,
)}
>
+9 -1
View File
@@ -60,7 +60,15 @@ export function SheetSection({
>
<CollapsiblePrimitive.Trigger
data-slot="sheet-section-trigger"
className="group focus-visible:ring-ring/50 -mx-1 flex w-[calc(100%+0.5rem)] items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
// `w-full`, not `-mx-1 w-[calc(100%+0.5rem)] px-1`. The old pair held
// the row's text at the section's own left edge while pushing the box
// 4 px outward on each side, purely to hold the focus ring off the
// label — but a Tailwind ring is drawn OUTSIDE the border box anyway,
// so the bleed bought nothing and made every row 8 px wider than the
// sheet body that scrolls it. In the desktop dialog, whose body has no
// horizontal padding to absorb it, that surfaced as a sideways-
// scrollable form on the cycle and mood sheets.
className="group focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{icon ? (
<span
+8 -2
View File
@@ -38,8 +38,12 @@ import { hourCycleOptions } from "@/lib/format-locale";
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// `min-w-0` mirrors `<DateField>` and carries the same reason: a flex item's
// automatic minimum size would otherwise be the 20-character intrinsic width of
// the text input inside, which floors the whole field at ~230 px and overflows
// the dialog that hosts it. See the note in `date-field.tsx`.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
@@ -153,7 +157,9 @@ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
// See `date-field.tsx`: without `min-w-0` the input's intrinsic
// 20-character width becomes the field's floor.
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? (use12h ? "--:-- --" : "--:--")}
disabled={disabled}
+83 -15
View File
@@ -1,10 +1,20 @@
import { describe, it, expect } from "vitest";
import {
LAB_CHANGE_RECENCY_DAYS,
summariseLabChanges,
type LabChangeRow,
} from "@/lib/insights/labs-changes";
/**
* Every case pins `now`. The fixtures are fixed calendar days and the summary
* has a relative freshness window, so a floating `new Date()` would leave the
* pairing case green until the day it silently slid out of the window and then
* fail for a reason that has nothing to do with the code under test.
*/
const NOW = new Date("2026-06-15T09:00:00.000Z");
const DAY_MS = 24 * 60 * 60 * 1000;
function row(
analyte: string,
value: number,
@@ -23,28 +33,31 @@ function row(
describe("summariseLabChanges", () => {
it("is absent with no rows", () => {
expect(summariseLabChanges([])).toMatchObject({ present: false });
expect(summariseLabChanges([], NOW)).toMatchObject({ present: false });
});
it("is absent with a single panel", () => {
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]);
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")], NOW);
expect(s.present).toBe(false);
});
it("is absent when no analyte is shared across panels", () => {
const s = summariseLabChanges([
row("LDL", 130, "2026-06-01"),
row("HbA1c", 5.4, "2026-05-01"),
]);
const s = summariseLabChanges(
[row("LDL", 130, "2026-06-01"), row("HbA1c", 5.4, "2026-05-01")],
NOW,
);
expect(s.present).toBe(false);
});
it("pairs the two most-recent panels for a shared analyte", () => {
const s = summariseLabChanges([
row("LDL", 120, "2026-06-01", { high: 116 }),
row("LDL", 140, "2026-05-01"),
row("LDL", 150, "2026-04-01"),
]);
const s = summariseLabChanges(
[
row("LDL", 120, "2026-06-01", { high: 116 }),
row("LDL", 140, "2026-05-01"),
row("LDL", 150, "2026-04-01"),
],
NOW,
);
expect(s.present).toBe(true);
expect(s.latestDate).toBe("2026-06-01");
expect(s.previousDate).toBe("2026-05-01");
@@ -58,10 +71,65 @@ describe("summariseLabChanges", () => {
});
it("skips qualitative (non-finite) values", () => {
const s = summariseLabChanges([
row("LDL", Number.NaN, "2026-06-01"),
row("LDL", 140, "2026-05-01"),
]);
const s = summariseLabChanges(
[row("LDL", Number.NaN, "2026-06-01"), row("LDL", 140, "2026-05-01")],
NOW,
);
expect(s.present).toBe(false);
});
// The comparison is a claim about what changed "since your last panel". Past
// the window that sentence is no longer true of anybody's data, so the
// summary reports absent rather than presenting a years-old delta as news.
it("is present while the newest panel is inside the recency window", () => {
const latest = new Date(
NOW.getTime() - (LAB_CHANGE_RECENCY_DAYS - 1) * DAY_MS,
);
const previous = new Date(latest.getTime() - 30 * DAY_MS);
const s = summariseLabChanges(
[
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
],
NOW,
);
expect(s.present).toBe(true);
expect(s.changes).toHaveLength(1);
});
it("is absent once the newest panel falls outside it", () => {
const latest = new Date(
NOW.getTime() - (LAB_CHANGE_RECENCY_DAYS + 1) * DAY_MS,
);
const previous = new Date(latest.getTime() - 30 * DAY_MS);
const s = summariseLabChanges(
[
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
],
NOW,
);
expect(s.present).toBe(false);
expect(s.changes).toEqual([]);
expect(s.latestDate).toBeNull();
});
// The boundary is read off the sample instant, not off the UTC day key: a
// panel drawn in the evening east of UTC keys to the NEXT calendar day, and
// re-parsing that key would move the cut-off by hours in one direction and
// hours the other way for anyone west of it.
it("measures the window from the sample instant, not the calendar-day key", () => {
const latest = new Date(
NOW.getTime() - LAB_CHANGE_RECENCY_DAYS * DAY_MS + 60_000,
);
const previous = new Date(latest.getTime() - 30 * DAY_MS);
const s = summariseLabChanges(
[
{ ...row("LDL", 120, "2026-01-01"), takenAt: latest },
{ ...row("LDL", 140, "2026-01-01"), takenAt: previous },
],
NOW,
);
expect(s.present).toBe(true);
});
});
+37 -2
View File
@@ -9,8 +9,9 @@
* numeric value, so they are skipped — a delta is meaningless for them.
*
* Pure + Prisma-free so the present / absent states are unit-testable: it is
* absent when there are fewer than two panel dates or no analyte is shared.
* Neutral framing only — a delta is not a diagnosis.
* absent when there are fewer than two panel dates, no analyte is shared, or
* the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing
* only — a delta is not a diagnosis.
*/
import {
classifyReferenceRange,
@@ -47,6 +48,29 @@ export interface LabChangesSummary {
changes: LabChange[];
}
/**
* How long the comparison keeps calling itself "since your last panel".
*
* The card's whole claim is time-bound: a delta between the two newest panels
* is news for a while, and then it is simply history the labs page already
* holds. Someone whose newest panel is years old was still being shown "what
* changed since your last panel", which is not a stale piece of UI state — it
* is a stale STATEMENT.
*
* A year, and not less, because routine panels are commonly drawn annually. A
* shorter window would hide a comparison that is genuinely the newest thing
* the person has for months of every year. Past a year the wording cannot be
* defended at all, so the comparison stops being surfaced rather than being
* re-worded into something nobody asked for.
*
* Expressed on the DATA, not per user: nothing to acknowledge, nothing to
* persist, no housekeeping asked of anyone, and every client — web and iOS —
* reads the same answer through the existing `present: false` contract.
*/
export const LAB_CHANGE_RECENCY_DAYS = 365;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ABSENT: LabChangesSummary = {
present: false,
latestDate: null,
@@ -73,6 +97,7 @@ const round2 = (n: number): number => Math.round(n * 100) / 100;
*/
export function summariseLabChanges(
rows: readonly LabChangeRow[],
now: Date = new Date(),
): LabChangesSummary {
const numeric = rows.filter((r) => Number.isFinite(r.value));
if (numeric.length === 0) return ABSENT;
@@ -101,6 +126,16 @@ export function summariseLabChanges(
const latest = byDay.get(latestDate)!;
const previous = byDay.get(previousDate)!;
// Read the freshness off the newest actual sample instant in that panel, not
// off the day key: the key is a UTC calendar day and re-parsing it would put
// the boundary a few hours out for anyone east or west of UTC.
const newestSampleAt = Math.max(
...[...latest.values()].map((r) => r.takenAt.getTime()),
);
if (now.getTime() - newestSampleAt > LAB_CHANGE_RECENCY_DAYS * MS_PER_DAY) {
return ABSENT;
}
const changes: LabChange[] = [];
for (const [key, latestRow] of latest) {
const previousRow = previous.get(key);
@@ -1,10 +1,12 @@
/**
* v1.22 (M4) — daily Coach-reminder sweep.
*
* Covers: overdue active date-reminders flip to `due`; a passed CoachPlan
* reviewDate mints a one-off reminder from the plan's own cue→action text and
* clears the reviewDate (activating the dangling B1 column); an undecryptable
* plan is skipped (counted errored) without sinking the tick.
* Covers: a passed CoachPlan reviewDate mints a one-off reminder from the
* plan's own cue→action text and clears the reviewDate (activating the dangling
* B1 column); an undecryptable plan is skipped (counted errored) without
* sinking the tick; and — v1.37 — surfacing writes the reminder into a
* conversation and flips its status in one transaction, so a badge can never
* point at a message that was never written.
*/
import { describe, it, expect, vi } from "vitest";
@@ -25,84 +27,179 @@ function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
/**
* A prisma double whose `$transaction` accepts BOTH shapes the sweep uses: an
* array of pre-built operations (the plan-review mint) and an interactive
* callback (the surfacing step).
*/
function makePrisma(options: {
overdue?: {
id: string;
userId: string;
noteEncrypted: Uint8Array;
}[];
plans?: {
id: string;
userId: string;
metric: string;
ifCueEncrypted: Uint8Array;
thenActionEncrypted: Uint8Array;
}[];
}) {
const tx = {
coachConversation: { create: vi.fn(async () => ({ id: "c1" })) },
coachMessage: { create: vi.fn(async () => ({ id: "m1" })) },
coachReminder: { updateMany: vi.fn(async () => ({ count: 0 })) },
};
const prisma = {
coachReminder: {
findMany: vi.fn(async () => options.overdue ?? []),
create: vi.fn(async () => ({ id: "r-new" })),
updateMany: vi.fn(async () => ({ count: 0 })),
},
coachPlan: {
findMany: vi.fn(async () => options.plans ?? []),
update: vi.fn(async () => ({})),
},
coachConversation: { create: vi.fn(async () => ({ id: "c1" })) },
coachMessage: { create: vi.fn(async () => ({ id: "m1" })) },
user: { findUnique: vi.fn(async () => ({ locale: "en" })) },
$transaction: vi.fn(async (arg: unknown) => {
if (typeof arg === "function") {
return (arg as (t: typeof tx) => Promise<unknown>)(tx);
}
return Promise.all(arg as Promise<unknown>[]);
}),
tx,
};
return prisma;
}
describe("runCoachReminderSweep", () => {
it("flips overdue active date-reminders to due and mints plan-review reminders", async () => {
const prisma = {
coachReminder: {
updateMany: vi.fn(async () => ({ count: 3 })),
create: vi.fn(async () => ({ id: "r-new" })),
},
coachPlan: {
findMany: vi.fn(async () => [
{
id: "p1",
userId: "u1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
},
]),
update: vi.fn(async () => ({})),
},
// The mint runs as one atomic batch; resolve the built ops together.
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
};
it("mints a plan-review reminder from the plan's own prose and clears the reviewDate", async () => {
const prisma = makePrisma({
plans: [
{
id: "p1",
userId: "u1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
},
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(3);
expect(summary.planReviewsMinted).toBe(1);
expect(summary.errored).toBe(0);
// The flip query is scoped to active, date-triggered, overdue rows.
const flipArgs = prisma.coachReminder.updateMany.mock
.calls[0] as unknown as [
{ where: { status: string; triggerKind: string; dueAt: unknown } },
];
const where = flipArgs[0].where;
expect(where.status).toBe("active");
expect(where.triggerKind).toBe("date");
expect(where.dueAt).toEqual({ not: null, lte: NOW });
// The minted reminder carries the plan's own prose + relatedPlanId.
const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [
{ data: { relatedPlanId: string; source: string; status: string } },
];
const data = createArgs[0].data;
expect(data.relatedPlanId).toBe("p1");
expect(data.source).toBe("extractor");
expect(data.status).toBe("due");
// Minted `active`, not `due`: the surfacing pass in this same tick is what
// takes it to the conversation, and there is only one such path.
expect(data.status).toBe("active");
// The plan's reviewDate is cleared so the review fires exactly once.
expect(prisma.coachPlan.update).toHaveBeenCalledWith({
where: { id: "p1" },
data: { reviewDate: null },
});
});
// The create + clear commit atomically — a half-applied mint would let the
// next tick re-select the plan and duplicate the review reminder.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
it("writes the reminder into a conversation and flips its status in ONE transaction", async () => {
const prisma = makePrisma({
overdue: [
{ id: "r1", userId: "u1", noteEncrypted: bytes("ask about my sleep") },
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(1);
expect(summary.errored).toBe(0);
// A conversation with the note as an ASSISTANT message — the row the
// unread badge is derived from.
expect(prisma.tx.coachConversation.create).toHaveBeenCalledTimes(1);
const msgArgs = prisma.tx.coachMessage.create.mock.calls[0] as unknown as [
{ data: { role: string; encryptedContent: Uint8Array } },
];
expect(msgArgs[0].data.role).toBe("assistant");
const body = Buffer.from(msgArgs[0].data.encryptedContent).toString("utf8");
expect(body).toContain("dec:ask about my sleep");
// …and the status flip rides the SAME transaction, so neither half can
// land without the other.
const flip = prisma.tx.coachReminder.updateMany.mock
.calls[0] as unknown as [
{ where: { id: { in: string[] } }; data: { status: string } },
];
expect(flip[0].where.id.in).toEqual(["r1"]);
expect(flip[0].data.status).toBe("surfaced");
});
it("picks up rows an older build left stuck on `due`", async () => {
const prisma = makePrisma({
overdue: [{ id: "r-old", userId: "u1", noteEncrypted: bytes("stuck") }],
});
await runCoachReminderSweep(prisma as never, NOW);
const where = (
prisma.coachReminder.findMany.mock.calls[0] as unknown as [
{ where: { status: { in: string[] } } },
]
)[0].where;
expect(where.status.in).toEqual(["active", "due"]);
});
it("leaves the reminder alone when the conversation write fails", async () => {
const prisma = makePrisma({
overdue: [{ id: "r1", userId: "u1", noteEncrypted: bytes("note") }],
});
prisma.$transaction = vi.fn(async (arg: unknown) => {
if (typeof arg === "function") throw new Error("db down");
return Promise.all(arg as Promise<unknown>[]);
}) as never;
const summary = await runCoachReminderSweep(prisma as never, NOW);
// Not counted as surfaced — the next tick retries, and until it succeeds
// nothing claims a message that does not exist.
expect(summary.remindersDue).toBe(0);
expect(summary.errored).toBe(1);
expect(prisma.tx.coachReminder.updateMany).not.toHaveBeenCalled();
});
it("skips an undecryptable note without losing the ones beside it", async () => {
const prisma = makePrisma({
overdue: [
{ id: "r-bad", userId: "u1", noteEncrypted: bytes("__bad__") },
{ id: "r-ok", userId: "u1", noteEncrypted: bytes("fine") },
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(1);
expect(summary.errored).toBe(1);
const flip = prisma.tx.coachReminder.updateMany.mock
.calls[0] as unknown as [{ where: { id: { in: string[] } } }];
expect(flip[0].where.id.in).toEqual(["r-ok"]);
});
it("skips an undecryptable plan without sinking the tick", async () => {
const prisma = {
coachReminder: {
updateMany: vi.fn(async () => ({ count: 0 })),
create: vi.fn(async () => ({ id: "r-new" })),
},
coachPlan: {
findMany: vi.fn(async () => [
{
id: "p1",
userId: "u1",
metric: "SLEEP",
ifCueEncrypted: bytes("__bad__"),
thenActionEncrypted: bytes("lights out"),
},
]),
update: vi.fn(async () => ({})),
},
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
};
const prisma = makePrisma({
plans: [
{
id: "p1",
userId: "u1",
metric: "SLEEP",
ifCueEncrypted: bytes("__bad__"),
thenActionEncrypted: bytes("lights out"),
},
],
});
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.planReviewsMinted).toBe(0);
+161 -28
View File
@@ -5,8 +5,7 @@
* per tick that brings a reminder back at its chosen moment. Two sources:
*
* 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt`
* has passed → flipped to `due` (the in-app tile + indicator read `due` /
* `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped.
* has passed.
* 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read
* nowhere until now — see the design §1c) has passed and that are still
* `active` → a one-off `CoachReminder` is minted from the plan's own
@@ -14,9 +13,30 @@
* `reviewDate` is cleared so the review fires once. This finally activates
* the column the B1 schema anticipated.
*
* In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4)
* land later. The sweep never calls a provider and never pushes — it only flips
* status + mints the plan-review reminder, both idempotent across re-fires.
* Both sources converge on ONE surfacing step, and that convergence is the
* point. Until v1.37 the sweep only flipped a reminder's status to `due` and
* stopped: nothing was written into the conversation, `surfaced` had no writer
* at all, and the FAB lit its unread dot off a separate "is anything due?"
* query. So the dot appeared over an empty Coach, opening it showed the empty
* state, and opening it did not clear the dot either — the only way out was to
* resolve the reminder in Settings, which nothing on the badge suggested. A
* badge and the content it promises must come from one event.
*
* They do now. Surfacing writes the reminder into the user's Coach thread as a
* real assistant message and flips the reminder to `surfaced` in the SAME
* transaction, so the two cannot drift: no message, no status change, no
* badge. The badge itself is derived from that message
* (`readCoachNudgeStatus` compares the newest assistant message against
* `User.coachLastSeenAt`), which means opening the Coach clears it the way it
* clears any other proactive message.
*
* Rows left `due` by an older build are picked up by the same pass, so a badge
* that has been stuck on an empty conversation resolves into the message it
* always implied rather than staying stuck.
*
* The sweep still never calls a provider and never pushes. The message body is
* a deterministic localized template around the user's OWN note text — the
* words they asked to be reminded of, never anything fabricated.
*
* Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that
* is a follow-on (F4); the column exists and capture supports it, but the date
@@ -25,6 +45,9 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep";
// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both
@@ -33,8 +56,13 @@ export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *";
/** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */
const PLAN_REVIEW_BATCH = 200;
/** Bound the surfacing fan-out per tick for the same reason. */
const SURFACE_BATCH = 500;
/** How many notes one surfacing message carries before the rest wait a tick. */
const MAX_NOTES_PER_MESSAGE = 6;
export interface CoachReminderSweepSummary {
/** Reminders whose moment arrived AND that reached the conversation. */
remindersDue: number;
planReviewsMinted: number;
errored: number;
@@ -42,12 +70,43 @@ export interface CoachReminderSweepSummary {
type SweepPrisma = Pick<
PrismaClient,
"coachReminder" | "coachPlan" | "$transaction"
| "coachReminder"
| "coachPlan"
| "coachConversation"
| "coachMessage"
| "user"
| "$transaction"
>;
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped,
* and a plan whose `reviewDate` was cleared on a prior tick is not re-minted.
* The surfacing message for one user's due reminders. Deterministic, localized,
* and built only from the user's own note text — the sweep composes, it never
* generates.
*/
export function buildReminderSurfaceMessage(
notes: readonly string[],
locale: string | null | undefined,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
const title = t("coachNudges.reminderSurfacedTitle");
const lead =
notes.length === 1
? t("coachNudges.reminderSurfacedLeadOne")
: t("coachNudges.reminderSurfacedLeadMany", { count: notes.length });
const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n");
return { title, body };
}
/**
* Run one sweep tick. Idempotent: a reminder already `surfaced` is not
* re-surfaced, and a plan whose `reviewDate` was cleared on a prior tick is not
* re-minted.
*/
export async function runCoachReminderSweep(
prisma: SweepPrisma,
@@ -59,23 +118,10 @@ export async function runCoachReminderSweep(
errored: 0,
};
// ── 1. flip overdue active date-reminders → due ──────────────
const due = await prisma.coachReminder.updateMany({
where: {
deletedAt: null,
status: "active",
triggerKind: "date",
dueAt: { not: null, lte: now },
},
data: {
status: "due",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
summary.remindersDue = due.count;
// ── 2. mint plan-review reminders for passed reviewDates ──────
// ── 1. mint plan-review reminders for passed reviewDates ──────
//
// Minted `active` with `dueAt: now` so the surfacing pass below picks them
// up in this same tick. One path reaches the conversation, not two.
const plans = await prisma.coachPlan.findMany({
where: {
deletedAt: null,
@@ -120,10 +166,8 @@ export async function runCoachReminderSweep(
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: now,
status: "due",
status: "active",
source: "extractor",
lastSurfacedAt: now,
surfaceCount: 1,
},
}),
prisma.coachPlan.update({
@@ -137,5 +181,94 @@ export async function runCoachReminderSweep(
}
}
// ── 2. surface everything whose moment has passed ─────────────
//
// `due` is included alongside `active` deliberately: rows an older build
// flipped to `due` never reached a conversation, and they are exactly the
// ones showing a badge with nothing behind it. Picking them up here converts
// a stuck indicator into the message it always promised.
const overdue = await prisma.coachReminder.findMany({
where: {
deletedAt: null,
status: { in: ["active", "due"] },
triggerKind: "date",
dueAt: { not: null, lte: now },
},
take: SURFACE_BATCH,
orderBy: { dueAt: "asc" },
select: { id: true, userId: true, noteEncrypted: true },
});
const byUser = new Map<string, typeof overdue>();
for (const row of overdue) {
const bucket = byUser.get(row.userId);
if (bucket) bucket.push(row);
else byUser.set(row.userId, [row]);
}
for (const [userId, rows] of byUser) {
try {
// Decrypt fault-isolated per row: one undecryptable note must not cost
// the user the reminders that sit beside it.
const usable: { id: string; note: string }[] = [];
for (const row of rows) {
try {
usable.push({
id: row.id,
note: decryptFromBytes(row.noteEncrypted),
});
} catch {
summary.errored += 1;
}
}
if (usable.length === 0) continue;
const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const { title, body } = buildReminderSurfaceMessage(
batch.map((r) => r.note),
user?.locale,
);
// Conversation + message + status flip in ONE transaction. A partial
// write is the failure this whole change exists to remove: a status
// without a message is a badge pointing at nothing, and a message
// without a status change re-surfaces the same note every night.
await prisma.$transaction(async (tx) => {
const conversation = await tx.coachConversation.create({
data: { userId, title: title.slice(0, 80) },
});
await tx.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes(body),
metricSourceJson: null,
// Same tag the proactive nudge uses — this IS a proactive message,
// and the nudge cron's frequency gate reads the tag back.
providerType: "nudge",
promptVersion: null,
},
});
await tx.coachReminder.updateMany({
where: { id: { in: batch.map((r) => r.id) } },
data: {
status: "surfaced",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
});
summary.remindersDue += batch.length;
} catch {
// Leave the rows where they were: the next tick retries, and until then
// no badge claims a message that was never written.
summary.errored += 1;
}
}
return summary;
}
+6 -4
View File
@@ -822,10 +822,12 @@ export async function registerStatusQueues(
},
);
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue
// reminder to `due` and minting plan-review reminders are both idempotent
// across re-fires (an already-due reminder is not re-flipped; a plan whose
// reviewDate was cleared is not re-minted). No provider call, no push.
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; surfacing an
// overdue reminder into the user's Coach thread and minting plan-review
// reminders are both idempotent across re-fires (an already-surfaced
// reminder is not re-written; a plan whose reviewDate was cleared is not
// re-minted). No provider call, no push — the message body is a
// deterministic localized template around the user's own note.
await createAndWork(
boss,
COACH_REMINDER_SWEEP_QUEUE,
@@ -0,0 +1,239 @@
/**
* The Coach badge and the Coach conversation, over the real assembly.
*
* The report: the FAB shows its unread dot, you open the Coach, the
* conversation is empty, and the dot is still there afterwards. Two separate
* paths were feeding one indicator — a "is any reminder due?" query lit the
* dot, while the nightly sweep that made the reminder due wrote nothing into a
* conversation and had no way to be cleared by opening one.
*
* The unit suite can prove the sweep CALLS the right prisma methods; it cannot
* prove a row exists, that the badge derives from that row, or that opening
* the Coach clears it. Those three claims are the defect, so they are asserted
* here against Postgres, through the same functions the cron and the FAB use:
* `runCoachReminderSweep` writes, `readCoachNudgeStatus` reads, and the seen
* stamp in between is the one `POST /api/insights/coach/seen` writes.
*
* The note text is round-tripped through the real AES codec rather than a
* mock, so an encoding mistake between the sweep and the conversation reader
* fails here instead of shipping as an unreadable reminder.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
const NOW = new Date("2026-08-07T05:20:00.000Z");
const YESTERDAY = new Date("2026-08-06T09:00:00.000Z");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seedUser(username: string, locale = "en") {
return getPrismaClient().user.create({
data: {
username,
email: `${username}@example.test`,
timezone: "UTC",
locale,
},
});
}
describe("a due Coach reminder reaches the conversation the badge points at", () => {
it("writes the note as an assistant message, and that message is what makes the badge unread", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-surfacing");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("ask me about my sleep again"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
// Before the sweep there is nothing to be unread ABOUT.
const before = await readCoachNudgeStatus(user.id);
expect(before.unread).toBe(false);
expect(before.conversationId).toBeNull();
const summary = await runCoachReminderSweep(prisma, NOW);
expect(summary.remindersDue).toBe(1);
expect(summary.errored).toBe(0);
// BOTH ENDS. The row exists…
const messages = await prisma.coachMessage.findMany({
where: { conversation: { userId: user.id } },
});
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("assistant");
expect(decryptFromBytes(messages[0].encryptedContent)).toContain(
"ask me about my sleep again",
);
// …and the badge is derived from THAT row, not from a parallel query.
const after = await readCoachNudgeStatus(user.id);
expect(after.unread).toBe(true);
expect(after.conversationId).toBe(messages[0].conversationId);
// The reminder is marked surfaced, so it stops being pending work.
const reminder = await prisma.coachReminder.findFirstOrThrow({
where: { userId: user.id },
});
expect(reminder.status).toBe("surfaced");
expect(reminder.surfaceCount).toBe(1);
});
it("opening the Coach clears the badge", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-seen");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("check the blood pressure trend"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
await runCoachReminderSweep(prisma, NOW);
expect((await readCoachNudgeStatus(user.id)).unread).toBe(true);
// What `POST /api/insights/coach/seen` writes when the Coach is opened.
// Anchored on the MESSAGE's own `createdAt`: the sweep takes its `now` for
// the due-date arithmetic, but the row is stamped by the database, and the
// route stamps the seen time from the same clock the row came from.
const written = await prisma.coachMessage.findFirstOrThrow({
where: { conversation: { userId: user.id } },
});
await prisma.user.update({
where: { id: user.id },
data: { coachLastSeenAt: new Date(written.createdAt.getTime() + 1_000) },
});
expect((await readCoachNudgeStatus(user.id)).unread).toBe(false);
});
it("does not surface the same reminder twice", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-idempotent");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("weekly review"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
await runCoachReminderSweep(prisma, NOW);
const second = await runCoachReminderSweep(
prisma,
new Date(NOW.getTime() + 86_400_000),
);
expect(second.remindersDue).toBe(0);
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(1);
});
it("takes a reminder an older build left stuck on `due` and gives it the message it promised", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-legacy");
await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("the note nobody ever saw"),
triggerKind: "date",
dueAt: new Date("2026-05-01T09:00:00.000Z"),
status: "due",
lastSurfacedAt: new Date("2026-05-01T05:20:00.000Z"),
surfaceCount: 1,
source: "user",
},
});
// This is the reported state exactly: something the UI treated as
// outstanding, with an empty conversation behind it.
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(0);
await runCoachReminderSweep(prisma, NOW);
const messages = await prisma.coachMessage.findMany({
where: { conversation: { userId: user.id } },
});
expect(messages).toHaveLength(1);
expect(decryptFromBytes(messages[0].encryptedContent)).toContain(
"the note nobody ever saw",
);
expect((await readCoachNudgeStatus(user.id)).unread).toBe(true);
});
it("keeps a failed write from leaving a badge with nothing behind it", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-partial");
const reminder = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("intact note"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
// Fail the transaction the way a real outage would — inside the write, so
// the conversation, the message and the status flip are all in flight.
const failing = new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "$transaction") {
return (arg: unknown) => {
if (typeof arg === "function") {
return Promise.reject(new Error("connection reset"));
}
const passthrough = Reflect.get(target, prop, receiver) as (
ops: unknown,
) => Promise<unknown>;
return passthrough.call(target, arg);
};
}
return Reflect.get(target, prop, receiver);
},
});
const summary = await runCoachReminderSweep(failing as typeof prisma, NOW);
expect(summary.remindersDue).toBe(0);
expect(summary.errored).toBe(1);
// Nothing written, nothing marked — and the badge stays dark, which is the
// honest state: there is no message to read.
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(0);
const untouched = await prisma.coachReminder.findFirstOrThrow({
where: { id: reminder.id },
});
expect(untouched.status).toBe("active");
expect((await readCoachNudgeStatus(user.id)).unread).toBe(false);
});
});