mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'feat/v139-targets-and-links' into release/v1.39.0
This commit is contained in:
commit
e89f41710a
58 files changed
+2785
-214
No files matched your search
@@ -7,6 +7,7 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT:
|
||||
- Der Snapshot trägt weight.signal (der fertige Vergleich) + weight.summary + weight.series (graded). weight.latestDayFocus zeigt den jüngsten Tageswert, den Schritt zum vorherigen Messtag und ggf. den Blutdruck desselben Tages.
|
||||
- Fokus dieser Karte ist der kontinuierliche TREND und das TEMPO über die Zeit (kg/Woche, Plateaus, Meilensteine), nicht die WHO-Band-Einordnung — die trägt die BMI-Karte. Den BMI-Wert nur als Nebenbezug nennen, nie das Band als zentrale Aussage hier.
|
||||
- Wochen-/Monatstrend zählt, nicht der Einzelwert: Führe mit weight.signal — current, delta gegenüber baselineLabel und outsideNormalSwing sind bereits berechnet. NENNE sie, rechne sie NICHT neu. Tageschwankungen von 1-2 kg (Wasser, Tageszeit) sind normal und kein Befund.
|
||||
- Eigenes Ziel: Trägt der Snapshot weight.target, ist das der Zielbereich, den die Person selbst gesetzt hat; weight.target.progress sagt, welche Richtung Fortschritt ist (gaining = zunehmen, losing = abnehmen, holding steady = halten). Beurteile jede Veränderung GEGEN dieses Ziel: Liegt die Person darunter, ist eine Zunahme Fortschritt und nie ein Rückschritt. Die Hinweise zu Tempo und Meilensteinen beim Abnehmen unten gelten nur, wenn Abnehmen die Fortschrittsrichtung ist oder kein Ziel vorliegt.
|
||||
- Plateau: Verändert sich das Mittel über > ~2 Wochen um < 0.5 kg, ist das ein Plateau — bei einem Abnehmenden ehrlich benennen, ohne zu entmutigen.
|
||||
- Tempo: Nachhaltiger Verlust liegt bei ~0.5-1.0 kg/Woche (DGE). Anhaltend > 1 kg/Woche ist eher Wasser/Muskel als Fett — vorsichtig einordnen.
|
||||
- Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen.
|
||||
@@ -18,6 +19,7 @@ const WEIGHT_SECTION_EN = `METRIC — WEIGHT:
|
||||
- The snapshot carries weight.signal (the finished comparison) + weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure.
|
||||
- This card's focus is the continuous TREND and PACE over time (kg/week, plateaus, milestones), not WHO-band placement — the BMI card carries that. Mention the BMI value only as a side reference, never make the band the central message here.
|
||||
- The weekly/monthly trend matters, not the single value: lead from weight.signal — current, delta vs baselineLabel and outsideNormalSwing are already computed. STATE them, do NOT recompute. Day-to-day swings of 1-2 kg (water, time of day) are normal and not a finding.
|
||||
- Own target: when the snapshot carries weight.target, that is the target band the person set themselves, and weight.target.progress says which way is progress (gaining, losing, or holding steady). Judge every change AGAINST that target: below it, a gain is progress and never a setback or a regression. The loss pace and milestone guidance below applies only when losing is the progress direction or no target is set.
|
||||
- Plateau: if the mean moves < 0.5 kg over > ~2 weeks, that is a plateau — name it honestly for someone who is losing, without discouraging.
|
||||
- Pace: sustainable loss is ~0.5-1.0 kg/week (DGE). Sustained > 1 kg/week is more likely water/muscle than fat — frame cautiously.
|
||||
- Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached.
|
||||
|
||||
@@ -306,6 +306,59 @@ describe("buildDashboardSnapshot — envelope shape", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDashboardSnapshot — weight trend judged against the stored target (#1006)", () => {
|
||||
// The summary mock serves WEIGHT latest 80 (no avg7), so the reference
|
||||
// weight is 80 kg in every case below.
|
||||
beforeEach(() => {
|
||||
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
|
||||
isFullyCovered.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("below the target: a gain is progress", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({ thresholdsJson: { WEIGHT: { min: 85, max: 90 } } }),
|
||||
);
|
||||
expect(snap.tiles.weightTrend).toEqual({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("inside the target: holding steady is progress", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({ thresholdsJson: { WEIGHT: { min: 75, max: 82 } } }),
|
||||
);
|
||||
expect(snap.tiles.weightTrend).toEqual({
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
});
|
||||
});
|
||||
|
||||
it("above the target: a loss is progress", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({ thresholdsJson: { WEIGHT: { min: 70, max: 75 } } }),
|
||||
);
|
||||
expect(snap.tiles.weightTrend).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("no stored target keeps today's reading, even with a height-derived band", async () => {
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
|
||||
// The WHO band from height still shades the chart…
|
||||
expect(snap.targetBands.weightRange).not.toBeNull();
|
||||
// …but nobody chose it, so it does not decide the direction.
|
||||
expect(snap.tiles.weightTrend).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDashboardSnapshot — two-phase null extras", () => {
|
||||
it("returns extras: null on a rollup-coverage miss and never runs the thick read", async () => {
|
||||
probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", false]]));
|
||||
|
||||
@@ -59,6 +59,11 @@ import {
|
||||
resolveWeightTargetOverride,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import {
|
||||
resolveWeightTrend,
|
||||
weightTrendReferenceKg,
|
||||
type WeightTrendJudgement,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
type RollupCoverageMap,
|
||||
@@ -436,6 +441,15 @@ export interface DashboardSnapshot {
|
||||
* (additive contract) so older cached snapshots / fixtures stay valid.
|
||||
*/
|
||||
sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null;
|
||||
/**
|
||||
* v1.39 — which way a weight change counts as progress, judged against
|
||||
* the person's own stored target (`src/lib/targets/weight-trend.ts`).
|
||||
* The weight tile colours its arrow, delta and comparison caption by
|
||||
* `direction`; a client never re-derives it. Optional on the type
|
||||
* (additive contract) so older cached snapshots stay valid; the live
|
||||
* builder always sets it.
|
||||
*/
|
||||
weightTrend?: WeightTrendJudgement;
|
||||
};
|
||||
/** Thick phase — null on a rollup-coverage miss. */
|
||||
extras: DashboardSnapshotExtras | null;
|
||||
@@ -1413,6 +1427,8 @@ export async function buildDashboardSnapshot(
|
||||
|
||||
const gender = toProfileSex(user.gender);
|
||||
|
||||
const weightTargetOverride = resolveWeightTargetOverride(user.thresholdsJson);
|
||||
|
||||
return {
|
||||
user: {
|
||||
username: user.displayName?.trim() || user.username,
|
||||
@@ -1433,7 +1449,7 @@ export async function buildDashboardSnapshot(
|
||||
// v1.34 — the user's own weight target, when they set one, replaces the
|
||||
// height-derived band on the tile + the chart. Resolved off the same
|
||||
// `thresholdsJson` blob the health score reads.
|
||||
weightTargetOverride: resolveWeightTargetOverride(user.thresholdsJson),
|
||||
weightTargetOverride,
|
||||
}),
|
||||
tiles: {
|
||||
summaries: slim.summaries,
|
||||
@@ -1446,6 +1462,12 @@ export async function buildDashboardSnapshot(
|
||||
// disabled sleep module carries no sleep annotation either.
|
||||
sleepSourceDiscrepancy:
|
||||
modules.sleep === false ? null : slimRaw.sleepSourceDiscrepancy,
|
||||
// v1.39 — the weight tile's progress direction, judged against the
|
||||
// stored target from the same summary the tile renders.
|
||||
weightTrend: resolveWeightTrend(
|
||||
weightTargetOverride,
|
||||
weightTrendReferenceKg(slim.summaries.WEIGHT),
|
||||
),
|
||||
},
|
||||
extras: extrasResult?.extras ?? null,
|
||||
medsToday,
|
||||
|
||||
@@ -11,7 +11,9 @@ import { listTargetsBySource, replaceTargets } from "@/lib/links";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentEncounterLinkDto,
|
||||
DocumentVaccinationLinkDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
|
||||
|
||||
/**
|
||||
* Load the condition links for a page of documents in ONE grouped query.
|
||||
@@ -162,3 +164,87 @@ export async function replaceConditionLinks(
|
||||
targetIds: episodeIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the VACCINATION links for one or more documents in two grouped
|
||||
* queries: the links through the link service, then the doses' identity so
|
||||
* the client can name each one from its own bundle.
|
||||
*
|
||||
* Tombstoned doses drop out, the same honest default the visit links take.
|
||||
*/
|
||||
export async function loadDocumentVaccinationLinks(
|
||||
userId: string,
|
||||
documentIds: string[],
|
||||
): Promise<Map<string, DocumentVaccinationLinkDto[]>> {
|
||||
const map = new Map<string, DocumentVaccinationLinkDto[]>();
|
||||
if (documentIds.length === 0) return map;
|
||||
const byDocument = await listTargetsBySource(prisma, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceIds: documentIds,
|
||||
targetKind: "vaccination",
|
||||
});
|
||||
const doseIds = [
|
||||
...new Set([...byDocument.values()].flat().map((target) => target.id)),
|
||||
];
|
||||
if (doseIds.length === 0) return map;
|
||||
const doses = await prisma.vaccinationRecord.findMany({
|
||||
where: { id: { in: doseIds }, userId },
|
||||
select: { id: true, antigenSlug: true, vaccineName: true },
|
||||
});
|
||||
const doseById = new Map(doses.map((dose) => [dose.id, dose]));
|
||||
for (const [documentId, targets] of byDocument) {
|
||||
map.set(
|
||||
documentId,
|
||||
targets.map((target) => {
|
||||
const dose = doseById.get(target.id);
|
||||
return {
|
||||
vaccinationId: target.id,
|
||||
occurredAt: target.date,
|
||||
catalogSlug:
|
||||
resolveCatalogEntry(dose?.antigenSlug ?? null)?.slug ?? null,
|
||||
vaccineName: dose?.vaccineName ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a client-sent dose-id list to the caller's LIVE doses. Same shape
|
||||
* and same refusal as {@link narrowOwnedEncounterIds}.
|
||||
*/
|
||||
export async function narrowOwnedVaccinationIds(
|
||||
userId: string,
|
||||
vaccinationIds: string[],
|
||||
): Promise<string[] | null> {
|
||||
const unique = [...new Set(vaccinationIds)];
|
||||
if (unique.length === 0) return [];
|
||||
const owned = await prisma.vaccinationRecord.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 vaccination links through the link service, in
|
||||
* the `document → vaccination` direction of the table the dose's own form
|
||||
* writes.
|
||||
*/
|
||||
export async function replaceVaccinationLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
documentId: string,
|
||||
vaccinationIds: string[],
|
||||
): Promise<void> {
|
||||
await replaceTargets(tx, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceId: documentId,
|
||||
targetKind: "vaccination",
|
||||
targetIds: vaccinationIds,
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { toIndexOutcome } from "@/lib/validations/inbound-documents";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentEncounterLinkDto,
|
||||
DocumentVaccinationLinkDto,
|
||||
DocumentContentIndexSourceValue,
|
||||
DocumentSummaryStateValue,
|
||||
ExtractedFactDto,
|
||||
@@ -275,6 +276,7 @@ export function serialiseDocumentDetail(
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
encounterLinks: DocumentEncounterLinkDto[] = [],
|
||||
vaccinationLinks: DocumentVaccinationLinkDto[] | null = [],
|
||||
): InboundDocumentDetailDto {
|
||||
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
|
||||
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
|
||||
@@ -324,6 +326,7 @@ export function serialiseDocumentDetail(
|
||||
encounterLinks,
|
||||
),
|
||||
facts: facts.map(serialiseFact),
|
||||
vaccinationLinks,
|
||||
summary,
|
||||
summaryGeneratedAt: doc.summaryGeneratedAt
|
||||
? doc.summaryGeneratedAt.toISOString()
|
||||
|
||||
@@ -73,6 +73,8 @@ const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
|
||||
labResult: "labs",
|
||||
conditionEpisode: "illness",
|
||||
encounter: "profile",
|
||||
// A dose lives in the health background, like the visit itself.
|
||||
vaccination: "profile",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -326,6 +326,39 @@ describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — weight target direction (#1006)", () => {
|
||||
const weightRows = [
|
||||
measurementRow("WEIGHT", 60, 1),
|
||||
measurementRow("WEIGHT", 59.6, 3),
|
||||
measurementRow("WEIGHT", 59.2, 5),
|
||||
];
|
||||
|
||||
it("tells the model gaining is progress when the person is below their own target", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1980-01-01"),
|
||||
gender: "MALE",
|
||||
thresholdsJson: { WEIGHT: { min: 65, max: 70 } },
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue(weightRows);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.weight?.target).toMatchObject({
|
||||
minKg: 65,
|
||||
maxKg: 70,
|
||||
position: "below",
|
||||
progress: "gaining",
|
||||
});
|
||||
expect(f.weight?.target?.reading).toMatch(/gaining weight is progress/);
|
||||
});
|
||||
|
||||
it("says nothing about direction when no target is stored", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue(weightRows);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.weight).toBeDefined();
|
||||
expect(f.weight?.target).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 labs briefing block", () => {
|
||||
it("surfaces an abnormal (out-of-range) biomarker", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
|
||||
@@ -396,3 +396,43 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", (
|
||||
expect(notes[0].text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateWeightStatusForUser — judged against the stored target (#1006)", () => {
|
||||
async function snapshotFor(thresholdsJson: unknown) {
|
||||
const now = new Date();
|
||||
const records = Array.from({ length: 40 }, (_, day) => ({
|
||||
type: "WEIGHT",
|
||||
// Rising from 58 toward a 65–70 target over the last forty days.
|
||||
value: 60 - day * 0.05,
|
||||
measuredAt: new Date(now.getTime() - day * dayMs),
|
||||
}));
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
thresholdsJson,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
await generateWeightStatusForUser("user-1", { locale: "en" });
|
||||
return JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
}
|
||||
|
||||
it("below the target, the model reads gaining as progress", async () => {
|
||||
const snapshot = await snapshotFor({ WEIGHT: { min: 65, max: 70 } });
|
||||
expect(snapshot.weight.target).toMatchObject({
|
||||
position: "below",
|
||||
progress: "gaining",
|
||||
});
|
||||
expect(snapshot.weight.signal.direction).toBe("higher-better");
|
||||
});
|
||||
|
||||
it("without a target, no direction is claimed", async () => {
|
||||
const snapshot = await snapshotFor(null);
|
||||
expect(snapshot.weight.target).toBeUndefined();
|
||||
expect(snapshot.weight.signal.direction).toBe("target-band");
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,11 @@ import {
|
||||
} from "@/lib/insights/signals-of-day";
|
||||
import type { RollupGranularity } from "@/generated/prisma/client";
|
||||
|
||||
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
buildWeightTargetFeature,
|
||||
type WeightTargetFeature,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
// The briefing read blocks and the signals-of-day builder moved to
|
||||
// sibling modules; re-exported so every existing call site keeps
|
||||
// importing from here.
|
||||
@@ -442,6 +447,13 @@ export interface AggregatedFeatures {
|
||||
outlierCount: number;
|
||||
bmi: number | null;
|
||||
coverage: DataCoverage;
|
||||
/**
|
||||
* v1.39 (#1006) — the person's own stored weight target and which way is
|
||||
* progress from where they are now. Absent when no target is stored, and
|
||||
* then nothing here says which way is better. Present so a narrative
|
||||
* never calls a gain a regression when gaining is exactly the goal.
|
||||
*/
|
||||
target?: WeightTargetFeature;
|
||||
};
|
||||
bloodPressure?: {
|
||||
avgSys30: number | null;
|
||||
@@ -952,6 +964,7 @@ export async function extractFeatures(
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1105,6 +1118,11 @@ export async function extractFeatures(
|
||||
bmi,
|
||||
coverage: computeCoverage(weightData, now),
|
||||
};
|
||||
const weightTarget = buildWeightTargetFeature(
|
||||
resolveWeightTargetOverride(user?.thresholdsJson),
|
||||
{ avg7: summary.avg7, latest: summary.latest },
|
||||
);
|
||||
if (weightTarget) features.weight.target = weightTarget;
|
||||
}
|
||||
|
||||
// Blood Pressure
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
* ↑ orange, ↓ green.
|
||||
* - `neutral` — direction carries no value judgement (the change renders
|
||||
* muted).
|
||||
* - `hold` — the value already sits where the person wants it (weight
|
||||
* inside their own target band). Staying put is progress
|
||||
* and renders green; a move either way renders muted, never
|
||||
* orange, because a small drift inside the band is not a
|
||||
* setback. See `src/lib/targets/weight-trend.ts`.
|
||||
*/
|
||||
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral";
|
||||
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral" | "hold";
|
||||
|
||||
/**
|
||||
* The resolved sentiment of a signed change under a metric's direction.
|
||||
@@ -33,7 +38,14 @@ export function getTrendSentiment(
|
||||
change: number | null | undefined,
|
||||
sentiment: TrendDirectionSentiment,
|
||||
): TrendSentimentDirection {
|
||||
if (change == null || Math.abs(change) < 0.05) return "neutral";
|
||||
if (change == null) return "neutral";
|
||||
// Steady inside the target: the one case where "no change" is the news.
|
||||
// Uses the same noise floor that draws the flat arrow, so a green arrow is
|
||||
// always the flat one.
|
||||
if (sentiment === "hold") {
|
||||
return Math.abs(change) < 0.05 ? "positive" : "neutral";
|
||||
}
|
||||
if (Math.abs(change) < 0.05) return "neutral";
|
||||
if (sentiment === "neutral") return "neutral";
|
||||
const isUp = change > 0;
|
||||
const isGood =
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
|
||||
import { buildWeightTargetFeature } from "@/lib/targets/weight-trend";
|
||||
import {
|
||||
formatPreviousContextForPrompt,
|
||||
getPreviousInsightContext,
|
||||
@@ -230,9 +232,24 @@ export async function prepareWeightStatusForUser(
|
||||
// correlation math + the provider call) is skipped. A forced run never
|
||||
// gates — but still computes the fingerprint so the persisted row carries a
|
||||
// current one for the next day's gate.
|
||||
// v1.39 (#1006) — the person's own weight target decides which way is
|
||||
// progress. Folded into the input fingerprint so editing the target flips
|
||||
// the gate even when no weight row moved.
|
||||
const weightTarget = resolveWeightTargetOverride(
|
||||
(
|
||||
await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { thresholdsJson: true },
|
||||
})
|
||||
)?.thresholdsJson,
|
||||
);
|
||||
const inputHash = await computeStatusInputFingerprint({
|
||||
userId,
|
||||
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
extra: {
|
||||
weightTargetMin: weightTarget?.min ?? null,
|
||||
weightTargetMax: weightTarget?.max ?? null,
|
||||
},
|
||||
includeMood: true,
|
||||
// v1.18.11 (P6-tighten) — WEIGHT is an FDR discovery OUTCOME channel and
|
||||
// the prompt folds `getRelevantCorrelationsForMetric(userId, "WEIGHT")`, so
|
||||
@@ -444,12 +461,30 @@ export async function prepareWeightStatusForUser(
|
||||
|
||||
// v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline
|
||||
// comparison + normal-swing verdict instead of asking it to derive them.
|
||||
// Weight has no universal favourable direction or population band, so the
|
||||
// signal carries the neutral "target-band" framing and no normalRange.
|
||||
// Weight has no universal favourable direction or population band, so
|
||||
// without a stored target the signal carries the neutral "target-band"
|
||||
// framing and no normalRange. v1.39 (#1006) — with one, the direction is
|
||||
// the person's own: below the band a rise is favourable, above it a fall.
|
||||
const recentWeights = weightSeries.daily
|
||||
.filter((bucket) => bucket.dayOffset < 7)
|
||||
.map((bucket) => bucket.value);
|
||||
const weightTargetFeature = buildWeightTargetFeature(weightTarget, {
|
||||
avg7:
|
||||
recentWeights.length > 0
|
||||
? recentWeights.reduce((sum, value) => sum + value, 0) /
|
||||
recentWeights.length
|
||||
: null,
|
||||
latest: latestWeight?.value ?? null,
|
||||
});
|
||||
const weightSignal = buildMetricSignal({
|
||||
metric: locale === "en" ? "your weight" : "dein Gewicht",
|
||||
unit: "kg",
|
||||
direction: "target-band",
|
||||
direction:
|
||||
weightTargetFeature?.position === "below"
|
||||
? "higher-better"
|
||||
: weightTargetFeature?.position === "above"
|
||||
? "lower-better"
|
||||
: "target-band",
|
||||
graded: weightGraded,
|
||||
newestDaysAgo: newestMeasurementDaysAgo,
|
||||
});
|
||||
@@ -465,6 +500,7 @@ export async function prepareWeightStatusForUser(
|
||||
},
|
||||
weight: {
|
||||
...(weightSignal ? { signal: weightSignal } : {}),
|
||||
...(weightTargetFeature ? { target: weightTargetFeature } : {}),
|
||||
summary: summarizeSeries(
|
||||
weightSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ export type LinkSourceKind = "encounter" | "document" | "vaccination";
|
||||
|
||||
/** The record a link points at. */
|
||||
export type LinkTargetKind =
|
||||
"document" | "labResult" | "conditionEpisode" | "encounter";
|
||||
"document" | "labResult" | "conditionEpisode" | "encounter" | "vaccination";
|
||||
|
||||
export interface LinkRequest {
|
||||
userId: string;
|
||||
@@ -187,6 +187,18 @@ const ENCOUNTER_ENDPOINT: EndpointSpec = {
|
||||
date: (row) => asDate(row.occurredAt),
|
||||
};
|
||||
|
||||
const VACCINATION_ENDPOINT: EndpointSpec = {
|
||||
delegate: (tx) => narrow<OwnedRowDelegate>(tx.vaccinationRecord),
|
||||
select: { id: true, antigenSlug: true, vaccineName: true, occurredAt: true },
|
||||
// The person's own wording first, then the catalogue slug. A caller that
|
||||
// renders a dose resolves the slug through its own bundle instead (see
|
||||
// `loadDocumentVaccinationLinks`); this label is the fallback every
|
||||
// generic consumer of the service gets.
|
||||
label: (row) =>
|
||||
asString(row.vaccineName) ?? asString(row.antigenSlug) ?? String(row.id),
|
||||
date: (row) => asDate(row.occurredAt),
|
||||
};
|
||||
|
||||
const CONDITION_ENDPOINT: EndpointSpec = {
|
||||
delegate: (tx) => narrow<OwnedRowDelegate>(tx.illnessEpisode),
|
||||
select: { id: true, label: true, onsetAt: true },
|
||||
@@ -255,6 +267,18 @@ const LINK_TABLES: Partial<
|
||||
targetColumn: "documentId",
|
||||
target: DOCUMENT_ENDPOINT,
|
||||
},
|
||||
// The SAME table as `vaccination:document`, read from the other end, like
|
||||
// `document:encounter` beside it. A childhood record covering a dozen doses
|
||||
// is filed from the document's own sheet in one pass rather than dose by
|
||||
// dose; the dose's form and the document's sheet write one table, so they
|
||||
// cannot disagree about what is filed where. A second direction, not a
|
||||
// second vaccination table.
|
||||
"document:vaccination": {
|
||||
delegate: (tx) => narrow<LinkRowDelegate>(tx.vaccinationDocumentLink),
|
||||
sourceColumn: "documentId",
|
||||
targetColumn: "vaccinationId",
|
||||
target: VACCINATION_ENDPOINT,
|
||||
},
|
||||
};
|
||||
|
||||
function tableFor(
|
||||
|
||||
@@ -128,6 +128,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"documents.inbound.restoreGone",
|
||||
"documents.inbound.uploadBusy",
|
||||
"documents.inbound.uploadTimeout",
|
||||
"documents.inbound.vaccinationNotFound",
|
||||
],
|
||||
encounter: [
|
||||
"encounter.invalid",
|
||||
|
||||
@@ -98,7 +98,7 @@ const inboundFactEditRequest = inboundFactEditSchema.meta({
|
||||
const documentUpdateRequest = 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), `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.",
|
||||
"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), `vaccinationIds` (the same replace-set semantics over the caller's vaccination doses, up to 100 so one childhood record can be filed against a whole primary schedule). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
|
||||
});
|
||||
|
||||
const documentBulkRequest = documentBulkSchema.meta({
|
||||
@@ -156,6 +156,19 @@ const encounterLink = z
|
||||
"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 vaccinationLink = z
|
||||
.object({
|
||||
vaccinationId: z.string(),
|
||||
occurredAt: z.string().nullable(),
|
||||
catalogSlug: z.string().nullable(),
|
||||
vaccineName: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "DocumentVaccinationLink",
|
||||
description:
|
||||
"A link to one of the caller's vaccination doses — \"this page records that dose\". `catalogSlug` is the catalogue entry when this release still knows the dose's slug, for the reader's own bundle to name; null is the signal to show `vaccineName`, the person's own wording. The same table the dose's own `/api/vaccinations/{id}/links` writes from the other end.",
|
||||
});
|
||||
|
||||
const inboundDocument = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -203,6 +216,10 @@ const inboundDocument = z
|
||||
const inboundDocumentDetail = inboundDocument
|
||||
.extend({
|
||||
facts: z.array(extractedFact),
|
||||
vaccinationLinks: z.array(vaccinationLink).nullable().meta({
|
||||
description:
|
||||
"Vaccination doses this document is filed against. Null when the caller acts inside somebody else's record under a grant that does not cover the health background the doses live in: the page is shared, the doses are not, and an empty list would claim the page records none.",
|
||||
}),
|
||||
summary: z.string().nullable(),
|
||||
summaryGeneratedAt: z.string().nullable(),
|
||||
summaryState: z.enum(DOCUMENT_SUMMARY_STATES),
|
||||
|
||||
@@ -1201,6 +1201,24 @@ export const dashboardSnapshotResponse = z
|
||||
.describe(
|
||||
"Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep tile's headline. Null when the writers agree or the sleep module is off; optional for older cached snapshots.",
|
||||
),
|
||||
weightTrend: z
|
||||
.object({
|
||||
direction: z
|
||||
.enum(["up-good", "up-bad", "hold"])
|
||||
.describe(
|
||||
"How the weight tile colours a change. `up-good`: a rise is progress (below the target band). `up-bad`: a fall is progress (above the band, and the reading when no target is stored). `hold`: the weight is inside the band, so a flat change is progress and a move either way is neutral, never a setback.",
|
||||
),
|
||||
targetPosition: z
|
||||
.enum(["below", "inside", "above"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Where the 7-day average (else the latest reading) sits against the person's own stored weight target. Null when no target is stored or there is no reading.",
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Which way a weight change counts as progress, judged against the person's own stored target rather than assuming a fall is good. Render the arrow, 7-day delta and comparison caption from `direction`; do not recompute it. Optional for older cached snapshots; when absent, treat it as `up-bad`.",
|
||||
),
|
||||
}),
|
||||
extras: z
|
||||
.object({
|
||||
|
||||
@@ -175,11 +175,33 @@ const vaccination = z
|
||||
"One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list.",
|
||||
});
|
||||
|
||||
const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({
|
||||
id: "VaccinationList",
|
||||
description:
|
||||
"The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.",
|
||||
});
|
||||
const vaccinationRenewal = z
|
||||
.object({
|
||||
antigen: z.string(),
|
||||
reminderId: z.string(),
|
||||
dueAt: z.string(),
|
||||
daysUntil: z.number().int(),
|
||||
state: z.enum(["current", "dueSoon", "overdue"]),
|
||||
})
|
||||
.meta({
|
||||
id: "VaccinationRenewal",
|
||||
description:
|
||||
"One antigen's renewal state, read from the booster reminder the person confirmed for it (`dueAt` is that reminder's next due instant). `daysUntil` counts calendar days on the person's own clock and is negative once the date has passed. `state` is `overdue` once it has passed, `dueSoon` within 30 days, `current` otherwise. Render from `state`; do not recompute it. Nothing here is derived from age, history or the catalogue: an antigen without a confirmed booster has no entry.",
|
||||
});
|
||||
|
||||
const vaccinationList = z
|
||||
.object({
|
||||
vaccinations: z.array(vaccination),
|
||||
renewals: z.array(vaccinationRenewal).nullable().meta({
|
||||
description:
|
||||
"Each antigen's renewal state, earliest due first. Null when the caller acts under a grant that does not cover the measurements section the booster reminders live in.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "VaccinationList",
|
||||
description:
|
||||
"The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.",
|
||||
});
|
||||
|
||||
const vaccinationSuggestion = z
|
||||
.object({
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { PersonalRecordDirection } from "@/generated/prisma/client";
|
||||
import { weightTargetPosition } from "@/lib/targets/weight-trend";
|
||||
|
||||
/**
|
||||
* v1.34 — the user-goal facts the resolver needs for the goal-dependent
|
||||
@@ -78,11 +79,11 @@ export function resolveWeightGoalDirection(
|
||||
target: { min: number; max: number } | null,
|
||||
latestWeightKg: number | null,
|
||||
): "lower" | "higher" | null {
|
||||
if (!target || latestWeightKg === null || !Number.isFinite(latestWeightKg)) {
|
||||
return null;
|
||||
}
|
||||
if (latestWeightKg > target.max) return "lower";
|
||||
if (latestWeightKg < target.min) return "higher";
|
||||
// Same placement the weight tile's trend colour reads, so a record and an
|
||||
// arrow can never disagree about which side of the target a person is on.
|
||||
const position = weightTargetPosition(target, latestWeightKg);
|
||||
if (position === "above") return "lower";
|
||||
if (position === "below") return "higher";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@ export const documentKeys = {
|
||||
*/
|
||||
inboundDocumentPicker: (q: string) =>
|
||||
["documents", "inbound", "picker", q] as const,
|
||||
/**
|
||||
* The whole vault as a link-picker option list (every page, walked at the
|
||||
* list route's ceiling), shared by the visit and vaccination forms. Own
|
||||
* segment because the shape is a flat document array, not a page.
|
||||
*/
|
||||
inboundDocumentVaultPicker: () =>
|
||||
["documents", "inbound", "vault-picker"] as const,
|
||||
/**
|
||||
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
|
||||
* Invalidated through the `["documents"]` prefix after every upload /
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildWeightTargetFeature,
|
||||
resolveWeightTrend,
|
||||
weightTargetPosition,
|
||||
weightTrendReferenceKg,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import { getTrendSentiment } from "@/lib/insights/trend-sentiment";
|
||||
|
||||
const TARGET = { min: 70, max: 75 };
|
||||
|
||||
describe("weightTargetPosition", () => {
|
||||
it("places a reading below, inside or above the stored target", () => {
|
||||
expect(weightTargetPosition(TARGET, 65)).toBe("below");
|
||||
expect(weightTargetPosition(TARGET, 70)).toBe("inside");
|
||||
expect(weightTargetPosition(TARGET, 72.5)).toBe("inside");
|
||||
expect(weightTargetPosition(TARGET, 75)).toBe("inside");
|
||||
expect(weightTargetPosition(TARGET, 80)).toBe("above");
|
||||
});
|
||||
|
||||
it("answers null without a target or without a usable reading", () => {
|
||||
expect(weightTargetPosition(null, 80)).toBeNull();
|
||||
expect(weightTargetPosition(TARGET, null)).toBeNull();
|
||||
expect(weightTargetPosition(TARGET, Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWeightTrend", () => {
|
||||
it("below the target, gaining is progress", () => {
|
||||
const trend = resolveWeightTrend(TARGET, 62);
|
||||
expect(trend).toEqual({ direction: "up-good", targetPosition: "below" });
|
||||
expect(getTrendSentiment(0.6, trend.direction)).toBe("positive");
|
||||
expect(getTrendSentiment(-0.6, trend.direction)).toBe("negative");
|
||||
});
|
||||
|
||||
it("above the target, losing is progress", () => {
|
||||
const trend = resolveWeightTrend(TARGET, 84);
|
||||
expect(trend).toEqual({ direction: "up-bad", targetPosition: "above" });
|
||||
expect(getTrendSentiment(-0.6, trend.direction)).toBe("positive");
|
||||
expect(getTrendSentiment(0.6, trend.direction)).toBe("negative");
|
||||
});
|
||||
|
||||
it("inside the target, holding steady is progress and no move is a setback", () => {
|
||||
const trend = resolveWeightTrend(TARGET, 72);
|
||||
expect(trend).toEqual({ direction: "hold", targetPosition: "inside" });
|
||||
expect(getTrendSentiment(0, trend.direction)).toBe("positive");
|
||||
expect(getTrendSentiment(0.01, trend.direction)).toBe("positive");
|
||||
expect(getTrendSentiment(0.6, trend.direction)).toBe("neutral");
|
||||
expect(getTrendSentiment(-0.6, trend.direction)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("without a stored target keeps the historical reading: a fall reads as good", () => {
|
||||
const trend = resolveWeightTrend(null, 84);
|
||||
expect(trend).toEqual({ direction: "up-bad", targetPosition: null });
|
||||
expect(getTrendSentiment(-0.6, trend.direction)).toBe("positive");
|
||||
});
|
||||
|
||||
it("with a target but no reading yet keeps the historical reading too", () => {
|
||||
expect(resolveWeightTrend(TARGET, null)).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("weightTrendReferenceKg", () => {
|
||||
it("prefers the 7-day average so one noisy reading at the band edge does not flip the colour", () => {
|
||||
expect(weightTrendReferenceKg({ avg7: 71, latest: 69 })).toBe(71);
|
||||
});
|
||||
|
||||
it("falls back to the latest reading, then to nothing", () => {
|
||||
expect(weightTrendReferenceKg({ avg7: null, latest: 69 })).toBe(69);
|
||||
expect(weightTrendReferenceKg({ avg7: null, latest: null })).toBeNull();
|
||||
expect(weightTrendReferenceKg(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTrendSentiment — existing directions unchanged", () => {
|
||||
it("still colours up-good, up-bad and neutral exactly as before", () => {
|
||||
expect(getTrendSentiment(1, "up-good")).toBe("positive");
|
||||
expect(getTrendSentiment(-1, "up-good")).toBe("negative");
|
||||
expect(getTrendSentiment(1, "up-bad")).toBe("negative");
|
||||
expect(getTrendSentiment(0.01, "up-bad")).toBe("neutral");
|
||||
expect(getTrendSentiment(1, "neutral")).toBe("neutral");
|
||||
expect(getTrendSentiment(null, "up-good")).toBe("neutral");
|
||||
});
|
||||
|
||||
it("a hold direction with no signal at all stays neutral rather than claiming progress", () => {
|
||||
expect(getTrendSentiment(null, "hold")).toBe("neutral");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWeightTargetFeature", () => {
|
||||
it("names the progress direction for each side of the band", () => {
|
||||
expect(
|
||||
buildWeightTargetFeature(TARGET, { avg7: 66, latest: 66 })?.progress,
|
||||
).toBe("gaining");
|
||||
expect(
|
||||
buildWeightTargetFeature(TARGET, { avg7: 72, latest: 72 })?.progress,
|
||||
).toBe("holding steady");
|
||||
expect(
|
||||
buildWeightTargetFeature(TARGET, { avg7: 80, latest: 80 })?.progress,
|
||||
).toBe("losing");
|
||||
});
|
||||
|
||||
it("claims nothing without a stored target or a reading", () => {
|
||||
expect(buildWeightTargetFeature(null, { avg7: 80, latest: 80 })).toBeNull();
|
||||
expect(
|
||||
buildWeightTargetFeature(TARGET, { avg7: null, latest: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Which way a weight change counts as progress, judged against the person's
|
||||
* own stored target rather than against gravity.
|
||||
*
|
||||
* The trend arrow, the 7-day delta and the comparison caption on the weight
|
||||
* tile used to be hard-wired `up-bad`: a falling number read green whatever
|
||||
* the person was aiming for. Someone below their target who was gaining, which
|
||||
* is exactly what they set out to do, saw every step coloured as a setback.
|
||||
*
|
||||
* The rule here is the whole answer, with no setting of its own:
|
||||
*
|
||||
* - below the target band → gaining is progress (`up-good`)
|
||||
* - above the target band → losing is progress (`up-bad`)
|
||||
* - inside the target band → holding steady is (`hold`)
|
||||
* - no stored target → the historical reading (`up-bad`)
|
||||
*
|
||||
* The target is the one the person entered on `/targets`, stored as
|
||||
* `User.thresholdsJson.WEIGHT` and narrowed by `resolveWeightTargetOverride`.
|
||||
* The height-derived WHO band is deliberately NOT a target here: nobody chose
|
||||
* it, and a band nobody chose is not a statement about which way they want to
|
||||
* move.
|
||||
*
|
||||
* Resolved on the server and published on the dashboard snapshot
|
||||
* (`tiles.weightTrend`) so the web tile and the native client colour the same
|
||||
* reading the same way. The web page's snapshot-disabled fallback calls this
|
||||
* same function; nothing else re-derives it.
|
||||
*
|
||||
* Pure and client-safe: no DB read, no clock.
|
||||
*/
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
|
||||
/** Where a weight sits against the person's own target band. */
|
||||
export type WeightTargetPosition = "below" | "inside" | "above";
|
||||
|
||||
export interface WeightTrendJudgement {
|
||||
/** The sentiment direction the tile's arrow, delta and caption colour by. */
|
||||
direction: Extract<TrendDirectionSentiment, "up-good" | "up-bad" | "hold">;
|
||||
/**
|
||||
* Where the reference weight sits against the stored target. Null when no
|
||||
* target is stored or there is no reading yet — the two cases where the
|
||||
* historical `up-bad` reading applies.
|
||||
*/
|
||||
targetPosition: WeightTargetPosition | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a weight against the target band. The band edges count as inside: a
|
||||
* person sitting exactly on the number they typed has arrived.
|
||||
*/
|
||||
export function weightTargetPosition(
|
||||
target: { min: number; max: number } | null,
|
||||
weightKg: number | null,
|
||||
): WeightTargetPosition | null {
|
||||
if (!target || weightKg === null || !Number.isFinite(weightKg)) return null;
|
||||
if (weightKg < target.min) return "below";
|
||||
if (weightKg > target.max) return "above";
|
||||
return "inside";
|
||||
}
|
||||
|
||||
/**
|
||||
* The weight the position is read from: the 7-day average when there is one,
|
||||
* else the latest reading. A single morning reading near a band edge would
|
||||
* otherwise flip the arrow colour back and forth from one day to the next,
|
||||
* while the average moves at the pace the tile's own trend does.
|
||||
*/
|
||||
export function weightTrendReferenceKg(
|
||||
summary: { avg7: number | null; latest: number | null } | null | undefined,
|
||||
): number | null {
|
||||
if (!summary) return null;
|
||||
return summary.avg7 ?? summary.latest ?? null;
|
||||
}
|
||||
|
||||
export function resolveWeightTrend(
|
||||
target: { min: number; max: number } | null,
|
||||
referenceKg: number | null,
|
||||
): WeightTrendJudgement {
|
||||
const targetPosition = weightTargetPosition(target, referenceKg);
|
||||
switch (targetPosition) {
|
||||
case "below":
|
||||
return { direction: "up-good", targetPosition };
|
||||
case "above":
|
||||
return { direction: "up-bad", targetPosition };
|
||||
case "inside":
|
||||
return { direction: "hold", targetPosition };
|
||||
default:
|
||||
return { direction: "up-bad", targetPosition: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same judgement in the shape a narrative model reads: the band, where
|
||||
* the person is against it, and a plain sentence saying which way is
|
||||
* progress. Rides `features.weight.target` (briefing, overview analysis,
|
||||
* Coach) and the weight status snapshot, so an AI note never calls a gain a
|
||||
* regression when the person is below the target they set.
|
||||
*
|
||||
* Null without a stored target or a reading: then no direction is claimed at
|
||||
* all, which is the honest default for a model (the tile keeps its historical
|
||||
* colour, the prose makes no promise about which way is better).
|
||||
*/
|
||||
export interface WeightTargetFeature {
|
||||
minKg: number;
|
||||
maxKg: number;
|
||||
position: WeightTargetPosition;
|
||||
progress: "gaining" | "losing" | "holding steady";
|
||||
reading: string;
|
||||
}
|
||||
|
||||
const PROGRESS_BY_POSITION: Record<
|
||||
WeightTargetPosition,
|
||||
WeightTargetFeature["progress"]
|
||||
> = {
|
||||
below: "gaining",
|
||||
above: "losing",
|
||||
inside: "holding steady",
|
||||
};
|
||||
|
||||
export function buildWeightTargetFeature(
|
||||
target: { min: number; max: number } | null,
|
||||
summary: { avg7: number | null; latest: number | null } | null | undefined,
|
||||
): WeightTargetFeature | null {
|
||||
const position = weightTargetPosition(
|
||||
target,
|
||||
weightTrendReferenceKg(summary),
|
||||
);
|
||||
if (!target || !position) return null;
|
||||
const band = `${target.min}–${target.max} kg`;
|
||||
const reading =
|
||||
position === "below"
|
||||
? `The person set their own weight target at ${band} and is currently below it, so gaining weight is progress and losing weight moves away from the goal. Judge weight changes against this target; never describe a gain as a setback or a regression.`
|
||||
: position === "above"
|
||||
? `The person set their own weight target at ${band} and is currently above it, so losing weight is progress and gaining moves away from the goal.`
|
||||
: `The person set their own weight target at ${band} and is currently inside it, so holding steady is progress; a small move either way within the band is not a setback.`;
|
||||
return {
|
||||
minKg: target.min,
|
||||
maxKg: target.max,
|
||||
position,
|
||||
progress: PROGRESS_BY_POSITION[position],
|
||||
reading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
VACCINATION_RENEWAL_SOON_DAYS,
|
||||
resolveRenewals,
|
||||
} from "@/lib/vaccinations/renewal";
|
||||
|
||||
const NOW = new Date("2026-09-24T10:00:00.000Z");
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function reminder(
|
||||
over: Partial<{
|
||||
id: string;
|
||||
vaccinationAntigen: string | null;
|
||||
nextDueAt: Date | null;
|
||||
enabled: boolean;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: "rem-1",
|
||||
vaccinationAntigen: "tetanus",
|
||||
nextDueAt: new Date("2030-01-01T08:00:00.000Z"),
|
||||
enabled: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveRenewals", () => {
|
||||
it("reads a booster far off as current", () => {
|
||||
expect(resolveRenewals([reminder()], NOW, TZ)).toEqual([
|
||||
{
|
||||
antigen: "tetanus",
|
||||
reminderId: "rem-1",
|
||||
dueAt: "2030-01-01T08:00:00.000Z",
|
||||
daysUntil: expect.any(Number),
|
||||
state: "current",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a booster inside the soon window as due soon, edges included", () => {
|
||||
const soon = new Date(
|
||||
NOW.getTime() + VACCINATION_RENEWAL_SOON_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const [renewal] = resolveRenewals([reminder({ nextDueAt: soon })], NOW, TZ);
|
||||
expect(renewal!.daysUntil).toBe(VACCINATION_RENEWAL_SOON_DAYS);
|
||||
expect(renewal!.state).toBe("dueSoon");
|
||||
const [today] = resolveRenewals([reminder({ nextDueAt: NOW })], NOW, TZ);
|
||||
expect(today!.state).toBe("dueSoon");
|
||||
});
|
||||
|
||||
it("reads a booster whose date has passed as overdue", () => {
|
||||
const [renewal] = resolveRenewals(
|
||||
[reminder({ nextDueAt: new Date("2026-09-01T08:00:00.000Z") })],
|
||||
NOW,
|
||||
TZ,
|
||||
);
|
||||
expect(renewal!.state).toBe("overdue");
|
||||
expect(renewal!.daysUntil).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("ignores a switched-off reminder, one with no antigen, and one with no date", () => {
|
||||
expect(
|
||||
resolveRenewals(
|
||||
[
|
||||
reminder({ enabled: false }),
|
||||
reminder({ vaccinationAntigen: null }),
|
||||
reminder({ nextDueAt: null }),
|
||||
],
|
||||
NOW,
|
||||
TZ,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers one renewal per antigen, the earliest due", () => {
|
||||
const renewals = resolveRenewals(
|
||||
[
|
||||
reminder({ id: "late", nextDueAt: new Date("2031-01-01T08:00:00Z") }),
|
||||
reminder({ id: "early", nextDueAt: new Date("2029-01-01T08:00:00Z") }),
|
||||
reminder({
|
||||
id: "flu",
|
||||
vaccinationAntigen: "influenza",
|
||||
nextDueAt: new Date("2026-10-01T08:00:00Z"),
|
||||
}),
|
||||
],
|
||||
NOW,
|
||||
TZ,
|
||||
);
|
||||
expect(renewals.map((r) => [r.antigen, r.reminderId])).toEqual([
|
||||
["influenza", "flu"],
|
||||
["tetanus", "early"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/lib/practitioners/dto";
|
||||
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
|
||||
import type { SeriesPosition } from "@/lib/vaccinations/series";
|
||||
import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal";
|
||||
|
||||
/**
|
||||
* One page a dose was transcribed from, as the caller is allowed to see it.
|
||||
@@ -162,4 +163,11 @@ export function toVaccinationDTO(
|
||||
/** The list response: one array, newest dose first. */
|
||||
export interface VaccinationListDTO {
|
||||
vaccinations: VaccinationDTO[];
|
||||
/**
|
||||
* v1.39 — each antigen's renewal state, from the booster reminder the
|
||||
* person confirmed (`src/lib/vaccinations/renewal.ts`). An antigen with no
|
||||
* confirmed booster is absent. `null` when the caller acts under a grant
|
||||
* that does not cover the measurements section the reminders live in.
|
||||
*/
|
||||
renewals: VaccinationRenewalDTO[] | null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Whether a vaccination is still current, surfaced where the person looks.
|
||||
*
|
||||
* The data already exists and is the person's own: when a dose is logged, the
|
||||
* app offers to plan the booster from the catalogue's interval, and what the
|
||||
* person confirms becomes an ordinary reminder keyed on the antigen
|
||||
* (`MeasurementReminder.vaccinationAntigen`, minted in `booster-mint.ts`).
|
||||
* Logging the next dose re-anchors it. Its `nextDueAt` is the renewal date.
|
||||
* Until now it lived only on `/checkups`, and the vaccination page itself said
|
||||
* nothing about it.
|
||||
*
|
||||
* This module reads that date and resolves it to a state, once, on the
|
||||
* server, so the web page and the native client agree. It does NOT compute a
|
||||
* due date from age, history or the catalogue: an antigen with no confirmed
|
||||
* booster has no renewal here. That line is the one `series.ts` draws — due
|
||||
* computation from a person's history is a different kind of product — and it
|
||||
* is why the source is the confirmed reminder and nothing else.
|
||||
*
|
||||
* Pure: no DB read. The route owns the read and the grant check.
|
||||
*/
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
|
||||
/**
|
||||
* How close a renewal has to be before it counts as due soon. A month is
|
||||
* enough to book an appointment for a ten-year booster and still meaningful
|
||||
* for a seasonal one.
|
||||
*/
|
||||
export const VACCINATION_RENEWAL_SOON_DAYS = 30;
|
||||
|
||||
export type VaccinationRenewalState = "current" | "dueSoon" | "overdue";
|
||||
|
||||
/** One antigen's renewal, resolved. */
|
||||
export interface VaccinationRenewalDTO {
|
||||
/** The component antigen slug the booster reminder keys on. */
|
||||
antigen: string;
|
||||
/** The reminder behind it, for a deep link to `/checkups`. */
|
||||
reminderId: string;
|
||||
/** ISO-8601 instant the booster is due. */
|
||||
dueAt: string;
|
||||
/**
|
||||
* Calendar days from today to `dueAt` on the person's own clock; negative
|
||||
* once it has passed. The same count the checkups page shows.
|
||||
*/
|
||||
daysUntil: number;
|
||||
state: VaccinationRenewalState;
|
||||
}
|
||||
|
||||
export interface RenewalReminderRow {
|
||||
id: string;
|
||||
vaccinationAntigen: string | null;
|
||||
nextDueAt: Date | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function resolveRenewals(
|
||||
reminders: readonly RenewalReminderRow[],
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
): VaccinationRenewalDTO[] {
|
||||
const byAntigen = new Map<string, RenewalReminderRow & { due: Date }>();
|
||||
for (const reminder of reminders) {
|
||||
if (!reminder.enabled) continue;
|
||||
if (!reminder.vaccinationAntigen || !reminder.nextDueAt) continue;
|
||||
// The mint keeps one live reminder per antigen; should two exist anyway,
|
||||
// the earlier date is the one that needs the person first.
|
||||
const current = byAntigen.get(reminder.vaccinationAntigen);
|
||||
if (!current || reminder.nextDueAt < current.due) {
|
||||
byAntigen.set(reminder.vaccinationAntigen, {
|
||||
...reminder,
|
||||
due: reminder.nextDueAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byAntigen.values()]
|
||||
.map((reminder) => {
|
||||
const daysUntil = calendarDaysUntil(reminder.due, now, timeZone);
|
||||
const state: VaccinationRenewalState =
|
||||
daysUntil < 0
|
||||
? "overdue"
|
||||
: daysUntil <= VACCINATION_RENEWAL_SOON_DAYS
|
||||
? "dueSoon"
|
||||
: "current";
|
||||
return {
|
||||
antigen: reminder.vaccinationAntigen!,
|
||||
reminderId: reminder.id,
|
||||
dueAt: reminder.due.toISOString(),
|
||||
daysUntil,
|
||||
state,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.dueAt.localeCompare(b.dueAt));
|
||||
}
|
||||
@@ -366,6 +366,24 @@ export interface DocumentEncounterLinkDto {
|
||||
occurredAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One vaccination link on a document detail DTO — "this page records that
|
||||
* dose".
|
||||
*
|
||||
* `catalogSlug` is the catalogue entry when this release still knows the
|
||||
* dose's slug, so the reader's own bundle names it
|
||||
* (`vaccinations.catalog.<slug>`); `null` is the signal to show
|
||||
* `vaccineName`, the person's own wording. The same degrade the vaccination
|
||||
* DTO's `catalogEntry: null` carries.
|
||||
*/
|
||||
export interface DocumentVaccinationLinkDto {
|
||||
vaccinationId: string;
|
||||
/** ISO-8601 instant of the dose (UTC midnight — a Pass carries dates). */
|
||||
occurredAt: string | null;
|
||||
catalogSlug: string | null;
|
||||
vaccineName: string | null;
|
||||
}
|
||||
|
||||
/** The document DTO (list + detail). */
|
||||
export interface InboundDocumentDto {
|
||||
id: string;
|
||||
@@ -429,6 +447,13 @@ export interface InboundDocumentDto {
|
||||
|
||||
export interface InboundDocumentDetailDto extends InboundDocumentDto {
|
||||
facts: ExtractedFactDto[];
|
||||
/**
|
||||
* Doses this document is filed against, newest link last. `null` when the
|
||||
* caller acts inside somebody else's record under a grant that does not
|
||||
* cover the health background the doses live in: the page is shared, the
|
||||
* doses are not, and an empty list would claim the page records none.
|
||||
*/
|
||||
vaccinationLinks: DocumentVaccinationLinkDto[] | null;
|
||||
/**
|
||||
* Short (3-4 sentence) plain-language summary of WHAT the document is,
|
||||
* generated once in the background after upload when the `documentsAutoAiRead`
|
||||
@@ -619,6 +644,20 @@ const encounterIdList = z
|
||||
.array(z.string().trim().min(1).max(40))
|
||||
.max(DOCUMENT_MAX_ENCOUNTER_LINKS);
|
||||
|
||||
/**
|
||||
* Max vaccination links a single document may carry / receive per request.
|
||||
*
|
||||
* Higher than the visit and condition bounds on purpose. One childhood
|
||||
* record routinely covers every dose of a whole primary schedule, which is
|
||||
* well past twenty rows, and it is the normal case rather than an edge one.
|
||||
* The link service's own per-call ceiling is the bound.
|
||||
*/
|
||||
export const DOCUMENT_MAX_VACCINATION_LINKS = 100;
|
||||
|
||||
const vaccinationIdList = z
|
||||
.array(z.string().trim().min(1).max(40))
|
||||
.max(DOCUMENT_MAX_VACCINATION_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
|
||||
@@ -665,6 +704,11 @@ export const documentUpdateSchema = z
|
||||
episodeIds: episodeIdList.optional(),
|
||||
/** Replace-set visit links, same semantics as `episodeIds`. */
|
||||
encounterIds: encounterIdList.optional(),
|
||||
/**
|
||||
* Replace-set vaccination links, same semantics. The same table the
|
||||
* dose's own form writes from the other end.
|
||||
*/
|
||||
vaccinationIds: vaccinationIdList.optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
@@ -672,7 +716,8 @@ export const documentUpdateSchema = z
|
||||
d.kind !== undefined ||
|
||||
d.documentDate !== undefined ||
|
||||
d.episodeIds !== undefined ||
|
||||
d.encounterIds !== undefined,
|
||||
d.encounterIds !== undefined ||
|
||||
d.vaccinationIds !== undefined,
|
||||
{ message: "Provide at least one field to update" },
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user