mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'main' into fix/cycle-correctness
This commit is contained in:
34 files changed
+1305
-383
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+7
-144
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user