mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
commit
2f1423fa09
17 files changed
+1289
-6
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user