Merge branch 'feat/cap-s11-pulse' into release/v1.29.0

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/daily/__tests__/digest.test.ts
#	src/lib/daily/digest.ts
#	src/lib/daily/load-digest.ts
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 08:25:44 +02:00
commit 2f1423fa09
17 files changed
+1289 -6

No files matched your search

+53
View File
@@ -59,6 +59,7 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
syncIssues: [],
preventiveDue: [],
coachPlans: [],
tensionWindow: null,
...over,
};
}
@@ -481,3 +482,55 @@ describe("S12 — the milestone reward card", () => {
expect(item?.body ?? "").not.toMatch(forbidden);
});
});
describe("buildDailyDigest — S11 tension_window item", () => {
function tension(d: ReturnType<typeof buildDailyDigest>) {
return d.worthALook.find((i) => i.kind === "tension_window");
}
it("emits a calm, non-diagnostic tension card when a window is present", () => {
const d = buildDailyDigest(
input({ tensionWindow: { partOfDay: "afternoon" } }),
t,
);
const item = tension(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
expect(item?.actions[0].intent).toBe("pulse.view");
expect(item?.actions[0].href).toBe("/insights/pulse");
expect(item?.body).toContain("afternoon");
});
it("emits nothing when there is no window (honest-absent)", () => {
const d = buildDailyDigest(input({ tensionWindow: null }), t);
expect(tension(d)).toBeUndefined();
});
it("stays silent when the insights module is off", () => {
const d = buildDailyDigest(
input({
modules: { insights: false },
tensionWindow: { partOfDay: "morning" },
}),
t,
);
expect(tension(d)).toBeUndefined();
});
it("yields the bounded rail to time-sensitive actions first", () => {
const d = buildDailyDigest(
input({
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
],
tensionWindow: { partOfDay: "evening" },
}),
t,
);
// dose + 2 sync fill the cap; the calm tension marker waits.
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
expect(tension(d)).toBeUndefined();
});
});
+49
View File
@@ -97,6 +97,17 @@ export interface DailyDigestPreventiveDue {
label: string;
}
/**
* S11 — a confident elevated-at-rest ("tension") window for the day, already
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
* The builder only formats it; the signal correctness lives in the analytics
* layer, and a null here means the honest "no window" — nothing is emitted.
*/
export interface DailyDigestTensionWindow {
/** Part of day the window's midpoint fell in — drives the cautious copy. */
partOfDay: "morning" | "afternoon" | "evening" | "night";
}
/**
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
* builder computes due-ness deterministically from the plain columns — it never
@@ -147,6 +158,8 @@ export interface DailyDigestInput {
* builder only ever sees a milestone worth celebrating today.
*/
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
}
export interface DailyDigest {
@@ -300,6 +313,37 @@ function buildMilestoneItem(
};
}
/**
* S11 — the elevated-at-rest ("tension") card. Emitted at most once per day
* (the window is already the day's single most confident stretch), gated on
* the `insights` module and on a non-null window (the analytics layer stays
* silent unless every confidence gate holds). Cautious, non-diagnostic copy:
* "possible tension", never a clinical stress verdict. The one action deep-
* links into the pulse insight where the intraday shape is charted.
*/
function buildTensionWindowItem(
window: DailyDigestTensionWindow | null,
modules: DigestModuleMap,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!window) return null;
return {
kind: "tension_window",
title: t("daily.item.tensionWindow.title"),
body: t(`daily.item.tensionWindow.body.${window.partOfDay}`),
status: "info",
actions: [
{
labelKey: "daily.action.viewPulse",
intent: "pulse.view",
href: "/insights/pulse",
},
],
moduleKey: "insights",
};
}
/**
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
@@ -443,6 +487,11 @@ export function buildDailyDigest(
if (checkin) worthALook.push(checkin);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// S11 — the calm, informational tension marker sits last: it is context, not
// an action that expires, so a time-sensitive dose / sync / check-in wins the
// bounded rail ahead of it.
const tension = buildTensionWindowItem(input.tensionWindow, input.modules, t);
if (tension) worthALook.push(tension);
// Defence-in-depth: no card ever exceeds the P1 action cap.
for (const item of worthALook) {
+13
View File
@@ -29,6 +29,7 @@ import {
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSyncIssue,
type DailyDigestTensionWindow,
} from "@/lib/daily/digest";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
@@ -40,6 +41,7 @@ import {
selectFreshMilestone,
type Milestone,
} from "@/lib/daily/milestones";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -269,6 +271,16 @@ export async function loadDailyDigest(
? await gatherFreshMilestone(user.id, now)
: null;
// S11 — the day's elevated-at-rest window, computed on demand from raw for
// TODAY only (read-swap, one bounded day-read; never persisted). Gated on the
// insights module and fault-isolated: a tension-read failure must never break
// the digest (a hot, must-not-fail path), it just omits the calm marker.
const tensionWindow: DailyDigestTensionWindow | null = insightsEnabled
? await loadIntradayPulse(user.id, user.timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null)
: null;
const { t } = getServerTranslator(resolveLocale(locale));
const digest = buildDailyDigest(
@@ -284,6 +296,7 @@ export async function loadDailyDigest(
preventiveDue,
coachPlans,
milestone,
tensionWindow,
},
t,
);