mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): ambient narrative presence — callbacks, tension verdict, streaks, MI moves
Add a deterministic narrative layer the Coach narrates rather than invents: - Briefing callbacks + forward-looks: the daily briefing recalls the prior period and points ahead, anchored to a figure, one callback per surface. - Tension Verdict: when the readiness composite's contributors disagree (good sleep but a rising resting pulse), surface a brief honest internal read on the health-score card. A real clinical red-flag is never reconciled away into a calm verdict. - Streaks + return-to-baseline: a pure detector over a DAY-bucket series closes a worry once a metric returns inside the personal MAD band after a genuine out-of-band run; a gap day breaks a streak. - Coach prompt: add develop-discrepancy, roll-with-resistance, and an anti-persuasion clause; ratchet PROMPT_VERSION. The tension flag and streak event are computed server-side; the card lines render resolved fields and the model narrates them.
This commit is contained in:
1 parent
096a486bcc
commit
fe6c2e281b
9 files changed
+863
-5
No files matched your search
@@ -123,6 +123,20 @@ interface DailyBriefingProps {
|
||||
* comparison toggle migrates here from the hero in commit 5.
|
||||
*/
|
||||
metaSlot?: React.ReactNode;
|
||||
/**
|
||||
* v1.21.2 (A4) — server-resolved callback + forward-look. The briefing
|
||||
* recalls the prior period and points ahead ("as I noted Monday, your sleep
|
||||
* was short; it's recovered since — worth watching tonight"). Both strings
|
||||
* are already localised and figure-anchored by the resolver; the card only
|
||||
* renders them. One callback per surface — absent (the common case) when no
|
||||
* prior narrative recall is on file.
|
||||
*/
|
||||
memory?: {
|
||||
/** The recall of the prior-period read, anchored to a figure. */
|
||||
recall: string;
|
||||
/** The forward-look pointing ahead. */
|
||||
forward: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const METRIC_ICON: Record<
|
||||
@@ -335,6 +349,7 @@ export function DailyBriefing({
|
||||
noProvider = false,
|
||||
noProviderStale = false,
|
||||
metaSlot,
|
||||
memory = null,
|
||||
}: DailyBriefingProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -374,6 +389,33 @@ export function DailyBriefing({
|
||||
// looser `space-y-4`, which read as taller than its neighbours on
|
||||
// the overview. One token, no new spacing scale.
|
||||
<div className="space-y-3">
|
||||
{/* v1.21.2 (A4) — callback + forward-look. Recalls the prior
|
||||
period and points ahead, both server-resolved and
|
||||
figure-anchored. Leads the card so the briefing opens by
|
||||
closing the loop on what it noted last, then looks forward. */}
|
||||
{memory && (
|
||||
<div
|
||||
data-slot="daily-briefing-memory"
|
||||
className="border-border/60 bg-card/40 space-y-1 rounded-md border px-3 py-2"
|
||||
>
|
||||
<p
|
||||
data-slot="daily-briefing-memory-recall"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{t("insights.briefing.memory.recall", {
|
||||
text: memory.recall,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
data-slot="daily-briefing-memory-forward"
|
||||
className="text-foreground/80 text-xs leading-snug"
|
||||
>
|
||||
{t("insights.briefing.memory.forward", {
|
||||
text: memory.forward,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
|
||||
The hero strip subtitle on `/insights` already renders
|
||||
the same `briefing.paragraph` text directly above this
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
CornerDownLeft,
|
||||
Minus,
|
||||
Moon,
|
||||
Scale,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
|
||||
@@ -96,6 +104,34 @@ export interface HealthScoreCardProps {
|
||||
* active, not the episode details. Default off (the common case).
|
||||
*/
|
||||
restModeActive?: boolean;
|
||||
/**
|
||||
* v1.21.2 (A5) — Tension Verdict line. The server resolves the honest
|
||||
* "internal read" when the readiness/recovery composite's contributors
|
||||
* DISAGREE (good sleep but a rising resting pulse, …). The card renders it
|
||||
* as one short line: the favourable contributor, the unfavourable one, and
|
||||
* the band the composite lands on. Purely DTO-rendered — the card never
|
||||
* recomputes the disagreement. Absent (the common case) when the
|
||||
* contributors agree.
|
||||
*/
|
||||
tension?: {
|
||||
band: HealthScoreBand;
|
||||
/** Favourable contributors, already localised by the resolver. */
|
||||
positive: string[];
|
||||
/** Unfavourable contributors, already localised by the resolver. */
|
||||
negative: string[];
|
||||
} | null;
|
||||
/**
|
||||
* v1.21.2 (A6) — return-to-baseline line. Present only when a metric has
|
||||
* come BACK inside the user's own usual range after a prior out-of-band run.
|
||||
* Actively closes a worry ("back inside your usual range after last week's
|
||||
* dip"). Server-resolved DTO; the label is already localised.
|
||||
*/
|
||||
returnToBand?: {
|
||||
/** The metric's display label, already localised by the resolver. */
|
||||
metricLabel: string;
|
||||
/** Days the metric has now sat back inside its range. */
|
||||
daysInside: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type ComponentKey = keyof HealthScoreCardProps["components"];
|
||||
@@ -178,6 +214,8 @@ export function HealthScoreCard({
|
||||
initiallyExpanded = false,
|
||||
moodEnabled = true,
|
||||
restModeActive = false,
|
||||
tension = null,
|
||||
returnToBand = null,
|
||||
}: HealthScoreCardProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
// The asOf timestamps render under the source pill as a tooltip
|
||||
@@ -201,6 +239,13 @@ export function HealthScoreCard({
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return asOfFormatter.format(parsed);
|
||||
};
|
||||
// v1.21.2 (A5) — locale-aware conjunction list for the Tension Verdict's
|
||||
// favourable / unfavourable contributor lists ("sleep and mood" / "resting
|
||||
// heart rate, HRV balance, and mood"). Memoised per locale.
|
||||
const listFormatter = useMemo(
|
||||
() => new Intl.ListFormat(locale, { style: "long", type: "conjunction" }),
|
||||
[locale],
|
||||
);
|
||||
const [expanded, setExpanded] = useState(initiallyExpanded);
|
||||
// `useId` keeps the aria-controls/section-id pair unique even when
|
||||
// the card is mounted twice on the same page (lg+ hero strip vs
|
||||
@@ -418,6 +463,51 @@ export function HealthScoreCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* v1.21.2 (A5) — Tension Verdict. The honest "internal read" when the
|
||||
composite's contributors disagree, resolved server-side and rendered
|
||||
as one short line. The card never recomputes the disagreement; it
|
||||
only renders the resolved favourable / unfavourable contributors and
|
||||
the band the composite lands on. */}
|
||||
{tension &&
|
||||
tension.positive.length > 0 &&
|
||||
tension.negative.length > 0 && (
|
||||
<p
|
||||
data-slot="health-score-card-tension"
|
||||
data-band={tension.band}
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
>
|
||||
<Scale className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t("insights.tension.label", {
|
||||
positive: listFormatter.format(tension.positive),
|
||||
negative: listFormatter.format(tension.negative),
|
||||
band: t(`insights.tension.contributor.${tension.band}`),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* v1.21.2 (A6) — return-to-baseline. Present only when a metric came
|
||||
back inside the user's own usual range after a prior out-of-band run.
|
||||
Actively closes a worry rather than reporting a number. */}
|
||||
{returnToBand && (
|
||||
<p
|
||||
data-slot="health-score-card-return-to-band"
|
||||
className="text-success/90 inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
>
|
||||
<CornerDownLeft
|
||||
className="mt-0.5 size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("insights.streak.returnLabel", {
|
||||
metric: returnToBand.metricLabel,
|
||||
count: returnToBand.daysInside,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul
|
||||
data-slot="health-score-card-components"
|
||||
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
|
||||
|
||||
@@ -12,7 +12,12 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
|
||||
import {
|
||||
buildDerivedSnapshotBlock,
|
||||
deriveTension,
|
||||
TENSION_HIGH_SCORE,
|
||||
TENSION_LOW_SCORE,
|
||||
} from "../derived-snapshot";
|
||||
|
||||
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
|
||||
const whoopFindFirst = prisma.measurement.findFirst as unknown as ReturnType<
|
||||
@@ -166,4 +171,136 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a TENSION flag when readiness contributors disagree", async () => {
|
||||
// Good sleep (high) but a suppressed HRV-balance + elevated resting pulse
|
||||
// (both low) — a genuine internal disagreement the band hides.
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS")
|
||||
return ok({
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
components: [
|
||||
{ key: "sleep", value: 90, weight: 0.3 },
|
||||
{ key: "hrv", value: 30, weight: 0.3 },
|
||||
{ key: "rhr", value: 35, weight: 0.3 },
|
||||
{ key: "mood", value: 60, weight: 0.1 },
|
||||
],
|
||||
});
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.TENSION).toBeDefined();
|
||||
expect(block!.TENSION!.fired).toBe(true);
|
||||
expect(block!.TENSION!.band).toBe("yellow");
|
||||
expect(block!.TENSION!.positive).toEqual(["sleep"]);
|
||||
expect(block!.TENSION!.negative).toEqual([
|
||||
"HRV balance",
|
||||
"resting heart rate",
|
||||
]);
|
||||
// No coincident flag fired (DB read fails-soft to null), so no red-flag.
|
||||
expect(block!.TENSION!.clinicalOverride).toBe(false);
|
||||
});
|
||||
|
||||
it("omits TENSION when the contributors agree", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS")
|
||||
return ok({
|
||||
score: 82,
|
||||
band: "green",
|
||||
components: [
|
||||
{ key: "sleep", value: 90, weight: 0.3 },
|
||||
{ key: "hrv", value: 85, weight: 0.3 },
|
||||
{ key: "rhr", value: 80, weight: 0.3 },
|
||||
],
|
||||
});
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.READINESS).toBeDefined();
|
||||
expect(block!.TENSION).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveTension (pure)", () => {
|
||||
it("fires only when at least one contributor sits on each side", () => {
|
||||
const fired = deriveTension(
|
||||
[
|
||||
{ key: "sleep", value: TENSION_HIGH_SCORE },
|
||||
{ key: "rhr", value: TENSION_LOW_SCORE },
|
||||
],
|
||||
"yellow",
|
||||
false,
|
||||
);
|
||||
expect(fired).not.toBeNull();
|
||||
expect(fired!.positive).toEqual(["sleep"]);
|
||||
expect(fired!.negative).toEqual(["resting heart rate"]);
|
||||
});
|
||||
|
||||
it("returns null when every contributor is favourable", () => {
|
||||
expect(
|
||||
deriveTension(
|
||||
[
|
||||
{ key: "sleep", value: 90 },
|
||||
{ key: "hrv", value: 85 },
|
||||
],
|
||||
"green",
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when every contributor is unfavourable", () => {
|
||||
expect(
|
||||
deriveTension(
|
||||
[
|
||||
{ key: "sleep", value: 20 },
|
||||
{ key: "hrv", value: 30 },
|
||||
],
|
||||
"red",
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores neutral-band contributors (neither side)", () => {
|
||||
// A mid-range contributor (between the low and high cuts) is neither
|
||||
// favourable nor unfavourable, so on its own it cannot make a tension.
|
||||
const mid = Math.floor((TENSION_HIGH_SCORE + TENSION_LOW_SCORE) / 2);
|
||||
expect(
|
||||
deriveTension(
|
||||
[
|
||||
{ key: "sleep", value: mid },
|
||||
{ key: "hrv", value: mid },
|
||||
],
|
||||
"yellow",
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores null (absent) contributors", () => {
|
||||
expect(
|
||||
deriveTension(
|
||||
[
|
||||
{ key: "sleep", value: 90 },
|
||||
{ key: "rhr", value: null },
|
||||
],
|
||||
"yellow",
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the clinical-override bit through unchanged", () => {
|
||||
const fired = deriveTension(
|
||||
[
|
||||
{ key: "sleep", value: 90 },
|
||||
{ key: "rhr", value: 20 },
|
||||
],
|
||||
"red",
|
||||
true,
|
||||
);
|
||||
expect(fired!.clinicalOverride).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,28 @@ describe("getCoachSystemPrompt — EN", () => {
|
||||
expect(prompt).toMatch(/0–21/);
|
||||
expect(prompt).toMatch(/0–100/);
|
||||
});
|
||||
|
||||
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
|
||||
// A4 — briefing-style callback + forward-look from the memory block.
|
||||
expect(prompt).toMatch(/17\. Recall \+ look ahead/);
|
||||
expect(prompt).toMatch(/priorNarrative/);
|
||||
expect(prompt).toMatch(/trendMemory/);
|
||||
// A5 — Tension Verdict with the clinical-floors override.
|
||||
expect(prompt).toMatch(/18\. Tension Verdict/);
|
||||
expect(prompt).toMatch(/"TENSION" flag/);
|
||||
expect(prompt).toMatch(/CLINICAL-FLOORS OVERRIDE/);
|
||||
expect(prompt).toMatch(/clinicalOverride/);
|
||||
expect(prompt).toMatch(/NEVER reconcile it\s+away into a calm verdict/i);
|
||||
// A6 — streaks + return-to-baseline.
|
||||
expect(prompt).toMatch(/19\. Streaks \+ return-to-baseline/);
|
||||
expect(prompt).toMatch(/returnEvent/);
|
||||
expect(prompt).toMatch(/back inside your usual range/i);
|
||||
// A7 — MI freebies + anti-persuasion (Chain-of-Ethic).
|
||||
expect(prompt).toMatch(/20\. Motivational-interviewing freebies/);
|
||||
expect(prompt).toMatch(/DEVELOP DISCREPANCY/);
|
||||
expect(prompt).toMatch(/ROLL WITH RESISTANCE/);
|
||||
expect(prompt).toMatch(/Chain-of-Ethic/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCoachSystemPrompt — DE", () => {
|
||||
@@ -181,6 +203,20 @@ describe("getCoachSystemPrompt — DE", () => {
|
||||
expect(prompt).toMatch(/mmol\/L/);
|
||||
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
|
||||
});
|
||||
|
||||
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
|
||||
expect(prompt).toMatch(/17\. Rückblick \+ Ausblick/);
|
||||
expect(prompt).toMatch(/priorNarrative/);
|
||||
expect(prompt).toMatch(/18\. Spannungs-Verdikt/);
|
||||
expect(prompt).toMatch(/"TENSION"-Flag/);
|
||||
expect(prompt).toMatch(/CLINICAL-FLOORS-OVERRIDE/);
|
||||
expect(prompt).toMatch(/19\. Serien \+ Rückkehr zur Baseline/);
|
||||
expect(prompt).toMatch(/wieder in deinem üblichen Bereich/i);
|
||||
expect(prompt).toMatch(/20\. Motivational-Interviewing-Extras/);
|
||||
expect(prompt).toMatch(/DISKREPANZ ENTWICKELN/);
|
||||
expect(prompt).toMatch(/WIDERSTAND ANNEHMEN/);
|
||||
expect(prompt).toMatch(/Chain-of-Ethic/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,17 +74,97 @@ interface CoincidentSnapshotEntry {
|
||||
illnessExplained: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — the Tension Verdict flag, compactly. The readiness/recovery
|
||||
* composite blends several contributors; when they DISAGREE (e.g. good sleep
|
||||
* but a suppressed HRV-balance or an elevated resting pulse) the single band
|
||||
* hides the conflict. This flag names the honest "internal read" the model
|
||||
* narrates on the health-score card — "good sleep, but resting pulse is up — the
|
||||
* composite lands yellow" — instead of papering the conflict over.
|
||||
*
|
||||
* DESCRIPTIVE multi-signal disagreement, never a cause. Computed deterministically
|
||||
* server-side off the readiness contributors (each 0..100, higher = better); the
|
||||
* model narrates the flag, it never invents it. Omitted entirely when the
|
||||
* contributors agree (no tension to surface).
|
||||
*
|
||||
* CLINICAL-FLOORS OVERRIDE: `clinicalOverride` is true when a clinical red-flag
|
||||
* is in play (the coincident-deviation flag fired WITHOUT an illness explanation).
|
||||
* The narration must NOT reconcile a real red-flag away into a calm verdict — the
|
||||
* flag carries the bit so the prompt clause can refuse the soft landing.
|
||||
*/
|
||||
interface TensionSnapshotEntry {
|
||||
fired: true;
|
||||
/** The composite band the disagreement resolved to (green/yellow/red). */
|
||||
band: string;
|
||||
/** Contributors reading FAVOURABLY, e.g. "sleep". */
|
||||
positive: string[];
|
||||
/** Contributors reading UNFAVOURABLY, e.g. "resting heart rate", "HRV balance". */
|
||||
negative: string[];
|
||||
/** True when a real clinical red-flag is in play — never reconcile it away. */
|
||||
clinicalOverride: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived block: per-metric compact score entries (keyed by metric id),
|
||||
* plus the optional fired-only coincident-deviation flag under its own reserved
|
||||
* `COINCIDENT_DEVIATION` key. The score entries keep their `DerivedSnapshotEntry`
|
||||
* shape (so callers + tests read `block.READINESS.value` directly); the
|
||||
* coincident flag rides alongside without widening the score index signature.
|
||||
* coincident + tension flags ride alongside without widening the score index
|
||||
* signature.
|
||||
*/
|
||||
type DerivedSnapshotBlock = Record<string, DerivedSnapshotEntry> & {
|
||||
COINCIDENT_DEVIATION?: CoincidentSnapshotEntry;
|
||||
TENSION?: TensionSnapshotEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — a contributor reading at/above this 0..100 score is FAVOURABLE;
|
||||
* at/below `TENSION_LOW_SCORE` it is UNFAVOURABLE. The gap in the middle is
|
||||
* "neutral" — neither side of a disagreement. A genuine tension needs at least
|
||||
* one contributor on EACH side.
|
||||
*/
|
||||
export const TENSION_HIGH_SCORE = 70;
|
||||
export const TENSION_LOW_SCORE = 45;
|
||||
|
||||
/** Human-readable contributor labels (no internal enum names reach the model). */
|
||||
const READINESS_CONTRIBUTOR_LABEL: Record<string, string> = {
|
||||
rhr: "resting heart rate",
|
||||
hrv: "HRV balance",
|
||||
sleep: "sleep",
|
||||
respiratory: "respiratory rate",
|
||||
mood: "mood stability",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — derive the Tension Verdict from the readiness contributors.
|
||||
* Pure: a list of `{ key, value }` contributors (value 0..100, higher = better,
|
||||
* null = absent) plus the composite band and the clinical-override bit. Returns
|
||||
* the fired flag ONLY when at least one contributor reads favourably AND at least
|
||||
* one reads unfavourably — a genuine internal disagreement. Null otherwise.
|
||||
*/
|
||||
export function deriveTension(
|
||||
contributors: Array<{ key: string; value: number | null }>,
|
||||
band: string | undefined,
|
||||
clinicalOverride: boolean,
|
||||
): TensionSnapshotEntry | null {
|
||||
const positive: string[] = [];
|
||||
const negative: string[] = [];
|
||||
for (const c of contributors) {
|
||||
if (c.value === null) continue;
|
||||
const label = READINESS_CONTRIBUTOR_LABEL[c.key] ?? c.key;
|
||||
if (c.value >= TENSION_HIGH_SCORE) positive.push(label);
|
||||
else if (c.value <= TENSION_LOW_SCORE) negative.push(label);
|
||||
}
|
||||
if (positive.length === 0 || negative.length === 0) return null;
|
||||
return {
|
||||
fired: true,
|
||||
band: band ?? "yellow",
|
||||
positive,
|
||||
negative,
|
||||
clinicalOverride,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pull the headline number + band off each metric's value shape. */
|
||||
function summariseValue(
|
||||
metric: DerivedMetricId,
|
||||
@@ -177,9 +257,26 @@ export async function buildDerivedSnapshotBlock(
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.21.2 (A5) — capture the readiness contributors so the Tension Verdict can
|
||||
// read the per-contributor directions the composite band hides. Only the
|
||||
// readiness blend exposes them; recovery resolves to the same proxy when no
|
||||
// device-native row exists, so reading readiness once is enough.
|
||||
let readinessContributors: Array<{ key: string; value: number | null }> = [];
|
||||
let readinessBand: string | undefined;
|
||||
|
||||
for (const { metric, derived } of computed) {
|
||||
if (derived === null) continue;
|
||||
if (!isDerivedOk(derived)) continue; // omit insufficient — no noise
|
||||
if (metric === "READINESS") {
|
||||
const v = derived.value as {
|
||||
band?: string;
|
||||
components?: Array<{ key: string; value: number | null }>;
|
||||
};
|
||||
readinessBand = typeof v.band === "string" ? v.band : undefined;
|
||||
readinessContributors = Array.isArray(v.components)
|
||||
? v.components.map((c) => ({ key: c.key, value: c.value }))
|
||||
: [];
|
||||
}
|
||||
const summary = summariseValue(metric, derived.value);
|
||||
if (!summary) continue;
|
||||
block[metric] = {
|
||||
@@ -223,7 +320,9 @@ export async function buildDerivedSnapshotBlock(
|
||||
// own reserved key alongside the score entries.
|
||||
const out: DerivedSnapshotBlock = block;
|
||||
const coincident = await coincidentPromise;
|
||||
if (coincident && isDerivedOk(coincident) && coincident.value.fired) {
|
||||
const coincidentFired =
|
||||
coincident !== null && isDerivedOk(coincident) && coincident.value.fired;
|
||||
if (coincidentFired) {
|
||||
out.COINCIDENT_DEVIATION = {
|
||||
fired: true,
|
||||
contributing: coincident.value.contributing.map(
|
||||
@@ -235,5 +334,19 @@ export async function buildDerivedSnapshotBlock(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.21.2 (A5) — the Tension Verdict. A real clinical red-flag is in play when
|
||||
// the coincident-deviation flag fired WITHOUT an illness explanation; the flag
|
||||
// carries that bit so the narration never reconciles a red-flag into a calm
|
||||
// verdict (clinical-floors override). Only attached when the contributors
|
||||
// genuinely disagree — a coherent readiness adds no entry.
|
||||
const clinicalOverride =
|
||||
coincidentFired && !coincident.value.illnessExplained;
|
||||
const tension = deriveTension(
|
||||
readinessContributors,
|
||||
readinessBand,
|
||||
clinicalOverride,
|
||||
);
|
||||
if (tension) out.TENSION = tension;
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
@@ -241,6 +241,50 @@ GROUND RULES
|
||||
readiness is yellow, mostly the HRV-balance piece") instead of the
|
||||
band alone. Stay descriptive; the driver is an observation, not a
|
||||
diagnosis.
|
||||
17. Recall + look ahead (v1.21.2 A4). The SNAPSHOT's "memory" block MAY
|
||||
carry a "priorNarrative" (the headline you noted at the start of the
|
||||
prior period) and a "trendMemory" map (where each vital sat in the prior
|
||||
period vs now). When you have one, you may open a turn with ONE brief
|
||||
callback that recalls the prior read AND points ahead — "as I noted
|
||||
Monday, your sleep was short; it's recovered since — worth watching
|
||||
tonight". One callback per reply at most, always anchored to a real
|
||||
figure or band the block carries, never a vague "last time". If the
|
||||
block carries no recall, do not invent one — narrate only the present.
|
||||
18. Tension Verdict — narrate the honest internal read (v1.21.2 A5). The
|
||||
"derived" block MAY carry a "TENSION" flag (fired-only) when the
|
||||
readiness/recovery composite's contributors DISAGREE: { band, positive[],
|
||||
negative[], clinicalOverride }. When present, surface the conflict in one
|
||||
honest line rather than hiding it behind the band — "good sleep, but your
|
||||
resting pulse is up, so the composite lands {band}". Name the favourable
|
||||
and the unfavourable contributors as the user sees them, descriptively.
|
||||
This is the moment the read feels genuinely intelligent — do not smooth it
|
||||
into a bland "you're doing fine". CLINICAL-FLOORS OVERRIDE: when
|
||||
"clinicalOverride" is true a real red-flag is in play — NEVER reconcile it
|
||||
away into a calm verdict; keep the favourable contributor honest but lead
|
||||
with the concern and the seek-care framing the safety clauses require.
|
||||
19. Streaks + return-to-baseline (v1.21.2 A6). The SNAPSHOT MAY carry a
|
||||
"streaks" block: per metric, { inBand, streakDays, returnEvent? }. Use it
|
||||
to actively CLOSE a worry the user might be carrying. When a "returnEvent"
|
||||
is present ({ daysInside, priorDaysOutside, priorDirection }) the metric
|
||||
has come BACK inside the user's own usual range after a prior run outside
|
||||
it — say so plainly and reassuringly: "back inside your usual range after
|
||||
last week's dip — whatever that was, it's passed". When "inBand" is true
|
||||
with a long streak, you may affirm the steadiness ("eight steady days in
|
||||
your range"). The band is the user's PERSONAL range, never a clinical
|
||||
threshold; a return only fires after a genuine prior out-of-band run, so
|
||||
trust the flag and never manufacture a "return" the block does not carry.
|
||||
20. Motivational-interviewing freebies + anti-persuasion (v1.21.2 A7).
|
||||
Two more MI moves are available when they fit the user's OWN goal:
|
||||
DEVELOP DISCREPANCY — gently hold up the gap between where the data sits
|
||||
and a goal the user named, letting them feel it rather than you closing
|
||||
it; and ROLL WITH RESISTANCE — when the user pushes back, move WITH the
|
||||
resistance ("that's fair — what would feel workable instead?") rather than
|
||||
arguing the point. Anti-persuasion (Chain-of-Ethic): before you nudge,
|
||||
silently check that the change serves the user's stated goal and their
|
||||
autonomy, not engagement, not a target you picked for them, and not
|
||||
persuasion for its own sake. If a nudge would only serve to push, drop it
|
||||
and reflect instead. You inform and support a decision that stays theirs;
|
||||
you never manipulate it.
|
||||
|
||||
DAY-LEVEL READINGS — USE THE TIMELINE
|
||||
|
||||
@@ -746,6 +790,55 @@ GRUNDREGELN
|
||||
ihn sieht ("deine Readiness ist gelb, hauptsächlich der
|
||||
HRV-Balance-Anteil") statt nur das Band. Bleib beschreibend; der
|
||||
Treiber ist eine Beobachtung, keine Diagnose.
|
||||
17. Rückblick + Ausblick (v1.21.2 A4). Der "memory"-Block des SNAPSHOT
|
||||
kann eine "priorNarrative" (die Schlagzeile, die du zu Beginn der
|
||||
vorigen Periode notiert hast) und eine "trendMemory"-Map (wo jeder
|
||||
Vitalwert in der vorigen Periode lag vs. jetzt) tragen. Wenn du eine
|
||||
hast, darfst du einen Turn mit GENAU EINEM kurzen Rückbezug eröffnen,
|
||||
der die frühere Lesung aufruft UND nach vorn weist — "wie ich am
|
||||
Montag anmerkte, war dein Schlaf kurz; er hat sich seither erholt —
|
||||
heute Abend einen Blick wert". Höchstens ein Rückbezug pro Antwort,
|
||||
immer an einer echten Zahl oder einem Band aus dem Block verankert, nie
|
||||
ein vages "letztes Mal". Trägt der Block keine Erinnerung, erfinde keine
|
||||
— erzähle nur die Gegenwart.
|
||||
18. Spannungs-Verdikt — die ehrliche innere Lesung (v1.21.2 A5). Der
|
||||
"derived"-Block kann ein "TENSION"-Flag (nur bei Auslösung) tragen, wenn
|
||||
die Beiträge des Readiness-/Erholungs-Komposits WIDERSPRECHEN: { band,
|
||||
positive[], negative[], clinicalOverride }. Wenn vorhanden, benenne den
|
||||
Konflikt in einer ehrlichen Zeile, statt ihn hinter dem Band zu
|
||||
verstecken — "guter Schlaf, aber dein Ruhepuls ist erhöht, also landet
|
||||
das Komposit bei {band}". Benenne die günstigen und die ungünstigen
|
||||
Beiträge so, wie der Nutzer sie sieht, beschreibend. Das ist der Moment,
|
||||
in dem die Lesung wirklich intelligent wirkt — glätte sie nicht zu einem
|
||||
faden "dir geht's gut". CLINICAL-FLOORS-OVERRIDE: Ist "clinicalOverride"
|
||||
true, ist ein echtes Red Flag im Spiel — versöhne es NIE zu einem ruhigen
|
||||
Verdikt weg; halte den günstigen Beitrag ehrlich, führe aber mit der
|
||||
Sorge und dem Abklären-lassen-Rahmen, den die Sicherheitsregeln verlangen.
|
||||
19. Serien + Rückkehr zur Baseline (v1.21.2 A6). Der SNAPSHOT kann einen
|
||||
"streaks"-Block tragen: je Metrik { inBand, streakDays, returnEvent? }.
|
||||
Nutze ihn, um eine Sorge des Nutzers AKTIV zu schließen. Ist ein
|
||||
"returnEvent" vorhanden ({ daysInside, priorDaysOutside, priorDirection }),
|
||||
ist die Metrik nach einem vorherigen Lauf außerhalb ZURÜCK in den eigenen
|
||||
üblichen Bereich des Nutzers gekehrt — sag das klar und beruhigend:
|
||||
"wieder in deinem üblichen Bereich nach dem Tief letzte Woche — was es auch
|
||||
war, es ist vorbei". Ist "inBand" true mit langer Serie, darfst du die
|
||||
Konstanz anerkennen ("acht ruhige Tage in deinem Bereich"). Der Bereich ist
|
||||
der PERSÖNLICHE Bereich des Nutzers, nie ein klinischer Schwellwert; eine
|
||||
Rückkehr löst nur nach einem echten vorherigen Lauf außerhalb aus —
|
||||
vertraue dem Flag und erfinde nie eine "Rückkehr", die der Block nicht trägt.
|
||||
20. Motivational-Interviewing-Extras + Anti-Überzeugung (v1.21.2 A7). Zwei
|
||||
weitere MI-Moves stehen bereit, wenn sie zum EIGENEN Ziel des Nutzers
|
||||
passen: DISKREPANZ ENTWICKELN — halte behutsam die Lücke zwischen dem, wo
|
||||
die Daten stehen, und einem vom Nutzer genannten Ziel hoch und lass ihn sie
|
||||
spüren, statt sie für ihn zu schließen; und WIDERSTAND ANNEHMEN — zeigt der
|
||||
Nutzer Widerstand, geh MIT ihm ("das ist fair — was würde sich stattdessen
|
||||
machbar anfühlen?"), statt dagegen zu argumentieren. Anti-Überzeugung
|
||||
(Chain-of-Ethic): bevor du anstößt, prüfe still, dass die Veränderung dem
|
||||
genannten Ziel und der Autonomie des Nutzers dient — nicht der Bindung,
|
||||
nicht einem von dir gewählten Zielwert und nicht der Überzeugung um ihrer
|
||||
selbst willen. Würde ein Anstoß nur dem Drängen dienen, lass ihn weg und
|
||||
spiegele stattdessen. Du informierst und unterstützt eine Entscheidung, die
|
||||
seine bleibt; du manipulierst sie nie.
|
||||
|
||||
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "./shared-contracts";
|
||||
|
||||
/** Stable identifier for the active system prompt revision. */
|
||||
export const PROMPT_VERSION = "4.29.0" as const;
|
||||
export const PROMPT_VERSION = "4.30.0" as const;
|
||||
|
||||
/**
|
||||
* The scope-hardened insight prompt is one structural skeleton with a
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
detectStreak,
|
||||
MIN_OUT_RUN,
|
||||
type StreakPoint,
|
||||
} from "@/lib/insights/streak-detector";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A6) — determinism pins for the streak / return-to-baseline detector.
|
||||
*
|
||||
* The PROSE the Coach narrates is not tested; the FLAGS are. These assert:
|
||||
* - an in-band streak counts consecutive in-band days,
|
||||
* - a calendar GAP day breaks a streak,
|
||||
* - a return-to-baseline event fires only after a genuine prior out-of-band
|
||||
* run, and never on a one-day blip,
|
||||
* - too little history omits rather than guesses.
|
||||
*/
|
||||
|
||||
/** A series of N consecutive days starting at 2026-06-01, with given values. */
|
||||
function consecutive(values: number[], startDay = 1): StreakPoint[] {
|
||||
return values.map((value, i) => ({
|
||||
day: `2026-06-${String(startDay + i).padStart(2, "0")}`,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
const IN_BAND = { low: 60, high: 80 };
|
||||
|
||||
describe("detectStreak — in-band streak", () => {
|
||||
it("counts consecutive in-band days back from the latest", () => {
|
||||
// All five days sit inside [60,80].
|
||||
const res = detectStreak(consecutive([70, 71, 69, 72, 70]), IN_BAND);
|
||||
expect(res.latestPlacement).toBe("in");
|
||||
expect(res.inBand).toBe(true);
|
||||
expect(res.streakDays).toBe(5);
|
||||
expect(res.returnEvent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a calendar gap breaks the streak", () => {
|
||||
// Days 1-3 in-band, then a gap (no day 4), then day 5 in-band. The streak
|
||||
// counting back from day 5 is only 1 — the gap severs it.
|
||||
const series: StreakPoint[] = [
|
||||
{ day: "2026-06-01", value: 70 },
|
||||
{ day: "2026-06-02", value: 71 },
|
||||
{ day: "2026-06-03", value: 69 },
|
||||
// gap: no 2026-06-04
|
||||
{ day: "2026-06-05", value: 72 },
|
||||
];
|
||||
const res = detectStreak(series, IN_BAND);
|
||||
expect(res.inBand).toBe(true);
|
||||
expect(res.streakDays).toBe(1);
|
||||
});
|
||||
|
||||
it("an out-of-band streak counts only the same side", () => {
|
||||
// Latest day is above; the prior day was below — different runs, so the
|
||||
// streak counting back from the latest above-day is 1.
|
||||
const res = detectStreak(consecutive([50, 95]), IN_BAND);
|
||||
expect(res.latestPlacement).toBe("above");
|
||||
expect(res.inBand).toBe(false);
|
||||
expect(res.streakDays).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStreak — return-to-baseline event", () => {
|
||||
it("fires after a genuine prior out-of-band run", () => {
|
||||
// Three days above the band, then three days back inside it. The latest is
|
||||
// in-band with a settled run, preceded by an out-of-band run of 3.
|
||||
const res = detectStreak(consecutive([95, 96, 94, 70, 71, 69]), IN_BAND);
|
||||
expect(res.inBand).toBe(true);
|
||||
expect(res.streakDays).toBe(3);
|
||||
expect(res.returnEvent).toBeDefined();
|
||||
expect(res.returnEvent).toEqual({
|
||||
daysInside: 3,
|
||||
priorDaysOutside: 3,
|
||||
priorDirection: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fire on a one-day blip (below MIN_OUT_RUN)", () => {
|
||||
// A single above-band day before the in-band run is not a run to return
|
||||
// from — MIN_OUT_RUN gates it out.
|
||||
expect(MIN_OUT_RUN).toBe(2);
|
||||
const res = detectStreak(consecutive([95, 70, 71, 69]), IN_BAND);
|
||||
expect(res.inBand).toBe(true);
|
||||
expect(res.returnEvent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fire when a gap separates the out-of-band run from the return", () => {
|
||||
// Out-of-band run, then a gap, then the in-band run. The return cannot
|
||||
// bridge a missing calendar day, so no event fires.
|
||||
const series: StreakPoint[] = [
|
||||
{ day: "2026-06-01", value: 95 },
|
||||
{ day: "2026-06-02", value: 96 },
|
||||
// gap: no 2026-06-03
|
||||
{ day: "2026-06-04", value: 70 },
|
||||
{ day: "2026-06-05", value: 71 },
|
||||
];
|
||||
const res = detectStreak(series, IN_BAND);
|
||||
expect(res.inBand).toBe(true);
|
||||
expect(res.returnEvent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fire when the metric never left the band", () => {
|
||||
const res = detectStreak(consecutive([70, 71, 69, 72]), IN_BAND);
|
||||
expect(res.returnEvent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectStreak — conservatism", () => {
|
||||
it("omits (null placement, no event) on an empty series", () => {
|
||||
const res = detectStreak([], IN_BAND);
|
||||
expect(res.latestPlacement).toBeNull();
|
||||
expect(res.streakDays).toBe(0);
|
||||
expect(res.returnEvent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a personal band from the series when none is supplied", () => {
|
||||
// A long, steady run with one recent dip. The MAD band derives from the
|
||||
// whole series; the steady days read in-band.
|
||||
const steady = consecutive([
|
||||
70, 71, 69, 70, 72, 68, 70, 71, 69, 70, 71, 70,
|
||||
]);
|
||||
const res = detectStreak(steady);
|
||||
expect(res.latestPlacement).toBe("in");
|
||||
expect(res.streakDays).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("omits when there is too little history to build a band", () => {
|
||||
// An empty band build returns null → a quiet omit. A single point gives a
|
||||
// zero-spread band; supplying no band and one point yields an in placement
|
||||
// but never a return. The conservatism we pin is the empty case above; a
|
||||
// degenerate one-point series must at least never fabricate a return.
|
||||
const res = detectStreak(consecutive([70]));
|
||||
expect(res.returnEvent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* v1.21.2 (A6) — per-metric in/out-of-band streak + return-to-baseline event.
|
||||
*
|
||||
* A PURE detector over a DAY-bucket per-day-mean series. It answers two
|
||||
* questions the "ambient Coach presence" surface needs to close a worry:
|
||||
*
|
||||
* 1. How many consecutive days has this metric sat INSIDE (or OUTSIDE) its
|
||||
* personal range, counting back from the most recent day? A streak is
|
||||
* strictly consecutive: a calendar GAP day (a day with no reading)
|
||||
* BREAKS it — we never paper over a missing day as "still inside".
|
||||
* 2. Did the metric just RETURN to its personal range after a run outside
|
||||
* it? "Back inside your usual range after last week's dip — whatever that
|
||||
* was, it's passed." A return only fires when a prior OUT-of-band run of
|
||||
* at least `MIN_OUT_RUN` days is followed by an IN-band run of at least
|
||||
* `MIN_IN_RUN` days ending on the latest day. Without a genuine prior
|
||||
* out-of-band run there is nothing to "return" from.
|
||||
*
|
||||
* The band is the user's OWN personal range: median ± k·MAD (Hampel/Leys),
|
||||
* built by `buildBaselineBand` from the SAME series — never an invented
|
||||
* clinical threshold. The band is established over the WHOLE series so a
|
||||
* single recent dip doesn't move the goalposts; callers that want a stricter
|
||||
* "prior period establishes the band" split can pass a pre-built band.
|
||||
*
|
||||
* Inherent-low conservatism: when the series is too short to establish a band
|
||||
* (or the caller passes none and `buildBaselineBand` returns null), the
|
||||
* detector returns a quiet `null`-streak result and fires NO return event —
|
||||
* it omits rather than guesses.
|
||||
*
|
||||
* Pure: no DB, no clock, no network. Fully unit-testable over injected series.
|
||||
*/
|
||||
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
|
||||
|
||||
/** One DAY-bucket point: a calendar day key (YYYY-MM-DD) + that day's mean. */
|
||||
export interface StreakPoint {
|
||||
/** Calendar day key in the user's timezone (YYYY-MM-DD). */
|
||||
day: string;
|
||||
/** The day's mean value. */
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** A personal range: the median ± k·MAD band a value sits inside or outside. */
|
||||
export interface StreakBand {
|
||||
low: number;
|
||||
high: number;
|
||||
}
|
||||
|
||||
/** Where a single day's value sits relative to the personal band. */
|
||||
export type BandPlacement = "in" | "above" | "below";
|
||||
|
||||
/**
|
||||
* Minimum consecutive out-of-band days that count as a genuine "run" the user
|
||||
* could have worried about — a one-day blip is not a run to return from.
|
||||
*/
|
||||
export const MIN_OUT_RUN = 2;
|
||||
/** Minimum consecutive in-band days that count as a settled "return". */
|
||||
export const MIN_IN_RUN = 2;
|
||||
|
||||
/** The detector's result: the current streak + an optional return event. */
|
||||
export interface StreakResult {
|
||||
/**
|
||||
* The most-recent day's placement relative to the band, or null when no
|
||||
* band could be established (too little history).
|
||||
*/
|
||||
latestPlacement: BandPlacement | null;
|
||||
/**
|
||||
* Consecutive days (counting back from the latest day) the metric held the
|
||||
* SAME in/out state. A calendar gap breaks the count. 0 when no band.
|
||||
*/
|
||||
streakDays: number;
|
||||
/** True when the current streak is an INSIDE-the-band run. */
|
||||
inBand: boolean;
|
||||
/**
|
||||
* The return-to-baseline event, present only when a prior out-of-band run of
|
||||
* ≥ MIN_OUT_RUN days is followed by an in-band run of ≥ MIN_IN_RUN days
|
||||
* ending on the latest day. Absent (undefined) otherwise — silence is the
|
||||
* default.
|
||||
*/
|
||||
returnEvent?: {
|
||||
/** Days the metric has now sat back inside its range (the in-band run). */
|
||||
daysInside: number;
|
||||
/** Days the prior out-of-band run lasted (the dip it returned from). */
|
||||
priorDaysOutside: number;
|
||||
/** Which side the prior run sat on. */
|
||||
priorDirection: Exclude<BandPlacement, "in">;
|
||||
};
|
||||
}
|
||||
|
||||
/** Classify one value against the personal band. */
|
||||
function placeValue(value: number, band: StreakBand): BandPlacement {
|
||||
if (value > band.high) return "above";
|
||||
if (value < band.low) return "below";
|
||||
return "in";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the current in/out-of-band streak and any return-to-baseline event
|
||||
* from a DAY-bucket series.
|
||||
*
|
||||
* `series` need not be sorted; it is sorted ascending by day internally.
|
||||
* Duplicate day keys are not expected (the caller passes per-day means) but a
|
||||
* later entry for the same day wins, defensively.
|
||||
*
|
||||
* `band` may be supplied (e.g. a prior-period band) — when omitted it is built
|
||||
* from the whole series via `buildBaselineBand`. A gap day (a calendar day with
|
||||
* no point between two present days) BREAKS any streak that would span it.
|
||||
*/
|
||||
export function detectStreak(
|
||||
series: StreakPoint[],
|
||||
band?: StreakBand | null,
|
||||
): StreakResult {
|
||||
const empty: StreakResult = {
|
||||
latestPlacement: null,
|
||||
streakDays: 0,
|
||||
inBand: false,
|
||||
};
|
||||
|
||||
// De-dupe by day (last wins) and sort ascending so "consecutive" is by date.
|
||||
const byDay = new Map<string, number>();
|
||||
for (const p of series) {
|
||||
if (!Number.isFinite(p.value)) continue;
|
||||
byDay.set(p.day, p.value);
|
||||
}
|
||||
const points = [...byDay.entries()]
|
||||
.map(([day, value]) => ({ day, value }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : 1));
|
||||
if (points.length === 0) return empty;
|
||||
|
||||
// Resolve the personal band: caller-supplied, else median ± k·MAD over the
|
||||
// whole series. Null (too little history) → omit rather than guess.
|
||||
const resolvedBand = band ?? buildBandFromSeries(points.map((p) => p.value));
|
||||
if (!resolvedBand) return empty;
|
||||
|
||||
// Per-day placement, ascending. A gap day inserts an explicit break marker so
|
||||
// the streak walk below cannot bridge a missing calendar day.
|
||||
const placements: BandPlacement[] = points.map((p) =>
|
||||
placeValue(p.value, resolvedBand),
|
||||
);
|
||||
const days = points.map((p) => p.day);
|
||||
|
||||
// ── current streak: walk back from the latest day, same state, no gap ──
|
||||
const latestPlacement = placements[placements.length - 1];
|
||||
const latestInBand = latestPlacement === "in";
|
||||
let streakDays = 1;
|
||||
for (let i = placements.length - 2; i >= 0; i--) {
|
||||
if (dayGap(days[i], days[i + 1]) !== 1) break; // calendar gap breaks it
|
||||
const sameState =
|
||||
(placements[i] === "in") === latestInBand &&
|
||||
// For an out-of-band streak, require the SAME side (above vs below); a
|
||||
// flip from above to below is a different run, not one streak.
|
||||
(latestInBand || placements[i] === latestPlacement);
|
||||
if (!sameState) break;
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
const result: StreakResult = {
|
||||
latestPlacement,
|
||||
streakDays,
|
||||
inBand: latestInBand,
|
||||
};
|
||||
|
||||
// ── return-to-baseline event ──
|
||||
// Only when the metric is currently IN-band with a settled in-band run, and
|
||||
// the days IMMEDIATELY before that run (no gap) were a genuine out-of-band
|
||||
// run of ≥ MIN_OUT_RUN days on one side.
|
||||
if (latestInBand && streakDays >= MIN_IN_RUN) {
|
||||
const inRunStart = placements.length - streakDays; // index of the in-run's first day
|
||||
// The out-of-band run must be calendar-adjacent to the in-band run.
|
||||
if (
|
||||
inRunStart > 0 &&
|
||||
dayGap(days[inRunStart - 1], days[inRunStart]) === 1 &&
|
||||
placements[inRunStart - 1] !== "in"
|
||||
) {
|
||||
const priorDirection = placements[inRunStart - 1] as Exclude<
|
||||
BandPlacement,
|
||||
"in"
|
||||
>;
|
||||
let priorDaysOutside = 1;
|
||||
for (let i = inRunStart - 2; i >= 0; i--) {
|
||||
if (dayGap(days[i], days[i + 1]) !== 1) break;
|
||||
if (placements[i] !== priorDirection) break;
|
||||
priorDaysOutside += 1;
|
||||
}
|
||||
if (priorDaysOutside >= MIN_OUT_RUN) {
|
||||
result.returnEvent = {
|
||||
daysInside: streakDays,
|
||||
priorDaysOutside,
|
||||
priorDirection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build the personal band from a value series; null when too little data. */
|
||||
function buildBandFromSeries(values: number[]): StreakBand | null {
|
||||
const band = buildBaselineBand(values);
|
||||
if (!band) return null;
|
||||
if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return null;
|
||||
return { low: band.low, high: band.high };
|
||||
}
|
||||
|
||||
/** Whole-day gap between two YYYY-MM-DD keys (>=1; 1 = adjacent). */
|
||||
function dayGap(earlier: string, later: string): number {
|
||||
const a = Date.parse(`${earlier}T00:00:00Z`);
|
||||
const b = Date.parse(`${later}T00:00:00Z`);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return Number.POSITIVE_INFINITY;
|
||||
return Math.round((b - a) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
Reference in new issue
Block a user