Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:17:40 +02:00
commit 4c2dfa57b4
138 files changed
+12046 -137

No files matched your search

@@ -0,0 +1,281 @@
/**
* The matrix: what becomes a column, what never does, and what happens to a
* day that is missing something.
*
* The cases that matter are the absences. A day with no context row must not
* contribute a zero to a work-status column, because zero there means "worked
* a regular day and not overtime" and the truth is "nobody said". That
* distinction is invisible in the fitted numbers and would quietly teach the
* model that every unlogged day was a day off.
*/
import { describe, expect, it } from "vitest";
import {
MAX_MODEL_FEATURES,
MIN_FEATURE_COVERAGE,
buildFeatureMatrix,
extractDayFeatures,
screenFeatures,
standardiseDay,
type PrognosisContextInput,
type PrognosisDayInput,
} from "../features";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
const EMPTY_LINKED = {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
};
function context(
overrides: Partial<PrognosisContextInput> = {},
): PrognosisContextInput {
return {
workStatus: null,
workMinutes: null,
overtimeMinutes: null,
workLoad: null,
workSatisfaction: null,
contactCircles: [],
contactForm: null,
contactExtent: null,
contactQuality: null,
contactSupport: null,
leisureCategories: [],
leisureMinutes: null,
leisureJoy: null,
leisureRecovery: null,
eventType: null,
eventValence: null,
...overrides,
};
}
function day(
index: number,
overrides: Partial<PrognosisDayInput> = {},
): PrognosisDayInput {
const date = new Date(Date.UTC(2026, 0, 1 + index));
return {
day: date.toISOString().slice(0, 10),
a1: 5,
dimensions: {},
context: null,
linked: { ...EMPTY_LINKED },
...overrides,
};
}
describe("extractDayFeatures", () => {
it("takes A2 through the dimension table's inverse flag, not inline", () => {
const a2 = MOOD_DIMENSIONS.find((d) => d.key === "a2");
expect(a2?.inverse, "A2 is the inverse dimension this case is about").toBe(
true,
);
const features = extractDayFeatures(
day(0, { dimensions: { a2: 8, a3: 8 } }),
);
// Stress 8 is a heavy day, so on an up-is-better axis it reads as 2.
expect(features.get("dimension:a2")).toBe(2);
// A3 is not inverse and is carried literally.
expect(features.get("dimension:a3")).toBe(8);
});
it("never makes A1 an input", () => {
const features = extractDayFeatures(
day(0, { a1: 7, dimensions: { a1: 7, a2: 3 } }),
);
expect(features.has("dimension:a1")).toBe(false);
});
it("a day with no context contributes no context column at all", () => {
const features = extractDayFeatures(day(0, { context: null }));
expect(
[...features.keys()].filter((k) => k.startsWith("context:")),
).toEqual([]);
});
it("an unanswered field contributes no column; an answered one contributes zeros and a one", () => {
const answered = extractDayFeatures(
day(0, { context: context({ workStatus: "overtime" }) }),
);
expect(answered.get("context:workStatus=overtime")).toBe(1);
expect(answered.get("context:workStatus=off")).toBe(0);
const unanswered = extractDayFeatures(
day(0, { context: context({ workLoad: 4 }) }),
);
expect(unanswered.has("context:workStatus=overtime")).toBe(false);
expect(unanswered.has("context:workStatus=off")).toBe(false);
expect(unanswered.get("context:workLoad")).toBe(4);
});
it("a recorded zero is a value, and absence is absence", () => {
const zero = extractDayFeatures(
day(0, { linked: { ...EMPTY_LINKED, steps: 0 } }),
);
expect(zero.get("linked:steps")).toBe(0);
const absent = extractDayFeatures(day(0));
expect(absent.has("linked:steps")).toBe(false);
});
it("an empty multi-select says nothing rather than saying no to everything", () => {
const features = extractDayFeatures(
day(0, { context: context({ contactCircles: [] }) }),
);
expect(features.has("context:contactCircles=partner")).toBe(false);
});
});
describe("screening", () => {
/** A2 drives A1 exactly; everything else is constant or noise. */
function drivenDays(count: number): PrognosisDayInput[] {
return Array.from({ length: count }, (_, i) => {
const stress = i % 11;
return day(i, {
// Up-is-better A2 is `10 - stress`, and the target follows it.
a1: 10 - stress,
dimensions: { a2: stress },
});
});
}
it("keeps a feature that tracks the target", () => {
const screened = screenFeatures(drivenDays(40));
const a2 = screened.find((s) => s.key === "dimension:a2");
expect(a2?.selected, "a perfectly tracking feature was screened out").toBe(
true,
);
expect(a2?.pairs).toBe(40);
});
it("refuses a feature recorded on too few of the days, before testing it", () => {
const days = drivenDays(40);
// Recorded on a quarter of the days — under the coverage floor whatever
// it correlates with.
const covered = Math.floor(40 * MIN_FEATURE_COVERAGE) - 5;
for (let i = 0; i < covered; i++) {
days[i] = { ...days[i], linked: { ...EMPTY_LINKED, steps: 1000 + i } };
}
const screened = screenFeatures(days);
const steps = screened.find((s) => s.key === "linked:steps");
expect(steps?.selected).toBe(false);
expect(steps?.reason).toBe("coverage");
});
it("refuses a one-hot seen fewer times than the occurrence floor", () => {
const days = drivenDays(40).map((d, i) => ({
...d,
// Everybody worked; two days were overtime. The column is present on
// every day, so coverage passes and the occurrence floor is what
// refuses it.
context: context({ workStatus: i < 2 ? "overtime" : "regular" }),
}));
const screened = screenFeatures(days);
const overtime = screened.find(
(s) => s.key === "context:workStatus=overtime",
);
expect(overtime?.selected).toBe(false);
expect(overtime?.reason).toBe("occurrences");
});
it("refuses pure noise — the whole reason the FDR layer is here", () => {
// A deterministic pseudo-random series, so the case cannot flake.
let seed = 42;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
const days = Array.from({ length: 60 }, (_, i) =>
day(i, {
a1: Math.round(random() * 10),
dimensions: { a2: Math.round(random() * 10) },
linked: { ...EMPTY_LINKED, steps: Math.round(random() * 10000) },
}),
);
const selected = screenFeatures(days).filter((s) => s.selected);
expect(selected.map((s) => s.key)).toEqual([]);
});
it("caps the column count", () => {
expect(MAX_MODEL_FEATURES).toBe(12);
});
});
describe("buildFeatureMatrix", () => {
function drivenDays(count: number): PrognosisDayInput[] {
return Array.from({ length: count }, (_, i) =>
day(i, { a1: 10 - (i % 11), dimensions: { a2: i % 11 } }),
);
}
it("standardises per user, so the columns are that person's own units", () => {
const matrix = buildFeatureMatrix(drivenDays(40));
expect(matrix).not.toBeNull();
if (!matrix) return;
const column = matrix.rows.map((row) => row[0]);
const mean = column.reduce((s, v) => s + v, 0) / column.length;
const sd = Math.sqrt(
column.reduce((s, v) => s + (v - mean) * (v - mean), 0) / column.length,
);
expect(mean).toBeCloseTo(0, 6);
expect(sd).toBeCloseTo(1, 6);
// The standardisation is kept so a later day can be scored on the same
// scale rather than re-standardised against itself.
expect(matrix.standardisation[0].key).toBe(matrix.features[0]);
expect(matrix.standardisation[0].sd).toBeGreaterThan(0);
});
it("orders rows oldest first, which is what makes the validation honest", () => {
const matrix = buildFeatureMatrix(drivenDays(40));
expect(matrix).not.toBeNull();
if (!matrix) return;
const sorted = [...matrix.days].sort();
expect(matrix.days).toEqual(sorted);
});
it("drops a day that is missing a selected feature rather than filling it in", () => {
const days = drivenDays(40);
days[5] = { ...days[5], dimensions: {} };
const matrix = buildFeatureMatrix(days);
expect(matrix).not.toBeNull();
if (!matrix) return;
expect(matrix.days).not.toContain(days[5].day);
expect(matrix.rows).toHaveLength(39);
// Nothing was imputed in its place.
expect(matrix.targets).toHaveLength(39);
});
it("refuses when nothing survives the screening", () => {
const flat = Array.from({ length: 40 }, (_, i) => day(i, { a1: 5 }));
expect(buildFeatureMatrix(flat)).toBeNull();
});
it("refuses a constant target instead of dividing by zero", () => {
const constant = Array.from({ length: 40 }, (_, i) =>
day(i, { a1: 5, dimensions: { a2: i % 7 } }),
);
expect(buildFeatureMatrix(constant)).toBeNull();
});
});
describe("standardiseDay", () => {
it("scores a day on the stored scale", () => {
const row = standardiseDay(day(0, { dimensions: { a2: 5 } }), [
{ key: "dimension:a2", mean: 5, sd: 2 },
]);
// Up-is-better A2 of a stress-5 day is 5, the mean, so zero.
expect(row).toEqual([0]);
});
it("refuses a day missing one of the fit's features", () => {
expect(
standardiseDay(day(0, { dimensions: {} }), [
{ key: "dimension:a2", mean: 5, sd: 2 },
]),
).toBeNull();
});
});
@@ -0,0 +1,71 @@
/**
* `computeForecast` — the two floors a fit has to clear, and the refusals.
*
* The first floor is the ladder, on rated days. The second is the one this
* file exists for: a complete-case matrix can shrink well below the rated-day
* count, because a day missing one of the fit's inputs is dropped rather than
* imputed. An account with thirty rated days but a feature recorded on only
* two thirds of them fits on twenty complete rows, and a forecast "built from
* twenty days" past a floor that was supposed to mean thirty is a floor that
* does not hold. The fit is refused below `MIN_ENTRIES_FOR_FORECAST` complete
* rows, not only below thirty rated days.
*/
import { describe, expect, it } from "vitest";
import { computeForecast } from "../forecast";
import type { PrognosisDayInput } from "../features";
import { MIN_ENTRIES_FOR_FORECAST } from "../thresholds";
const EMPTY_LINKED = {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
};
function dayKey(index: number): string {
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
}
/**
* `rated` days carrying a rating driven by A2, of which the most recent
* `withFeature` actually carry the A2 slider. The rest are older face-only
* days: rated, but with no input the fit can use, so they drop out of the
* complete-case matrix. The feature sits on the RECENT days on purpose, so the
* days the write window would score are scorable — otherwise the refusal is
* "no recent day to score" rather than the "too few complete rows" this file
* is about.
*/
function sparseDays(rated: number, withFeature: number): PrognosisDayInput[] {
return Array.from({ length: rated }, (_, i) => {
const stress = i % 11;
const hasFeature = i >= rated - withFeature;
return {
day: dayKey(i),
a1: Math.round(0.8 * (10 - stress) + 1),
dimensions: hasFeature ? { a2: stress } : {},
context: null,
linked: { ...EMPTY_LINKED },
};
});
}
describe("computeForecast complete-row floor", () => {
it("refuses a fit that clears the rated-day floor but not the complete-row one", () => {
// 40 rated days, A2 on 25 of them: coverage 62.5% clears the candidate
// floor, so A2 is screened in, but the complete-case matrix is 25 rows —
// under the 30-row floor.
const result = computeForecast(sparseDays(40, 25));
expect(result.status, JSON.stringify(result)).toBe("refused");
});
it("writes once the complete rows themselves reach the floor", () => {
// Every rated day carries the feature, so complete rows equal rated days.
const result = computeForecast(sparseDays(40, 40));
expect(result.status).toBe("written");
if (result.status === "written") {
expect(result.n).toBeGreaterThanOrEqual(MIN_ENTRIES_FOR_FORECAST);
}
});
});
@@ -0,0 +1,205 @@
/**
* The minimum-n ladder, at every boundary, from both sides.
*
* Each case names the threshold it is about, so a red here says which floor
* moved rather than "expected false to be true". That matters more than usual:
* the thing these gates prevent is invisible on screen, so the test output is
* the only place the failure is ever legible.
*
* Every one of these has been watched failing — the comparison flipped from
* `>=` to `>`, the red read, the file restored and the hash checked — and one
* check was deleted outright to prove the absence of a gate fails too. The log
* is in the phase's execution notes.
*
* The assertions name the REASON, not just the absence: "no forecast" and "no
* forecast because there are only eleven entries and fifteen is where anything
* starts" are different answers, and only the second one can be shown to
* somebody.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
MIN_ENTRIES_FOR_FORECAST,
MIN_ENTRIES_FOR_REGULAR_FORECAST,
MIN_ENTRIES_FOR_SEASONAL,
MIN_ENTRIES_LEARNING_PHASE,
MIN_TAG_OCCURRENCES,
forecastGate,
occurrencesSuffice,
prognosisStage,
seasonalComparisonsUnlocked,
} from "../thresholds";
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
describe("the ladder is ordered and its rungs are the documented numbers", () => {
it("holds the values the concept asks for", () => {
expect(MIN_ENTRIES_LEARNING_PHASE).toBe(15);
expect(MIN_ENTRIES_FOR_FORECAST).toBe(30);
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60);
expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90);
});
it("rises strictly, so no rung can be reached before the one below it", () => {
expect(MIN_ENTRIES_LEARNING_PHASE).toBeLessThan(MIN_ENTRIES_FOR_FORECAST);
expect(MIN_ENTRIES_FOR_FORECAST).toBeLessThan(
MIN_ENTRIES_FOR_REGULAR_FORECAST,
);
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBeLessThan(
MIN_ENTRIES_FOR_SEASONAL,
);
});
});
describe("MIN_ENTRIES_LEARNING_PHASE — below it, nothing at all", () => {
it(`refuses at ${MIN_ENTRIES_LEARNING_PHASE - 1}, and says why`, () => {
const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE - 1);
expect(gate.present, "MIN_ENTRIES_LEARNING_PHASE let n-1 through").toBe(
false,
);
expect(gate).toEqual({
present: false,
reason: "no-output-yet",
stage: "insufficient",
entries: MIN_ENTRIES_LEARNING_PHASE - 1,
nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
});
});
it(`reaches the learning phase at exactly ${MIN_ENTRIES_LEARNING_PHASE}`, () => {
const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE);
expect(
gate,
"MIN_ENTRIES_LEARNING_PHASE did not admit its own value",
).toEqual({
present: false,
reason: "learning-phase",
stage: "learning",
entries: MIN_ENTRIES_LEARNING_PHASE,
nextThreshold: MIN_ENTRIES_FOR_FORECAST,
});
});
it("an empty account is absence, never a zero", () => {
const gate = forecastGate(0);
expect(gate.present).toBe(false);
expect(gate).not.toHaveProperty("predicted");
if (!gate.present) expect(gate.entries).toBe(0);
});
});
describe("MIN_ENTRIES_FOR_FORECAST — below it, no forecast", () => {
it(`refuses at ${MIN_ENTRIES_FOR_FORECAST - 1}, naming the learning phase`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST - 1);
expect(gate.present, "MIN_ENTRIES_FOR_FORECAST let n-1 through").toBe(
false,
);
if (!gate.present) {
expect(gate.reason).toBe("learning-phase");
expect(gate.nextThreshold).toBe(MIN_ENTRIES_FOR_FORECAST);
expect(gate.entries).toBe(MIN_ENTRIES_FOR_FORECAST - 1);
}
});
it(`forecasts at exactly ${MIN_ENTRIES_FOR_FORECAST}, labelled provisional`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST);
expect(gate.present, "MIN_ENTRIES_FOR_FORECAST refused its own value").toBe(
true,
);
if (gate.present) {
expect(gate.stage).toBe("provisional");
expect(gate.provisional).toBe(true);
}
});
});
describe("MIN_ENTRIES_FOR_REGULAR_FORECAST — below it, provisional", () => {
it(`is still provisional at ${MIN_ENTRIES_FOR_REGULAR_FORECAST - 1}`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST - 1);
expect(gate.present).toBe(true);
if (gate.present) {
expect(
gate.provisional,
"MIN_ENTRIES_FOR_REGULAR_FORECAST dropped the provisional label at n-1",
).toBe(true);
expect(gate.stage).toBe("provisional");
}
});
it(`drops the label at exactly ${MIN_ENTRIES_FOR_REGULAR_FORECAST}`, () => {
const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST);
expect(gate.present).toBe(true);
if (gate.present) {
expect(
gate.provisional,
"MIN_ENTRIES_FOR_REGULAR_FORECAST kept the provisional label at its own value",
).toBe(false);
expect(gate.stage).toBe("regular");
}
});
});
describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", () => {
it(`refuses the comparison at ${MIN_ENTRIES_FOR_SEASONAL - 1}`, () => {
expect(
seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL - 1),
"MIN_ENTRIES_FOR_SEASONAL unlocked the comparison at n-1",
).toBe(false);
expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL - 1)).toBe("regular");
});
it(`unlocks it at exactly ${MIN_ENTRIES_FOR_SEASONAL}`, () => {
expect(
seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL),
"MIN_ENTRIES_FOR_SEASONAL refused its own value",
).toBe(true);
expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL)).toBe("seasonal");
});
});
describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => {
it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => {
expect(
occurrencesSuffice(MIN_TAG_OCCURRENCES - 1),
"MIN_TAG_OCCURRENCES admitted a value seen n-1 times",
).toBe(false);
});
it(`admits at exactly ${MIN_TAG_OCCURRENCES}`, () => {
expect(
occurrencesSuffice(MIN_TAG_OCCURRENCES),
"MIN_TAG_OCCURRENCES refused its own value",
).toBe(true);
});
it("is the crosstab's floor rather than a second opinion about it", () => {
expect(MIN_TAG_OCCURRENCES).toBe(CONTEXT_MIN_PRESENT_DAYS);
});
});
describe("no second number is minted for an idea that already has one", () => {
const THRESHOLDS = readFileSync(join(__dirname, "../thresholds.ts"), "utf8");
it("the file really is the one under test", () => {
// The positive control: the two checks below pass on an empty read, so
// each needs proof the file was found and holds what it claims to.
expect(THRESHOLDS.length).toBeGreaterThan(1000);
expect(THRESHOLDS).toContain("MIN_ENTRIES_FOR_SEASONAL");
});
it("declares no paired-observation floor of its own", () => {
// `MIN_PAIRED_N` lives in `correlations.ts` and the screening imports it
// from there. A constant here named for the same idea would be a second
// answer to one question, and the two would drift.
expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRED/);
expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRS/);
});
it("takes the occurrence floor from the crosstab rather than restating it", () => {
expect(THRESHOLDS).toMatch(
/MIN_TAG_OCCURRENCES\s*=\s*CONTEXT_MIN_PRESENT_DAYS/,
);
});
});
@@ -0,0 +1,269 @@
/**
* The fit, and the one property that makes its band worth reading.
*
* The leakage case is the reason this file exists. A random k-fold over days
* produces a model that scores beautifully and predicts nothing, and there is
* nothing in the reported error that says so — which is why the assertion is
* on the FOLD INDICES and on the day keys themselves rather than on the error.
* An error-based check passes a leaking split whenever the leak happens to be
* small, which is exactly when nobody would look.
*
* Watched red: the split was replaced with a shuffle, and
* `describe("no training fold contains a day after its validation fold")`
* went red naming a future day inside a training fold. Reverted, hash checked.
*/
import { describe, expect, it } from "vitest";
import { buildFeatureMatrix, type PrognosisDayInput } from "../features";
import { fitRidge, predictWithFit, solveLinearSystem } from "../ridge";
import {
BAND_LOWER_QUANTILE,
BAND_UPPER_QUANTILE,
LAMBDA_GRID,
MIN_VALIDATION_ROWS,
MODEL_VERSION,
fitPrognosisModel,
quantile,
rollingOriginFolds,
} from "../validate";
const EMPTY_LINKED = {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
};
function dayKey(index: number): string {
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
}
/** A deterministic pseudo-random series — a flaky statistics test is useless. */
function seeded(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
}
/**
* Days whose target is a clean linear function of one dimension.
*
* One driver rather than two, and that is a statement about the screening
* rather than about the fit: the screening is univariate, so a feature that
* only matters jointly with another is refused before the fit ever sees it.
* A two-driver fixture measures that property, not this one.
*/
function linearDays(count: number): PrognosisDayInput[] {
return Array.from({ length: count }, (_, i) => {
const stress = i % 11;
return {
day: dayKey(i),
// 0.8 × up-is-better stress, plus one — inside the 0-10 scale.
a1: 0.8 * (10 - stress) + 1,
dimensions: { a2: stress, a3: (i * 7) % 11 },
context: null,
linked: { ...EMPTY_LINKED },
};
});
}
/** Days whose target is noise and whose inputs are unrelated noise. */
function noisyDays(count: number, seed = 7): PrognosisDayInput[] {
const random = seeded(seed);
return Array.from({ length: count }, (_, i) => ({
day: dayKey(i),
a1: Math.round(random() * 10),
dimensions: {
a2: Math.round(random() * 10),
a3: Math.round(random() * 10),
},
context: null,
linked: { ...EMPTY_LINKED },
}));
}
describe("no training fold contains a day after its validation fold", () => {
const ROWS = 60;
const folds = rollingOriginFolds(ROWS);
it("produces folds at all — the assertions below pass on an empty list", () => {
expect(folds.length).toBeGreaterThan(0);
expect(folds.length).toBeLessThanOrEqual(4);
// Every validation row is covered exactly once and the last fold reaches
// the end, so no day is silently dropped from the measurement.
expect(folds[folds.length - 1].validationEnd).toBe(ROWS);
});
it("trains on a prefix that ends exactly where the validation starts", () => {
for (const fold of folds) {
expect(fold.trainEnd).toBe(fold.validationStart);
expect(fold.trainEnd).toBeGreaterThan(0);
expect(fold.validationEnd).toBeGreaterThan(fold.validationStart);
}
});
it("holds on the day keys, not just on the indices", () => {
// The indices are only meaningful because the matrix is time-ordered, so
// the property is asserted against the dates the rows actually carry.
const matrix = buildFeatureMatrix(linearDays(ROWS));
expect(matrix).not.toBeNull();
if (!matrix) return;
for (const fold of rollingOriginFolds(matrix.rows.length)) {
const trainingDays = matrix.days.slice(0, fold.trainEnd);
const validationDays = matrix.days.slice(
fold.validationStart,
fold.validationEnd,
);
const latestTrainingDay = trainingDays[trainingDays.length - 1];
const earliestValidationDay = validationDays[0];
const leaked = trainingDays.filter((d) => d >= earliestValidationDay);
expect(
leaked,
`a training fold holds ${leaked.length} day(s) dated at or after the validation fold that starts on ${earliestValidationDay}`,
).toEqual([]);
expect(latestTrainingDay < earliestValidationDay).toBe(true);
}
});
it("expands rather than slides — later folds train on more history", () => {
const sizes = folds.map((f) => f.trainEnd);
for (let i = 1; i < sizes.length; i++) {
expect(sizes[i]).toBeGreaterThan(sizes[i - 1]);
}
});
it("refuses to split a window too short to measure anything on", () => {
expect(rollingOriginFolds(MIN_VALIDATION_ROWS - 1)).toEqual([]);
});
});
describe("the ridge solve", () => {
it("recovers the coefficients of a clean linear signal", () => {
// y = 2*x1 - 3*x2, standardised columns, tiny penalty.
const rows: number[][] = [];
const targets: number[] = [];
for (let i = 0; i < 40; i++) {
const x1 = ((i % 7) - 3) / 2;
const x2 = ((i % 5) - 2) / 1.5;
rows.push([x1, x2]);
targets.push(2 * x1 - 3 * x2 + 5);
}
const fit = fitRidge(rows, targets, 1e-6);
expect(fit).not.toBeNull();
if (!fit) return;
expect(fit.coefficients[0]).toBeCloseTo(2, 2);
expect(fit.coefficients[1]).toBeCloseTo(-3, 2);
// The intercept is the mean of the training targets, and the columns are
// centred against their own means, so a row at the column means predicts
// the average day exactly.
expect(predictWithFit(fit, fit.columnMeans)).toBeCloseTo(fit.intercept, 6);
// And every training row is predicted back to its own target.
rows.forEach((row, i) => {
expect(predictWithFit(fit, row)).toBeCloseTo(targets[i], 2);
});
});
it("does not divide by zero on a constant target", () => {
const rows = Array.from({ length: 20 }, (_, i) => [i / 10, (i % 3) / 2]);
const fit = fitRidge(rows, new Array(20).fill(4), 1);
expect(fit).not.toBeNull();
if (!fit) return;
expect(fit.coefficients.every(Number.isFinite)).toBe(true);
expect(fit.intercept).toBe(4);
expect(predictWithFit(fit, [0, 0])).toBeCloseTo(4, 6);
});
it("refuses a singular system rather than answering zeros", () => {
// Two identical columns and no penalty: the system has no unique answer,
// and a vector of zeros would look exactly like "these features do not
// matter".
expect(
solveLinearSystem(
[
[1, 1],
[1, 1],
],
[1, 1],
),
).toBeNull();
});
it("penalises: a larger lambda pulls the coefficients towards zero", () => {
const rows = Array.from({ length: 30 }, (_, i) => [((i % 7) - 3) / 2]);
const targets = rows.map((r) => 4 * r[0] + 5);
const light = fitRidge(rows, targets, 0.01);
const heavy = fitRidge(rows, targets, 100);
expect(light).not.toBeNull();
expect(heavy).not.toBeNull();
if (!light || !heavy) return;
expect(Math.abs(heavy.coefficients[0])).toBeLessThan(
Math.abs(light.coefficients[0]),
);
});
});
describe("the band reacts to how wrong the model is", () => {
it("is narrow on a clean signal and wide on noise", () => {
const clean = buildFeatureMatrix(linearDays(80));
expect(clean).not.toBeNull();
if (!clean) return;
const cleanModel = fitPrognosisModel(clean);
expect(cleanModel).not.toBeNull();
if (!cleanModel) return;
const cleanWidth = cleanModel.bandHigh - cleanModel.bandLow;
// Noise cannot go through the screening — it is refused before a fit is
// attempted, which is the stronger answer. So the band's reaction is
// measured on the matrix directly, with the screening bypassed by handing
// the fit a matrix built from the noisy targets and the clean columns.
const noisy = buildFeatureMatrix(linearDays(80));
expect(noisy).not.toBeNull();
if (!noisy) return;
const random = seeded(11);
const noisyMatrix = {
...noisy,
targets: noisy.targets.map(() => Math.round(random() * 10)),
};
const noisyModel = fitPrognosisModel(noisyMatrix);
expect(noisyModel).not.toBeNull();
if (!noisyModel) return;
const noisyWidth = noisyModel.bandHigh - noisyModel.bandLow;
expect(
noisyWidth,
"the band did not widen on noise — a confident wrong number is the failure this band exists to prevent",
).toBeGreaterThan(cleanWidth * 2);
expect(cleanWidth).toBeLessThan(2);
});
it("refuses noise at the screening, before any fit is attempted", () => {
expect(buildFeatureMatrix(noisyDays(80))).toBeNull();
});
it("takes the band from the validation residuals, at the stated quantiles", () => {
const matrix = buildFeatureMatrix(linearDays(80));
expect(matrix).not.toBeNull();
if (!matrix) return;
const model = fitPrognosisModel(matrix);
expect(model).not.toBeNull();
if (!model) return;
expect(model.bandLow).toBeLessThanOrEqual(model.bandHigh);
expect(BAND_LOWER_QUANTILE).toBe(0.1);
expect(BAND_UPPER_QUANTILE).toBe(0.9);
expect(model.n).toBe(matrix.rows.length);
expect(model.modelVersion).toBe(MODEL_VERSION);
expect(LAMBDA_GRID).toContain(model.fit.lambda);
// Never zero: an unpenalised solve memorises the training days.
expect(Math.min(...LAMBDA_GRID)).toBeGreaterThan(0);
});
it("quantile interpolates rather than rounding to a sample", () => {
expect(quantile([0, 10], 0.5)).toBe(5);
expect(quantile([1, 2, 3, 4], 0)).toBe(1);
expect(quantile([1, 2, 3, 4], 1)).toBe(4);
});
});
@@ -0,0 +1,98 @@
/**
* The names the model's inputs are known by, everywhere.
*
* One string per feature, stored in `MoodPrediction.features`, read back by
* the surface that names what the model weighted, and compared against by the
* tests. It is therefore a wire format in all but name: renaming a key
* silently orphans every stored row that used it, which is why the encoding is
* a function here rather than a template literal at four call sites.
*
* The shape is `<family>:<rest>`, and the four families are the four places an
* input can come from:
*
* * `dimension:a2` — a level-A self-rating (A1 is the target)
* * `context:workLoad` — a numeric field of the day's context
* * `context:workStatus=off` — one value of a closed context vocabulary
* * `linked:steps` — a figure read from the module that owns it
*
* Nothing here resolves a label. The label is a locale lookup and this module
* is pure and bundle-safe, so the resolver lives beside the component that
* renders it and takes a parsed key from here.
*/
import type { MoodDimensionKey } from "@/lib/mood/dimensions";
/** The linked figures the model may read, and the day-figure each names. */
export const LINKED_FEATURE_METRICS = [
"sleepAsleep",
"steps",
"activeEnergy",
"restingHeartRate",
"heartRateVariability",
] as const;
export type LinkedFeatureMetric = (typeof LINKED_FEATURE_METRICS)[number];
export type ParsedFeatureKey =
| { family: "dimension"; dimension: MoodDimensionKey }
| { family: "context"; field: string; value: null }
| { family: "context"; field: string; value: string }
| { family: "linked"; metric: LinkedFeatureMetric };
/** `dimension:a2` */
export function dimensionFeatureKey(dimension: MoodDimensionKey): string {
return `dimension:${dimension}`;
}
/** `context:workLoad` — a numeric field. */
export function contextNumericFeatureKey(field: string): string {
return `context:${field}`;
}
/** `context:workStatus=off` — one value of a closed vocabulary. */
export function contextValueFeatureKey(field: string, value: string): string {
return `context:${field}=${value}`;
}
/** `linked:steps` */
export function linkedFeatureKey(metric: LinkedFeatureMetric): string {
return `linked:${metric}`;
}
const DIMENSION_KEYS = new Set(["a1", "a2", "a3", "a4", "a5"]);
const LINKED_KEYS = new Set<string>(LINKED_FEATURE_METRICS);
/**
* Read a stored key back.
*
* `null` for anything this version does not recognise, and that case is real
* rather than defensive: a row written by an older `modelVersion` can name a
* feature the current build has dropped, and the surface has to skip it rather
* than render `undefined` at somebody.
*/
export function parseFeatureKey(key: string): ParsedFeatureKey | null {
const separator = key.indexOf(":");
if (separator < 0) return null;
const family = key.slice(0, separator);
const rest = key.slice(separator + 1);
if (rest.length === 0) return null;
if (family === "dimension") {
return DIMENSION_KEYS.has(rest)
? { family: "dimension", dimension: rest as MoodDimensionKey }
: null;
}
if (family === "linked") {
return LINKED_KEYS.has(rest)
? { family: "linked", metric: rest as LinkedFeatureMetric }
: null;
}
if (family === "context") {
const equals = rest.indexOf("=");
if (equals < 0) return { family: "context", field: rest, value: null };
const field = rest.slice(0, equals);
const value = rest.slice(equals + 1);
if (field.length === 0 || value.length === 0) return null;
return { family: "context", field, value };
}
return null;
}
@@ -0,0 +1,489 @@
/**
* One row per day, one column per thing the day was made of.
*
* The target is the day's own pleasantness (A1) and everything else is an
* input: the other four level-A dimensions, the numbers and the closed
* vocabularies of the day's context, and the figures the sleep, activity and
* recovery modules already hold for that day. A1 is the only target on
* purpose — five targets would be five ways to be wrong and five bands to
* explain.
*
* Four rules this file is built on, each of which has a way of being got
* wrong that looks fine on screen:
*
* 1. **Nothing is imputed. Ever.** A day with a missing input is not a day
* with an average input. The stated rule is complete cases: a day enters
* the fit only if it carries the target and every SELECTED feature, and a
* feature only becomes a candidate if it was recorded on at least
* {@link MIN_FEATURE_COVERAGE} of the days that carry a target. The
* coverage floor is what keeps the complete-case rule from quietly
* emptying the matrix: without it, one field somebody filled in twice
* would exclude every other day they ever logged.
* 2. **Standardised per user, not min-max.** A person whose stress never
* exceeds six must not have six read as "maximum stress". The mean and the
* spread come from their own fit window, so every feature is expressed in
* that person's own units — which is also what makes the stored
* contributions comparable to each other.
* 3. **Screened before it is fitted, with the multiple-comparison correction
* the rest of the product already runs.** A sweep over sixty context
* features at raw p < 0.05 finds three things that are not there. Pearson
* with the shared `MIN_PAIRED_N` floor, Benjamini-Hochberg at the shared
* `FDR_Q`, and the shared effect-size floor on the shrunk estimate — all
* imported, none re-tuned here.
* 4. **Orientation comes from the table that owns it.** A2 is inverse and is
* flipped through `MOOD_DIMENSIONS[i].inverse`, never inline, so the chart
* and the model read one source. Context ratings are NOT flipped: no table
* states their orientation, and inventing one here would be exactly the
* second opinion the dimension table exists to prevent. It costs nothing
* mathematically — a flip only changes the sign of a coefficient, and the
* stored contribution is signed anyway.
*/
import {
EFFECT_SIZE_FLOOR,
FDR_Q,
benjaminiHochberg,
shrinkEstimate,
} from "@/lib/insights/correlation-discovery";
import {
MAX_P_VALUE,
MIN_PAIRED_N,
pearson,
} from "@/lib/insights/correlations";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import type { MoodDimensionKey } from "@/lib/mood/dimensions";
import {
contextNumericFeatureKey,
contextValueFeatureKey,
dimensionFeatureKey,
linkedFeatureKey,
LINKED_FEATURE_METRICS,
} from "./feature-keys";
import { occurrencesSuffice } from "./thresholds";
/**
* Most features the fit may carry.
*
* Twelve over thirty days is already generous — a ridge penalty is what makes
* that survivable at all — and the cap is what stops a well-logged account
* from being fitted with sixty columns and a hundred rows.
*/
export const MAX_MODEL_FEATURES = 12;
/**
* Share of target-carrying days a feature must be recorded on to be a
* candidate at all.
*
* The other half of the complete-case rule. A field somebody filled in for a
* fortnight and then stopped is not a column of the matrix; it is a fortnight,
* and admitting it would silently discard every day outside that fortnight.
*/
export const MIN_FEATURE_COVERAGE = 0.6;
/** The day's context as the model reads it. The note never comes here. */
export interface PrognosisContextInput {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string[];
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string[];
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
}
/** One day, as everything upstream of the fit has resolved it. */
export interface PrognosisDayInput {
/** Local day key, `YYYY-MM-DD`. */
day: string;
/** The target: the day's own pleasantness, 0-10. `null` days are not fitted. */
a1: number | null;
/** The other four self-ratings, literal as the person set them. */
dimensions: Partial<Record<MoodDimensionKey, number | null>>;
/** The day's stored context, or `null` when no section was opened. */
context: PrognosisContextInput | null;
/** What the owning modules hold for the day. `null` is absence. */
linked: {
sleepAsleep: number | null;
steps: number | null;
activeEnergy: number | null;
restingHeartRate: number | null;
heartRateVariability: number | null;
};
}
/** The numeric context fields, in capture order. */
const CONTEXT_NUMERIC_FIELDS = [
"workMinutes",
"overtimeMinutes",
"workLoad",
"workSatisfaction",
"contactQuality",
"contactSupport",
"leisureMinutes",
"leisureJoy",
"leisureRecovery",
"eventValence",
] as const satisfies readonly (keyof PrognosisContextInput)[];
/** The single-valued vocabularies, each expanded to one column per value. */
const CONTEXT_SINGLE_VOCABULARIES = [
{ field: "workStatus", keys: WORK_STATUS_KEYS },
{ field: "contactForm", keys: CONTACT_FORM_KEYS },
{ field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ field: "eventType", keys: EVENT_TYPE_KEYS },
] as const;
/** The multi-select vocabularies, same expansion. */
const CONTEXT_MULTI_VOCABULARIES = [
{ field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
] as const;
function isNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
/**
* Every feature this day carries a value for.
*
* A feature the day says nothing about is ABSENT from the map — it is not
* zero. The difference is the whole of rule 1: a day with no context row has
* no work-status column at all, while a day whose work status is `off` has a
* zero in every other work-status column and a one in that one.
*/
export function extractDayFeatures(
day: PrognosisDayInput,
): Map<string, number> {
const out = new Map<string, number>();
for (const dimension of MOOD_DIMENSIONS) {
// A1 is the target, not an input. Fitting a value on itself would produce
// a perfect model of nothing.
if (dimension.key === "a1") continue;
const raw = day.dimensions[dimension.key];
if (!isNumber(raw)) continue;
// The orientation flip goes through the table that declares it, so a
// dimension that becomes inverse later moves the chart and the model
// together.
out.set(
dimensionFeatureKey(dimension.key),
dimension.inverse ? dimension.max - raw : raw,
);
}
const context = day.context;
if (context) {
for (const field of CONTEXT_NUMERIC_FIELDS) {
const raw = context[field];
if (isNumber(raw)) out.set(contextNumericFeatureKey(field), raw);
}
for (const { field, keys } of CONTEXT_SINGLE_VOCABULARIES) {
const value = context[field];
// A field nobody answered contributes no columns. Zeroing them would
// tell the fit "not overtime" on a day nobody said anything about work.
if (typeof value !== "string" || value.length === 0) continue;
for (const key of keys) {
out.set(contextValueFeatureKey(field, key), value === key ? 1 : 0);
}
}
for (const { field, keys } of CONTEXT_MULTI_VOCABULARIES) {
const values = context[field];
if (!Array.isArray(values) || values.length === 0) continue;
const chosen = new Set(values);
for (const key of keys) {
out.set(contextValueFeatureKey(field, key), chosen.has(key) ? 1 : 0);
}
}
}
for (const metric of LINKED_FEATURE_METRICS) {
const raw = day.linked[metric];
if (isNumber(raw)) out.set(linkedFeatureKey(metric), raw);
}
return out;
}
/** What the screening decided about one candidate, kept for the tests. */
export interface ScreenedFeature {
key: string;
/** Days carrying both the feature and the target. */
pairs: number;
r: number;
/** The shrunk estimate the effect floor is applied to. */
shrunk: number;
pValue: number;
qValue: number;
selected: boolean;
/** Why it was refused, when it was. */
reason?:
| "coverage"
| "too-few-pairs"
| "no-variance"
| "p-value"
| "fdr"
| "effect-size"
| "occurrences"
| "cap";
}
/**
* Which candidates earn a column, and why the others did not.
*
* Order of refusal matters and is the order below: coverage, then pairs, then
* the raw p, then the family-wide FDR, then the effect floor, then the cap.
* A feature that fails an earlier gate is not tested against a later one, so
* the family the FDR corrects over is the set that was actually tested.
*/
export function screenFeatures(
days: readonly PrognosisDayInput[],
): ScreenedFeature[] {
const targetDays = days.filter((d) => isNumber(d.a1));
const perDay = new Map<string, Map<string, number>>();
for (const day of targetDays) perDay.set(day.day, extractDayFeatures(day));
const candidates = new Set<string>();
for (const features of perDay.values()) {
for (const key of features.keys()) candidates.add(key);
}
const coverageFloor = targetDays.length * MIN_FEATURE_COVERAGE;
const refused: ScreenedFeature[] = [];
const tested: Array<{
key: string;
pairs: number;
r: number;
shrunk: number;
pValue: number;
}> = [];
for (const key of [...candidates].sort()) {
const xs: number[] = [];
const ys: number[] = [];
let occurrences = 0;
for (const day of targetDays) {
const value = perDay.get(day.day)?.get(key);
if (value === undefined) continue;
xs.push(value);
ys.push(day.a1 as number);
if (value !== 0) occurrences += 1;
}
if (xs.length < coverageFloor) {
refused.push(zero(key, xs.length, "coverage"));
continue;
}
// A one-hot column seen four times is not evidence, however well it
// correlates. This is the ladder's occurrence floor, not a second one.
if (!occurrencesSuffice(occurrences)) {
refused.push(zero(key, xs.length, "occurrences"));
continue;
}
const result = pearson({ xs, ys, minPairs: MIN_PAIRED_N });
if (result.status === "insufficient") {
refused.push(
zero(
key,
result.n,
result.reason === "no_variance" ? "no-variance" : "too-few-pairs",
),
);
continue;
}
if (result.pValue >= MAX_P_VALUE) {
refused.push({
key,
pairs: result.n,
r: result.r,
shrunk: shrinkEstimate(result.r, result.n),
pValue: result.pValue,
qValue: 1,
selected: false,
reason: "p-value",
});
continue;
}
tested.push({
key,
pairs: result.n,
r: result.r,
shrunk: shrinkEstimate(result.r, result.n),
pValue: result.pValue,
});
}
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
const survivors: ScreenedFeature[] = [];
tested.forEach((t, index) => {
const qValue = qValues[index] ?? 1;
if (qValue > FDR_Q) {
survivors.push({ ...t, qValue, selected: false, reason: "fdr" });
return;
}
if (Math.abs(t.shrunk) < EFFECT_SIZE_FLOOR) {
survivors.push({ ...t, qValue, selected: false, reason: "effect-size" });
return;
}
survivors.push({ ...t, qValue, selected: true });
});
// The cap keeps the strongest, and a stable tie-break by key keeps two runs
// over the same data returning the same columns.
const admitted = survivors
.filter((s) => s.selected)
.sort(
(a, b) =>
Math.abs(b.shrunk) - Math.abs(a.shrunk) || a.key.localeCompare(b.key),
);
for (const feature of admitted.slice(MAX_MODEL_FEATURES)) {
feature.selected = false;
feature.reason = "cap";
}
return [...survivors, ...refused].sort((a, b) => a.key.localeCompare(b.key));
}
function zero(
key: string,
pairs: number,
reason: ScreenedFeature["reason"],
): ScreenedFeature {
return {
key,
pairs,
r: 0,
shrunk: 0,
pValue: 1,
qValue: 1,
selected: false,
reason,
};
}
/** Per-feature centre and spread, over this account's own fit window. */
export interface FeatureStandardisation {
key: string;
mean: number;
sd: number;
}
export interface FeatureMatrix {
/** The selected feature keys, in column order. */
features: string[];
/** Standardised rows, one per complete day, oldest first. */
rows: number[][];
/** The target for each row, unstandardised (0-10). */
targets: number[];
/** The day each row is, so the validation can stay time-ordered. */
days: string[];
/** What each column was standardised by, needed to score a new day. */
standardisation: FeatureStandardisation[];
}
/**
* Build the matrix: screen, then keep the complete days, then standardise.
*
* Returns `null` when nothing survives the screening or when no day carries
* every surviving feature. That is a refusal, not an empty matrix — the caller
* writes no forecast rather than one built on nothing.
*/
export function buildFeatureMatrix(
days: readonly PrognosisDayInput[],
): FeatureMatrix | null {
const screened = screenFeatures(days);
const selected = screened.filter((s) => s.selected).map((s) => s.key);
if (selected.length === 0) return null;
const ordered = [...days]
.filter((d) => isNumber(d.a1))
.sort((a, b) => a.day.localeCompare(b.day));
const rawRows: number[][] = [];
const targets: number[] = [];
const dayKeys: string[] = [];
for (const day of ordered) {
const features = extractDayFeatures(day);
const row: number[] = [];
let complete = true;
for (const key of selected) {
const value = features.get(key);
if (value === undefined) {
complete = false;
break;
}
row.push(value);
}
// Complete cases only. A day missing one selected feature is a day the fit
// does not see; nothing is filled in for it.
if (!complete) continue;
rawRows.push(row);
targets.push(day.a1 as number);
dayKeys.push(day.day);
}
if (rawRows.length === 0) return null;
const standardisation = selected.map((key, column) => {
const values = rawRows.map((row) => row[column]);
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance =
values.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) /
values.length;
return { key, mean, sd: Math.sqrt(variance) };
});
// A column with no spread inside the complete-case set carries no
// information and would divide by zero. It goes, and if that empties the
// set the whole fit is refused.
const keep = standardisation
.map((s, index) => ({ ...s, index }))
.filter((s) => s.sd > 0);
if (keep.length === 0) return null;
return {
features: keep.map((k) => k.key),
rows: rawRows.map((row) => keep.map((k) => (row[k.index] - k.mean) / k.sd)),
targets,
days: dayKeys,
standardisation: keep.map(({ key, mean, sd }) => ({ key, mean, sd })),
};
}
/**
* Standardise one day's raw features against a stored fit, for scoring.
*
* `null` when the day does not carry every feature the fit uses — the same
* complete-case rule the matrix applies, asked of a single day. A day that
* cannot be scored gets no forecast, which is the honest answer.
*/
export function standardiseDay(
day: PrognosisDayInput,
standardisation: readonly FeatureStandardisation[],
): number[] | null {
const features = extractDayFeatures(day);
const row: number[] = [];
for (const { key, mean, sd } of standardisation) {
const value = features.get(key);
if (value === undefined || sd <= 0) return null;
row.push((value - mean) / sd);
}
return row;
}
@@ -0,0 +1,160 @@
/**
* One account's answer, computed and handed back as rows to write.
*
* Pure: it takes the days somebody logged and returns either the rows a pass
* should store or a refusal with the reason. It does not touch the database,
* and that is a boundary rather than a style — `MoodPrediction` has exactly
* one writer, the refresh job, and a guard holds it there
* (`src/__tests__/mood-prediction-write-surface-guard.test.ts`). Keeping the
* arithmetic here and the write there is what lets the whole decision path be
* tested without a container.
*
* **It refuses more often than it answers, and that is the design.** Too few
* days, no feature that clears the screening, no recent day carrying every
* feature the fit needs, a singular system — every one of them returns a
* refusal and the account is left with no forecast at all. The surface then
* says there is not enough to go on, which is true, instead of showing a
* number built out of three days and a coincidence.
*/
import {
buildFeatureMatrix,
standardiseDay,
type PrognosisDayInput,
} from "./features";
import { PROGNOSIS_WRITE_WINDOW_DAYS } from "./load";
import { featureContributions, predictWithFit } from "./ridge";
import { MIN_ENTRIES_FOR_FORECAST, forecastGate } from "./thresholds";
import { fitPrognosisModel } from "./validate";
/** The lowest and highest value the target scale can express. */
export const TARGET_MIN = 0;
export const TARGET_MAX = 10;
/** One stored contribution: what a feature moved the expected value by. */
export interface FeatureContribution {
feature: string;
contribution: number;
}
/** One day's forecast, in the shape the row is written in. */
export interface ForecastRow {
date: string;
predicted: number;
ciLow: number;
ciHigh: number;
n: number;
modelVersion: string;
/** Ordered by absolute contribution, largest first. */
contributions: FeatureContribution[];
}
/** Why an account gets no forecast. Every one of these is a normal outcome. */
export type ForecastRefusal =
/** Below the ladder's forecast threshold. */
| "too-few-days"
/** Nothing recorded often enough, or varied enough, to screen in. */
| "no-pattern"
/** A pattern, but no recent day carrying every feature it needs. */
| "no-scorable-day";
export type ForecastResult =
| {
status: "written";
rows: ForecastRow[];
/** Complete days the fit used. The `n` every surface must show. */
n: number;
modelVersion: string;
/** How wide the band came out, for the job's event only. */
bandWidth: number;
featureCount: number;
}
| { status: "refused"; reason: ForecastRefusal };
function clampToScale(value: number): number {
return Math.min(TARGET_MAX, Math.max(TARGET_MIN, value));
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/**
* Fit the account's window and score its most recent days.
*
* The ladder is read against the number of DAYS carrying a self-rating, which
* is the number a person can see themselves growing. The `n` stored on each
* row is the number of complete days the fit actually used, which can be
* smaller — a day that is missing one of the fit's inputs cannot be part of
* it. Both numbers are honest and they answer different questions; the one on
* the surface beside the value is `n`, because that is what the value rests on.
*/
export function computeForecast(
days: readonly PrognosisDayInput[],
writeWindowDays: number = PROGNOSIS_WRITE_WINDOW_DAYS,
): ForecastResult {
const rated = days.filter((d) => typeof d.a1 === "number");
const gate = forecastGate(rated.length);
if (!gate.present) return { status: "refused", reason: "too-few-days" };
const matrix = buildFeatureMatrix(days);
const model = matrix === null ? null : fitPrognosisModel(matrix);
if (model === null) return { status: "refused", reason: "no-pattern" };
// The second floor, and the one the rated-day gate above cannot see. A day
// missing one of the selected features is dropped rather than imputed, so a
// sparse-context account can clear thirty rated days and still fit on a
// handful of complete rows. `n` shown beside the value and a wide band on a
// small fit keep it from being dishonest, but a forecast "built from twelve
// days" past a floor that was supposed to mean thirty is a floor that does
// not hold. The number of days the fit ACTUALLY used has to clear the same
// threshold, not only the number of days that carried a rating.
if (model.n < MIN_ENTRIES_FOR_FORECAST) {
return { status: "refused", reason: "no-pattern" };
}
const rows: ForecastRow[] = [];
for (const day of rated.slice(-writeWindowDays)) {
const standardised = standardiseDay(day, model.standardisation);
// A day missing one of the fit's features cannot be scored, and nothing is
// filled in for it. It simply has no forecast.
if (standardised === null) continue;
const raw = predictWithFit(model.fit, standardised);
if (raw === null) continue;
rows.push({
date: day.day,
predicted: round(clampToScale(raw), 2),
// The band travels with the prediction and is clamped to the same scale:
// a lower edge below zero would be a claim the scale cannot express.
ciLow: round(clampToScale(raw + model.bandLow), 2),
ciHigh: round(clampToScale(raw + model.bandHigh), 2),
n: model.n,
modelVersion: model.modelVersion,
contributions: featureContributions(
model.fit,
model.features,
standardised,
)
.filter((c) => Number.isFinite(c.contribution))
.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))
.map((c) => ({
feature: c.feature,
contribution: round(c.contribution, 3),
})),
});
}
if (rows.length === 0) {
return { status: "refused", reason: "no-scorable-day" };
}
return {
status: "written",
rows,
n: model.n,
modelVersion: model.modelVersion,
bandWidth: round(model.bandHigh - model.bandLow, 2),
featureCount: model.features.length,
};
}
+213
View File
@@ -0,0 +1,213 @@
/**
* The database read behind the fit, and nothing else.
*
* Separate from `features.ts` on purpose. `features.ts` is pure, and it shares
* `feature-keys.ts` with the client component that labels a stored feature key
* (`components/insights/mood/prognosis-feature-label.ts` imports the parser
* from there) — so those two modules form a Prisma-free island the browser
* bundle can reach. A database import in `features.ts` would drag the client
* towards that bundle; keeping the read here is what holds the line. The split
* is the boundary, not a preference.
*
* **One row per day, taken whole from one entry.** A day with two entries
* contributes the later one — its target, its dimensions and its context
* together. Averaging the ratings and taking the context from whichever entry
* happened to carry one would build a row nobody logged, and the model would
* then be fitted on days that did not happen.
*
* The day key is the entry's own `date`, already resolved in the zone the
* entry was written in. Re-deriving it here from `moodLoggedAt` would be a
* second opinion about which day a row belongs to, which is the class of bug
* the per-row `tz` column was added to end.
*/
import { prisma } from "@/lib/db";
import { decodeKeyList } from "@/lib/mood/context-vocabulary";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { resolveLinkedDayFigures } from "@/lib/mood/linked-context";
import type { PrognosisDayInput } from "./features";
/**
* How far back the fit looks.
*
* A year: long enough to hold a seasonal swing, short enough that a life that
* changed two years ago is not still being fitted against today. Longer would
* also make the per-user standardisation describe somebody the person no
* longer is.
*/
export const PROGNOSIS_FIT_WINDOW_DAYS = 365;
/**
* How many recent days the job writes a forecast for.
*
* Not the whole window: a forecast is worth storing for the days somebody
* might still be looking at. Older rows are recomputable and are deleted on
* every pass, which is also what keeps a stale forecast from outliving the
* data it was fitted on.
*/
export const PROGNOSIS_WRITE_WINDOW_DAYS = 7;
/**
* Hard cap on the rows one fit reads.
*
* A year holds 365 days and the fold keeps one entry per day, so the cap only
* ever bites on an account that logs many times a day — and there it bites on
* the oldest days, which is the right end to lose. It is here because an
* unbounded `findMany` in a nightly job over an account with years of history
* is a table scan waiting for the account that has one.
*/
export const MAX_PROGNOSIS_ROWS = 5000;
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
}
/**
* Every day of the fit window, in day order, with everything the matrix needs.
*
* The linked figures come from the resolver that owns them, over the whole
* window in one query. They are read in the account's display zone: the entry
* rows carry their own zone for their own day key, but a step count has to be
* bucketed under some zone, and the account's own is the one every other
* surface uses.
*/
export async function loadPrognosisDays(
userId: string,
now: Date,
timezone: string | null,
windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS,
): Promise<PrognosisDayInput[]> {
const since = dayKeyBefore(now, windowDays);
const entries = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, date: { gte: since } },
// Descending, and the fold below keeps the FIRST row it sees per day, so
// "the last entry of a day wins" is the read order rather than a
// comparison. Descending also puts the cap on the right end: an account
// that logs many times a day loses its oldest days to the limit, never
// its newest ones, and the newest are the days a forecast is written for.
orderBy: [{ date: "desc" }, { moodLoggedAt: "desc" }],
take: MAX_PROGNOSIS_ROWS,
select: {
date: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
context: {
select: {
workStatus: true,
workMinutes: true,
overtimeMinutes: true,
workLoad: true,
workSatisfaction: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
contactQuality: true,
contactSupport: true,
leisureCategories: true,
leisureMinutes: true,
leisureJoy: true,
leisureRecovery: true,
eventType: true,
eventValence: true,
// The note is deliberately not selected. It is encrypted free text
// about somebody's day and the model has no use for it; a fit does
// not read prose, and a query that decrypted it anyway would be
// reading a diary to compute an average.
},
},
},
});
const byDay = new Map<string, PrognosisDayInput>();
for (const entry of entries) {
// The rows arrive newest first, so the first one seen for a day is that
// day's last entry.
if (byDay.has(entry.date)) continue;
byDay.set(entry.date, {
day: entry.date,
a1: entry.moodA1,
dimensions: {
a2: entry.stressA2,
a3: entry.energyA3,
a4: entry.connectionA4,
a5: entry.stabilityA5,
},
context: entry.context
? {
workStatus: entry.context.workStatus,
workMinutes: entry.context.workMinutes,
overtimeMinutes: entry.context.overtimeMinutes,
workLoad: entry.context.workLoad,
workSatisfaction: entry.context.workSatisfaction,
contactCircles: decodeKeyList(entry.context.contactCircles),
contactForm: entry.context.contactForm,
contactExtent: entry.context.contactExtent,
contactQuality: entry.context.contactQuality,
contactSupport: entry.context.contactSupport,
leisureCategories: decodeKeyList(entry.context.leisureCategories),
leisureMinutes: entry.context.leisureMinutes,
leisureJoy: entry.context.leisureJoy,
leisureRecovery: entry.context.leisureRecovery,
eventType: entry.context.eventType,
eventValence: entry.context.eventValence,
}
: null,
linked: {
sleepAsleep: null,
steps: null,
activeEnergy: null,
restingHeartRate: null,
heartRateVariability: null,
},
});
}
const days = [...byDay.keys()].sort();
if (days.length === 0) return [];
const linked = await resolveLinkedDayFigures(
userId,
days,
timezone ?? DEFAULT_TIMEZONE,
);
for (const day of days) {
const figures = linked.get(day);
if (figures) byDay.get(day)!.linked = figures;
}
return days.map((day) => byDay.get(day)!);
}
/**
* How many days of the window carry a self-rated pleasantness.
*
* The number the ladder is read against, and the number a surface quotes when
* it explains why there is nothing yet. Days, not entries: two entries on one
* Tuesday are one Tuesday, and counting them as two would let somebody reach
* a threshold by logging twice a day for a fortnight.
*/
export async function countQualifyingDays(
userId: string,
now: Date,
windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS,
): Promise<number> {
const since = dayKeyBefore(now, windowDays);
const days = await prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodA1: { not: null },
date: { gte: since },
},
distinct: ["date"],
select: { date: true },
// Bounded by the window either way — a year holds 366 distinct day keys —
// and the cap says so rather than trusting the window to be small.
take: 400,
});
return days.length;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* What the surfaces read: the two values of a day, side by side, never merged.
*
* The self-assessment is the leading value and is returned exactly as it was
* stored. The prognosis is the second reading — computed, banded, counted, and
* not editable. The deviation between them is the finding, and it is measured
* against the BAND rather than against the midpoint: a rating inside the band
* is a day that went as the person's own past days suggest, and only a rating
* outside it is worth a sentence.
*
* One reader for every surface. The route, the Coach snapshot and the daily
* briefing all come through here, so a change to what "the day's forecast"
* means moves all three together instead of two of them.
*
* Absence is explicit and carries its reason and its count. `no-output-yet`
* and `learning-phase` come from the ladder; `no-pattern` is the honest answer
* when there are plenty of days and nothing in them the fit could hold on to.
* None of the three is an error and none of them is a zero.
*/
import { prisma } from "@/lib/db";
import {
dayKeyAgeInDays,
isCurrentForTodayClaim,
} from "@/lib/insights/measurement-freshness";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import type { FeatureContribution } from "./forecast";
import { countQualifyingDays } from "./load";
import {
forecastGate,
seasonalComparisonsUnlocked,
type PrognosisStage,
} from "./thresholds";
/** How the day's own rating sat against the band. */
export type PrognosisDeviation = "within" | "above" | "below";
export interface PrognosisPresent {
present: true;
/** The day the forecast is about, `YYYY-MM-DD`. */
date: string;
/** The expected pleasantness, 0-10. Never shown without `n` and the band. */
predicted: number;
ciLow: number | null;
ciHigh: number | null;
/** Complete days the fit used. Displayed wherever `predicted` is. */
n: number;
modelVersion: string;
computedAt: string;
/**
* Whole days between the forecast's day and the reader's today. A forecast
* about the day before yesterday cannot back a sentence about today, and
* `current` is the same rule every other present-tense claim in the product
* uses — one constant, not a second one.
*/
ageDays: number | null;
current: boolean;
/** Below the regular threshold the forecast is labelled provisional. */
provisional: boolean;
/**
* The rung the value is shown at. Only the three rungs that CARRY a value —
* a below-floor reading never reaches here, it becomes the absence shape —
* so the type matches the published enum exactly and an `insufficient` /
* `learning` stage cannot be constructed for a present reading.
*/
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
/** Days of history the ladder was read against. */
entries: number;
/** Whether weekday and seasonal comparisons have unlocked. */
seasonalUnlocked: boolean;
/** The person's own rating for that day. `null` when they gave none. */
selfAssessment: number | null;
/** Where that rating sat against the band. `null` without a rating. */
deviation: PrognosisDeviation | null;
/** How far outside the band it sat, in scale points. `0` when inside. */
deviationAmount: number | null;
/** What the model weighted for that day, largest absolute first. */
contributions: FeatureContribution[];
}
export interface PrognosisAbsent {
present: false;
/**
* `no-output-yet` — too few days for anything at all.
* `learning-phase` — enough to describe, not enough to forecast.
* `no-pattern` — enough days, nothing in them the fit could hold on to.
*/
reason: "no-output-yet" | "learning-phase" | "no-pattern";
/** Days of history carrying a self-rating. */
entries: number;
/** Days at which the next step unlocks, when a count is what is missing. */
nextThreshold: number | null;
}
export type PrognosisReading = PrognosisPresent | PrognosisAbsent;
/** Parse the stored contributions. A malformed value reads as none. */
export function parseContributions(stored: string): FeatureContribution[] {
try {
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(item): item is FeatureContribution =>
typeof item === "object" &&
item !== null &&
typeof (item as FeatureContribution).feature === "string" &&
typeof (item as FeatureContribution).contribution === "number",
);
} catch {
return [];
}
}
/** Where a rating sits against a band. */
export function deviationAgainstBand(
actual: number,
low: number | null,
high: number | null,
): { deviation: PrognosisDeviation; amount: number } | null {
if (low === null || high === null) return null;
if (actual > high) return { deviation: "above", amount: actual - high };
if (actual < low) return { deviation: "below", amount: low - actual };
return { deviation: "within", amount: 0 };
}
/**
* The newest forecast an account holds, with the day's own rating beside it.
*
* The job writes a trailing window and the newest row in it is normally
* yesterday's — the pass runs before anybody has logged today. That is why
* the age travels with the value instead of being assumed to be zero.
*/
export async function readMoodPrognosis(
userId: string,
now: Date = new Date(),
timezone?: string | null,
): Promise<PrognosisReading> {
const row = await prisma.moodPrediction.findFirst({
where: { userId },
orderBy: { date: "desc" },
select: {
date: true,
predicted: true,
ciLow: true,
ciHigh: true,
n: true,
modelVersion: true,
features: true,
computedAt: true,
},
});
if (row === null) {
// No row is not automatically "too few days": an account can have a year
// of entries and still get no forecast, because nothing in them cleared
// the screening. The count decides which of the three answers is true.
const entries = await countQualifyingDays(userId, now);
const gate = forecastGate(entries);
if (!gate.present) {
return {
present: false,
reason: gate.reason,
entries,
nextThreshold: gate.nextThreshold,
};
}
return {
present: false,
reason: "no-pattern",
entries,
nextThreshold: null,
};
}
const entries = await countQualifyingDays(userId, now);
// A row on disk is not enough to answer with. The job writes nightly, so
// between an entry deletion and the next pass a row outlives its own
// eligibility. The read path re-reads the ladder and refuses below the
// floor, exactly as the job would — otherwise this route serves a value the
// account no longer has the data to support for up to a day, and it serves
// it under a stage ("insufficient" / "learning") that is not in the
// published enum, which fails a strict client decode of the whole payload.
const gate = forecastGate(entries);
if (!gate.present) {
return {
present: false,
reason: gate.reason,
entries,
nextThreshold: gate.nextThreshold,
};
}
const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE);
const ageDays = dayKeyAgeInDays(row.date, today);
const entry = await prisma.moodEntry.findFirst({
where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } },
orderBy: { moodLoggedAt: "desc" },
select: { moodA1: true },
});
const selfAssessment = entry?.moodA1 ?? null;
const against =
selfAssessment === null
? null
: deviationAgainstBand(selfAssessment, row.ciLow, row.ciHigh);
return {
present: true,
date: row.date,
predicted: row.predicted,
ciLow: row.ciLow,
ciHigh: row.ciHigh,
n: row.n,
modelVersion: row.modelVersion,
computedAt: row.computedAt.toISOString(),
ageDays,
current: isCurrentForTodayClaim(ageDays),
// `gate` is the present variant here — the absence arm returned above — so
// the stage is one of provisional / regular / seasonal, never the
// out-of-enum "insufficient" or "learning" a stale row could otherwise
// carry onto the wire.
provisional: gate.provisional,
stage: gate.stage,
entries,
seasonalUnlocked: seasonalComparisonsUnlocked(entries),
selfAssessment,
deviation: against?.deviation ?? null,
deviationAmount: against?.amount ?? null,
contributions: parseContributions(row.features),
};
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Ridge regression, closed form, in about sixty lines of arithmetic.
*
* `(XᵀX + λI)β = Xᵀy`, solved by Gaussian elimination with partial pivoting.
* That is the whole model. It is here rather than in a dependency because a
* dozen standardised columns is a 12×12 system, and adding a package to solve
* one of those would be adding a supply chain to avoid writing a loop.
*
* Two properties the rest of the feature relies on:
*
* * **The intercept is not penalised, because there is no intercept in the
* system.** The target is centred before the solve and the mean is added
* back afterwards, which is the standard treatment: penalising the
* intercept would shrink every prediction towards zero rather than
* towards the person's own average day, and towards zero on a 0-10
* pleasantness scale means "towards the worst day imaginable".
* * **λ is never zero.** With more columns than the ridge penalty can
* afford, an unpenalised solve is a memoriser: it fits the training days
* perfectly and says nothing about any other day. The grid the validation
* searches starts above zero for that reason.
*
* This is arithmetic, not an AI surface. No provider, no consent gate, no
* egress — see the note at the top of the refresh job.
*/
/** A fitted model: one coefficient per standardised column, plus the mean. */
export interface RidgeFit {
/** One per column of the matrix it was fitted on, in the same order. */
coefficients: number[];
/** The mean of the training targets, added back on every prediction. */
intercept: number;
/**
* The mean of each training column, subtracted before every prediction.
*
* Usually zero to floating point, because the matrix arrives z-scored. It is
* carried anyway rather than assumed: centring the target while leaving the
* columns uncentred biases every coefficient, and the bias is small enough
* to look like rounding. Somebody handing this function a raw matrix should
* get the right answer, not a plausible one.
*/
columnMeans: number[];
/** The penalty this fit used. */
lambda: number;
}
/**
* Solve `A x = b` by Gaussian elimination with partial pivoting.
*
* `null` when the system is singular to working precision. The caller treats
* that as "no fit" rather than as a fit of zeros: a singular system means the
* columns are telling the same story twice, and a zero vector would silently
* become a model that predicts the average and calls it a pattern.
*/
export function solveLinearSystem(
matrix: readonly number[][],
vector: readonly number[],
): number[] | null {
const n = vector.length;
if (matrix.length !== n) return null;
// Copy: the caller's XᵀX is reused across the λ grid and elimination is
// destructive.
const a = matrix.map((row, i) => [...row, vector[i]]);
for (let column = 0; column < n; column++) {
let pivot = column;
for (let row = column + 1; row < n; row++) {
if (Math.abs(a[row][column]) > Math.abs(a[pivot][column])) pivot = row;
}
if (Math.abs(a[pivot][column]) < 1e-10) return null;
if (pivot !== column) {
const swap = a[pivot];
a[pivot] = a[column];
a[column] = swap;
}
const pivotValue = a[column][column];
for (let row = column + 1; row < n; row++) {
const factor = a[row][column] / pivotValue;
if (factor === 0) continue;
for (let k = column; k <= n; k++) a[row][k] -= factor * a[column][k];
}
}
const solution = new Array<number>(n).fill(0);
for (let row = n - 1; row >= 0; row--) {
let sum = a[row][n];
for (let k = row + 1; k < n; k++) sum -= a[row][k] * solution[k];
solution[row] = sum / a[row][row];
}
return solution.every((v) => Number.isFinite(v)) ? solution : null;
}
/**
* Fit `rows` against `targets` at one penalty.
*
* `rows` are already standardised per user (see `features.ts`), so the columns
* are comparable and one λ is a defensible penalty across all of them —
* which is not true of raw minutes beside a 0-10 rating.
*/
export function fitRidge(
rows: readonly (readonly number[])[],
targets: readonly number[],
lambda: number,
): RidgeFit | null {
const n = rows.length;
if (n === 0 || targets.length !== n) return null;
const columns = rows[0].length;
if (columns === 0) return null;
if (rows.some((row) => row.length !== columns)) return null;
const intercept = targets.reduce((sum, v) => sum + v, 0) / n;
const centredTargets = targets.map((v) => v - intercept);
const columnMeans = Array.from({ length: columns }, (_, j) => {
let sum = 0;
for (let i = 0; i < n; i++) sum += rows[i][j];
return sum / n;
});
const centredRows = rows.map((row) => row.map((v, j) => v - columnMeans[j]));
const xtx: number[][] = Array.from({ length: columns }, () =>
new Array<number>(columns).fill(0),
);
const xty = new Array<number>(columns).fill(0);
for (let i = 0; i < n; i++) {
const row = centredRows[i];
for (let j = 0; j < columns; j++) {
xty[j] += row[j] * centredTargets[i];
for (let k = j; k < columns; k++) {
const product = row[j] * row[k];
xtx[j][k] += product;
if (k !== j) xtx[k][j] += product;
}
}
}
for (let j = 0; j < columns; j++) xtx[j][j] += lambda;
const coefficients = solveLinearSystem(xtx, xty);
if (coefficients === null) return null;
return { coefficients, intercept, columnMeans, lambda };
}
/** Score one standardised row. */
export function predictWithFit(
fit: RidgeFit,
row: readonly number[],
): number | null {
if (row.length !== fit.coefficients.length) return null;
let sum = fit.intercept;
for (let i = 0; i < row.length; i++) {
sum += fit.coefficients[i] * (row[i] - fit.columnMeans[i]);
}
return Number.isFinite(sum) ? sum : null;
}
/**
* The per-feature contributions of one scored day.
*
* `coefficient × the day's standardised value`, which is what the stored
* `features` column holds and what the explanation line is generated from.
* Signed, so a feature that pulled the expected value down reads as having
* pulled it down, and the parts sum to the distance between the prediction and
* the person's own average day.
*/
export function featureContributions(
fit: RidgeFit,
featureKeys: readonly string[],
row: readonly number[],
): Array<{ feature: string; contribution: number }> {
return featureKeys.map((feature, index) => ({
feature,
contribution:
fit.coefficients[index] * (row[index] - fit.columnMeans[index]),
}));
}
@@ -0,0 +1,181 @@
/**
* How much history a prognosis needs before it is allowed to say anything.
*
* This is the most dangerous file in the feature, and it is dangerous for one
* reason: **the failure mode is silent.** A forecast built from eight days
* renders exactly like a forecast built from eighty. Nobody looking at the
* screen can tell the difference, and the person reading it is being told
* something about their own health record. So every floor is a named constant
* with a boundary test, and every one of those tests has been watched failing
* — a gate nobody has seen go red is a decoration, not a gate.
*
* The ladder has five rungs, and each rung says what the surface may claim:
*
* | entries | stage | what may be shown |
* |---------|---------------|------------------------------------------------|
* | 0-14 | `insufficient`| nothing beyond the self-assessment trend |
* | 15-29 | `learning` | descriptive hints, labelled as a learning phase |
* | 30-59 | `provisional` | a forecast, labelled provisional |
* | 60-89 | `regular` | a forecast without the provisional label |
* | 90+ | `seasonal` | plus weekday and seasonal comparisons |
*
* Absence is `{ present: false }` with a reason and the count, never `0` and
* never an empty forecast object with a null inside it. That shape is a
* contract elsewhere in the product already — the MCP surface means exactly
* "honestly not recorded" by it — and a surface that answered zero here would
* be claiming a bad day where the truth is that nothing can be said yet.
*
* **Two numbers are deliberately NOT minted here.** The paired-observation
* floor for a correlation is `MIN_PAIRED_N` in
* `src/lib/insights/correlations.ts`, and the screening in `features.ts`
* imports it from there; a second constant for the same idea is how two
* surfaces start disagreeing about what counts as enough. The occurrence floor
* below is the crosstab's own floor by assignment rather than by coincidence,
* for the same reason — Phase 2's context comparison says at its declaration
* that this file would import it, and this file does.
*/
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
/**
* Below this, no output at all — not even a descriptive hint.
*
* Fifteen days of a person's life is not a pattern, and a surface that
* described one would be describing noise back to somebody who would
* reasonably believe it.
*/
export const MIN_ENTRIES_LEARNING_PHASE = 15;
/**
* Below this, descriptive hints only, labelled as a learning phase. At or
* above it, a fitted forecast is allowed.
*/
export const MIN_ENTRIES_FOR_FORECAST = 30;
/** Below this, a forecast is shown but labelled provisional. */
export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60;
/**
* Below this, a weekday or seasonal comparison built on the forecast may not
* be shown.
*
* Two things it does NOT do, both worth stating because the omission is a
* decision rather than an oversight:
*
* * It does not retro-gate the weekday board the mood insights page has
* carried for releases. That board is built from the entry aggregates,
* not from this fit, and it has its own floors; hiding it from accounts
* under ninety days would take a surface away from people who have been
* reading it, to enforce a rule about a different calculation.
* * It does not build a second weekday board. One exists, and a second
* answer to "how do your Mondays go" is exactly the drift this milestone
* spent its effort avoiding.
*
* What it does is gate the `seasonalUnlocked` flag the forecast publishes, so
* any surface that wants to hang a weekday claim off THIS comparison — the
* clients included — asks one place whether the history supports one.
*/
export const MIN_ENTRIES_FOR_SEASONAL = 90;
/**
* How often a tag or a context value must have been recorded before it may
* appear in an explanation at all.
*
* The crosstab's floor, taken rather than re-declared: it is the same
* question ("has this been seen often enough to be worth a sentence") asked
* by a different surface, and two answers to it would drift.
*/
export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS;
// A per-weekday observation floor was drafted here for a weekday-comparison
// board built on the forecast. That board was not built — the forecast
// publishes `seasonalUnlocked` and leaves the weekday view to the mood
// insights page, which has its own floors — so the constant had no consumer
// and was removed rather than left as a number nothing reads. If a weekday
// comparison on THIS fit is ever built, reintroduce the floor with it.
/** Where an account sits on the ladder. */
export type PrognosisStage =
"insufficient" | "learning" | "provisional" | "regular" | "seasonal";
/**
* Why there is no forecast. The reason travels with the count and with the
* next rung, so a surface can say "eleven of fifteen" instead of an error.
*/
export type PrognosisAbsenceReason = "no-output-yet" | "learning-phase";
export interface PrognosisAbsent {
present: false;
reason: PrognosisAbsenceReason;
stage: Extract<PrognosisStage, "insufficient" | "learning">;
/** Qualifying entries the account has. */
entries: number;
/** Entries at which the next thing becomes available. */
nextThreshold: number;
}
export interface PrognosisAvailable {
present: true;
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
entries: number;
/** The forecast carries a provisional label below the regular threshold. */
provisional: boolean;
}
export type PrognosisGate = PrognosisAbsent | PrognosisAvailable;
/**
* Which rung this many qualifying entries reaches.
*
* Every comparison is `>=`, so the constant names the first value that
* qualifies rather than the last that does not. The boundary tests pin both
* sides of each one.
*/
export function prognosisStage(entries: number): PrognosisStage {
if (entries >= MIN_ENTRIES_FOR_SEASONAL) return "seasonal";
if (entries >= MIN_ENTRIES_FOR_REGULAR_FORECAST) return "regular";
if (entries >= MIN_ENTRIES_FOR_FORECAST) return "provisional";
if (entries >= MIN_ENTRIES_LEARNING_PHASE) return "learning";
return "insufficient";
}
/**
* Whether a forecast may be shown for an account with this many qualifying
* entries, and if not, why not and what would change it.
*/
export function forecastGate(entries: number): PrognosisGate {
const stage = prognosisStage(entries);
if (stage === "insufficient") {
return {
present: false,
reason: "no-output-yet",
stage,
entries,
nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
};
}
if (stage === "learning") {
return {
present: false,
reason: "learning-phase",
stage,
entries,
nextThreshold: MIN_ENTRIES_FOR_FORECAST,
};
}
return {
present: true,
stage,
entries,
provisional: stage === "provisional",
};
}
/** Whether weekday and seasonal comparisons have unlocked for this account. */
export function seasonalComparisonsUnlocked(entries: number): boolean {
return entries >= MIN_ENTRIES_FOR_SEASONAL;
}
/** Whether a tag or context value has been recorded often enough to name. */
export function occurrencesSuffice(occurrences: number): boolean {
return occurrences >= MIN_TAG_OCCURRENCES;
}
@@ -0,0 +1,230 @@
/**
* Choosing the penalty, and measuring how wrong the model is, without letting
* it see the future.
*
* **Rolling origin, expanding window. Never a random k-fold.** Days are not
* exchangeable: a Tuesday sits between a Monday and a Wednesday that resemble
* it, and a shuffled split puts Wednesday in the training set and asks the
* model about Tuesday. The result is a model that looks excellent and is
* useless, and nothing about the number it reports says so. So the folds are
* ranges of a time-ordered array, the training range always ends where the
* validation range starts, and `rolling-origin.test.ts` asserts that on the
* fold indices and on the day keys themselves rather than on the resulting
* error — an error-based assertion would pass a leaking split whenever the
* leak happened to be small.
*
* **The band comes from the validation residuals**, not from a textbook
* standard error on the training fit. A training-fit interval on a model with
* twelve columns and thirty rows is narrow by construction and says nothing;
* the spread of the errors the model actually made on days it had not seen is
* the honest width. It widens when the model is bad, which is the property the
* surface depends on to report low confidence instead of a confident wrong
* number.
*
* **What is out-of-sample here, and what is not — stated exactly, because the
* boundary is easy to overclaim.** The RIDGE FIT is honest: within every fold
* the coefficients are solved on the training prefix alone and scored on
* validation rows the fit never saw, and no training fold holds a row dated
* after its validation fold. That is the claim `rolling-origin.test.ts`
* proves, and it is the one that matters for "does λ generalise".
*
* Two steps sit OUTSIDE that guarantee, on purpose:
*
* * **Feature selection.** The screening in `features.ts` runs once over the
* whole window before the split, so the feature set a fold trains on was
* chosen with that fold's validation rows in view. This is the classic
* "screen, then cross-validate" optimism.
* * **Standardisation.** The per-user mean and spread are taken over the
* whole window too, so a validation row is z-scored against statistics its
* own value helped compute.
*
* Both make the reported error slightly better than a fully-nested procedure
* would — measured here at roughly one part in sixty on the mean absolute
* error and a couple of points of coverage, i.e. small enough that the band is
* not dishonest but not zero. Nesting the screening and the standardisation
* inside each fold would remove even that, at the cost of a per-fold feature
* set and a materially larger amount of machinery; the trade was taken the
* cheap way deliberately, and this comment is the price of taking it — a
* reader must not read "no fold sees ahead" as covering the SELECTION, only
* the FIT. If the feature set ever grows past a handful of strong columns, or
* the optimism is ever measured larger, nest it.
*/
import { fitRidge, predictWithFit, type RidgeFit } from "./ridge";
import type { FeatureMatrix } from "./features";
/**
* The stored model identity. Bump it whenever the feature set, the screening
* or the fit changes, so a re-fit is legible in the rows rather than silently
* changing what they mean. Rows carrying an older version stay readable; the
* surface skips a feature key the current build no longer knows.
*/
export const MODEL_VERSION = "mood-ridge-1";
/**
* The penalties the validation searches.
*
* Never zero: with a dozen standardised columns over a few dozen days, an
* unpenalised solve memorises the training days and describes no other day.
* The grid is coarse on purpose — the difference between λ=8 and λ=10 is far
* below the noise in thirty days of self-ratings, and a fine grid would only
* be a slower way to overfit the validation set.
*/
export const LAMBDA_GRID = [0.5, 1, 2, 5, 10, 25, 50] as const;
/** How many validation blocks the window is cut into. */
export const VALIDATION_FOLDS = 4;
/**
* The band is the central 80% of the validation residuals: the 10th and the
* 90th percentile. A range wide enough to be honest and narrow enough to be
* worth reading — and empirical, so an asymmetric error stays asymmetric
* instead of being averaged into a symmetric claim.
*/
export const BAND_LOWER_QUANTILE = 0.1;
export const BAND_UPPER_QUANTILE = 0.9;
/** Fewest rows a fold split is attempted on at all. */
export const MIN_VALIDATION_ROWS = 10;
/** One expanding-window split. Every index is into the time-ordered matrix. */
export interface RollingOriginFold {
/** Training rows are `[0, trainEnd)`. */
trainEnd: number;
/** Validation rows are `[validationStart, validationEnd)`. */
validationStart: number;
validationEnd: number;
}
/**
* Cut a time-ordered window into expanding-window folds.
*
* `trainEnd === validationStart` in every fold, which is the whole invariant:
* the training range is a prefix and the validation range starts exactly where
* it ends. No gap (a gap would waste days), no overlap (an overlap is the
* leak).
*/
export function rollingOriginFolds(
rowCount: number,
folds: number = VALIDATION_FOLDS,
): RollingOriginFold[] {
if (rowCount < MIN_VALIDATION_ROWS || folds < 1) return [];
const block = Math.floor(rowCount / (folds + 1));
if (block < 1) return [];
const out: RollingOriginFold[] = [];
for (let index = 1; index <= folds; index++) {
const validationStart = block * index;
const validationEnd =
index === folds ? rowCount : Math.min(rowCount, block * (index + 1));
if (validationEnd <= validationStart) continue;
out.push({ trainEnd: validationStart, validationStart, validationEnd });
}
return out;
}
/** Linear-interpolated quantile of an unsorted sample. */
export function quantile(values: readonly number[], q: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 1) return sorted[0];
const position = (sorted.length - 1) * Math.min(1, Math.max(0, q));
const lower = Math.floor(position);
const upper = Math.ceil(position);
if (lower === upper) return sorted[lower];
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
/** What one penalty scored across every fold. */
export interface LambdaScore {
lambda: number;
/** Residuals (`actual − predicted`) from validation rows only. */
residuals: number[];
meanAbsoluteError: number;
}
/**
* Score one penalty over the folds. Training folds never contain a row dated
* after their validation fold, by construction of {@link rollingOriginFolds}.
*/
export function scoreLambda(
matrix: FeatureMatrix,
lambda: number,
folds: readonly RollingOriginFold[],
): LambdaScore | null {
const residuals: number[] = [];
for (const fold of folds) {
const fit = fitRidge(
matrix.rows.slice(0, fold.trainEnd),
matrix.targets.slice(0, fold.trainEnd),
lambda,
);
if (fit === null) continue;
for (let i = fold.validationStart; i < fold.validationEnd; i++) {
const predicted = predictWithFit(fit, matrix.rows[i]);
if (predicted === null) continue;
residuals.push(matrix.targets[i] - predicted);
}
}
if (residuals.length === 0) return null;
return {
lambda,
residuals,
meanAbsoluteError:
residuals.reduce((sum, r) => sum + Math.abs(r), 0) / residuals.length,
};
}
/** A model, ready to score a day, with the band its own errors earned it. */
export interface PrognosisModel {
fit: RidgeFit;
features: string[];
standardisation: FeatureMatrix["standardisation"];
/** Rows the final fit used. This is the `n` every surface must show. */
n: number;
/** Offsets from a prediction to the edges of the band, in target units. */
bandLow: number;
bandHigh: number;
/** Mean absolute error over the validation folds, for the record. */
meanAbsoluteError: number;
modelVersion: string;
}
/**
* Validate, choose λ, refit on the whole window, and take the band from the
* validation residuals.
*
* `null` is a refusal and is a normal outcome: too few complete days to split,
* a singular system, or a fold set that produced no residual at all. The
* caller writes no forecast rather than a forecast nobody measured.
*/
export function fitPrognosisModel(
matrix: FeatureMatrix,
lambdaGrid: readonly number[] = LAMBDA_GRID,
): PrognosisModel | null {
const folds = rollingOriginFolds(matrix.rows.length);
if (folds.length === 0) return null;
let best: LambdaScore | null = null;
for (const lambda of lambdaGrid) {
const score = scoreLambda(matrix, lambda, folds);
if (score === null) continue;
if (best === null || score.meanAbsoluteError < best.meanAbsoluteError) {
best = score;
}
}
if (best === null) return null;
const fit = fitRidge(matrix.rows, matrix.targets, best.lambda);
if (fit === null) return null;
return {
fit,
features: matrix.features,
standardisation: matrix.standardisation,
n: matrix.rows.length,
bandLow: quantile(best.residuals, BAND_LOWER_QUANTILE),
bandHigh: quantile(best.residuals, BAND_UPPER_QUANTILE),
meanAbsoluteError: best.meanAbsoluteError,
modelVersion: MODEL_VERSION,
};
}
@@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => {
ecgRecording: { findFirst: async () => null },
personalRecord: { findMany: none },
arrivalReaction: { findMany: none },
encounter: { findFirst: async () => null },
};
});
@@ -20,6 +20,7 @@ vi.mock("@/lib/db", () => ({
dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) },
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findFirst: vi.fn().mockResolvedValue(null) },
},
}));
+65
View File
@@ -126,6 +126,21 @@ export interface DailyDigestPreventiveDue {
label: string;
}
/**
* A booked visit falling today or tomorrow.
*
* Read off the VISIT table, never off the reminder engine: an appointment's
* one-shot reminder is excluded from every preventive-care read, and this is
* how the appointment reaches the rail without relaxing that exclusion. The
* kind rides as the enum constant so the reader's own bundle names it.
*/
export interface DailyDigestUpcomingVisit {
id: string;
kind: string;
occurredAt: string;
practitionerName: string | null;
}
/**
* S11 — a confident elevated-at-rest ("tension") window for the day, already
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
@@ -251,6 +266,8 @@ export interface DailyDigestInput {
morningRefreshedToday: boolean;
syncIssues: DailyDigestSyncIssue[];
preventiveDue: DailyDigestPreventiveDue[];
/** The booked visit falling today or tomorrow, or null. Honest-absent. */
upcomingVisit?: DailyDigestUpcomingVisit | null;
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
coachPlans: DailyDigestCoachPlan[];
/**
@@ -504,6 +521,44 @@ function buildPreventiveCareItem(
};
}
/**
* The booked visit falling today or tomorrow, as one rail line.
*
* No module gate: a visit is core, like the checkups page it lives on. No
* action beyond opening the page either — there is nothing to do about an
* appointment except keep it, and a "mark done" here would duplicate the
* visit's own status field with a second place to set it.
*/
function buildUpcomingVisitItem(
visit: DailyDigestUpcomingVisit | null,
now: Date,
t: Translate,
): PriorityItem | null {
if (!visit) return null;
const at = new Date(visit.occurredAt);
if (Number.isNaN(at.getTime())) return null;
// Today vs tomorrow is decided on the SAME instants the read window used, so
// a visit admitted by the query can never be described as neither.
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`);
return {
kind: "upcoming_visit",
title: t("daily.item.upcomingVisit.title"),
body:
hoursAway <= 24
? t("daily.item.upcomingVisit.bodyToday", { what })
: t("daily.item.upcomingVisit.bodyTomorrow", { what }),
status: "info",
actions: [
{
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups",
},
],
};
}
/**
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
* state was reached TODAY (the IO seam already applied the reached-once gate).
@@ -880,6 +935,16 @@ export function buildDailyDigest(
if (ecg) worthALook.push(ecg);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// Beside the checkups rather than among them: the two are neighbours on the
// rail because a person reads "what am I meant to arrange" and "where am I
// meant to be" in one glance, and they stay separate items because one is
// overdue and the other is simply scheduled.
const visit = buildUpcomingVisitItem(
input.upcomingVisit ?? null,
input.now,
t,
);
if (visit) worthALook.push(visit);
// 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.
+57
View File
@@ -32,6 +32,7 @@ import {
type DailyDigestCoachPlan,
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestUpcomingVisit,
type DailyDigestScore,
type DailyDigestSameTime,
type DailyDigestSyncIssue,
@@ -63,6 +64,16 @@ import {
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
/**
* How far ahead the rail mentions a booked appointment: today or tomorrow.
*
* The rail is a "what about today" surface, so anything further out is not a
* thing to act on this morning. Two days rather than one because an evening
* reader wants tomorrow's 08:00 appointment, and a 24-hour window would hide it
* from them.
*/
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
/** Defensive cap on how many overdue reminders we read for the rail summary. */
const PREVENTIVE_DUE_READ_LIMIT = 20;
@@ -355,6 +366,7 @@ export async function loadDailyDigest(
planRows,
ecgRow,
arrivalRows,
nextVisitRow,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
@@ -428,6 +440,27 @@ export async function loadDailyDigest(
generatedAt: true,
},
}),
// The next PLANNED visit inside the two-day horizon. Read straight off the
// visit table, never off the reminder engine — see the mapping below for
// why the exclusion above must stay untouched.
prisma.encounter.findFirst({
where: {
userId: user.id,
deletedAt: null,
status: "PLANNED",
occurredAt: {
gt: now,
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
},
},
orderBy: { occurredAt: "asc" },
select: {
id: true,
kind: true,
occurredAt: true,
practitioner: { select: { name: true } },
},
}),
]);
const score: DailyDigestScore | null = snapshot.healthScore
@@ -463,6 +496,29 @@ export async function loadDailyDigest(
label: row.label,
}));
/**
* The appointment falling today or tomorrow, read from the VISIT table.
*
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
* every preventive-care read, which is right — an appointment is not a
* checkup — but a notification about something the app never shows is worse
* than none. So the appointment reaches the rail from the record it lives
* in, and the exclusion above stays exactly as it is. A diff here that
* touches `origin` is the wrong fix.
*
* `occurredAt > now` deliberately, not `>= todayStart`: an appointment that
* has already happened today is not something to be reminded about, and the
* person is more likely to be at it than looking at this.
*/
const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow
? {
id: nextVisitRow.id,
kind: nextVisitRow.kind,
occurredAt: nextVisitRow.occurredAt.toISOString(),
practitionerName: nextVisitRow.practitioner?.name ?? null,
}
: null;
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
// builder decides "new" and gates on the `insights` module. An ECG row only
// ever carries an AFib-screening verdict; the shared enum's other members
@@ -563,6 +619,7 @@ export async function loadDailyDigest(
morningRefreshedToday,
syncIssues,
preventiveDue,
upcomingVisit,
coachPlans,
milestone,
tensionWindow,
+2 -1
View File
@@ -35,6 +35,7 @@ export const PRIORITY_ITEM_KINDS = [
"ecg_new_recording",
"tension_window",
"same_time_baseline",
"upcoming_visit",
] as const;
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
@@ -52,7 +53,7 @@ export function isPriorityItemKind(value: unknown): value is PriorityItemKind {
* action; the user can only
* acknowledge them, so they're the only kinds the Today rail lets you
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
* `preventive_care`, `coach_checkin`, `upcoming_visit`) are ACTIONABLE — they clear on their
* own once the user acts (logs the dose, reconnects the integration, …) and
* are never dismissible; offering a dismiss on them would just let a user
* silence a still-open action item.
+6
View File
@@ -75,6 +75,12 @@ export const WIPE_MODELS = [
// but a parent deleted first leaves the child's own count reporting zero for
// rows that existed, and the confirmation screen quotes those counts.
"MoodContext",
// Derived, and wiped anyway. The forecast rows are recomputable from the
// entries above — which is why they stay out of the backup — but they are
// still a day-by-day statement about somebody's mood, and leaving them
// behind after a screen that promised to delete all health data would leave
// the shape of the record standing where the record used to be.
"MoodPrediction",
"MoodEntry",
"MoodEntryRollup",
"MoodReminderDispatch",
@@ -217,6 +217,73 @@ export function buildClinicalRecordsNotesSection(
.finalY + 8;
}
// Visits inside the window — what happened at a practice, and what came out
// of it. Present only when the VISITS leaf was selected AND the window held a
// visit (the aggregator gates `data.visits`); the leaf carries no module of
// its own, because a visit is core and the whole report is already gated by
// `doctorReport`. Same calm factual register as the illness table above: no
// colour, no status tint. The encrypted reason and outcome decrypt at render
// and never enter the stored selection.
if (data.visits && data.visits.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.visitsTitle"), margin, y);
y += 6;
const visitRows = data.visits.map((visit) => [
fmtDate(visit.occurredAt),
// The practice, with its specialty when there is one; an em dash when
// the visit named neither, which is honest rather than invented.
[visit.practitionerName, visit.practitionerSpecialty]
.filter(Boolean)
.join(" · ") || "—",
t(`encounters.kind.${visit.kind}`),
visit.reason ?? "—",
visit.outcome ?? "—",
visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.visitsColDate"),
t("doctorReport.visitsColPractice"),
t("doctorReport.visitsColKind"),
t("doctorReport.visitsColReason"),
t("doctorReport.visitsColOutcome"),
t("doctorReport.visitsColConditions"),
],
],
body: visitRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
// v1.27.x — structured allergy / intolerance records. Reference data
// (not time-windowed) the aggregator populates when the `allergies`
// toggle is ON (default) and rows exist. Stored fields only — substance,
+22
View File
@@ -274,6 +274,28 @@ export interface DoctorReportData {
* module is off or no episode overlaps the window; both builders then skip
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
*/
/**
* Visits inside the window — the contacts with the healthcare system this
* period held. Null when the leaf was not selected or none fell inside the
* window; both builders then skip the section. Optional so pre-v1.38
* fixtures still typecheck.
*
* `kind` rides as the enum constant: the renderer names it in the REPORT's
* language, which is the reading clinician's, not whoever generated it.
* `reason` and `outcome` are the decrypted free text and are null on a
* key-rotation gap — an absence rather than a fabricated line.
*/
visits?: Array<{
occurredAt: string;
kind: string;
status: string;
practitionerName: string | null;
practitionerSpecialty: string | null;
reason: string | null;
outcome: string | null;
/** Labels of the conditions this visit was filed against. */
conditionLabels: string[];
}> | null;
illnessEpisodes?: Array<{
/** User-facing condition label (e.g. "Erkältung"). */
label: string;
@@ -216,6 +216,17 @@ function seed() {
resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
},
];
rows.encounter = [
{
occurredAt: new Date("2026-02-05T09:00:00.000Z"),
kind: "SPECIALIST",
status: "DONE",
reasonEncrypted: null,
outcomeEncrypted: null,
practitioner: { name: "Sample practice", specialty: "Cardiology" },
conditionLinks: [],
},
];
rows.allergy = [
{
substance: "Penicillin",
@@ -262,6 +273,7 @@ describe("per-leaf gating sweep", () => {
expect(allPayload.allergies).not.toBeNull();
expect(allPayload.familyHistory).not.toBeNull();
expect(allPayload.illnessEpisodes).not.toBeNull();
expect(allPayload.visits).not.toBeNull();
expect(allPayload.mood).not.toBeNull();
expect(allPayload.cycle).not.toBeNull();
expect(allPayload.anamnesis).not.toBeNull();
@@ -415,6 +427,7 @@ describe("zero read for unchosen leaves", () => {
["ALLERGIES", "allergy"],
["LAB_RESULTS", "labResult"],
["ILLNESS_EPISODES", "illnessEpisode"],
["VISITS", "encounter"],
["ANAMNESIS", "userHealthProfile"],
["ANAMNESIS", "healthProfileFactRevision"],
];
@@ -474,6 +487,7 @@ describe("the empty selection serves nothing", () => {
expect(payload.allergies).toBeNull();
expect(payload.familyHistory).toBeNull();
expect(payload.illnessEpisodes).toBeNull();
expect(payload.visits).toBeNull();
expect(payload.anamnesis).toBeNull();
expect(payload.cycle).toBeNull();
expect(payload.mood).toBeNull();
+72
View File
@@ -122,6 +122,78 @@ export async function loadIllnessEpisodes(
return mapped.length > 0 ? mapped : null;
}
/**
* Visits inside the window, with the conditions each was filed against.
*
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft, the
* same stance the allergy reaction takes: a key-rotation gap on one row reads
* as a visit with no reason recorded rather than as a failed report. `kind` and
* `status` cross as enum constants — the renderer names them in the REPORT's
* language, which is the reading clinician's.
*
* The condition labels come off the link rows directly rather than through the
* link service: this is a read of the OWNER's own record for the owner's own
* report, with no grant to narrow against and no redaction to apply, and the
* service's per-family visibility predicate has no meaning here.
*/
export async function loadVisits(
userId: string,
start: Date,
end: Date,
): Promise<DoctorReportData["visits"]> {
const rows = await prisma.encounter.findMany({
where: {
userId,
deletedAt: null,
occurredAt: { gte: start, lte: end },
},
orderBy: { occurredAt: "asc" },
select: {
occurredAt: true,
kind: true,
status: true,
reasonEncrypted: true,
outcomeEncrypted: true,
practitioner: { select: { name: true, specialty: true } },
conditionLinks: {
select: { episode: { select: { label: true, deletedAt: true } } },
},
},
});
const decrypt = (
value: Uint8Array | null,
field: "reason" | "outcome",
): string | null => {
if (!value || value.byteLength === 0) return null;
try {
return decryptFromBytes(value);
} catch {
getEvent()?.addWarning(
`doctor-report: visit ${field} decrypt failed for ${userId}`,
);
return null;
}
};
const mapped = rows.map((row) => ({
occurredAt: row.occurredAt.toISOString(),
kind: row.kind,
status: row.status,
practitionerName: row.practitioner?.name ?? null,
practitionerSpecialty: row.practitioner?.specialty ?? null,
reason: decrypt(row.reasonEncrypted, "reason"),
outcome: decrypt(row.outcomeEncrypted, "outcome"),
// A condition the person has since deleted is not named in a clinical
// document: the link row survives the soft delete, and printing a label
// they removed would put a withdrawn diagnosis in front of a doctor.
conditionLabels: row.conditionLinks
.filter((link) => link.episode?.deletedAt === null)
.map((link) => link.episode.label),
}));
return mapped.length > 0 ? mapped : null;
}
/**
* Structured allergy records. Reference data, not time-windowed — a
* penicillin allergy does not expire with the report window.
+6
View File
@@ -64,6 +64,7 @@ import {
loadAnamnesis,
loadFamilyHistory,
loadIllnessEpisodes,
loadVisits,
loadLabResults,
} from "./clinical-records";
@@ -498,6 +499,7 @@ export async function collectDoctorReportData(
cycle,
labResults,
illnessEpisodes,
visits,
allergies,
familyHistory,
anamnesis,
@@ -511,6 +513,9 @@ export async function collectDoctorReportData(
gate.admits("ILLNESS_EPISODES")
? loadIllnessEpisodes(userId, start, end)
: Promise.resolve(null),
gate.admits("VISITS")
? loadVisits(userId, start, end)
: Promise.resolve(null),
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
gate.admits("FAMILY_HISTORY")
? loadFamilyHistory(userId)
@@ -579,6 +584,7 @@ export async function collectDoctorReportData(
cycle,
labResults,
illnessEpisodes,
visits,
allergies,
familyHistory,
anamnesis,
+80 -1
View File
@@ -8,7 +8,10 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { listTargetsBySource, replaceTargets } from "@/lib/links";
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
} from "@/lib/validations/inbound-documents";
/**
* Load the condition links for a page of documents in ONE grouped query.
@@ -39,6 +42,82 @@ export async function loadConditionLinks(
return map;
}
/**
* Load the VISIT links for a page of documents in ONE grouped query.
*
* Unlike the condition chips this filters tombstoned visits out: the condition
* behaviour is a long-standing one this refactor deliberately did not change,
* while a visit link is new and starts from the honest default — a deleted
* visit is not something the document still belongs to.
*/
export async function loadDocumentEncounterLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentEncounterLinkDto[]>> {
const map = new Map<string, DocumentEncounterLinkDto[]>();
if (documentIds.length === 0) return map;
const byDocument = await listTargetsBySource(prisma, {
userId,
sourceKind: "document",
sourceIds: documentIds,
targetKind: "encounter",
});
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => ({
encounterId: target.id,
kind: target.label,
occurredAt: target.date,
})),
);
}
return map;
}
/**
* Narrow a client-sent visit-id list to the caller's LIVE visits.
*
* Same shape and same refusal as {@link narrowOwnedEpisodeIds}: null when any
* id names nothing, so an attacker probing another account's visit ids learns
* only "not found".
*/
export async function narrowOwnedEncounterIds(
userId: string,
encounterIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(encounterIds)];
if (unique.length === 0) return [];
const owned = await prisma.encounter.findMany({
where: { id: { in: unique }, userId, deletedAt: null },
select: { id: true },
});
if (owned.length !== unique.length) return null;
return unique;
}
/**
* Replace-set a document's visit links inside the given client.
*
* Goes through the link service in the `document → encounter` direction, which
* is the same table the visit's own sheet writes from the other end, so the two
* surfaces cannot disagree about what is filed where.
*/
export async function replaceEncounterLinks(
tx: Prisma.TransactionClient,
userId: string,
documentId: string,
encounterIds: string[],
): Promise<void> {
await replaceTargets(tx, {
userId,
sourceKind: "document",
sourceId: documentId,
targetKind: "encounter",
targetIds: encounterIds,
});
}
/**
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
* Returns the deduplicated owned ids, or null when any id is unknown /
+5
View File
@@ -19,6 +19,7 @@ import type {
import { servingClassFor } from "@/lib/documents/upload-policy";
import type {
DocumentConditionLinkDto,
DocumentEncounterLinkDto,
DocumentContentIndexSourceValue,
DocumentSummaryStateValue,
ExtractedFactDto,
@@ -199,6 +200,7 @@ export function serialiseDocument(
hasContentIndex = false,
contentIndexSource: DocumentContentIndexSourceValue | null = null,
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
): InboundDocumentDto {
return {
id: doc.id,
@@ -219,6 +221,7 @@ export function serialiseDocument(
factCount: counts.factCount,
pendingCount: counts.pendingCount,
conditionLinks,
encounterLinks,
servingClass: servingClassFor(doc.mimeType),
hasContentIndex,
contentIndexSource: hasContentIndex ? contentIndexSource : null,
@@ -251,6 +254,7 @@ export function serialiseDocumentDetail(
hasContentIndex = false,
contentIndexSource: DocumentContentIndexSourceValue | null = null,
hasThumbnail = false,
encounterLinks: DocumentEncounterLinkDto[] = [],
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
@@ -286,6 +290,7 @@ export function serialiseDocumentDetail(
hasContentIndex,
contentIndexSource,
hasThumbnail,
encounterLinks,
),
facts: facts.map(serialiseFact),
summary,
@@ -0,0 +1,244 @@
/**
* The suggestion rule, pinned.
*
* Two properties carry the whole feature and both are easy to break without
* noticing:
*
* - the window is ±7 days and not "recently";
* - two candidates offer a picker with nothing pre-selected, and never the
* first one.
*
* Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never
* `2026-03-14`). A fixed-date fixture slides out of a relative window as the
* calendar moves and the test rots into a false green; this repository has
* shipped that bug before.
*
* The fake below honours the `where` it is given. A fake that ignores it would
* return every seeded row for every query, so the window assertions would pass
* against a module with no window at all — a check that cannot fail is worse
* than none.
*
* Mutation checks (both run, both seen red):
* - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go
* red naming the 30-day visit that started matching;
* - make the two-candidate branch return `{ kind: "one" }` with the first row
* → "offers a picker with nothing pre-selected" goes red.
*/
import { describe, it, expect } from "vitest";
import {
ENCOUNTER_SUGGEST_WINDOW_DAYS,
suggestEncounterForDate,
} from "../suggest-window";
import type { Prisma } from "@/generated/prisma/client";
const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
interface Row {
id: string;
userId: string;
occurredAt: Date;
status: string;
kind: string;
deletedAt: Date | null;
practitioner: { name: string } | null;
}
function row(over: Partial<Row> & { id: string; occurredAt: Date }): Row {
return {
userId: "u1",
status: "DONE",
kind: "ROUTINE",
deletedAt: null,
practitioner: null,
...over,
};
}
/**
* An in-memory `encounter.findMany` that actually applies the filter.
*
* It evaluates every clause the module writes — the record id, the tombstone,
* the status set and both window edges — so a module that dropped one of them
* returns rows this fake would have excluded, and the assertion below fails.
*/
function fakeTx(rows: Row[]): Prisma.TransactionClient {
return {
encounter: {
findMany: async (args: {
where: {
userId: string;
deletedAt: null;
status: { in: string[] };
occurredAt: { gte: Date; lte: Date };
};
take?: number;
}) => {
const { where } = args;
const matched = rows
.filter((r) => r.userId === where.userId)
.filter((r) =>
where.deletedAt === null ? r.deletedAt === null : true,
)
.filter((r) => where.status.in.includes(r.status))
.filter(
(r) =>
r.occurredAt.getTime() >= where.occurredAt.gte.getTime() &&
r.occurredAt.getTime() <= where.occurredAt.lte.getTime(),
)
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
return matched.slice(0, args.take ?? matched.length);
},
},
} as unknown as Prisma.TransactionClient;
}
const anchor = new Date();
const ago = (days: number, extraMs = 0) =>
new Date(anchor.getTime() - days * DAY_MS - extraMs);
const ahead = (days: number, extraMs = 0) =>
new Date(anchor.getTime() + days * DAY_MS + extraMs);
describe("the window", () => {
it("is seven days wide on each side", () => {
expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7);
});
it("admits a visit exactly seven days before the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(7) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: expect.objectContaining({ id: "e1" }),
});
});
it("admits a visit exactly seven days after the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ahead(7) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: expect.objectContaining({ id: "e1" }),
});
});
it("refuses a visit seven days and one minute before the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("refuses a visit seven days and one minute after the anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("refuses a visit thirty days out", async () => {
// The widened-window mutation lands here first: at ±90 days this row
// starts matching and the verdict stops being "none".
const result = await suggestEncounterForDate(
fakeTx([row({ id: "far", occurredAt: ahead(30) })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
});
describe("the verdict", () => {
it("offers nothing when there is no candidate", async () => {
const result = await suggestEncounterForDate(fakeTx([]), {
userId: "u1",
anchor,
});
expect(result).toEqual({ kind: "none" });
});
it("pre-selects the single candidate, resolved", async () => {
const result = await suggestEncounterForDate(
fakeTx([
row({
id: "e1",
occurredAt: ago(3),
kind: "SPECIALIST",
practitioner: { name: "Praxis am Park" },
}),
]),
{ userId: "u1", anchor },
);
expect(result).toEqual({
kind: "one",
encounter: {
id: "e1",
occurredAt: ago(3).toISOString(),
status: "DONE",
kind: "SPECIALIST",
practitionerName: "Praxis am Park",
},
});
});
it("offers a picker with nothing pre-selected for two candidates", async () => {
const result = await suggestEncounterForDate(
fakeTx([
row({ id: "e1", occurredAt: ago(3) }),
row({ id: "e2", occurredAt: ago(1) }),
]),
{ userId: "u1", anchor },
);
// The shape is the assertion: there is no field on a "many" verdict a
// caller could read as a pre-selection.
expect(result.kind).toBe("many");
expect(result).not.toHaveProperty("encounter");
if (result.kind !== "many") throw new Error("unreachable");
expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]);
});
});
describe("what is never a candidate", () => {
it("a soft-deleted visit", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("a cancelled visit, and a no-show", async () => {
// Neither happened, so neither produced the record being filed. Admitting
// them would widen the set with rows that are wrong most of the time.
const result = await suggestEncounterForDate(
fakeTx([
row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }),
row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }),
]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("another account's visit", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]),
{ userId: "u1", anchor },
);
expect(result).toEqual({ kind: "none" });
});
it("an unusable anchor", async () => {
const result = await suggestEncounterForDate(
fakeTx([row({ id: "e1", occurredAt: ago(1) })]),
{ userId: "u1", anchor: new Date("not a date") },
);
expect(result).toEqual({ kind: "none" });
});
});
+11 -2
View File
@@ -64,7 +64,15 @@ export interface EncounterDTO {
* happened, or once the one-shot reminder is spent.
*/
reminderNextDueAt: string | null;
/** Present on the detail response; omitted from the list. */
/**
* The three link families, resolved.
*
* Present on the list as well as the detail. Omitting them from the list
* would break two things at once: the card counts what a visit produced, and
* the edit sheet seeds its pickers from the row it was handed — an edit
* opened from a linkless row would save three empty arrays and unfile
* everything. The list pays three grouped queries for the whole page.
*/
links?: EncounterLinksDTO;
/**
* What the write could not do, named.
@@ -130,7 +138,8 @@ export function toEncounterDTO(
* Two arrays rather than one sorted list, resolved server-side: "what is
* coming up" and "what happened" are read in opposite directions, and making
* the client split and re-sort one array would put that decision in two
* places.
* places. Each row carries its links, for the reasons stated at
* `EncounterDTO.links`.
*/
export interface EncounterListDTO {
upcoming: EncounterDTO[];
+82 -2
View File
@@ -12,7 +12,12 @@ import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links";
import {
listTargets,
listTargetsBySource,
replaceTargets,
type LinkTargetKind,
} from "@/lib/links";
import { grantCoversDomain } from "@/lib/sharing/grants";
import type { ShareDomain } from "@/lib/sharing/scope";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
@@ -75,11 +80,21 @@ export async function actingDomainVisibility(
return (domain) => grantCoversDomain(grant, domain);
}
/** Which section of the record each link family's TARGET belongs to. */
/**
* Which section of the record each link family's TARGET belongs to.
*
* Exhaustive over `LinkTargetKind` by the compiler, so a link kind added to
* the service cannot reach a visit's link list without somebody deciding which
* domain governs its names. `encounter` is here for that reason and is never
* read from this side — a visit does not point at another visit — but a
* `Partial` here would turn "nobody classified it" into "undefined", and an
* undefined domain read as a falsy check is a leak.
*/
const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
document: "documents",
labResult: "labs",
conditionEpisode: "illness",
encounter: "profile",
};
/**
@@ -134,6 +149,71 @@ export async function loadEncounterLinks(
return { documents, labResults, conditions };
}
/**
* The same three families for a PAGE of visits, in three grouped queries.
*
* Two grouped reads per family rather than two per visit — `listTargetsBySource`
* is the batched arm of the same service, so the list pays six round trips for
* a page instead of six per row.
*
* The list carries links for two reasons that are both defects when it does
* not. The card shows counts of what a visit produced, and a card reading zero
* for a visit with three documents is a lie the person cannot see past. And the
* edit sheet seeds its pickers from the row it was handed: an edit opened from a
* list row that did not know its links would save a replace-set of three empty
* arrays and silently unfile everything the visit had collected.
*
* Redaction is the same rule, applied per family, from the same predicate.
*/
export async function loadEncounterLinksForMany(
tx: Prisma.TransactionClient,
userId: string,
encounterIds: string[],
visible: (domain: ShareDomain) => boolean,
): Promise<Map<string, EncounterLinksDTO>> {
const out = new Map<string, EncounterLinksDTO>();
if (encounterIds.length === 0) return out;
const read = async (targetKind: LinkTargetKind) =>
listTargetsBySource(tx, {
userId,
sourceKind: "encounter",
sourceIds: encounterIds,
targetKind,
});
const [documents, labResults, conditions] = await Promise.all([
read("document"),
read("labResult"),
read("conditionEpisode"),
]);
const family = (
map: Map<string, { id: string; label: string; date: string | null }[]>,
id: string,
targetKind: LinkTargetKind,
) => {
const rows = map.get(id) ?? [];
if (visible(TARGET_DOMAIN[targetKind])) {
return rows.map((row) => ({ ...row, redacted: false }));
}
return rows.map((row) => ({
id: row.id,
label: null,
date: null,
redacted: true,
}));
};
for (const id of encounterIds) {
out.set(id, {
documents: family(documents, id, "document"),
labResults: family(labResults, id, "labResult"),
conditions: family(conditions, id, "conditionEpisode"),
});
}
return out;
}
/**
* Apply whichever of the three link arrays the request actually named.
*
+150
View File
@@ -0,0 +1,150 @@
/**
* "Gehört das zu einem Termin?" — one rule, one constant, one module.
*
* Three moments ask it: a document arriving in the vault, a lab panel being
* committed from an extraction, and a lab panel typed by hand. They must ask
* the same question, so none of them owns the answer.
*
* ── The rule ────────────────────────────────────────────────────────────────
*
* candidates = the caller's own live visits, DONE or PLANNED, whose instant
* is within ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days of the
* anchor
*
* exactly one → pre-select it, visibly, with one undo
* two or more → offer a picker with NOTHING pre-selected
* zero → offer nothing at all
*
* The middle branch is the whole point. Pre-selecting the first of two is the
* failure that teaches a person to stop reading suggestions, which costs more
* than the suggestion ever saved. This module therefore returns the SHAPE of
* the answer rather than a best guess, and a caller cannot collapse "many" into
* "one" without writing the collapse itself.
*
* ── What it is not ──────────────────────────────────────────────────────────
*
* It is not an AI call. The match is a date-window query over the person's own
* rows: it costs one index scan, cannot hallucinate, and resolves on an account
* with no provider configured — which matters, because the document assist
* itself refuses without one.
*
* It never blocks. Every caller treats the verdict as an offer; a save works
* with it ignored, dismissed, or never rendered.
*
* ── Why CANCELLED and NO_SHOW are not candidates ────────────────────────────
*
* Both name an appointment that did not happen, so neither produced the letter
* or the panel being filed. Admitting them would widen the candidate set with
* rows that are wrong most of the time, which pushes the honest single-match
* case into the picker branch and trains exactly the distrust the two-candidate
* rule exists to prevent.
*/
import type { Prisma } from "@/generated/prisma/client";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
/**
* The half-width of the match window, in days, exported so the three call
* sites and the tests read the same number. A literal at three call sites is
* three numbers that agree until one of them is edited.
*/
export const ENCOUNTER_SUGGEST_WINDOW_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* A bound on how many candidates are read. Anything above one already means
* "offer a picker", so the exact count past this cap changes no decision — and
* an unbounded read on a busy account would load a page of rows to answer a
* yes/no question.
*/
const MAX_CANDIDATES = 20;
/**
* One candidate, resolved. Enough to render a row without a second round trip
* and without the client re-deriving a label the server already holds.
*/
export interface EncounterSuggestion {
id: string;
occurredAt: string;
status: EncounterStatus;
kind: EncounterKind;
/** The practice, when the visit names one. `null` is an absence, not a gap. */
practitionerName: string | null;
}
/**
* The verdict. A discriminated union rather than an array plus a boolean,
* because "one" and "many" are different offers and a caller that receives an
* array will eventually take `[0]`.
*/
export type EncounterSuggestionResult =
| { kind: "one"; encounter: EncounterSuggestion }
| { kind: "many"; encounters: EncounterSuggestion[] }
| { kind: "none" };
/** The statuses a visit can carry and still be the source of a filed record. */
const CANDIDATE_STATUSES: EncounterStatus[] = ["DONE", "PLANNED"];
/**
* The window around an anchor instant, inclusive on both edges.
*
* Exported so a caller that needs the bounds for a different query — the
* report offer reads the same idea forward-only — states them from here rather
* than re-deriving the arithmetic.
*/
export function encounterSuggestWindow(anchor: Date): { from: Date; to: Date } {
const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
return {
from: new Date(anchor.getTime() - half),
to: new Date(anchor.getTime() + half),
};
}
/**
* Resolve the visit a record dated `anchor` most plausibly belongs to.
*
* `userId` is the caller's resolved record id and is fed straight to the
* `where`; this module never accepts one from a request body.
*/
export async function suggestEncounterForDate(
tx: Prisma.TransactionClient,
input: { userId: string; anchor: Date },
): Promise<EncounterSuggestionResult> {
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
const { from, to } = encounterSuggestWindow(input.anchor);
const rows = await tx.encounter.findMany({
where: {
userId: input.userId,
deletedAt: null,
status: { in: CANDIDATE_STATUSES },
occurredAt: { gte: from, lte: to },
},
// Nearest to the anchor would be the kinder order, but Prisma cannot sort
// by a computed distance; the caller renders a picker in chronological
// order, which is how a person reads their own week anyway.
orderBy: { occurredAt: "asc" },
take: MAX_CANDIDATES,
select: {
id: true,
occurredAt: true,
status: true,
kind: true,
practitioner: { select: { name: true } },
},
});
const encounters: EncounterSuggestion[] = rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitionerName: row.practitioner?.name ?? null,
}));
if (encounters.length === 0) return { kind: "none" };
if (encounters.length === 1) return { kind: "one", encounter: encounters[0] };
return { kind: "many", encounters };
}
+2
View File
@@ -367,6 +367,8 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
"Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.",
MoodEntryRollup:
"A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.",
MoodPrediction:
"What the account's own past days imply about a day, fitted from `MoodEntry` + `MoodContext` and the linked modules. The nightly `mood-prognosis-refresh` job rebuilds it after a restore, and rebuilding it is better than carrying it: a restored row would state a forecast made by a model version that may no longer exist, against data the restore has just moved.",
CyclePrediction:
"A forecast derived from `MenstrualCycle` history; regenerated on the next cycle read.",
StrainTrimpCache:
+8
View File
@@ -261,6 +261,14 @@ export function buildFhirDocumentBundle(
// --- Condition + Encounter per illness/condition episode (opt-in) ------
// v1.18.1 — present only when the illness module is enabled AND the window
// No visits section, and the absence is a decision written at the
// `Encounter` model in `prisma/schema.prisma`: FHIR splits the future from
// the past across `Appointment` and `Encounter`, which is a serialisation
// question this release does not answer. `FHIR_REST_RESOURCE_TYPES` stays
// untouched for the same reason — a capability statement naming a type with
// no route module lies. The PDF carries the visits; the Bundle does not, and
// a reader who notices the gap should find this comment rather than a bug.
//
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
// episode emits a patient-reported Condition (generic SNOMED root, label on
// `code.text`) plus a bounding Encounter that references it.
+119
View File
@@ -17,6 +17,7 @@ import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -321,6 +322,113 @@ async function buildMoodDimensionBlock(userId: string): Promise<{
};
}
/**
* The day's two readings for the model, with the age of the second one.
*
* Absence is a present field rather than a missing one: `{ present: false }`
* with the reason and the count, so the model says "not enough entries yet"
* instead of inventing a number. That shape is a contract elsewhere in the
* product already and it is the reason this block is built even when there is
* nothing to report.
*
* The wording rule travels with the values. A model handed a number and no
* instruction writes the sentence its training suggests, and the sentence its
* training suggests about a forecast is a causal one.
*/
interface MoodPrognosisFeature {
present: boolean;
/** Why there is nothing, when there is nothing. */
reason?: string;
/** Days of the account's own history the ladder was read against. */
entries: number;
/** The day the forecast is about. */
date?: string;
/** The expected value, 0-10. Never to be stated without `n` and the band. */
predicted?: number;
ciLow?: number | null;
ciHigh?: number | null;
/** Complete days the fit used. State it in any sentence that uses the value. */
n?: number;
modelVersion?: string;
/** The person's own rating for that day, unchanged. */
selfAssessment?: number | null;
/** Where that rating sat against the band: within, above or below. */
deviation?: string | null;
/** Whole days between the forecast's day and today. */
daysAgo?: number | null;
/** Whether it is fresh enough to back a present-tense sentence. */
current?: boolean;
/** Present only when it is not, and says in words what the flag says. */
staleNotice?: string;
/** The wording rule, stated rather than implied. */
note: string;
}
/**
* The forecast block, or an honest absence.
*
* One reader for every surface: this calls the same `readMoodPrognosis` the
* route calls, so the Coach and the page cannot disagree about what today's
* comparison is. The freshness rule is the shared one — `isCurrentForTodayClaim`
* against the same `TODAY_CLAIM_MAX_AGE_DAYS` — because a second constant for
* "how old may a value be before today stops being true about it" is how two
* surfaces start narrating the same reading differently.
*/
async function buildMoodPrognosisBlock(userId: string): Promise<{
prognosis?: MoodPrognosisFeature;
}> {
const reading = await readMoodPrognosis(
userId,
new Date(),
await resolveUserTimezone(userId),
);
if (!reading.present) {
return {
prognosis: {
present: false,
reason: reading.reason,
entries: reading.entries,
note:
"There is no expected-value comparison for this account yet. Say so " +
"plainly if it comes up; do not estimate one, and do not describe " +
"the absence as a bad sign.",
},
};
}
return {
prognosis: {
present: true,
entries: reading.entries,
date: reading.date,
predicted: reading.predicted,
ciLow: reading.ciLow,
ciHigh: reading.ciHigh,
n: reading.n,
modelVersion: reading.modelVersion,
selfAssessment: reading.selfAssessment,
deviation: reading.deviation,
daysAgo: reading.ageDays,
current: reading.current,
...(reading.current
? {}
: {
staleNotice: `This comparison is about ${reading.date}. State it as that day, with the date; do not describe it as today.`,
}),
note:
"Two separate readings of one day: `selfAssessment` is what the person " +
"recorded and leads; `predicted` is what a comparison with their own " +
"past days would have expected, built from `n` days. Never merge them, " +
"never present `predicted` as the person's mood, and never state it " +
"without `n` and the band. Phrase it as a counterfactual — a value " +
"around X would have been expected — and never say that anything " +
"caused anything: the comparison is an association over the account's " +
"own days and supports no cause.",
},
};
}
export interface AggregatedFeatures {
weight?: {
latest: number;
@@ -467,6 +575,16 @@ export interface AggregatedFeatures {
withoutAvg: number;
}>;
};
/**
* v1.38 — the day's two readings and the distance between them.
*
* Always present once the mood module is on, because an honest absence is
* information: `present: false` with a reason and a count keeps the model
* from filling the gap with an estimate. The block carries its own age, so
* a comparison about the day before yesterday cannot back a sentence about
* today.
*/
prognosis?: MoodPrognosisFeature;
};
sleep?: {
avg7: number | null;
@@ -1363,6 +1481,7 @@ export async function extractFeatures(
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
...(await buildMoodContextBlock(userId)),
...(await buildMoodPrognosisBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
@@ -106,6 +106,8 @@ describe("evaluateMeasurementReminderDue — window boundary", () => {
interface FakeReminder {
id: string;
/** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */
origin?: string;
measurementType: string | null;
intervalDays: number | null;
rrule: string | null;
@@ -248,6 +250,46 @@ describe("runMeasurementReminderTick", () => {
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
});
it("dispatches an appointment nudge with its completion affordance suppressed", async () => {
// `measure_done` / `measure_later` and the iOS "Erledigt" action all
// resolve the reminder by id, and every by-id surface refuses an
// ENCOUNTER-origin row. The producer knows the origin, so it is the
// producer that says the dispatch carries no action; the channels drop
// their own affordance from the flag.
const { prisma } = makePrisma({
reminders: [
reminder({ origin: "ENCOUNTER", measurementType: null, id: "appt-1" }),
],
measurementMatch: null,
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
const payload = dispatch.mock.calls[0][0];
expect(payload.suppressActions).toBe(true);
// The id still rides the dispatch — it identifies the nudge for the
// client-managed suppression tag. What is withheld is the offer to act.
expect(payload.metadata?.reminderId).toBe("appt-1");
});
it("leaves a checkup nudge actionable", async () => {
// The negative control. Without it the assertion above would still pass
// against a producer that suppressed every reminder's affordance.
const { prisma } = makePrisma({
reminders: [reminder({})],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined();
});
it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => {
const { prisma } = makePrisma({
reminders: [reminder({ measurementType: null })],
@@ -0,0 +1,152 @@
/**
* The nightly mood-forecast rebuild.
*
* Three properties, each of which fails silently if it breaks:
*
* 1. **The sweep visits accounts that hold rows, not only accounts that
* qualify.** An account that deleted its entries drops out of the
* eligibility half of the cohort, and if that were the whole cohort it
* would keep last month's forecast forever, under whatever date the last
* pass stamped on it. Nothing on the surface would say so.
* 2. **A refusal clears.** Below the threshold, module off, no pattern — all
* three leave the account with no rows rather than with stale ones.
* 3. **One account's bad data costs one account.** The pass counts the
* failure and carries on; only an all-accounts failure fails the job.
*
* The cohort query and the module gate are mocked, which is what makes the
* decision path testable without a container. The arithmetic underneath has
* its own tests, and the whole thing is exercised end to end against real
* Postgres in `tests/integration/mood-prognosis-ladder.test.ts`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/jobs/reminder/shared", () => ({
getWorkerPrisma: () => ({
moodEntry: { groupBy: groupByMock },
moodPrediction: {
findMany: predictionFindManyMock,
deleteMany: deleteManyMock,
},
}),
}));
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/background", () => ({
withBackgroundEvent: async (
_name: string,
fn: (evt: { addMeta: () => void }) => Promise<unknown>,
) => fn({ addMeta: () => {} }),
}));
const groupByMock = vi.fn();
const predictionFindManyMock = vi.fn();
const deleteManyMock = vi.fn();
import {
MOOD_PROGNOSIS_REFRESH_CRON,
MOOD_PROGNOSIS_REFRESH_QUEUE,
handleMoodPrognosisRefresh,
runMoodPrognosisRefresh,
} from "@/lib/jobs/mood-prognosis-refresh";
import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds";
const NOW = new Date("2026-08-08T02:35:00.000Z");
beforeEach(() => {
vi.clearAllMocks();
groupByMock.mockResolvedValue([
{ userId: "user-1", _count: { _all: MIN_ENTRIES_FOR_FORECAST } },
{ userId: "user-2", _count: { _all: 120 } },
]);
predictionFindManyMock.mockResolvedValue([]);
deleteManyMock.mockResolvedValue({ count: 0 });
});
describe("mood-prognosis-refresh", () => {
it("runs at 04:35, in the slot the neighbouring jobs leave free", () => {
expect(MOOD_PROGNOSIS_REFRESH_CRON).toBe("35 4 * * *");
expect(MOOD_PROGNOSIS_REFRESH_QUEUE).toBe("mood-prognosis-refresh");
});
it("refreshes every eligible account", async () => {
const refresh = vi.fn(async () => ({ rows: 7, refusal: null }));
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
expect(summary.scanned).toBe(2);
// The count is the point: a cohort query that matched nothing would
// satisfy "no bad rows" exactly as well as a working sweep.
expect(refresh).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledWith("user-1", NOW);
expect(summary.refreshed).toBe(2);
expect(summary.rowsWritten).toBe(14);
});
it("leaves an account below the entry cut out of the cohort", async () => {
groupByMock.mockResolvedValue([
{ userId: "user-1", _count: { _all: MIN_ENTRIES_FOR_FORECAST - 1 } },
]);
const refresh = vi.fn(async () => ({ rows: 1, refusal: null }));
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
expect(summary.scanned).toBe(0);
expect(refresh).not.toHaveBeenCalled();
});
it("still visits an account that holds rows but no longer qualifies", async () => {
groupByMock.mockResolvedValue([]);
predictionFindManyMock.mockResolvedValue([{ userId: "user-stale" }]);
const refresh = vi.fn(async () => ({
rows: 0,
refusal: "too-few-days" as const,
}));
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
// Without this arm the account would keep its forecast forever: it is not
// in the eligible set any more, so nothing would ever come back for it.
expect(summary.scanned).toBe(1);
expect(refresh).toHaveBeenCalledWith("user-stale", NOW);
expect(summary.refused).toBe(1);
expect(summary.refreshed).toBe(0);
});
it("computes nothing and stores nothing while the module is off, and clears what it stored before", async () => {
const refresh = vi.fn(async () => ({ rows: 7, refusal: null }));
const summary = await runMoodPrognosisRefresh(NOW, {
isAvailable: async () => false,
refresh,
});
expect(refresh).not.toHaveBeenCalled();
expect(summary.moduleOff).toBe(2);
expect(summary.rowsWritten).toBe(0);
expect(deleteManyMock).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(deleteManyMock).toHaveBeenCalledTimes(2);
});
it("counts an account whose pass throws and carries on", async () => {
const refresh = vi
.fn()
.mockRejectedValueOnce(new Error("bad rows"))
.mockResolvedValueOnce({ rows: 3, refusal: null });
const summary = await runMoodPrognosisRefresh(NOW, { refresh });
expect(summary.failed).toBe(1);
expect(summary.refreshed).toBe(1);
expect(summary.rowsWritten).toBe(3);
});
it("fails the job only when every account failed", async () => {
const outcome = await handleMoodPrognosisRefresh([]);
// The default refresh path reaches the real loader, which the mocked
// client cannot serve, so every account throws — which is exactly the
// all-accounts case.
expect(outcome.ok).toBe(false);
});
});
+3
View File
@@ -121,6 +121,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
"medications_evaluated",
"metric_assessments_warmed",
"missed_doses_minted",
"module_off",
"mood_entries_migrated",
"mood_pruned",
"notifications_dispatched",
@@ -147,8 +148,10 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
"rearmed",
"records_read",
"refreshed",
"refused",
"reminders_due",
"removed",
"rows_written",
"restore_drill_age_days",
"restore_drill_ciphertext_bytes",
"restore_drill_intake_events",
+8
View File
@@ -338,12 +338,20 @@ export async function runMeasurementReminderTick(
return { title: rendered.title, message: rendered.body };
};
// An appointment nudge is dispatched without its Done / Later
// affordance. It rides the same event type as a checkup — one engine,
// one cron, deliberately — but every by-id reminder surface refuses an
// ENCOUNTER-origin row: satisfying one would mark an appointment
// attended from a notification, and postponing one would move a date the
// visit record still believes it owns. The channels each drop their own
// affordance from this flag rather than re-deriving the origin.
const outcome = await dispatchImpl({
eventType: "MEASUREMENT_REMINDER",
userId: reminder.user.id,
title,
message: body,
renderForRecipient,
...(reminder.origin === "ENCOUNTER" ? { suppressActions: true } : {}),
metadata: {
scheduledAt: now.toISOString(),
reminderId: reminder.id,
+241
View File
@@ -0,0 +1,241 @@
/**
* Nightly rebuild of the mood forecast rows.
*
* **This is not an AI surface.** A ridge regression over a dozen standardised
* columns is arithmetic: no provider, no key, no egress, no consent gate. It
* is stated here because the shape of the feature invites the assumption in
* both directions — nobody should wire a consent check that does not apply,
* and nobody should assume one exists where none is needed. If a later change
* narrates this forecast through a language model, THAT call site inherits
* `ai-consent-enforcement-guard.test.ts` and has to declare its surface.
*
* **This file is the only writer of `MoodPrediction`**, held there by
* `src/__tests__/mood-prediction-write-surface-guard.test.ts`. The reason is
* the product rule: a day carries the person's own rating and the value their
* past days imply, the two are never merged, and the second one is not
* editable. A route that wrote this table would make it editable. The cycle
* module learned the other half of the same lesson the expensive way — a
* forecast written as a side effect of opening a page is a function of
* browsing rather than of data.
*
* **A pass replaces; it never accumulates.** Every account it touches ends the
* pass holding exactly the rows this run wrote, or none. An account that has
* fallen below the threshold, switched the module off, or stopped showing a
* pattern is left with nothing rather than with last week's answer under a
* fresh-looking date. That is the stale rule, chosen over marking rows
* superseded because a derived row is cheap to rebuild and a superseded row is
* one more state every reader has to handle correctly.
*
* Runs at 04:35 Europe/Berlin. The 03:xx block is retention; 04:10 is the
* document purge, 04:20 the cycle forecast, 04:25 the achievement sweep, 04:30
* the insight pre-generation, and 04:45/50/55 the recovery, stress and strain
* scores. 04:35 is free, and it sits after the inputs this job reads have
* settled. (04:40 is not free: the geo backfill runs at `40 * * * *`.)
*/
import type { Job } from "pg-boss";
import {
computeForecast,
type ForecastRefusal,
} from "@/lib/analytics/mood-prognosis/forecast";
import {
PROGNOSIS_FIT_WINDOW_DAYS,
dayKeyBefore,
loadPrognosisDays,
} from "@/lib/analytics/mood-prognosis/load";
import { MIN_ENTRIES_FOR_FORECAST } from "@/lib/analytics/mood-prognosis/thresholds";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { withBackgroundEvent } from "@/lib/logging/background";
import { isModuleEnabled } from "@/lib/modules/gate";
import { getWorkerPrisma } from "./reminder/shared";
export const MOOD_PROGNOSIS_REFRESH_QUEUE = "mood-prognosis-refresh";
export const MOOD_PROGNOSIS_REFRESH_CRON = "35 4 * * *";
export interface MoodPrognosisRefreshPayload {
triggeredAt: string;
}
export interface MoodPrognosisRefreshSummary {
/** Accounts the cohort query returned, plus any holding stale rows. */
scanned: number;
/** Accounts whose forecast rows now reflect their data. */
refreshed: number;
/** Rows written across every account. */
rowsWritten: number;
/** Accounts the fit refused, whose rows were cleared. */
refused: number;
/** Accounts whose module is off, whose rows were cleared. */
moduleOff: number;
/** Accounts whose pass threw. */
failed: number;
}
/**
* Rebuild one account's rows, or clear them.
*
* The write and the delete are the two calls the guard allows in this file,
* and they are here rather than beside the arithmetic so the single-writer
* claim is a property of one module rather than a convention across two.
*/
async function refreshAccount(
userId: string,
now: Date,
): Promise<{ rows: number; refusal: ForecastRefusal | null }> {
const prisma = getWorkerPrisma();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const days = await loadPrognosisDays(userId, now, user?.timezone ?? null);
const result = computeForecast(days);
if (result.status === "refused") {
await prisma.moodPrediction.deleteMany({ where: { userId } });
return { rows: 0, refusal: result.reason };
}
const written: string[] = [];
for (const row of result.rows) {
const data = {
predicted: row.predicted,
ciLow: row.ciLow,
ciHigh: row.ciHigh,
n: row.n,
modelVersion: row.modelVersion,
features: JSON.stringify(row.contributions),
computedAt: now,
};
await prisma.moodPrediction.upsert({
where: { userId_date: { userId, date: row.date } },
create: { userId, date: row.date, ...data },
update: data,
});
written.push(row.date);
}
// Everything outside this pass goes, so no account holds a row from an older
// fit beside a row from this one.
await prisma.moodPrediction.deleteMany({
where: { userId, date: { notIn: written } },
});
return { rows: written.length, refusal: null };
}
/**
* Refresh every eligible account. Exported for the test; the handler below is
* the queue binding.
*
* The cohort is the union of two sets, and the second one is the whole point:
* accounts with enough recent rated days to be worth fitting, AND accounts
* that already hold forecast rows. Without the second, an account that deleted
* its entries would drop out of the cohort and keep its old forecast forever —
* the sweep would never visit it again to clear it.
*/
export async function runMoodPrognosisRefresh(
now: Date = new Date(),
options: {
isAvailable?: (userId: string) => Promise<boolean>;
refresh?: typeof refreshAccount;
} = {},
): Promise<MoodPrognosisRefreshSummary> {
const isAvailable =
options.isAvailable ??
((userId: string) => isModuleEnabled(userId, "mood"));
const refresh = options.refresh ?? refreshAccount;
const prisma = getWorkerPrisma();
const since = dayKeyBefore(now, PROGNOSIS_FIT_WINDOW_DAYS);
const [eligible, holders] = await Promise.all([
prisma.moodEntry.groupBy({
by: ["userId"],
where: {
deletedAt: null,
moodA1: { not: null },
date: { gte: since },
},
_count: { _all: true },
}),
prisma.moodPrediction.findMany({
distinct: ["userId"],
select: { userId: true },
}),
]);
const userIds = new Set<string>();
for (const group of eligible) {
// The cheap cut, so an account with a handful of entries never pays for a
// window read. The real gate is the ladder inside the fit, which counts
// DAYS rather than rows.
if (group._count._all >= MIN_ENTRIES_FOR_FORECAST)
userIds.add(group.userId);
}
for (const holder of holders) userIds.add(holder.userId);
const summary: MoodPrognosisRefreshSummary = {
scanned: userIds.size,
refreshed: 0,
rowsWritten: 0,
refused: 0,
moduleOff: 0,
failed: 0,
};
for (const userId of userIds) {
try {
if (!(await isAvailable(userId))) {
// An operator- or user-disabled module computes nothing and stores
// nothing — and gives back anything it stored before.
await prisma.moodPrediction.deleteMany({ where: { userId } });
summary.moduleOff += 1;
continue;
}
const outcome = await refresh(userId, now);
if (outcome.refusal !== null) {
summary.refused += 1;
continue;
}
summary.refreshed += 1;
summary.rowsWritten += outcome.rows;
} catch {
// One account's bad data must not cost every other account its forecast.
// The count is what makes a systemic failure visible.
summary.failed += 1;
}
}
return summary;
}
export async function handleMoodPrognosisRefresh(
jobs: Job<MoodPrognosisRefreshPayload>[],
): Promise<JobOutcome> {
void jobs;
return withBackgroundEvent("job.mood-prognosis-refresh", async (evt) => {
const summary = await runMoodPrognosisRefresh();
evt.addMeta("mood_prognosis_scanned", summary.scanned);
evt.addMeta("mood_prognosis_refreshed", summary.refreshed);
evt.addMeta("mood_prognosis_rows", summary.rowsWritten);
evt.addMeta("mood_prognosis_refused", summary.refused);
evt.addMeta("mood_prognosis_module_off", summary.moduleOff);
evt.addMeta("mood_prognosis_failed", summary.failed);
// Every account failing is a systemic fault (a dead pool, a schema drift),
// not one account's bad data. Anything short of that stays a success with
// the count on the event.
if (summary.failed > 0 && summary.failed === summary.scanned) {
return jobFailed(
`mood-prognosis-refresh failed for all ${summary.failed} accounts`,
);
}
return jobDone({
users_scanned: summary.scanned,
refreshed: summary.refreshed,
rows_written: summary.rowsWritten,
refused: summary.refused,
module_off: summary.moduleOff,
users_failed: summary.failed,
});
});
}
@@ -195,6 +195,12 @@ import {
handleCyclePredictionRefresh,
type CyclePredictionRefreshPayload,
} from "@/lib/jobs/cycle-prediction-refresh";
import {
MOOD_PROGNOSIS_REFRESH_QUEUE,
MOOD_PROGNOSIS_REFRESH_CRON,
handleMoodPrognosisRefresh,
type MoodPrognosisRefreshPayload,
} from "@/lib/jobs/mood-prognosis-refresh";
import {
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
ACHIEVEMENT_UNLOCK_SWEEP_CRON,
@@ -365,6 +371,10 @@ const allQueues = [
// scans. Without this entry the schedule silently no-ops and the forecast
// is only as fresh as the last time somebody opened the calendar.
CYCLE_PREDICTION_REFRESH_QUEUE,
// v1.38 — nightly rebuild of the mood forecast rows. Without this entry the
// schedule silently no-ops: nothing else writes them, so the surface would
// answer "not enough data yet" forever on an account that has plenty.
MOOD_PROGNOSIS_REFRESH_QUEUE,
// v1.35.3 — nightly pin for newly unlocked achievements. Without this entry
// the schedule silently no-ops and no unlock date is ever made durable.
ACHIEVEMENT_UNLOCK_SWEEP_QUEUE,
@@ -521,6 +531,10 @@ const schedules: ScheduleEntry[] = [
CYCLE_PREDICTION_REFRESH_CRON,
cronIsTheRetry,
],
// v1.38 — daily 04:35 Europe/Berlin rebuild of the mood forecast rows.
// Tomorrow's tick is the retry: the fit moves on a day scale and a night
// late writes the same rows from the same days.
[MOOD_PROGNOSIS_REFRESH_QUEUE, MOOD_PROGNOSIS_REFRESH_CRON, cronIsTheRetry],
// v1.35.3 — daily 04:25 Europe/Berlin pin for newly unlocked achievements.
// Tomorrow's tick is the retry: the date written is the computed completion
// date, so a night late writes the identical row.
@@ -743,6 +757,14 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleCyclePredictionRefresh,
);
// v1.38 — nightly mood-forecast rebuild. Single-flight: two ticks would fit
// the same windows twice and the second pass would write the identical rows.
await createAndWork<MoodPrognosisRefreshPayload>(
boss,
MOOD_PROGNOSIS_REFRESH_QUEUE,
{ localConcurrency: 1 },
handleMoodPrognosisRefresh,
);
// v1.35.3 — nightly achievement-unlock pin. Single-flight: the underlying
// createMany is idempotent on the (userId, achievementId) unique, so a
// second concurrent tick would only re-walk the same accounts.
+1
View File
@@ -117,6 +117,7 @@ const subsystemSurface = {
"push-attempt-cleanup": { audience: "system" },
"arrival-reaction-cleanup": { audience: "system" },
"cycle-prediction-refresh": { audience: "system" },
"mood-prognosis-refresh": { audience: "system" },
"achievement-unlock-sweep": { audience: "system" },
"measurement-tombstone-cleanup": { audience: "system" },
"coach-message-cleanup": { audience: "system" },
+28 -1
View File
@@ -15,7 +15,8 @@ export const MAX_TARGETS_PER_CALL = 100;
export type LinkSourceKind = "encounter" | "document";
/** The record a link points at. */
export type LinkTargetKind = "document" | "labResult" | "conditionEpisode";
export type LinkTargetKind =
"document" | "labResult" | "conditionEpisode" | "encounter";
export interface LinkRequest {
userId: string;
@@ -168,6 +169,20 @@ const LAB_RESULT_ENDPOINT: EndpointSpec = {
date: (row) => asDate(row.takenAt),
};
const ENCOUNTER_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.encounter),
select: { id: true, kind: true, occurredAt: true },
// The visit's KIND, as the enum constant, and this is the one endpoint whose
// label is deliberately not prose. A document title and an analyte name are
// the person's own words and mean the same thing in every language; a visit
// kind is one of eight closed values whose human name is a presentation
// choice. Publishing the constant is what lets a German reader and an
// English one each see it named correctly from their own bundle, which a
// server-side string chosen once could not do.
label: (row) => String(row.kind),
date: (row) => asDate(row.occurredAt),
};
const CONDITION_ENDPOINT: EndpointSpec = {
delegate: (tx) => narrow<OwnedRowDelegate>(tx.illnessEpisode),
select: { id: true, label: true, onsetAt: true },
@@ -211,6 +226,18 @@ const LINK_TABLES: Partial<
targetColumn: "episodeId",
target: CONDITION_ENDPOINT,
},
// The SAME table as `encounter:document`, read from the other end. A
// document's detail sheet answers "which visits is this filed against"
// and the visit's own sheet answers "what did this visit produce"; both
// questions live in `encounter_document_links`, so this is a second
// direction rather than a fourth table. The three-table ceiling counts
// tables.
"document:encounter": {
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterDocumentLink),
sourceColumn: "documentId",
targetColumn: "encounterId",
target: ENCOUNTER_ENDPOINT,
},
};
function tableFor(
+172 -1
View File
@@ -99,6 +99,20 @@ export interface LinkedBody {
episodeId: string | null;
}
/**
* The five linked figures of one day as plain numbers, for callers that need
* a window of days rather than one day's blocks — the prognosis fit is the
* only one so far. `null` is absence, and a module switched off makes its own
* figures absent rather than zero.
*/
export interface LinkedDayFigures {
sleepAsleep: number | null;
steps: number | null;
activeEnergy: number | null;
restingHeartRate: number | null;
heartRateVariability: number | null;
}
export interface LinkedDayContext {
/** The entry's local day, resolved through the entry's own `tz`. */
day: string;
@@ -396,6 +410,17 @@ export async function resolveLinkedDayContext(
}
: MODULE_OFF;
const bodyBlock = await resolveBody(userId, day, enabled);
return { day, sleep, activity, vitals, body: bodyBlock };
}
/** The illness day-log block, split out so the day loop above reads straight. */
async function resolveBody(
userId: string,
day: string,
enabled: ReadonlyMap<ModuleKey, boolean>,
): Promise<LinkedBlock<LinkedBody>> {
let body: LinkedBlock<LinkedBody> = MODULE_OFF;
if (enabled.get("illness") ?? true) {
// Read only. The illness module owns symptom capture and its severity
@@ -419,6 +444,152 @@ export async function resolveLinkedDayContext(
episodeId: dayLog?.episodeId ?? null,
};
}
return body;
}
return { day, sleep, activity, vitals, body };
/**
* The five linked figures of one day, as plain numbers, for a whole window.
*
* The same figures the block above answers with, resolved for many days in
* one query instead of one query per day. It exists because the prognosis fits
* over a year of days and the per-day resolver would have run several hundred
* round trips to build one matrix.
*
* It is the SAME engine, deliberately: the module gates come from
* `moduleForMeasurementType` through `typeAvailable`, the cross-source de-dup
* runs through `canonicalRowsOfDay`, the night reconstruction is the one in
* `sleep-night.ts`, and the day totals are the same `sumOfDay` / `latestOfDay`
* the single-day path uses. A second implementation of "what did this day look
* like" would be a second answer, and the mood sheet and the model would start
* disagreeing about the same Tuesday.
*
* A switched-off module answers `null` for its metrics, not zero and not a
* value the owner declined to surface: the model must not learn from a module
* whose figures the account asked not to see.
*/
export async function resolveLinkedDayFigures(
userId: string,
days: readonly string[],
tz: string,
): Promise<Map<string, LinkedDayFigures>> {
const out = new Map<string, LinkedDayFigures>();
if (days.length === 0) return out;
const sorted = [...days].sort();
const { from } = localDayWindow(sorted[0]);
const { to } = localDayWindow(sorted[sorted.length - 1]);
const gateStates = await Promise.all(
LINKED_MODULE_KEYS.map(
async (key) => [key, await isModuleEnabled(userId, key)] as const,
),
);
const enabled = new Map<ModuleKey, boolean>(gateStates);
const priority = await loadUserSourcePriority(userId);
const measurements: LinkedRow[] = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: { in: [...LINKED_MEASUREMENT_TYPES] },
measuredAt: { gte: from, lte: to },
},
select: {
type: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
// The canonical-source picker keeps insertion order inside a bucket, so a
// stable read order is what makes its device-type tie-break reproducible
// rather than whatever the planner returned this time.
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
});
// One pass to bucket by local day, rather than filtering the whole window
// once per day: over a year of history that difference is the whole cost of
// the fit's data preparation.
const byDay = new Map<string, LinkedRow[]>();
for (const row of measurements) {
const key = moodDateKey(row.measuredAt, tz);
const bucket = byDay.get(key);
if (bucket) bucket.push(row);
else byDay.set(key, [row]);
}
const sleepAvailable = typeAvailable("SLEEP_DURATION", enabled);
const activityAvailable = typeAvailable("ACTIVITY_STEPS", enabled);
const vitalsAvailable = typeAvailable("RESTING_HEART_RATE", enabled);
const nightByDay = new Map<string, number | null>();
const inBedByDay = new Map<string, number | null>();
if (sleepAvailable) {
const stageRows = measurements.filter((r) => r.type === "SLEEP_DURATION");
if (stageRows.length > 0) {
for (const night of reconstructSleepNights(
stageRows as unknown as SleepStageRow[],
tz,
priority,
)) {
nightByDay.set(night.night, night.asleepMinutes ?? null);
inBedByDay.set(night.night, night.inBedMinutes ?? null);
}
}
}
for (const day of days) {
const dayRows = byDay.get(day) ?? [];
out.set(day, {
sleepAsleep: sleepAvailable ? (nightByDay.get(day) ?? null) : null,
steps: activityAvailable
? valueOf(
sumOfDay(dayRows, "ACTIVITY_STEPS", "steps", day, tz, priority),
)
: null,
activeEnergy: activityAvailable
? valueOf(
sumOfDay(
dayRows,
"ACTIVE_ENERGY_BURNED",
"kcal",
day,
tz,
priority,
),
)
: null,
restingHeartRate: vitalsAvailable
? valueOf(
latestOfDay(
dayRows,
"RESTING_HEART_RATE",
"bpm",
day,
tz,
priority,
),
)
: null,
heartRateVariability: vitalsAvailable
? valueOf(
latestOfDay(
dayRows,
"HEART_RATE_VARIABILITY",
"ms",
day,
tz,
priority,
),
)
: null,
});
}
return out;
}
/** A figure as a plain number, with absence staying absence. */
function valueOf(figure: LinkedFigure): number | null {
return figure.present ? figure.value : null;
}
+18 -3
View File
@@ -15,10 +15,22 @@ import {
* fail closed in chrome until it has been intentionally classified.
*/
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
measurements: ["/measurements"],
// `/checkups` carries content from two domains and is listed under both.
// The preventive-care list has always been a `measurements` read
// (`/api/measurement-reminders` declares it), and the page was in neither
// list — under a whole-record grant it painted anyway, because a null
// section set admits everything, so the omission stayed invisible for as
// long as a scoped grant had no reason to open it. The visits section makes
// it visible: a delegate scoped to `profile` is granted the visit data by
// the API and would be denied the only page that shows it. The table is
// built by a flatMap over (domain, href) pairs, so one href under two
// domains means a grant naming either presents the page — and the handler
// still evaluates the grant on every read, because this is a presentation
// inventory and not an authorization table.
measurements: ["/measurements", "/checkups"],
medications: ["/medications"],
labs: ["/labs"],
profile: ["/profile"],
profile: ["/profile", "/checkups"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
@@ -78,7 +90,10 @@ export function resolveSharedRecordNavigation(
);
return {
destinationHrefs: routeFamilies.map((family) => family.href),
// Deduplicated: an href listed under two domains is one destination, and a
// grant holding both would otherwise offer it twice — and make
// `sharedRecordLandingHref` count a page as two doorways.
destinationHrefs: [...new Set(routeFamilies.map((family) => family.href))],
allowsPath(pathname: string): boolean {
return routeFamilies.some((family) =>
matchesRouteFamily(pathname, family.href),
@@ -674,6 +674,32 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload.replyMarkup).toBeUndefined();
});
it("withholds the category and the reminder id when actions are suppressed", async () => {
// The category IS the action row on iOS, and the id is what the action
// would POST. An appointment nudge has neither: the by-id reminder family
// refuses an ENCOUNTER-origin row, so both are dropped and the push
// arrives as a plain alert. The event type still routes it.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEASUREMENT_REMINDER",
suppressActions: true,
metadata: {
reminderId: "rem-1",
scheduledAt: "2026-08-08T09:00:00.000Z",
},
});
const note = sendMock.mock.calls[0][0];
expect(note.category).toBeUndefined();
expect(note.payload.reminderId).toBeUndefined();
expect(note.payload.eventType).toBe("MEASUREMENT_REMINDER");
expect(note.payload.scheduledAt).toBe("2026-08-08T09:00:00.000Z");
});
it("forwards MOOD_REMINDER eventType as aps.category too", async () => {
// SR-2 contract: the mood-reminder daily job emits this event-type;
// the iOS app registers a `MOOD_REMINDER` category whose only
@@ -318,6 +318,23 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => {
);
});
it("offers no keyboard on a MEASUREMENT_REMINDER whose actions are suppressed", async () => {
// An appointment nudge. The reminder id is present and the event type
// matches, so only the flag stands between the person and two buttons
// that can answer nothing but "not found".
await sendViaTelegram(config, {
eventType: "MEASUREMENT_REMINDER",
userId: "user-1",
title: "t",
message: "m",
suppressActions: true,
metadata: { reminderId: "rem-1" },
});
const opts = sendTelegramMessageMock.mock.calls[0][3];
expect(opts.replyMarkup).toBeUndefined();
expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled();
});
it("sends a plain MEASUREMENT_REMINDER (no keyboard, no self-clean) without a reminderId", async () => {
await sendViaTelegram(config, {
eventType: "MEASUREMENT_REMINDER",
+47 -4
View File
@@ -592,6 +592,8 @@ export async function sendViaApns(
metadata?: Record<string, unknown>;
discreet?: boolean;
urgent?: boolean;
/** The dispatch offers no completion action — see `NotificationPayload`. */
suppressActions?: boolean;
recordUserId?: string;
recipientUserId?: string;
},
@@ -732,12 +734,21 @@ export async function sendViaApns(
// an iOS action identifier or deep link that can mutate it.
// Keep the ordinary event envelope so opening the notification
// follows the normal authorised record-selection flow.
// `suppressActions` removes the act-on-it keys for the same
// reason it clears the category below: the affordance the app
// would build from them resolves to a refusal.
...pickIosMetadata(
managedDelivery
? undefined
: payload.discreet
? stripCycleMetadata(payload.metadata)
: payload.metadata,
: payload.suppressActions
? stripActionMetadata(
payload.discreet
? stripCycleMetadata(payload.metadata)
: payload.metadata,
)
: payload.discreet
? stripCycleMetadata(payload.metadata)
: payload.metadata,
),
},
threadId: routingEvent,
@@ -746,7 +757,13 @@ export async function sendViaApns(
// ignores categories it doesn't know about, so adding the key
// here is safe for event-types that don't have an actionable
// category registered yet — they fall back to a plain alert.
category: managedDelivery ? undefined : routingEvent,
// A suppressed-action dispatch drops the category for the same
// reason a managed delivery does: the category IS the action row,
// and an appointment nudge has no action the server would accept.
category:
managedDelivery || payload.suppressActions
? undefined
: routingEvent,
// Future-proof for an iOS Notification Service Extension. iOS
// ignores the flag when no extension is registered, so it's a
// no-op on today's binary but unblocks NSE work without a
@@ -1028,6 +1045,32 @@ function pickIosMetadata(
return out;
}
/**
* Metadata keys that exist so the iOS app can ACT on the notification rather
* than merely read it. Dropped when the producer says the dispatch carries no
* completion affordance.
*/
const ACTION_METADATA_KEYS = new Set(["reminderId"]);
/**
* Strip the act-on-it keys from a payload whose actions the server will refuse.
*
* An appointment nudge rides the same `MEASUREMENT_REMINDER` event type as a
* checkup, so the iOS app would offer the registered "Erledigt" action and
* POST a completion the by-id reminder family answers 404 for. Removing the
* id is what removes the action: the handler has nothing to complete.
*/
function stripActionMetadata(
metadata: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!metadata) return metadata;
const out: Record<string, unknown> = {};
for (const key of Object.keys(metadata)) {
if (!ACTION_METADATA_KEYS.has(key)) out[key] = metadata[key];
}
return out;
}
/** Cycle-semantic metadata keys that must never ride a discreet payload. */
const CYCLE_METADATA_KEYS = new Set(["phase"]);
+9 -1
View File
@@ -150,7 +150,15 @@ export async function sendViaTelegram(
interactiveKeyboard = buildMoodKeyboard(
await resolveSenderLocale(userId),
);
} else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) {
} else if (
payload.eventType === "MEASUREMENT_REMINDER" &&
reminderId &&
// An appointment nudge is dispatched with the affordance suppressed:
// `measure_done` / `measure_later` both refuse an ENCOUNTER-origin row,
// so offering the pair would put two buttons in the chat that can only
// answer "not found".
!payload.suppressActions
) {
interactiveKeyboard = buildMeasurementKeyboard(
reminderId,
await resolveSenderLocale(userId),
+17
View File
@@ -319,6 +319,23 @@ export interface NotificationPayload {
* Normal events leave `urgent` unset and behave exactly as before.
*/
urgent?: boolean;
/**
* The nudge carries no completion affordance.
*
* A reminder push normally ships a Done / Later pair — the Telegram inline
* keyboard and the APNs category whose actions the iOS app registers. Both
* resolve the reminder by id and both are refused for an appointment: an
* appointment reminder belongs to a visit, is managed through the visit
* routes, and every by-id reminder surface answers "not found" for one. An
* offered button the server will refuse is worse than no button, so the
* producer that knows the origin says so here and each channel drops its own
* affordance rather than each channel re-deriving the rule.
*
* The reminder's id still rides `metadata` — it identifies the dispatch for
* the client-managed suppression tag and the wide event; what this flag
* removes is the OFFER to act on it.
*/
suppressActions?: boolean;
}
/**
+26 -2
View File
@@ -91,13 +91,13 @@ inboundFactEditSchema.meta({
documentUpdateSchema.meta({
id: "DocumentUpdateRequest",
description:
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
});
documentBulkSchema.meta({
id: "DocumentBulkRequest",
description:
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `linkEncounter` / `unlinkEncounter` (require `encounterId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
});
const factProvenance = z.object({
@@ -137,6 +137,18 @@ const conditionLink = z
"A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.",
});
const encounterLink = z
.object({
encounterId: z.string(),
kind: z.string(),
occurredAt: z.string().nullable(),
})
.meta({
id: "DocumentEncounterLink",
description:
"A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the constant is what lets a German reader and an English one each see it named correctly.",
});
const inboundDocument = z
.object({
id: z.string(),
@@ -153,6 +165,7 @@ const inboundDocument = z
factCount: z.number(),
pendingCount: z.number(),
conditionLinks: z.array(conditionLink),
encounterLinks: z.array(encounterLink),
servingClass: z.enum(["inline", "attachment"]),
hasContentIndex: z.boolean(),
contentIndexSource: z
@@ -256,6 +269,13 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"Only documents linked to this illness/condition episode.",
},
{
name: "encounterId",
in: "query",
required: false,
schema: { type: "string" },
description: "Only documents linked to this visit.",
},
{
name: "year",
in: "query",
@@ -356,6 +376,10 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller.",
}),
encounterIds: z.array(z.string()).optional().meta({
description:
"Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown one refuses the upload rather than dropping the link the person asked for.",
}),
}),
},
},
+63 -3
View File
@@ -21,6 +21,7 @@ import {
encounterCreateSchema,
encounterUpdateSchema,
encounterListQuerySchema,
encounterSuggestQuerySchema,
encounterLinkSchema,
encounterStatusEnum,
encounterKindEnum,
@@ -53,7 +54,13 @@ encounterUpdateSchema.meta({
encounterListQuerySchema.meta({
id: "ListEncountersQuery",
description:
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).",
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, `episodeId` (only visits filed against that condition episode — the condition side of the link, and the only direction it is offered in), and `limit` (1–200, default 100).",
});
encounterSuggestQuerySchema.meta({
id: "SuggestEncounterQuery",
description:
"The date to resolve around, as an ISO-8601 instant with an offset. A document uses `reportDate ?? documentDate`; a lab panel uses `takenAt`.",
});
encounterLinkSchema.meta({
@@ -77,7 +84,7 @@ practitionerUpdateSchema.meta({
practitionerListQuerySchema.meta({
id: "ListPractitionersQuery",
description:
"Query params for the address book: `q` (case-insensitive substring of the name) and `limit` (1–200, default 100). Name-ordered.",
"Query params for the address book: `q` (case-insensitive substring of the name OR the practice — a person looks one up by whichever of the two they remember) and `limit` (1–200, default 100). Name-ordered.",
});
const practitioner = z
@@ -120,7 +127,7 @@ const encounterLinks = z
.meta({
id: "EncounterLinks",
description:
"The three link families of one visit, each ordered by when it was filed. Present on the detail response, absent from the list.",
"The three link families of one visit, each ordered by when it was filed. Present on BOTH the list and the detail: a card shows counts of what the visit produced, and an edit opened from a list row seeds its pickers from that row — a list without them would make the first read zero and the second unfile everything on save. The list resolves them in three grouped queries for the whole page, not three per row.",
});
const skipReport = z
@@ -172,6 +179,35 @@ const encounterList = z
"Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.",
});
const suggestionCandidate = z
.object({
id: z.string(),
occurredAt: z.string(),
status: encounterStatusEnum,
kind: encounterKindEnum,
practitionerName: z.string().nullable(),
})
.meta({
id: "EncounterSuggestionCandidate",
description:
"One visit a record might belong to, resolved far enough to render a row without a second round trip. `practitionerName` is null when the visit names no practice, which is an absence rather than a gap.",
});
const encounterSuggestion = z
.discriminatedUnion("kind", [
z.object({ kind: z.literal("one"), encounter: suggestionCandidate }),
z.object({
kind: z.literal("many"),
encounters: z.array(suggestionCandidate),
}),
z.object({ kind: z.literal("none") }),
])
.meta({
id: "EncounterSuggestion",
description:
"The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to make impossible.",
});
const encounterNotFound = {
"404": {
description: "Visit not found (or owned by another user).",
@@ -232,6 +268,30 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/encounters/suggest": {
get: {
tags: ["Records"],
summary: "Which visit does a record dated around this day belong to?",
description:
"Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required.",
requestParams: { query: encounterSuggestQuerySchema },
responses: {
...recordRefusal(),
"200": {
description: "The verdict.",
content: {
"application/json": {
schema: dataEnvelope(
encounterSuggestion,
"SuggestEncounterEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/encounters/{id}": {
get: {
tags: ["Records"],
+1 -1
View File
@@ -26,7 +26,7 @@ import {
createLabResultSchema.meta({
id: "CreateLabResultRequest",
description:
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted.',
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted. The optional `encounterId` files the reading against a visit the caller owns — always optional, and a link that could not be made never fails the reading.',
});
updateLabResultSchema.meta({
+132
View File
@@ -35,7 +35,9 @@ import {
import {
dataEnvelope,
errorEnvelope,
recordRefusal,
stdResponses,
MODULE_DISABLED_DESCRIPTION,
baseUpdatedAtField,
updatedAtTokenField,
conflictResponse409,
@@ -386,7 +388,137 @@ const bulkMoodResponse = z
})
.meta({ id: "BulkMoodResponse" });
// ── The day's two readings ───────────────────────────────────────────
//
// Published as the RESOLVED value. The server computes the expected value, the
// band, the count, the deviation and the age; the client renders them. Nothing
// here is a hint the client is meant to re-derive — two clients deriving the
// same figure is how they start disagreeing about it, and this figure is about
// somebody's health record.
const moodPrognosisContribution = z
.object({
feature: z
.string()
.describe(
"Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. A client that does not recognise one skips it — an older row can name a feature a later model dropped.",
),
contribution: z
.number()
.describe(
"Signed: how far this feature moved the expected value away from the account's own average day. Ordered by absolute size, largest first.",
),
})
.meta({ id: "MoodPrognosisContribution" });
const moodPrognosisPresent = z.object({
present: z.literal(true),
date: z.string().describe("The local day this is about, `YYYY-MM-DD`."),
predicted: z
.number()
.describe(
"The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, never a measurement — and never to be displayed without `n` and the band.",
),
ciLow: z.number().nullable(),
ciHigh: z
.number()
.nullable()
.describe(
"The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal to present the value cautiously.",
),
n: z
.number()
.int()
.describe(
"Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and one built from 300 look identical otherwise.",
),
modelVersion: z.string(),
computedAt: z.iso.datetime({ offset: true }),
ageDays: z
.number()
.int()
.nullable()
.describe("Whole days between that day and the reader's today."),
current: z
.boolean()
.describe(
"Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is about.",
),
provisional: z
.boolean()
.describe("True below 60 days of history: label it as provisional."),
stage: z.enum(["provisional", "regular", "seasonal"]),
entries: z.number().int().describe("Days of history behind the ladder."),
seasonalUnlocked: z.boolean(),
selfAssessment: z
.number()
.int()
.nullable()
.describe(
"The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on any surface that shows both, and it is never merged with `predicted`.",
),
deviation: z
.enum(["within", "above", "below"])
.nullable()
.describe(
"Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the account's own past days suggest.",
),
deviationAmount: z
.number()
.nullable()
.describe("How far outside the band, in scale points. `0` when inside."),
contributions: z.array(moodPrognosisContribution),
});
const moodPrognosisAbsent = z.object({
present: z.literal(false),
reason: z
.enum(["no-output-yet", "learning-phase", "no-pattern"])
.describe(
"`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a zero.",
),
entries: z.number().int(),
nextThreshold: z
.number()
.int()
.nullable()
.describe("Days at which the next step unlocks, when a count is missing."),
});
const moodPrognosisResponse = z
.union([moodPrognosisPresent, moodPrognosisAbsent])
.meta({
id: "MoodPrognosis",
description:
"Two separate readings of one day: what the person recorded, and what a comparison with their own past days would have expected. They are never merged — the distance between them is the finding, and a single blended figure destroys it. The comparison is a regularised regression over the account's own history: observational only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have been expected), must carry `n`, and must not say that anything caused anything.",
});
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/mood/prognosis": {
get: {
tags: ["Mood"],
summary: "The day's own rating and what the account's days imply (v1.38)",
description:
"The newest stored comparison for the account, with the person's own rating for that day beside it. Written nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood module is off.",
responses: {
"200": {
description: "The day's two readings, or an explained absence.",
content: {
"application/json": {
schema: dataEnvelope(
moodPrognosisResponse,
"MoodPrognosisEnvelope",
),
},
},
},
// Two reasons, one status: the module gate and the sharing fence both
// answer 403, and OpenAPI allows one response per status.
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
"/api/mood-entries/bulk": {
post: {
tags: ["Mood"],
+1 -1
View File
@@ -22,7 +22,7 @@ import { dataEnvelope, stdResponses } from "./shared";
ocrCommitSchema.meta({
id: "OcrCommitRequest",
description:
"The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value).",
"The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit.",
});
const capabilityResponse = z
+2
View File
@@ -22,6 +22,8 @@ export interface DocumentVaultFilters {
kinds?: InboundDocumentKindValue[];
/** Only documents linked to this illness/condition episode. */
episodeId?: string;
/** Only documents linked to this visit. Arrives by deep link from it. */
encounterId?: string;
/** Only documents whose filing date falls in this calendar year. */
year?: number;
}
+31 -2
View File
@@ -31,9 +31,29 @@ export const encounterKeys = {
* year at a time, and two windows are two different answers rather than one
* answer that overwrites the other.
*/
encounterList: (from: string | null, to: string | null, status?: string) =>
["encounters", "list", from, to, status ?? null] as const,
encounterList: (
from: string | null,
to: string | null,
status?: string,
episodeId?: string,
) =>
[
"encounters",
"list",
from,
to,
status ?? null,
episodeId ?? null,
] as const,
encounter: (id: string) => ["encounters", "detail", id] as const,
/**
* The "which visit does this belong to" verdict for one anchor date. The
* anchor is part of the key because it IS the question: a document dated the
* 3rd and one dated the 20th get different answers, and one overwriting the
* other would pre-select the wrong visit on the second review step.
*/
encounterSuggestion: (anchor: string) =>
["encounters", "suggest", anchor] as const,
/**
* The address book. Under its own root rather than nested beneath
* `["encounters"]`: a practitioner outlives the visits that name it, and a
@@ -43,4 +63,13 @@ export const encounterKeys = {
practitionerList: (q?: string) =>
["practitioners", "list", q ?? null] as const,
practitioner: (id: string) => ["practitioners", "detail", id] as const,
/* ── mutation keys ──────────────────────────────────────────────── */
encounterCreate: () => ["encounters", "create"] as const,
encounterUpdate: () => ["encounters", "update"] as const,
encounterDelete: () => ["encounters", "delete"] as const,
practitionerCreate: () => ["practitioners", "create"] as const,
practitionerUpdate: () => ["practitioners", "update"] as const,
practitionerDelete: () => ["practitioners", "delete"] as const,
};
+19
View File
@@ -238,6 +238,25 @@ export const medicationDependentKeys = [
*/
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
/**
* Keys invalidated when a visit or an address-book entry changes.
*
* Both roots ride together in one bundle rather than two, because the two
* writes reach each other: renaming a practice changes the label every visit
* that names it renders, and deleting one nulls the reference on visits that
* stay. A bundle that evicted only the surface being written would leave the
* other showing the pre-write name.
*
* `useEncounterMutations` and `usePractitionerMutations` are its consumers.
* It briefly had none — the roots landed a release before the surfaces that
* read them — and was removed on trunk for that reason; it is back here
* together with the writes that need it.
*/
export const encounterDependentKeys = [
queryKeys.encounters(),
queryKeys.practitioners(),
];
/**
* Invalidate every key in the bundle in parallel. Use this from mutation
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
+12
View File
@@ -58,6 +58,18 @@ export const moodKeys = {
moodLinkedContext: (date: string, tz: string) =>
["mood-linked-context", date, tz] as const,
/**
* v1.38 — the day's two readings: the person's own rating and what their
* past days imply, with the band and the case count.
*
* Under the `["mood-entries"]` prefix so `moodDependentKeys` reaches it: the
* self-assessment half of the payload comes straight off a mood row, and an
* edit to today's entry changes the deviation the card shows. The forecast
* half does not move until the nightly job runs again, which is why the read
* is cheap enough to refetch on a mood write.
*/
moodPrognosis: () => ["mood-entries", "prognosis"] as const,
moodAnalytics: () => ["mood-analytics"] as const,
/**
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
expect(STRUCTURED_LEAF_IDS).toHaveLength(14);
expect(ALL_LEAF_IDS).toHaveLength(91);
expect(STRUCTURED_LEAF_IDS).toHaveLength(15);
expect(ALL_LEAF_IDS).toHaveLength(92);
});
it("resolves a label for every leaf", () => {
+8 -1
View File
@@ -46,6 +46,7 @@ export type StructuredLeafId =
| "GLP1_THERAPY"
| "ALLERGIES"
| "ILLNESS_EPISODES"
| "VISITS"
| "FAMILY_HISTORY"
| "MOOD"
| "CYCLE"
@@ -199,13 +200,14 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
GLP1_THERAPY: "medications",
ALLERGIES: "history",
ILLNESS_EPISODES: "history",
VISITS: "history",
FAMILY_HISTORY: "sensitive",
MOOD: "sensitive",
CYCLE: "sensitive",
ANAMNESIS: "sensitive",
};
/** i18n label keys for the 14 structured leaves. */
/** i18n label keys for the 15 structured leaves. */
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
INSURANCE: "reportSelection.leafInsurance",
@@ -217,6 +219,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
ALLERGIES: "reportSelection.leafAllergies",
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
VISITS: "reportSelection.leafVisits",
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
MOOD: "reportSelection.leafMood",
CYCLE: "reportSelection.leafCycle",
@@ -306,6 +309,10 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
MEDICATION_ADMINISTRATIONS: "medications",
MEDICATION_COMPLIANCE: "medications",
GLP1_THERAPY: "medications",
// VISITS is deliberately absent, like ALLERGIES beside it. A visit carries
// no module of its own — it is core, like the checkups page it lives on —
// and the whole report is already gated by `doctorReport`. Reading the
// absence as an oversight is the mistake this comment exists to prevent.
};
/** Every measurement leaf id, in enum order. */
+25
View File
@@ -138,6 +138,15 @@ export const encounterListQuerySchema = z
to: z.iso.datetime({ offset: true }).optional(),
status: encounterStatusEnum.optional(),
practitionerId: id.optional(),
/**
* Only visits filed against this condition episode.
*
* The condition side of the link, and the only direction it is offered in:
* an episode spans weeks and a visit is a point, so matching from the visit
* side would produce false positives at a rate that trains a person to
* ignore every suggestion in the product.
*/
episodeId: id.optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
})
.strict()
@@ -148,6 +157,22 @@ export const encounterListQuerySchema = z
export type EncounterListQuery = z.infer<typeof encounterListQuerySchema>;
/**
* The suggestion query.
*
* One input, and it is a date rather than an id: the answer is derived from the
* caller's own visits and the anchor only says where to look. Bounded by the
* same instant rule the visit itself carries, so an anchor a client mis-parsed
* into the year 12000 is a 422 rather than a full-table scan.
*/
export const encounterSuggestQuerySchema = z
.object({
anchor: visitInstant,
})
.strict();
export type EncounterSuggestQuery = z.infer<typeof encounterSuggestQuerySchema>;
/* ── links ────────────────────────────────────────────────────────── */
export const encounterLinkTargetEnum = z.enum([
+57 -3
View File
@@ -290,6 +290,24 @@ export interface DocumentConditionLinkDto {
name: string;
}
/**
* One visit link on a document DTO — "this letter belongs to that appointment".
*
* Carries the visit's KIND as the enum constant rather than a rendered name.
* A condition's label is the person's own words and reads the same in every
* language; a visit kind is one of eight closed values whose human name the
* reader's own bundle owns, so publishing the constant is what lets a German
* reader and an English one each see it named correctly. The date and the id
* are resolved server-side as usual.
*/
export interface DocumentEncounterLinkDto {
encounterId: string;
/** `ROUTINE` | `ACUTE` | … — the client renders the name. */
kind: string;
/** ISO-8601 instant of the visit. */
occurredAt: string | null;
}
/** The document DTO (list + detail). */
export interface InboundDocumentDto {
id: string;
@@ -312,6 +330,8 @@ export interface InboundDocumentDto {
pendingCount: number;
/** Linked illness/condition episodes (empty when unlinked). */
conditionLinks: DocumentConditionLinkDto[];
/** Visits this document is filed against (empty when unlinked). */
encounterLinks: DocumentEncounterLinkDto[];
/** How the serve route delivers the original: render inline or download. */
servingClass: "inline" | "attachment";
/**
@@ -484,19 +504,36 @@ const episodeIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_EPISODE_LINKS);
/**
* Max visit links a single document may carry / receive per request.
*
* The same bound as the condition links, and for the same reason: a document
* filed against twenty visits is a client defect rather than a large but
* legitimate batch.
*/
export const DOCUMENT_MAX_ENCOUNTER_LINKS = 20;
const encounterIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_ENCOUNTER_LINKS);
/**
* The store-only upload metadata (the multipart form fields beside the file).
* Every field is optional — a bare file upload is valid and lands as a STORED
* document with no title / category / filing date. `episodeIds` pre-links the
* document to the caller's illness/condition episodes (repeated `episodeIds`
* form fields; ownership is re-checked in the route). No `userId` field; it
* is always narrowed from the session.
* form fields; ownership is re-checked in the route), and `encounterIds` does
* the same for the caller's visits — the review step offers the visit the
* document's own date falls near, and the link rides the upload rather than
* becoming a second trip. No `userId` field; it is always narrowed from the
* session.
*/
export const documentCreateSchema = z.object({
title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
documentDate: isoDateString.optional(),
episodeIds: episodeIdList.optional(),
encounterIds: encounterIdList.optional(),
});
export type DocumentCreateInput = z.infer<typeof documentCreateSchema>;
@@ -524,13 +561,16 @@ export const documentUpdateSchema = z
* is re-checked in the route against the caller's episodes.
*/
episodeIds: episodeIdList.optional(),
/** Replace-set visit links, same semantics as `episodeIds`. */
encounterIds: encounterIdList.optional(),
})
.refine(
(d) =>
d.title !== undefined ||
d.kind !== undefined ||
d.documentDate !== undefined ||
d.episodeIds !== undefined,
d.episodeIds !== undefined ||
d.encounterIds !== undefined,
{ message: "Provide at least one field to update" },
);
@@ -561,6 +601,8 @@ export const documentListQuerySchema = z.object({
kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(),
/** Only documents linked to this illness/condition episode. */
episodeId: z.string().trim().min(1).max(40).optional(),
/** Only documents linked to this visit. */
encounterId: z.string().trim().min(1).max(40).optional(),
/** Only documents whose filing date falls in this calendar year (UTC). */
year: z.coerce.number().int().min(1900).max(9999).optional(),
from: isoDateString.optional(),
@@ -587,6 +629,8 @@ export const DOCUMENT_BULK_ACTIONS = [
"setKind",
"linkEpisode",
"unlinkEpisode",
"linkEncounter",
"unlinkEncounter",
"delete",
"restore",
] as const;
@@ -608,6 +652,7 @@ export const documentBulkSchema = z
action: z.enum(DOCUMENT_BULK_ACTIONS),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
episodeId: z.string().trim().min(1).max(40).optional(),
encounterId: z.string().trim().min(1).max(40).optional(),
})
.refine((d) => d.action !== "setKind" || d.kind !== undefined, {
message: "Action 'setKind' requires a kind",
@@ -621,6 +666,15 @@ export const documentBulkSchema = z
message: "Episode actions require an episodeId",
path: ["episodeId"],
},
)
.refine(
(d) =>
(d.action !== "linkEncounter" && d.action !== "unlinkEncounter") ||
d.encounterId !== undefined,
{
message: "Visit actions require an encounterId",
path: ["encounterId"],
},
);
export type DocumentBulkInput = z.infer<typeof documentBulkSchema>;
+11
View File
@@ -209,6 +209,17 @@ export const ocrCommitSchema = z.object({
* is verified server-side before any link is written.
*/
documentId: z.string().trim().min(1).max(64).optional(),
/**
* The visit this panel came out of, when the review step offered one.
*
* Optional, always. A panel commits with no visit named and the commit
* succeeds unchanged — the link is the by-the-way half of the flow, never
* a condition of the write. Ownership is verified server-side, and one link
* is written PER RESULT ROW rather than one for the panel: a re-run panel on
* a different day links to its own visit, which is why the join is m:n and
* not a scalar column on the result.
*/
encounterId: z.string().trim().min(1).max(40).optional(),
});
export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
+6
View File
@@ -131,6 +131,12 @@ export const createLabResultSchema = z
takenAt: takenAtField,
note: optionalNote,
source,
/**
* The visit this reading came out of, when the form offered one. Optional
* always: a reading saves with no visit named, and the link never gates
* the write.
*/
encounterId: z.string().trim().min(1).max(40).optional(),
})
// Either a catalog link OR a free-text analyte must be present.
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {