mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'v143/ios' into release/v1.43.0
This commit is contained in:
commit
3fc37c9183
78 files changed
+5928
-489
No files matched your search
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Client code holds no threshold or polarity table of its own (v1.43, ios
|
||||
* #116 items 3 and 4).
|
||||
*
|
||||
* Which way a change counts and where a chart's reference lines sit depend on
|
||||
* the person: their own target, their age, their sex, their glucose unit. The
|
||||
* server resolves both (`src/lib/targets/trend-verdict.ts`,
|
||||
* `src/lib/measurements/series-reference-lines.ts`) and publishes them on the
|
||||
* dashboard snapshot (`tiles.directions`), the vitals baseline (`direction`),
|
||||
* the summary and target reads and the series read. A client that keeps its
|
||||
* own copy answers for an imaginary average person: a rising blood pressure
|
||||
* painted as a setback for someone below their target, a 140 mmHg line on a
|
||||
* chart whose person has a band of 115 to 125.
|
||||
*
|
||||
* Four shapes are refused in client code (`src/components`, `src/hooks`, and
|
||||
* every `"use client"` module under `src/app` outside the API):
|
||||
*
|
||||
* 1. a `directionSentiment` given as a string literal: the tile must read
|
||||
* the resolved verdict;
|
||||
* 2. a `ReferenceLine` / `ReferenceArea` drawn at a number literal: a line
|
||||
* has to come from data the server resolved;
|
||||
* 3. a polarity table, i.e. a map or set of metric names to `up-good` /
|
||||
* `up-bad`, or a `…Sentiment(type)` function deciding one;
|
||||
* 4. an import of the server's resolvers into the browser (a type import
|
||||
* is fine).
|
||||
*
|
||||
* The guard cannot pass by reading nothing: the walk carries a floor, every
|
||||
* rule's matcher is proven against a sample of the shape it exists to catch,
|
||||
* every allowlist entry must still match with its exact count, and the rules
|
||||
* must see at least one real match across the tree.
|
||||
*
|
||||
* Its limit: it reads the source, not the running page. A polarity computed
|
||||
* from a variable that happens to hold a literal is not seen; review catches
|
||||
* that.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
interface ClientFile {
|
||||
rel: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function isClientModule(rel: string, source: string): boolean {
|
||||
if (rel.startsWith("src/components/") || rel.startsWith("src/hooks/")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*["']use client["']/.test(stripComments(source).trimStart());
|
||||
}
|
||||
|
||||
function clientFiles(): ClientFile[] {
|
||||
const roots: Array<{ dir: string; floor: number }> = [
|
||||
{ dir: "src/components", floor: 900 },
|
||||
{ dir: "src/hooks", floor: 50 },
|
||||
{ dir: "src/app", floor: 900 },
|
||||
];
|
||||
const out: ClientFile[] = [];
|
||||
for (const { dir, floor } of roots) {
|
||||
for (const file of walkSourceFiles(join(ROOT, dir), { floor })) {
|
||||
const rel = `${dir}/${file}`;
|
||||
if (rel.startsWith("src/app/api/")) continue;
|
||||
if (rel.includes("/__tests__/") || /\.test\.tsx?$/.test(rel)) continue;
|
||||
const source = readFileSync(join(ROOT, rel), "utf8");
|
||||
if (!isClientModule(rel, source)) continue;
|
||||
out.push({ rel, source });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parse(name: string, source: string): ts.SourceFile {
|
||||
return ts.createSourceFile(
|
||||
name,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
name.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
}
|
||||
|
||||
/** The literal text a JSX attribute initialiser carries, if it is one. */
|
||||
function literalOf(init: ts.JsxAttributeValue | undefined): string | null {
|
||||
if (!init) return null;
|
||||
if (ts.isStringLiteral(init)) return init.text;
|
||||
if (ts.isJsxExpression(init) && init.expression) {
|
||||
const e = init.expression;
|
||||
if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) {
|
||||
return e.text;
|
||||
}
|
||||
if (ts.isNumericLiteral(e)) return e.text;
|
||||
if (
|
||||
ts.isPrefixUnaryExpression(e) &&
|
||||
e.operator === ts.SyntaxKind.MinusToken &&
|
||||
ts.isNumericLiteral(e.operand)
|
||||
) {
|
||||
return `-${e.operand.text}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsxAttributes(
|
||||
file: ts.SourceFile,
|
||||
visit: (
|
||||
tag: string,
|
||||
name: string,
|
||||
init: ts.JsxAttributeValue | undefined,
|
||||
node: ts.Node,
|
||||
) => void,
|
||||
): void {
|
||||
const walk = (node: ts.Node) => {
|
||||
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
||||
const tag = node.tagName.getText(file);
|
||||
for (const attr of node.attributes.properties) {
|
||||
if (!ts.isJsxAttribute(attr)) continue;
|
||||
visit(tag, attr.name.getText(file), attr.initializer, attr);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, walk);
|
||||
};
|
||||
walk(file);
|
||||
}
|
||||
|
||||
function lineOf(file: ts.SourceFile, node: ts.Node): number {
|
||||
return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
|
||||
}
|
||||
|
||||
/** Rule 1 — `directionSentiment` given as a literal. */
|
||||
function literalDirections(name: string, source: string): string[] {
|
||||
const file = parse(name, source);
|
||||
const hits: string[] = [];
|
||||
jsxAttributes(file, (tag, attr, init, node) => {
|
||||
if (attr !== "directionSentiment") return;
|
||||
const literal = literalOf(init);
|
||||
if (literal !== null) {
|
||||
hits.push(
|
||||
`${name}:${lineOf(file, node)}: <${tag} directionSentiment="${literal}">`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
const LINE_TAGS = new Set(["ReferenceLine", "ReferenceArea"]);
|
||||
const LINE_ATTRS = new Set(["y", "y1", "y2"]);
|
||||
|
||||
/** Rule 2 — a chart reference drawn at a number literal. */
|
||||
function literalLines(name: string, source: string): string[] {
|
||||
const file = parse(name, source);
|
||||
const hits: string[] = [];
|
||||
jsxAttributes(file, (tag, attr, init, node) => {
|
||||
if (!LINE_TAGS.has(tag) || !LINE_ATTRS.has(attr)) return;
|
||||
const literal = literalOf(init);
|
||||
if (literal !== null && /^-?\d/.test(literal)) {
|
||||
hits.push(`${name}:${lineOf(file, node)}: <${tag} ${attr}={${literal}}>`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Rule 3 — a polarity table: line-shaped, over comment-stripped code. */
|
||||
const POLARITY_PATTERNS: RegExp[] = [
|
||||
// `RESTING_HEART_RATE: "up-bad"` / `["HRV", "up-good"]`
|
||||
/\b[A-Z][A-Z0-9_]{2,}["']?\s*[:,]\s*["']up-(good|bad)["']/,
|
||||
// `const UP_BAD_VITALS = new Set([...])`
|
||||
/\b(UP_GOOD|UP_BAD)_[A-Z0-9_]+\s*=/,
|
||||
// `function vitalSentiment(type: string)`
|
||||
/function\s+\w*Sentiment\s*\(\s*(type|metric|kind)\b/,
|
||||
];
|
||||
const POLARITY_SAMPLES = [
|
||||
'const POLARITY = { RESTING_HEART_RATE: "up-bad" };',
|
||||
'new Map([["HEART_RATE_VARIABILITY", "up-good"]])',
|
||||
"const UP_BAD_VITALS = new Set(['RESTING_HEART_RATE']);",
|
||||
"function vitalSentiment(type: string): TrendDirectionSentiment {",
|
||||
];
|
||||
|
||||
function polarityTables(name: string, source: string): string[] {
|
||||
const hits: string[] = [];
|
||||
stripComments(source)
|
||||
.split("\n")
|
||||
.forEach((line, index) => {
|
||||
if (POLARITY_PATTERNS.some((p) => p.test(line))) {
|
||||
hits.push(`${name}:${index + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Rule 4 — the server's resolvers, imported into the browser. */
|
||||
const SERVER_RESOLVERS = [
|
||||
"@/lib/targets/trend-verdict",
|
||||
"@/lib/measurements/series-reference-lines",
|
||||
"@/lib/measurements/series-change",
|
||||
"@/lib/personal-records/record-benchmark",
|
||||
"@/lib/insights/mood-pattern-cards",
|
||||
];
|
||||
|
||||
function resolverImports(name: string, source: string): string[] {
|
||||
const file = parse(name, source);
|
||||
const hits: string[] = [];
|
||||
for (const statement of file.statements) {
|
||||
if (!ts.isImportDeclaration(statement)) continue;
|
||||
const from = (statement.moduleSpecifier as ts.StringLiteral).text;
|
||||
if (!SERVER_RESOLVERS.includes(from)) continue;
|
||||
if (statement.importClause?.isTypeOnly) continue;
|
||||
hits.push(`${name}:${lineOf(file, statement)}: import from "${from}"`);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
name: string;
|
||||
find: (name: string, source: string) => string[];
|
||||
/** Sources that must produce at least one hit, proving the matcher. */
|
||||
samples: Array<{ name: string; source: string }>;
|
||||
/** File → exact hit count it may have, with the reason. */
|
||||
allow: Record<string, { count: number; reason: string }>;
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
name: "a literal directionSentiment",
|
||||
find: literalDirections,
|
||||
samples: [
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: 'const a = <TrendCard directionSentiment="up-bad" />;',
|
||||
},
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: 'const a = <TrendCard directionSentiment={"up-good"} />;',
|
||||
},
|
||||
],
|
||||
allow: {
|
||||
"src/components/insights/derived/vitals-dashboard.tsx": {
|
||||
count: 6,
|
||||
reason:
|
||||
"the six derived re-frame tiles (same-time steps, six-minute walk, fitness age, vascular age, HRV balance, BMI) colour a figure whose good direction is its definition (a longer walk, a younger vascular age, no judgement on BMI), not a person's target; the per-vital baseline tile reads the server's `direction`",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a reference line at a literal",
|
||||
find: literalLines,
|
||||
samples: [
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: 'const a = <ReferenceLine y={140} stroke="red" />;',
|
||||
},
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: "const a = <ReferenceArea y1={70} y2={180} />;",
|
||||
},
|
||||
],
|
||||
allow: {
|
||||
"src/components/charts/medication-compliance-chart.tsx": {
|
||||
count: 2,
|
||||
reason:
|
||||
"the opt-in adherence overlay: an 80 % minimum and the 100 % goal are properties of a share of doses, the same for every person, not a clinical threshold the person's profile moves",
|
||||
},
|
||||
"src/components/insights/mood/mood-time-of-day-chart.tsx": {
|
||||
count: 1,
|
||||
reason:
|
||||
"the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person",
|
||||
},
|
||||
"src/components/insights/mood/mood-weekday-chart.tsx": {
|
||||
count: 1,
|
||||
reason:
|
||||
"the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a polarity table",
|
||||
find: polarityTables,
|
||||
samples: POLARITY_SAMPLES.map((source) => ({ name: "sample.ts", source })),
|
||||
allow: {},
|
||||
},
|
||||
{
|
||||
name: "a server resolver imported into the browser",
|
||||
find: resolverImports,
|
||||
samples: [
|
||||
{
|
||||
name: "sample.ts",
|
||||
source:
|
||||
'import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";',
|
||||
},
|
||||
],
|
||||
allow: {},
|
||||
},
|
||||
];
|
||||
|
||||
const FILES = clientFiles();
|
||||
|
||||
function hitsFor(rule: Rule): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const { rel, source } of FILES) {
|
||||
const hits = rule.find(rel, source);
|
||||
if (hits.length > 0) out.set(rel, hits);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("client threshold literal guard", () => {
|
||||
it("walks a client tree of the size it expects", () => {
|
||||
expect(FILES.length).toBeGreaterThan(700);
|
||||
});
|
||||
|
||||
for (const rule of RULES) {
|
||||
describe(rule.name, () => {
|
||||
it("still catches the shape it exists to catch", () => {
|
||||
for (const sample of rule.samples) {
|
||||
expect(
|
||||
rule.find(sample.name, sample.source).length,
|
||||
`no hit on: ${sample.source}`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("finds none outside its allowlist, and the allowlist exactly", () => {
|
||||
const hits = hitsFor(rule);
|
||||
const offenders = [...hits.entries()]
|
||||
.filter(([rel]) => !(rel in rule.allow))
|
||||
.flatMap(([, lines]) => lines);
|
||||
expect(
|
||||
offenders,
|
||||
"client code decides a threshold or a polarity itself. Read the " +
|
||||
"server's resolved field instead (`tiles.directions`, `direction`, " +
|
||||
"`referenceLines`), or allowlist the file with its reason.",
|
||||
).toEqual([]);
|
||||
for (const [rel, { count }] of Object.entries(rule.allow)) {
|
||||
expect(
|
||||
hits.get(rel)?.length ?? 0,
|
||||
`${rel} is allowlisted for exactly ${count} hits`,
|
||||
).toBe(count);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("reads a non-empty set of matches across the tree", () => {
|
||||
const total = RULES.reduce(
|
||||
(sum, rule) =>
|
||||
sum + [...hitsFor(rule).values()].reduce((n, l) => n + l.length, 0),
|
||||
0,
|
||||
);
|
||||
expect(total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,10 @@ const TS_READERS: Record<string, Entry> = {
|
||||
"lib/analytics/correlations-fast-path.ts": {
|
||||
exempt: "mood mean; pulse enters as the resting series, never a day mean",
|
||||
},
|
||||
"app/api/dashboard/summary/route.ts": {
|
||||
exempt:
|
||||
"places a card's seven-day sparkline mean against its band for the trend verdict; each sparkline point is already one rollup day value, so the mean weighs every day once",
|
||||
},
|
||||
"lib/dashboard/snapshot.ts": {
|
||||
exempt: "summarises water-intake days and glucose contexts",
|
||||
},
|
||||
|
||||
@@ -289,3 +289,134 @@ describe("resolved-field consumer guard", () => {
|
||||
expect(total).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 (ios #116 items 3, 4, 5, 6, 9; #77) — the fields the server resolves
|
||||
* for the native client reach the web as well, so the two clients render the
|
||||
* same answer. Each field names the web module that reads it, by a pattern
|
||||
* over that module's code. A field the web has no screen for names the web
|
||||
* twin it is resolved alongside (the same resolver, published where the web
|
||||
* reads it) and why; the twin's reader is checked the same way.
|
||||
*
|
||||
* The rebuild side of the same rule (a client deciding a polarity or drawing
|
||||
* a threshold of its own) is `client-threshold-literal-guard.test.ts`.
|
||||
*/
|
||||
interface WebReader {
|
||||
file: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
interface ResolvedFieldReader {
|
||||
field: string;
|
||||
readers: WebReader[];
|
||||
/** Set when the web reads a twin rather than this route's field. */
|
||||
twin?: string;
|
||||
}
|
||||
|
||||
const V143_FIELDS: ResolvedFieldReader[] = [
|
||||
{
|
||||
field: "GET /api/dashboard/snapshot tiles.directions",
|
||||
readers: [
|
||||
{ file: "src/app/page-client.tsx", pattern: /tiles\.directions/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
field:
|
||||
"GET /api/dashboard/summary DashboardMetricCard.direction / trendSentiment",
|
||||
twin: "the summary route is the native client's dashboard; the web dashboard reads the same `resolveTrendVerdict` answer from the snapshot's `tiles.directions`",
|
||||
readers: [{ file: "src/app/page-client.tsx", pattern: /directionOf\(/ }],
|
||||
},
|
||||
{
|
||||
field: "GET /api/insights/targets TargetItem.direction / trendSentiment",
|
||||
twin: "the web target panels show the band, not a change; the change they would colour is the dashboard tile's, read from `tiles.directions`",
|
||||
readers: [
|
||||
{ file: "src/app/page-client.tsx", pattern: /tileDirections\?\.\[key\]/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/insights/derived VITALS_BASELINE value.direction",
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/derived/vitals-dashboard.tsx",
|
||||
pattern: /v\.direction\?\.direction/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/measurements/series direction / change / referenceLines",
|
||||
twin: "the web charts read the series-batch and draw the person's bands from the snapshot's `targetBands`; the series read is the native chart source",
|
||||
readers: [{ file: "src/app/page-client.tsx", pattern: /targetBands/ }],
|
||||
},
|
||||
{
|
||||
field: "GET /api/personal-records benchmark",
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/personal-record-badge.tsx",
|
||||
pattern: /fresh\.benchmark/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/mood/insights patterns",
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/mood/mood-insights-breakdowns.tsx",
|
||||
pattern: /data\.patterns/,
|
||||
},
|
||||
{
|
||||
file: "src/components/insights/mood/mood-patterns.tsx",
|
||||
pattern: /pattern\.values\.subject/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "POST /api/cycle/period absorbed",
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/cycle/use-cycle.ts",
|
||||
pattern: /data\?\.absorbed/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "DELETE /api/cycle/day-logs/{id} (Prefer) and PATCH restored",
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/cycle/use-cycle.ts",
|
||||
pattern: /Prefer: "return=representation"/,
|
||||
},
|
||||
{
|
||||
file: "src/components/cycle/use-cycle.ts",
|
||||
pattern: /data\?\.restored/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/integrations/healthkit metricFreshness[].lastBackfillAt",
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/settings/integrations/apple-health-card.tsx",
|
||||
pattern: /entry\.lastBackfillAt/,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("v1.43 resolved fields reach the web", () => {
|
||||
for (const entry of V143_FIELDS) {
|
||||
it(`${entry.field} has a web reader${entry.twin ? " through its twin" : ""}`, () => {
|
||||
expect(entry.readers.length).toBeGreaterThan(0);
|
||||
if (entry.twin !== undefined)
|
||||
expect(entry.twin.length).toBeGreaterThan(40);
|
||||
for (const reader of entry.readers) {
|
||||
const code = stripComments(
|
||||
readFileSync(join(ROOT, reader.file), "utf8"),
|
||||
);
|
||||
expect(
|
||||
reader.pattern.test(code),
|
||||
`${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -87,8 +87,8 @@ const SERVER_FEATURES = {
|
||||
trackIntake: true,
|
||||
// v1.43 — each turns true in the change that makes its route or request
|
||||
// form work; until then a client must not send it (contract §0.6).
|
||||
cumulativeHourBuckets: false,
|
||||
syncTriggerBackfill: false,
|
||||
cumulativeHourBuckets: true,
|
||||
syncTriggerBackfill: true,
|
||||
biomarkerMerge: true,
|
||||
lifeCircumstances: false,
|
||||
} as const;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
|
||||
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
|
||||
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
|
||||
* With `Prefer: return=representation` (v1.43, RFC 7240) the answer is 200
|
||||
* with what the delete changed — the removed cycle and the starts it gave
|
||||
* back — so a client can say "the start on 17 Sep is back".
|
||||
*
|
||||
* The delete also hands the span back to the preceding cycle. Opening a cycle
|
||||
* closes the one before it, so removing it without re-deriving that boundary
|
||||
@@ -18,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
|
||||
|
||||
@@ -44,7 +48,7 @@ export const DELETE = apiHandler(
|
||||
}
|
||||
// Already a tombstone: the delete is done, and nothing is left to move.
|
||||
if (existing.deletedAt !== null) {
|
||||
return new Response(null, { status: 204 });
|
||||
return cycleDeleteOutcome(request, null, []);
|
||||
}
|
||||
|
||||
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
|
||||
@@ -99,6 +103,10 @@ export const DELETE = apiHandler(
|
||||
meta: { reanchored_prior: reanchored !== null },
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return cycleDeleteOutcome(
|
||||
request,
|
||||
removed?.cycleId ?? null,
|
||||
removed?.restored ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -3,7 +3,8 @@
|
||||
* (ios-contract §2.A).
|
||||
* `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F):
|
||||
* set `deletedAt` + bump `syncVersion`, emit a tombstone on the next
|
||||
* sync page. 204. Idempotent.
|
||||
* sync page. 204. Idempotent. With `Prefer: return=representation` 200
|
||||
* with the removed cycle and the starts given back (v1.43).
|
||||
*
|
||||
* Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by
|
||||
* another user 404s).
|
||||
@@ -34,6 +35,7 @@ import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
|
||||
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
|
||||
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
|
||||
import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -176,11 +178,13 @@ export const PATCH = apiHandler(
|
||||
row,
|
||||
);
|
||||
let removedCycleId: string | null = null;
|
||||
let restored: Array<{ cycleId: string; startDate: string }> = [];
|
||||
if (!openedCycleId && !stillBleeds(row)) {
|
||||
const removed = await prisma.$transaction((db) =>
|
||||
removeCycleStartedOn(db, user.id, existing.date),
|
||||
);
|
||||
removedCycleId = removed?.cycleId ?? null;
|
||||
restored = removed?.restored ?? [];
|
||||
}
|
||||
|
||||
await auditLog("cycle.day-log.update", {
|
||||
@@ -221,7 +225,9 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toCycleDayLogDTO(row));
|
||||
// v1.43 (ios #116 item 9) — the starts this edit gave back by clearing
|
||||
// the flow on a start day; empty when it removed none.
|
||||
return apiSuccess({ ...toCycleDayLogDTO(row), restored });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -303,7 +309,11 @@ export const DELETE = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return cycleDeleteOutcome(
|
||||
request,
|
||||
removed?.cycleId ?? null,
|
||||
removed?.restored ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"`
|
||||
* stamps the current cycle's `periodEndDate` and writes the boundary
|
||||
* day-log. Convenience over §2.A; returns
|
||||
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`.
|
||||
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO, absorbed }`, where
|
||||
* `absorbed` names a later start this one folded in (v1.43, null otherwise).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -221,12 +222,22 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
return apiSuccess({
|
||||
cycle: toMenstrualCycleDTO(cycleRow),
|
||||
dayLog: toCycleDayLogDTO(dayLogRow),
|
||||
// v1.43 (ios #116 item 9) — a later start inside the same period that
|
||||
// this start folded in, so the client can say which date stopped being a
|
||||
// start. Null when nothing was folded; present on every `start`.
|
||||
absorbed:
|
||||
txResult.moved.absorbedCycleId && txResult.moved.absorbedStartDate
|
||||
? {
|
||||
cycleId: txResult.moved.absorbedCycleId,
|
||||
startDate: txResult.moved.absorbedStartDate,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** `end` with no preceding cycle is a no-op the client should not hit. */
|
||||
function apiSuccessNoCycle(): Response {
|
||||
return apiSuccess({ cycle: null, dayLog: null });
|
||||
return apiSuccess({ cycle: null, dayLog: null, absorbed: null });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(bp?.allTimeCount).toBe(28);
|
||||
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
|
||||
expect(bp?.sparkline).toEqual([130, 132]);
|
||||
// v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the
|
||||
// sparkline's mean sits above it, so the rise is a setback.
|
||||
const judged = bp as unknown as {
|
||||
direction: unknown;
|
||||
trendSentiment: string;
|
||||
};
|
||||
expect(judged.direction).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "profile_default",
|
||||
});
|
||||
expect(judged.trendSentiment).toBe("negative");
|
||||
});
|
||||
|
||||
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
|
||||
|
||||
@@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOfTrend,
|
||||
type TrendSentiment,
|
||||
type TrendVerdict,
|
||||
type TrendVerdictContext,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -185,6 +192,69 @@ interface MetricCard {
|
||||
* at all.
|
||||
*/
|
||||
lastSeenAt: string | null;
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
|
||||
* the person, against their own target or a profile default
|
||||
* (`src/lib/targets/trend-verdict.ts`).
|
||||
*/
|
||||
direction?: TrendVerdict | null;
|
||||
/** v1.43 — `direction` applied to this card's `trend`. */
|
||||
trendSentiment?: TrendSentiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict key behind each card: the measurement type it reads, or the
|
||||
* derived key for the two cards that are not one.
|
||||
*/
|
||||
const VERDICT_KEY: Record<MetricKind, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
mood: "MOOD",
|
||||
bmi: "BMI",
|
||||
};
|
||||
|
||||
/**
|
||||
* Lay the verdict over each card. The reference placed against the band is
|
||||
* the mean of the card's own seven-day sparkline, else its latest value, the
|
||||
* seven-day average the web tiles place. Sleep cards are in hours and the
|
||||
* verdict reads minutes.
|
||||
*/
|
||||
function withVerdicts(
|
||||
cards: MetricCard[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): MetricCard[] {
|
||||
const referenceOf = (card: MetricCard | undefined): number | null => {
|
||||
if (!card) return null;
|
||||
const spark = card.sparkline.filter((v) => Number.isFinite(v));
|
||||
return spark.length > 0
|
||||
? spark.reduce((sum, v) => sum + v, 0) / spark.length
|
||||
: card.latestValue;
|
||||
};
|
||||
// BMI is judged by the weight it is derived from (the target is a weight).
|
||||
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
|
||||
return cards.map((card) => {
|
||||
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
|
||||
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
|
||||
...ctx,
|
||||
referenceValue:
|
||||
reference !== null && card.kind === "sleep"
|
||||
? reference * 60
|
||||
: reference,
|
||||
});
|
||||
return {
|
||||
...card,
|
||||
direction,
|
||||
trendSentiment: trendSentimentOfTrend(card.trend, direction),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -945,6 +1015,17 @@ async function buildDashboardSummary(
|
||||
|
||||
const t = getServerTranslator(ctx.locale).t;
|
||||
|
||||
// v1.43 — the record's profile and own targets, for the card verdicts.
|
||||
const verdictProfile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: timings });
|
||||
|
||||
return {
|
||||
@@ -962,7 +1043,14 @@ async function buildDashboardSummary(
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
metrics: withVerdicts(metrics, {
|
||||
profile: {
|
||||
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
|
||||
gender: verdictProfile?.gender ?? null,
|
||||
heightCm: verdictProfile?.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
|
||||
}),
|
||||
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
|
||||
// the Sleep page's `/api/sleep/rhythm` read returns.
|
||||
sleepRhythm,
|
||||
|
||||
@@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "WHO BMI",
|
||||
direction: {
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--info)",
|
||||
},
|
||||
source: "ESH 2023",
|
||||
direction: {
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
basis: "profile_default",
|
||||
},
|
||||
trendSentiment: "positive",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "ESH 2023",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--warning)",
|
||||
},
|
||||
source: "CDC/NCHS",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
basis: "profile_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
daysInRange7d: 0,
|
||||
daysLogged7d: 4,
|
||||
daysInRange30d: 0,
|
||||
@@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "AASM/SRS",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "WHO",
|
||||
direction: {
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--warning)",
|
||||
},
|
||||
source: "ACE",
|
||||
direction: {
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
basis: "profile_default",
|
||||
},
|
||||
trendSentiment: "positive",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fourDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
range: { min: 90, max: 100 },
|
||||
classification: null,
|
||||
source: "7-day",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
details: {
|
||||
medications: [
|
||||
{
|
||||
@@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "Mood entries",
|
||||
direction: {
|
||||
direction: "up-good",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fiveDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "Mood entries",
|
||||
direction: {
|
||||
direction: "neutral",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
...fiveDaysInRange,
|
||||
},
|
||||
{
|
||||
@@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
color: "var(--success)",
|
||||
},
|
||||
source: "ADA 2024 / DDG",
|
||||
direction: {
|
||||
direction: "neutral",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
},
|
||||
trendSentiment: "neutral",
|
||||
daysInRange7d: 3,
|
||||
daysLogged7d: 3,
|
||||
daysInRange30d: 3,
|
||||
|
||||
@@ -533,10 +533,72 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
lastTrigger: "foreground",
|
||||
lastNewSampleAt: null,
|
||||
lastBackgroundAt: backgroundAt.toISOString(),
|
||||
lastBackfillAt: null,
|
||||
});
|
||||
expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null });
|
||||
});
|
||||
|
||||
// v1.43 (ios #77) — a backfill's own ledger row reads as `lastBackfillAt`
|
||||
// of its type and moves none of the live facts.
|
||||
it("serves lastBackfillAt from the backfill row, apart from the live arrival", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
|
||||
"PULSE",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
mockProbes([
|
||||
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
|
||||
{ type: "ACTIVITY_STEPS", last_seen: ago(60 * 60 * 1000) },
|
||||
]);
|
||||
const receivedAt = ago(5 * 60 * 60 * 1000);
|
||||
const backfillAt = ago(60 * 1000);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
lastReceivedAt: receivedAt,
|
||||
lastTrigger: "foreground",
|
||||
lastNewSampleAt: receivedAt,
|
||||
lastBackgroundAt: null,
|
||||
},
|
||||
{
|
||||
type: "backfill:PULSE",
|
||||
lastReceivedAt: backfillAt,
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
lastBackgroundAt: null,
|
||||
},
|
||||
{
|
||||
type: "backfill:ACTIVITY_STEPS",
|
||||
lastReceivedAt: backfillAt,
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
lastBackgroundAt: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const body = await read();
|
||||
const byType = Object.fromEntries(
|
||||
body.metricFreshness.map((entry) => [entry.type, entry]),
|
||||
);
|
||||
expect(byType.PULSE).toMatchObject({
|
||||
lastReceivedAt: receivedAt.toISOString(),
|
||||
lastTrigger: "foreground",
|
||||
lastBackfillAt: backfillAt.toISOString(),
|
||||
});
|
||||
// A type that only ever arrived through a backfill has no live arrival.
|
||||
expect(byType.ACTIVITY_STEPS).toMatchObject({
|
||||
lastReceivedAt: null,
|
||||
lastTrigger: null,
|
||||
lastBackfillAt: backfillAt.toISOString(),
|
||||
});
|
||||
expect(body.metricFreshness.map((e) => e.type)).not.toContain(
|
||||
"backfill:PULSE",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the dose entry out when there is no Apple Health dose", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
|
||||
@@ -73,10 +73,19 @@ import {
|
||||
findFoldedWindowDuplicates,
|
||||
} from "@/lib/measurements/folded-window";
|
||||
import {
|
||||
HEALTHKIT_SYNC_TRIGGERS,
|
||||
BACKFILL_SYNC_TRIGGER,
|
||||
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
|
||||
recordHealthKitTypeArrivals,
|
||||
type HealthKitTypeArrival,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import {
|
||||
classifyHourBucket,
|
||||
HOUR_BUCKET_SKIP_REASONS,
|
||||
parseHourBucketId,
|
||||
writeHourBuckets,
|
||||
type HourBucketWrite,
|
||||
} from "@/lib/measurements/intraday-hour-buckets";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import {
|
||||
classifyExternalId,
|
||||
@@ -199,7 +208,9 @@ const batchEntrySchema = z.object({
|
||||
// pre-#66 caller stays byte-for-byte unchanged.
|
||||
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
|
||||
// the app simply being open (`foreground`).
|
||||
const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS);
|
||||
// v1.43 (ios #77) — `backfill` names a history catch-up. It moves only the
|
||||
// per-type `lastBackfillAt`, never the live sync stamps (see below).
|
||||
const syncTriggerEnum = z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS);
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
entries: z.array(batchEntrySchema).min(1),
|
||||
@@ -380,6 +391,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const prepared: Prepared[] = [];
|
||||
const results: EntryResult[] = new Array(entries.length);
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
// v1.43 — hourly sums for the intraday profile, and the zone they are cut in
|
||||
// (the account's, the one `/api/auth/me` publishes as `timezone`).
|
||||
const hourWrites: HourBucketWrite[] = [];
|
||||
let accountTz: string | undefined;
|
||||
const batchNow = new Date();
|
||||
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const entry = entries[index];
|
||||
@@ -402,6 +418,75 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.43 (ios #77) — an hourly sum of a summed type
|
||||
// (`stats:<HK>:<YYYY-MM-DD>T<HH>`) is never a measurement: it lands in the
|
||||
// intraday profile behind the same-time baseline, and the day's total
|
||||
// stays the day row. Validated here, written after the measurements.
|
||||
const hourId = parseHourBucketId(entry.externalId);
|
||||
if (hourId) {
|
||||
const skip = (reason: string) => {
|
||||
results[index] = { index, status: "skipped", reason };
|
||||
};
|
||||
// The profile has no source dimension and is the phone's: a bridge or a
|
||||
// hand-entered row cannot post hours into it.
|
||||
if (scoped || (entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") {
|
||||
skip(HOUR_BUCKET_SKIP_REASONS.unsupportedType);
|
||||
continue;
|
||||
}
|
||||
accountTz ??= await resolveStoredTimezone(user.timezone);
|
||||
const startDate = new Date(entry.startDate);
|
||||
const verdict = classifyHourBucket(
|
||||
hourId,
|
||||
startDate,
|
||||
accountTz,
|
||||
batchNow,
|
||||
);
|
||||
if (!verdict.ok) {
|
||||
skip(verdict.reason);
|
||||
continue;
|
||||
}
|
||||
if (!isPlausibleEntryInstant(startDate)) {
|
||||
skip("implausible_timestamp");
|
||||
continue;
|
||||
}
|
||||
const hourMapped = mapAppleHealthEntry({
|
||||
hkIdentifier: entry.hkIdentifier,
|
||||
value: entry.value,
|
||||
unit: entry.unit,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
});
|
||||
if (!hourMapped || hourMapped.type !== verdict.type) {
|
||||
// The entry's identifier must be the one its id names.
|
||||
skip(
|
||||
hourMapped
|
||||
? HOUR_BUCKET_SKIP_REASONS.unsupportedType
|
||||
: "unmappable_identifier",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
hourMapped.value < 0 ||
|
||||
validateMeasurementRange(hourMapped.type, hourMapped.value) !== null
|
||||
) {
|
||||
results[index] = outOfRangeResult(
|
||||
index,
|
||||
hourMapped.type,
|
||||
hourMapped.value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
hourWrites.push({
|
||||
index,
|
||||
type: verdict.type,
|
||||
hkIdentifier: hourId.hkIdentifier,
|
||||
dateKey: hourId.dateKey,
|
||||
hour: hourId.hour,
|
||||
value: hourMapped.value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapAppleHealthEntry({
|
||||
hkIdentifier: entry.hkIdentifier,
|
||||
value: entry.value,
|
||||
@@ -898,15 +983,79 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the hours, after the measurements. The last entry for one slot
|
||||
// wins, as for the day rows; an earlier one in the same batch is a
|
||||
// superseded duplicate.
|
||||
let hourInsertedCount = 0;
|
||||
let hourUpdatedCount = 0;
|
||||
const hourLanded: HourBucketWrite[] = [];
|
||||
if (hourWrites.length > 0 && accountTz) {
|
||||
const lastBySlot = new Map<string, HourBucketWrite>();
|
||||
for (const w of hourWrites) {
|
||||
lastBySlot.set(`${w.type}::${w.dateKey}::${w.hour}`, w);
|
||||
}
|
||||
for (const w of hourWrites) {
|
||||
if (lastBySlot.get(`${w.type}::${w.dateKey}::${w.hour}`) !== w) {
|
||||
results[w.index] = {
|
||||
index: w.index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount++;
|
||||
}
|
||||
}
|
||||
const toWrite = [...lastBySlot.values()];
|
||||
try {
|
||||
const statuses = await writeHourBuckets(
|
||||
prisma,
|
||||
user.id,
|
||||
accountTz,
|
||||
toWrite,
|
||||
);
|
||||
for (const w of toWrite) {
|
||||
const status = statuses.get(w.index) ?? "inserted";
|
||||
results[w.index] = { index: w.index, status };
|
||||
if (status === "inserted") hourInsertedCount++;
|
||||
else hourUpdatedCount++;
|
||||
hourLanded.push(w);
|
||||
}
|
||||
} catch (err) {
|
||||
for (const w of toWrite) {
|
||||
results[w.index] = {
|
||||
index: w.index,
|
||||
status: "failed",
|
||||
reason: "write_failed",
|
||||
};
|
||||
failedCount++;
|
||||
}
|
||||
annotate({
|
||||
action: { name: "measurement.batch.hour-bucket.failed" },
|
||||
meta: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "measurement.batch.hour-bucket" },
|
||||
meta: {
|
||||
attempted: hourWrites.length,
|
||||
inserted: hourInsertedCount,
|
||||
updated: hourUpdatedCount,
|
||||
processed: entries.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const healthKitSyncSucceeded =
|
||||
failedCount === 0 &&
|
||||
prepared.some((p) => {
|
||||
if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false;
|
||||
const status = results[p.index]?.status;
|
||||
return (
|
||||
status === "inserted" || status === "updated" || status === "duplicate"
|
||||
);
|
||||
});
|
||||
(hourLanded.length > 0 ||
|
||||
prepared.some((p) => {
|
||||
if ((p.entry.source ?? "APPLE_HEALTH") !== "APPLE_HEALTH") return false;
|
||||
const status = results[p.index]?.status;
|
||||
return (
|
||||
status === "inserted" ||
|
||||
status === "updated" ||
|
||||
status === "duplicate"
|
||||
);
|
||||
}));
|
||||
|
||||
// This batch route is the native ingestion boundary, so it is also where the
|
||||
// SyncMode checkpoint (`User.lastSyncedAt`, the same column
|
||||
@@ -938,6 +1087,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// uses to size its undelivered window, so letting a scale advance it can make
|
||||
// the phone skip a window of its own samples — a silent data loss whose cause
|
||||
// would be nowhere near the code that caused it.
|
||||
//
|
||||
// v1.43 (ios #77) — a `backfill` batch is a catch-up, not a sync. It records
|
||||
// its types under `lastBackfillAt` only and moves none of the live stamps:
|
||||
// thirty days of history must not read as thirty healthy daily syncs.
|
||||
const backfill = syncTrigger === BACKFILL_SYNC_TRIGGER;
|
||||
if (failedCount === 0 && !scoped) {
|
||||
const syncedAt = new Date();
|
||||
const deliveredWithoutTheApp =
|
||||
@@ -962,6 +1116,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
if (status !== "duplicate") entry.newSamples += 1;
|
||||
arrivals.set(type, entry);
|
||||
}
|
||||
// The hours the phone posted are an arrival of their type as well.
|
||||
for (const w of hourLanded) {
|
||||
const entry = arrivals.get(w.type) ?? { accepted: 0, newSamples: 0 };
|
||||
entry.accepted += 1;
|
||||
entry.newSamples += 1;
|
||||
arrivals.set(w.type, entry);
|
||||
}
|
||||
if (arrivals.size > 0) {
|
||||
await recordHealthKitTypeArrivals(prisma, {
|
||||
userId: user.id,
|
||||
@@ -970,21 +1131,23 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
at: syncedAt,
|
||||
});
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
lastSyncedAt: syncedAt,
|
||||
...(healthKitSyncSucceeded
|
||||
? {
|
||||
healthKitLastSyncedAt: syncedAt,
|
||||
healthKitLastSyncTrigger: syncTrigger ?? null,
|
||||
...(deliveredWithoutTheApp
|
||||
? { healthKitLastBackgroundSyncAt: syncedAt }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (!backfill) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
lastSyncedAt: syncedAt,
|
||||
...(healthKitSyncSucceeded
|
||||
? {
|
||||
healthKitLastSyncedAt: syncedAt,
|
||||
healthKitLastSyncTrigger: syncTrigger ?? null,
|
||||
...(deliveredWithoutTheApp
|
||||
? { healthKitLastBackgroundSyncAt: syncedAt }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const freshlyInsertedRows = insertedPrepared.map((p) => ({
|
||||
@@ -1256,10 +1419,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.43 — the hours are not measurements, so they touched none of the
|
||||
// measurement consumers above; only the caches that read the intraday
|
||||
// profile (the same-time baseline) are marked stale.
|
||||
if (
|
||||
hourInsertedCount + hourUpdatedCount > 0 &&
|
||||
insertedCount === 0 &&
|
||||
updatedCount === 0
|
||||
) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
inserted: insertedCount + hourInsertedCount,
|
||||
updated: updatedCount + hourUpdatedCount,
|
||||
duplicates: duplicateCount,
|
||||
failed: failedCount,
|
||||
skipped,
|
||||
|
||||
@@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => {
|
||||
function bpDays(values: Array<[string, number]>) {
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { type: string } };
|
||||
if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]);
|
||||
return Promise.resolve(
|
||||
values.map(([day, value], i) => ({
|
||||
id: `s${i}`,
|
||||
value,
|
||||
measuredAt: new Date(`${day}T08:00:00.000Z`),
|
||||
})),
|
||||
);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
it("judges the window's change against the person's own target and draws its lines", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
} as never);
|
||||
bpDays([
|
||||
["2026-05-01", 128],
|
||||
["2026-05-02", 130],
|
||||
["2026-05-03", 134],
|
||||
]);
|
||||
const res = await GET(req("kind=bloodPressure&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
direction: unknown;
|
||||
change: unknown;
|
||||
referenceLines: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
expect(body.data.direction).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "personal_target",
|
||||
});
|
||||
expect(body.data.change).toEqual({
|
||||
from: 128,
|
||||
to: 134,
|
||||
delta: 6,
|
||||
sentiment: "negative",
|
||||
});
|
||||
expect(
|
||||
body.data.referenceLines.map((l) => [
|
||||
l.appliesTo,
|
||||
l.kind,
|
||||
l.value,
|
||||
l.source,
|
||||
]),
|
||||
).toEqual([
|
||||
["value", "lower", 115, "personal"],
|
||||
["value", "upper", 125, "personal"],
|
||||
["secondary", "upper", 90, "clinical"],
|
||||
]);
|
||||
expect(body.data.referenceLines[0].label).toBe("Your target, lower limit");
|
||||
});
|
||||
|
||||
it("calls the same rise progress for someone below their target", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
} as never);
|
||||
bpDays([
|
||||
["2026-05-01", 100],
|
||||
["2026-05-03", 106],
|
||||
]);
|
||||
const res = await GET(req("kind=bloodPressure&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: { change: { sentiment: string } };
|
||||
};
|
||||
expect(body.data.change.sentiment).toBe("positive");
|
||||
});
|
||||
|
||||
it("has no change under two days and the clinical glucose lines in mmol/L", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
locale: "de",
|
||||
thresholdsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
change: unknown;
|
||||
direction: { direction: string };
|
||||
referenceLines: Array<{ value: number; label: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.change).toBeNull();
|
||||
expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]);
|
||||
expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
|
||||
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
*
|
||||
* The `kind=bloodPressure` case pairs systolic + diastolic by
|
||||
* `measuredAt` so iOS can render the dual-line chart with one fetch.
|
||||
*
|
||||
* v1.43 (ios #116 items 3 and 4) — the answer also carries what a client
|
||||
* would otherwise decide itself: `direction` (which way a change of this
|
||||
* metric counts for this person), `change` (first and last day value of the
|
||||
* window, judged by that direction) and `referenceLines` (the person's own
|
||||
* target lines, else the clinical ones, in the unit of the points).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
computeSeriesChange,
|
||||
seriesDayValues,
|
||||
seriesReferenceValue,
|
||||
} from "@/lib/measurements/series-change";
|
||||
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
|
||||
/**
|
||||
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
|
||||
@@ -199,7 +214,7 @@ function stdDev(values: number[]): number {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { user, actor } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = querySchema.safeParse(rawQuery);
|
||||
@@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
|
||||
const values = dataPoints.map((p) => p.value);
|
||||
|
||||
// v1.43 — the resolved judgements. The profile and targets are the
|
||||
// RECORD's (the chart is theirs); the labels are in the language of the
|
||||
// account reading it.
|
||||
const [record, reader, seriesTz] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
glucoseUnit: true,
|
||||
locale: true,
|
||||
},
|
||||
}),
|
||||
actor.id === user.id
|
||||
? null
|
||||
: prisma.user.findUnique({
|
||||
where: { id: actor.id },
|
||||
select: { locale: true },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
const rangeProfile = {
|
||||
dateOfBirth: record?.dateOfBirth ?? null,
|
||||
gender: record?.gender ?? null,
|
||||
heightCm: record?.heightCm ?? null,
|
||||
};
|
||||
const verdictType = KIND_TO_TYPE[kind];
|
||||
const dayValues = seriesDayValues(verdictType, points, seriesTz);
|
||||
const reference = seriesReferenceValue(dayValues);
|
||||
const direction = resolveTrendVerdict(verdictType, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
// The verdict reads stored units: sleep is served in hours here and
|
||||
// stored in minutes. Glucose has no band of its own on this chart.
|
||||
referenceValue:
|
||||
reference !== null && kind === "sleep" ? reference * 60 : reference,
|
||||
});
|
||||
const translator = getServerTranslator(
|
||||
await resolveJobLocale((reader ?? record)?.locale ?? null),
|
||||
);
|
||||
const referenceLines = resolveSeriesReferenceLines(kind, {
|
||||
profile: rangeProfile,
|
||||
thresholdsJson: record?.thresholdsJson ?? null,
|
||||
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
|
||||
t: (key) => translator.t(key),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurements.series" },
|
||||
meta: { kind, days, count: points.length },
|
||||
@@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
kind,
|
||||
unit,
|
||||
points,
|
||||
direction,
|
||||
change: computeSeriesChange(dayValues, direction),
|
||||
referenceLines,
|
||||
stats:
|
||||
statsOverride ??
|
||||
(summary
|
||||
|
||||
@@ -80,7 +80,9 @@ const USER = {
|
||||
};
|
||||
|
||||
/** A replayed Apple dose: the cheapest batch that still counts as an arrival. */
|
||||
async function postReplayedDose(): Promise<Response> {
|
||||
async function postReplayedDose(
|
||||
syncTrigger: string = "background",
|
||||
): Promise<Response> {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{ id: "row-existing", externalId: "hk-dose-1" },
|
||||
] as never);
|
||||
@@ -89,7 +91,7 @@ async function postReplayedDose(): Promise<Response> {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
syncTrigger: "background",
|
||||
syncTrigger,
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-apple",
|
||||
@@ -134,4 +136,33 @@ describe("the dose arrival record and the credential that sent the batch", () =>
|
||||
expect(res.status).toBe(200);
|
||||
expect(recordHealthKitTypeArrivals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// v1.43 (ios #77) — a history catch-up records the dose arrival under
|
||||
// `lastBackfillAt` only, and does not move the sync checkpoint.
|
||||
it("passes a backfill through and leaves the checkpoint where it was", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
session: { id: "s" },
|
||||
user: USER,
|
||||
} as never);
|
||||
const res = await postReplayedDose("backfill");
|
||||
expect(res.status).toBe(200);
|
||||
expect(recordHealthKitTypeArrivals).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ trigger: "backfill" }),
|
||||
);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves the checkpoint for a live sync", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
session: { id: "s" },
|
||||
user: USER,
|
||||
} as never);
|
||||
await postReplayedDose("background");
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ lastSyncedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,8 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
HEALTHKIT_SYNC_TRIGGERS,
|
||||
BACKFILL_SYNC_TRIGGER,
|
||||
HEALTHKIT_REQUEST_SYNC_TRIGGERS,
|
||||
MEDICATION_DOSE_ARRIVAL_TYPE,
|
||||
recordHealthKitTypeArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
@@ -176,7 +177,9 @@ const bulkPayloadSchema = z.object({
|
||||
// v1.42.4 — what woke the phone for this sync, the same diagnostic-only
|
||||
// field `POST /api/measurements/batch` takes. It changes no row; it only
|
||||
// feeds the `MEDICATION_DOSE` arrival record below.
|
||||
syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(),
|
||||
// v1.43 (ios #77) — `backfill` for a history catch-up: it moves only the
|
||||
// dose entry's `lastBackfillAt`, never the live stamps or the checkpoint.
|
||||
syncTrigger: z.enum(HEALTHKIT_REQUEST_SYNC_TRIGGERS).optional(),
|
||||
});
|
||||
|
||||
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
|
||||
@@ -1038,8 +1041,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// The SyncMode checkpoint belongs to the push that moved the data, not to
|
||||
// the `/api/sync/state` handshake that reports it. This endpoint is one of
|
||||
// the three domains that handshake summarises, so an intake drain advances
|
||||
// it.
|
||||
await markSyncCheckpoint(user.id);
|
||||
// it. A `backfill` catch-up does not: it is history, not a sync.
|
||||
if (syncTrigger !== BACKFILL_SYNC_TRIGGER) {
|
||||
await markSyncCheckpoint(user.id);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
|
||||
@@ -41,6 +41,22 @@ const AGGREGATE = {
|
||||
tags: [],
|
||||
stability: year,
|
||||
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
|
||||
patternsByWindow: {
|
||||
30: [],
|
||||
90: [],
|
||||
180: [],
|
||||
365: [
|
||||
{
|
||||
id: "weekend",
|
||||
kind: "weekend",
|
||||
strength: "strong",
|
||||
direction: "higher",
|
||||
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
|
||||
sampleSize: { subject: 100, comparison: 250 },
|
||||
qValue: 0.001,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const call = (query = "") =>
|
||||
@@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => {
|
||||
expect(body.data.stabilityWindowDays).toBe(90);
|
||||
});
|
||||
|
||||
it("serves the patterns of the same window, without the cache's internals", async () => {
|
||||
const year = await (await call()).json();
|
||||
expect(year.data.patterns).toEqual([
|
||||
{
|
||||
id: "weekend",
|
||||
kind: "weekend",
|
||||
strength: "strong",
|
||||
direction: "higher",
|
||||
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
|
||||
sampleSize: { subject: 100, comparison: 250 },
|
||||
},
|
||||
]);
|
||||
expect(year.data).not.toHaveProperty("patternsByWindow");
|
||||
const month = await (await call("?days=30")).json();
|
||||
expect(month.data.patterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a window it does not offer", async () => {
|
||||
const res = await call("?days=45");
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type MoodStabilityWindow,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,7 +35,8 @@ export const dynamic = "force-dynamic";
|
||||
*/
|
||||
/**
|
||||
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
|
||||
* the steadiness of the period it shows. Without it the score covers the
|
||||
* the steadiness of the period it shows. Since v1.43 it cuts `patterns` the
|
||||
* same way. Without it the score covers the
|
||||
* whole year, as it always has; `stabilityWindowDays` names the window the
|
||||
* served score covers either way.
|
||||
*/
|
||||
@@ -75,5 +78,10 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(moodInsightsWire(result, stabilityWindowDays));
|
||||
// v1.43 — `patterns` follows the same `?days` window, and a catalogue
|
||||
// tag in a pattern is named in the reader's language.
|
||||
const translator = getServerTranslator(await resolveJobLocale(user.locale));
|
||||
return apiSuccess(
|
||||
moodInsightsWire(result, stabilityWindowDays, (key) => translator.t(key)),
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ vi.mock("@/lib/db", () => ({
|
||||
personalRecord: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -186,4 +188,77 @@ describe("GET /api/personal-records", () => {
|
||||
expect(body.data[0].value).toBe(47.2);
|
||||
expect(body.data[0].metricType).toBe("VO2_MAX");
|
||||
});
|
||||
|
||||
// v1.43 (ios #116 item 5) — every stored field stays, and the band for the
|
||||
// age the record was set at rides beside it.
|
||||
it("projects every stored field and adds the benchmark", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const createdAt = new Date("2026-05-02T00:00:00.000Z");
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "pr-1",
|
||||
userId: "user-1",
|
||||
metricType: "VO2_MAX",
|
||||
metricSlot: null,
|
||||
direction: "MAX",
|
||||
value: 47.2,
|
||||
unit: "mL/(kg·min)",
|
||||
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
|
||||
sourceMeasurementId: "m-1",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "x-1",
|
||||
createdAt,
|
||||
},
|
||||
{
|
||||
id: "pr-2",
|
||||
userId: "user-1",
|
||||
metricType: "ACTIVITY_STEPS",
|
||||
metricSlot: null,
|
||||
direction: "MAX",
|
||||
value: 21000,
|
||||
unit: "steps",
|
||||
achievedAt: new Date("2026-04-01T07:00:00.000Z"),
|
||||
sourceMeasurementId: null,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
createdAt,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1981-03-10T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
} as never);
|
||||
const res = await GET(req("http://localhost/api/personal-records"));
|
||||
const body = (await res.json()) as { data: Array<Record<string, unknown>> };
|
||||
expect(Object.keys(body.data[0]).sort()).toEqual(
|
||||
[
|
||||
"achievedAt",
|
||||
"benchmark",
|
||||
"createdAt",
|
||||
"direction",
|
||||
"externalId",
|
||||
"id",
|
||||
"metricSlot",
|
||||
"metricType",
|
||||
"source",
|
||||
"sourceMeasurementId",
|
||||
"unit",
|
||||
"userId",
|
||||
"value",
|
||||
].sort(),
|
||||
);
|
||||
// 45 when the record was set: between the 40s and 50s male FRIEND bands.
|
||||
expect(body.data[0].benchmark).toMatchObject({
|
||||
unit: "mL/(kg·min)",
|
||||
source: "friend_2015",
|
||||
});
|
||||
const band = body.data[0].benchmark as { low: number; high: number };
|
||||
expect(band.low).toBeGreaterThan(31);
|
||||
expect(band.high).toBeLessThan(49);
|
||||
// A step record has no cited norm.
|
||||
expect(body.data[1].benchmark).toBeNull();
|
||||
// No six-minute walk on the page, so no weight read.
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@
|
||||
*
|
||||
* Response envelope (matches the project-wide `apiSuccess` contract):
|
||||
* { data: PersonalRecord[], error: null }
|
||||
*
|
||||
* v1.43 (ios #116 item 5) — the route projects each row instead of passing the
|
||||
* Prisma result through. Every stored field stays on the wire, and each row
|
||||
* gains `benchmark`: the cited age- and sex-aware band for the age the record
|
||||
* was set at (`resolveRecordBenchmark`), or null.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -28,6 +33,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { listPersonalRecordsSchema } from "@/lib/validations/measurement";
|
||||
import {
|
||||
mayCarryBenchmark,
|
||||
resolveRecordBenchmark,
|
||||
} from "@/lib/personal-records/record-benchmark";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -61,5 +71,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return apiSuccess(records);
|
||||
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
|
||||
const profile = wantsBand
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dateOfBirth: true, gender: true, heightCm: true },
|
||||
})
|
||||
: null;
|
||||
// Only the six-minute-walk equation needs a weight.
|
||||
const weight =
|
||||
profile && records.some((r) => r.metricType === "SIX_MINUTE_WALK_DISTANCE")
|
||||
? await prisma.measurement.findFirst({
|
||||
where: { userId: user.id, type: "WEIGHT", deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true },
|
||||
})
|
||||
: null;
|
||||
const benchmarkProfile = {
|
||||
dateOfBirth: profile?.dateOfBirth ?? null,
|
||||
sex: toProfileSex(profile?.gender) ?? undefined,
|
||||
heightCm: profile?.heightCm ?? null,
|
||||
weightKg: weight?.value ?? null,
|
||||
};
|
||||
|
||||
return apiSuccess(
|
||||
records.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
metricType: r.metricType,
|
||||
metricSlot: r.metricSlot,
|
||||
direction: r.direction,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
achievedAt: r.achievedAt,
|
||||
sourceMeasurementId: r.sourceMeasurementId,
|
||||
source: r.source,
|
||||
externalId: r.externalId,
|
||||
createdAt: r.createdAt,
|
||||
benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import {
|
||||
@@ -91,10 +93,17 @@ export default async function InsightsMoodPage() {
|
||||
),
|
||||
]);
|
||||
const queryClient = new QueryClient();
|
||||
const translator = getServerTranslator(
|
||||
await resolveJobLocale(user.locale),
|
||||
);
|
||||
if (aggregates) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.moodInsights(),
|
||||
JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))),
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
moodInsightsWire(aggregates, 365, (key) => translator.t(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (series) {
|
||||
|
||||
+29
-16
@@ -169,6 +169,7 @@ import {
|
||||
resolveWeightTrend,
|
||||
weightTrendReferenceKg,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -785,6 +786,15 @@ export default function DashboardPageClient({
|
||||
clientBandProfile.weightTargetOverride,
|
||||
weightTrendReferenceKg(w),
|
||||
);
|
||||
// v1.43 — every other tile's direction comes resolved from the server
|
||||
// (`tiles.directions`, against the record's own targets and profile). The
|
||||
// snapshot-disabled path has no resolved answer and renders neutral rather
|
||||
// than guessing a polarity.
|
||||
const tileDirections = snapshotEnabled
|
||||
? snapshotQuery.data?.tiles.directions
|
||||
: undefined;
|
||||
const directionOf = (key: string): TrendDirectionSentiment =>
|
||||
tileDirections?.[key]?.direction ?? "neutral";
|
||||
const bpTargets = bands.bpTargets;
|
||||
const weightRange = bands.weightRange;
|
||||
// The range hint tooltip prints its band bounds in the display unit, so scale
|
||||
@@ -1093,7 +1103,7 @@ export default function DashboardPageClient({
|
||||
slope30={sys?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(sys)}
|
||||
icon={Heart}
|
||||
directionSentiment="up-bad"
|
||||
directionSentiment={directionOf("BLOOD_PRESSURE_SYS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(sys)}
|
||||
staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")}
|
||||
@@ -1135,7 +1145,7 @@ export default function DashboardPageClient({
|
||||
slope30={dia?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(dia)}
|
||||
icon={Heart}
|
||||
directionSentiment="up-bad"
|
||||
directionSentiment={directionOf("BLOOD_PRESSURE_DIA")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(dia)}
|
||||
staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")}
|
||||
@@ -1177,6 +1187,9 @@ export default function DashboardPageClient({
|
||||
slope30={pulseTileSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(pulseTileSummary)}
|
||||
icon={TrendingUp}
|
||||
directionSentiment={directionOf(
|
||||
hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
|
||||
)}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(pulseTileSummary)}
|
||||
staleDays={tileStaleDays(
|
||||
@@ -1201,7 +1214,7 @@ export default function DashboardPageClient({
|
||||
slope30={bf?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(bf)}
|
||||
icon={Percent}
|
||||
directionSentiment="up-bad"
|
||||
directionSentiment={directionOf("BODY_FAT")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(bf)}
|
||||
staleDays={tileStaleDays("BODY_FAT")}
|
||||
@@ -1224,7 +1237,7 @@ export default function DashboardPageClient({
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(moodSummary)}
|
||||
icon={Smile}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("MOOD")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(moodSummary)}
|
||||
/>
|
||||
@@ -1246,7 +1259,7 @@ export default function DashboardPageClient({
|
||||
slope30={sleepSummaryHours?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(sleepSummaryHours)}
|
||||
icon={Moon}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("SLEEP_DURATION")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(sleepSummaryHours)}
|
||||
staleDays={tileStaleDays("SLEEP_DURATION")}
|
||||
@@ -1282,7 +1295,7 @@ export default function DashboardPageClient({
|
||||
slope30={stepsSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(stepsSummary)}
|
||||
icon={Footprints}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("ACTIVITY_STEPS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(stepsSummary)}
|
||||
staleDays={tileStaleDays("ACTIVITY_STEPS")}
|
||||
@@ -1312,7 +1325,7 @@ export default function DashboardPageClient({
|
||||
slope30={vo2Summary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(vo2Summary)}
|
||||
icon={Gauge}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("VO2_MAX")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(vo2Summary)}
|
||||
staleDays={tileStaleDays("VO2_MAX")}
|
||||
@@ -1354,7 +1367,7 @@ export default function DashboardPageClient({
|
||||
slope30={hrvSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(hrvSummary)}
|
||||
icon={HeartPulse}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf(hrvType)}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(hrvSummary)}
|
||||
// The type that actually backs the tile — reading freshness
|
||||
@@ -1380,7 +1393,7 @@ export default function DashboardPageClient({
|
||||
slope30={spo2Summary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(spo2Summary)}
|
||||
icon={Droplets}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("OXYGEN_SATURATION")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(spo2Summary)}
|
||||
staleDays={tileStaleDays("OXYGEN_SATURATION")}
|
||||
@@ -1403,7 +1416,7 @@ export default function DashboardPageClient({
|
||||
slope30={respRateSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(respRateSummary)}
|
||||
icon={Wind}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("RESPIRATORY_RATE")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(respRateSummary)}
|
||||
staleDays={tileStaleDays("RESPIRATORY_RATE")}
|
||||
@@ -1429,7 +1442,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(wristTempSummary),
|
||||
)}
|
||||
icon={Thermometer}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("WRIST_TEMPERATURE")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"WRIST_TEMPERATURE",
|
||||
@@ -1461,7 +1474,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(muscleMassSummary),
|
||||
)}
|
||||
icon={Dumbbell}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("MUSCLE_MASS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"MUSCLE_MASS",
|
||||
@@ -1490,7 +1503,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(bodyWaterSummary),
|
||||
)}
|
||||
icon={Droplet}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("TOTAL_BODY_WATER")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"TOTAL_BODY_WATER",
|
||||
@@ -1519,7 +1532,7 @@ export default function DashboardPageClient({
|
||||
summaryToTrend7Delta(boneMassSummary),
|
||||
)}
|
||||
icon={Bone}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("BONE_MASS")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tdd(
|
||||
"BONE_MASS",
|
||||
@@ -1548,7 +1561,7 @@ export default function DashboardPageClient({
|
||||
slope30={waterIntakeSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
|
||||
icon={GlassWater}
|
||||
directionSentiment="neutral"
|
||||
directionSentiment={directionOf("NUTRIENT_WATER")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(waterIntakeSummary)}
|
||||
staleDays={tileStaleDays("NUTRIENT_WATER")}
|
||||
@@ -1639,7 +1652,7 @@ export default function DashboardPageClient({
|
||||
slope30={null}
|
||||
trend7Delta={bpSufficient ? bpTrendDelta : null}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
directionSentiment={directionOf("BLOOD_PRESSURE_IN_TARGET")}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={bpSufficient ? bpCompareDelta : null}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
@@ -318,10 +319,33 @@ export interface CycleDayLogPatch {
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A period start the server folded in or gave back (v1.43). The server names
|
||||
* it; the toast only says it, so the person learns which date moved without
|
||||
* comparing the calendar before and after.
|
||||
*/
|
||||
export interface CycleStartRef {
|
||||
cycleId: string;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
/** The toast after a write that may have given starts back. */
|
||||
function useRestoredStartsMessage() {
|
||||
const { t } = useTranslations();
|
||||
const formatDay = useCalendarDate();
|
||||
return (fallback: string, restored: readonly CycleStartRef[] | undefined) =>
|
||||
restored && restored.length > 0
|
||||
? t("cycle.startRestored", {
|
||||
dates: restored.map((r) => formatDay(r.startDate)).join(", "),
|
||||
})
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
|
||||
export function usePatchDayLog() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const restoredMessage = useRestoredStartsMessage();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
@@ -330,11 +354,17 @@ export function usePatchDayLog() {
|
||||
id: string;
|
||||
patch: CycleDayLogPatch;
|
||||
}) => {
|
||||
return apiPatch<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
|
||||
return apiPatch<CycleDayLogDTO & { restored?: CycleStartRef[] }>(
|
||||
`/api/cycle/day-logs/${id}`,
|
||||
patch,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.saveSuccess"));
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
restoredMessage(t("cycle.saveSuccess"), data?.restored),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -342,17 +372,26 @@ export function usePatchDayLog() {
|
||||
export function useStartPeriod() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const formatDay = useCalendarDate();
|
||||
return useMutation({
|
||||
mutationFn: async (date: string) => {
|
||||
return apiPost<unknown>("/api/cycle/period", {
|
||||
return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", {
|
||||
action: "start",
|
||||
date,
|
||||
loggedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, date) => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.periodStartSaved"));
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
data?.absorbed
|
||||
? t("cycle.startAbsorbed", {
|
||||
absorbed: formatDay(data.absorbed.startDate),
|
||||
date: formatDay(date),
|
||||
})
|
||||
: t("cycle.periodStartSaved"),
|
||||
);
|
||||
},
|
||||
// v1.16.4 — period boundaries had no failure signal at all (the sheet
|
||||
// simply stayed open); a toast names the rejection.
|
||||
@@ -386,13 +425,24 @@ export function useEndPeriod() {
|
||||
export function useDeleteDayLog() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const restoredMessage = useRestoredStartsMessage();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/cycle/day-logs/${id}`);
|
||||
// Ask for what the delete changed: removing a start day can give back a
|
||||
// start it had folded in, and the toast names it. A server that does
|
||||
// not know the preference answers 204, read as nothing given back.
|
||||
return apiDelete<{ restored?: CycleStartRef[] } | null>(
|
||||
`/api/cycle/day-logs/${id}`,
|
||||
undefined,
|
||||
{ headers: { Prefer: "return=representation" } },
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
restoredMessage(t("cycle.deleteSuccess"), data?.restored),
|
||||
);
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.deleteError"), { cause: failure }),
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "./use-dashboard-derived";
|
||||
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
|
||||
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
|
||||
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
|
||||
// Type-only — the compute payloads never drag the server graph into the bundle.
|
||||
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
|
||||
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
|
||||
@@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
|
||||
// and feed it to both the wellness strip and this grid. Imported above.
|
||||
|
||||
/**
|
||||
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
|
||||
* them: which way it should move depends on the person's own target (#1006,
|
||||
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
|
||||
* it stays neutral rather than assuming a fall is good.
|
||||
*/
|
||||
const UP_BAD_VITALS = new Set([
|
||||
"RESTING_HEART_RATE",
|
||||
"BODY_TEMPERATURE",
|
||||
"BLOOD_GLUCOSE",
|
||||
]);
|
||||
/** Up-is-good for the vitals where a rise is favourable. */
|
||||
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
|
||||
|
||||
function vitalSentiment(type: string): TrendDirectionSentiment {
|
||||
if (UP_BAD_VITALS.has(type)) return "up-bad";
|
||||
if (UP_GOOD_VITALS.has(type)) return "up-good";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
/**
|
||||
* The shared overview derived batch (owned by the page so the wellness
|
||||
@@ -306,7 +285,10 @@ function BaselineTile({
|
||||
icon={Icon}
|
||||
series={displaySeries}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
// v1.43 — resolved on the server against the person's own target
|
||||
// or profile default; an older server sends none and the tile
|
||||
// stays neutral rather than assuming a polarity.
|
||||
directionSentiment={v.direction?.direction ?? "neutral"}
|
||||
provenance={
|
||||
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
|
||||
// "typical range = median ± MAD" caption repeated across every vital
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Link2,
|
||||
Repeat,
|
||||
Tag,
|
||||
Tags,
|
||||
TrendingUp,
|
||||
@@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile";
|
||||
import { MoodTagInfluence } from "./mood-tag-influence";
|
||||
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
|
||||
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
|
||||
import { MoodPatterns } from "./mood-patterns";
|
||||
|
||||
export function MoodInsightsBreakdowns({
|
||||
data,
|
||||
@@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({
|
||||
const factorCrosstabRows = data.factorCrosstab ?? [];
|
||||
const hasFactorCrosstab = factorCrosstabRows.length > 0;
|
||||
const dimensionSummaries = data.dimensions ?? [];
|
||||
const patterns = data.patterns ?? [];
|
||||
const hasDimensions = dimensionSummaries.some((d) => d.present);
|
||||
|
||||
return (
|
||||
@@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({
|
||||
renders nothing when both halves are empty. */}
|
||||
<MoodWhatStandsOut narratives={narratives} />
|
||||
|
||||
{/* v1.43 — the patterns the server found and gated; the card stays
|
||||
away when none passed. */}
|
||||
{patterns.length > 0 && (
|
||||
<SectionCard title={t("insights.mood.patterns.title")} icon={Repeat}>
|
||||
<MoodPatterns patterns={patterns} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* v1.37 — the five level-A dimensions. Renders only once at least one
|
||||
of them carries a value; an account that has never opened the detail
|
||||
section sees the page it saw before. */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days";
|
||||
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
|
||||
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
|
||||
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
|
||||
import type { MoodPatternRow } from "./mood-patterns";
|
||||
|
||||
export interface MoodInsightsResponse {
|
||||
summary: {
|
||||
@@ -65,6 +66,11 @@ export interface MoodInsightsResponse {
|
||||
// rollout; the live endpoint always populates it.
|
||||
factorCrosstab?: MoodFactorMetricCrosstabRow[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
/**
|
||||
* v1.43 — the gated patterns the server found over the requested window.
|
||||
* Optional to tolerate a payload from before it existed.
|
||||
*/
|
||||
patterns?: MoodPatternRow[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
steps: MoodMetricCorrelationData;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.43 (ios #116 item 6) — the mood patterns the server found.
|
||||
*
|
||||
* Nothing here decides whether a pattern exists: every row is one the server
|
||||
* published after its gate (eight days a side, Welch, false discovery
|
||||
* control), computed on the mood day. This component only phrases each one.
|
||||
* Rows render in the order delivered; a kind this build does not know is
|
||||
* skipped rather than guessed at.
|
||||
*/
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface MoodPatternRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
direction: "higher" | "lower" | "none";
|
||||
values: {
|
||||
subject: number;
|
||||
comparison: number;
|
||||
delta: number;
|
||||
weekday?: number;
|
||||
tagKey?: string;
|
||||
tagLabel?: string;
|
||||
};
|
||||
sampleSize: { subject: number; comparison: number };
|
||||
}
|
||||
|
||||
const WEEKDAY_KEYS = [
|
||||
"charts.weekdaysFull.mon",
|
||||
"charts.weekdaysFull.tue",
|
||||
"charts.weekdaysFull.wed",
|
||||
"charts.weekdaysFull.thu",
|
||||
"charts.weekdaysFull.fri",
|
||||
"charts.weekdaysFull.sat",
|
||||
"charts.weekdaysFull.sun",
|
||||
] as const;
|
||||
|
||||
/** The sentence key per kind and direction; `none` cannot pass the gate. */
|
||||
const SENTENCE_KEYS: Record<string, { higher: string; lower: string | null }> =
|
||||
{
|
||||
previous_day: {
|
||||
higher: "insights.mood.patterns.previousDayHigher",
|
||||
lower: "insights.mood.patterns.previousDayLower",
|
||||
},
|
||||
weekend: {
|
||||
higher: "insights.mood.patterns.weekendHigher",
|
||||
lower: "insights.mood.patterns.weekendLower",
|
||||
},
|
||||
best_weekday: {
|
||||
higher: "insights.mood.patterns.bestWeekday",
|
||||
lower: null,
|
||||
},
|
||||
notes: {
|
||||
higher: "insights.mood.patterns.notesHigher",
|
||||
lower: "insights.mood.patterns.notesLower",
|
||||
},
|
||||
delayed_tag: {
|
||||
higher: "insights.mood.patterns.delayedTagHigher",
|
||||
lower: "insights.mood.patterns.delayedTagLower",
|
||||
},
|
||||
multiple_entries: {
|
||||
higher: "insights.mood.patterns.multipleEntriesHigher",
|
||||
lower: "insights.mood.patterns.multipleEntriesLower",
|
||||
},
|
||||
};
|
||||
|
||||
export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const rows = patterns.flatMap((pattern) => {
|
||||
const keys = SENTENCE_KEYS[pattern.kind];
|
||||
if (!keys || pattern.direction === "none") return [];
|
||||
const key = pattern.direction === "higher" ? keys.higher : keys.lower;
|
||||
if (!key) return [];
|
||||
const weekdayKey =
|
||||
pattern.values.weekday !== undefined
|
||||
? WEEKDAY_KEYS[pattern.values.weekday]
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id: pattern.id,
|
||||
sentence: t(key, {
|
||||
subject: fmt.number(pattern.values.subject, 1),
|
||||
comparison: fmt.number(pattern.values.comparison, 1),
|
||||
weekday: weekdayKey ? t(weekdayKey) : "",
|
||||
tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "",
|
||||
}),
|
||||
basedOn: t("insights.mood.patterns.basedOn", {
|
||||
subject: pattern.sampleSize.subject,
|
||||
comparison: pattern.sampleSize.comparison,
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="space-y-3" data-slot="mood-patterns">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} data-pattern-id={row.id} className="space-y-1">
|
||||
<p className="text-sm">{row.sentence}</p>
|
||||
<p className="text-muted-foreground text-xs">{row.basedOn}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,17 @@ interface PersonalRecordRow {
|
||||
value: number;
|
||||
unit: string;
|
||||
achievedAt: string;
|
||||
/**
|
||||
* v1.43 — the cited band for the age the record was set at, resolved on
|
||||
* the server; null (or absent on an older server) when none applies.
|
||||
*/
|
||||
benchmark?: {
|
||||
low: number;
|
||||
high: number;
|
||||
unit: string;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PersonalRecordBadgeProps {
|
||||
@@ -146,6 +157,19 @@ export function PersonalRecordBadge({
|
||||
data-slot="insights-pr-badge-tooltip"
|
||||
>
|
||||
<span className="text-xs">{tooltipText}</span>
|
||||
{fresh.benchmark ? (
|
||||
<span
|
||||
className="text-muted-foreground block text-xs"
|
||||
data-slot="insights-pr-badge-benchmark"
|
||||
>
|
||||
{t("insights.personalRecord.benchmark", {
|
||||
low: fmt.number(fresh.benchmark.low, 1),
|
||||
high: fmt.number(fresh.benchmark.high, 1),
|
||||
unit: fresh.benchmark.unit,
|
||||
source: fresh.benchmark.sourceLabel,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -474,6 +474,31 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
|
||||
expect(out).not.toContain("Last in background");
|
||||
});
|
||||
|
||||
// v1.43 (ios #77) — a history catch-up is named apart, and a type that
|
||||
// only ever arrived through one still reads as not received live.
|
||||
it("names the last backfill apart from the live arrival", () => {
|
||||
const live = html({
|
||||
...base,
|
||||
lastReceivedAt: hoursAgo(1),
|
||||
lastTrigger: "foreground",
|
||||
lastNewSampleAt: hoursAgo(1),
|
||||
lastBackfillAt: hoursAgo(3),
|
||||
});
|
||||
expect(live).toContain('data-state="received"');
|
||||
expect(live).toContain("History caught up 3 h ago");
|
||||
const backfillOnly = html({
|
||||
...base,
|
||||
lastReceivedAt: null,
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
lastBackfillAt: hoursAgo(2),
|
||||
});
|
||||
expect(backfillOnly).toContain('data-state="never"');
|
||||
expect(backfillOnly).toContain(
|
||||
"Not received yet, History caught up 2 h ago",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the medication dose row in the freshness list", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: hoursAgo(1),
|
||||
|
||||
@@ -148,16 +148,25 @@ export function ArrivalDetail({
|
||||
now?: Date;
|
||||
}) {
|
||||
if (entry.lastReceivedAt === undefined) return null;
|
||||
if (entry.lastReceivedAt === null) {
|
||||
return (
|
||||
<span data-testid="apple-health-arrival" data-state="never">
|
||||
{t("settings.appleHealth.freshness.never")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const reference = (now ?? new Date()).getTime();
|
||||
const relative = (iso: string) =>
|
||||
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
|
||||
// v1.43 (ios #77) — a history catch-up is named apart from the live
|
||||
// arrivals, and never counts as one.
|
||||
const backfill = entry.lastBackfillAt
|
||||
? t("settings.appleHealth.freshness.lastBackfill", {
|
||||
relative: relative(entry.lastBackfillAt),
|
||||
})
|
||||
: null;
|
||||
if (entry.lastReceivedAt === null) {
|
||||
return (
|
||||
<span data-testid="apple-health-arrival" data-state="never">
|
||||
{[t("settings.appleHealth.freshness.never"), backfill]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const trigger = entry.lastTrigger ?? null;
|
||||
const parts = [
|
||||
t("settings.appleHealth.freshness.lastReceived", {
|
||||
@@ -189,6 +198,7 @@ export function ArrivalDetail({
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (backfill) parts.push(backfill);
|
||||
return (
|
||||
<span
|
||||
data-testid="apple-health-arrival"
|
||||
|
||||
@@ -332,7 +332,12 @@ export function resolveWeightTargetOverride(
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function resolveThresholdOverride(
|
||||
/**
|
||||
* Narrow one metric's own target out of a raw `User.thresholdsJson`, the same
|
||||
* way `resolveWeightTargetOverride` does for weight. `null` for anything that
|
||||
* is not a well-formed, ordered pair of finite numbers.
|
||||
*/
|
||||
export function resolveThresholdOverride(
|
||||
thresholdsJson: unknown,
|
||||
metric: ThresholdMetric,
|
||||
): { min: number; max: number } | null {
|
||||
|
||||
@@ -366,6 +366,8 @@ export async function removeCycleStartedOn(
|
||||
cycleId: string;
|
||||
reanchored: ReanchoredCycle | null;
|
||||
restoredCycleIds: string[];
|
||||
/** The same starts with their dates, for the answer to the client. */
|
||||
restored: Array<{ cycleId: string; startDate: string }>;
|
||||
} | null> {
|
||||
const opened = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: date },
|
||||
@@ -432,6 +434,10 @@ export async function removeCycleStartedOn(
|
||||
cycleId: opened.id,
|
||||
reanchored,
|
||||
restoredCycleIds: restored.map((row) => row.id),
|
||||
restored: restored.map((row) => ({
|
||||
cycleId: row.id,
|
||||
startDate: row.startDate,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The answer to a delete that can take a period start with it (v1.43, ios
|
||||
* #116 item 9).
|
||||
*
|
||||
* Without a preference the delete answers 204, exactly as before, which is
|
||||
* what every client predating this relies on. With `Prefer:
|
||||
* return=representation` (RFC 7240) it answers 200 with the cycle it removed
|
||||
* and the starts that removal gave back — a start folded into the removed one
|
||||
* stands again — and says so in `Preference-Applied`.
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { hasPreference, RETURN_REPRESENTATION } from "@/lib/http/prefer";
|
||||
|
||||
export interface CycleRestoredStart {
|
||||
cycleId: string;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export function cycleDeleteOutcome(
|
||||
request: Request,
|
||||
removedCycleId: string | null,
|
||||
restored: readonly CycleRestoredStart[],
|
||||
): Response {
|
||||
if (!hasPreference(request, RETURN_REPRESENTATION)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const response = apiSuccess({ removedCycleId, restored: [...restored] });
|
||||
response.headers.set("Preference-Applied", RETURN_REPRESENTATION);
|
||||
return response;
|
||||
}
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
weightTrendReferenceKg,
|
||||
type WeightTrendJudgement,
|
||||
} from "@/lib/targets/weight-trend";
|
||||
import {
|
||||
resolveSummaryVerdicts,
|
||||
type TrendVerdict,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
typesWithReadingsInFoldWindow,
|
||||
@@ -452,6 +456,15 @@ export interface DashboardSnapshot {
|
||||
* builder always sets it.
|
||||
*/
|
||||
weightTrend?: WeightTrendJudgement;
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — per measurement type (and `MOOD`,
|
||||
* `BLOOD_PRESSURE_IN_TARGET`), which way a change counts for this
|
||||
* person, against their own target or a profile default where one
|
||||
* applies (`src/lib/targets/trend-verdict.ts`). Every tile colours its
|
||||
* arrow and deltas from this; `weightTrend` stays for older clients.
|
||||
* Optional on the type so older cached snapshots stay valid.
|
||||
*/
|
||||
directions?: Record<string, TrendVerdict>;
|
||||
};
|
||||
/** Thick phase — null on a rollup-coverage miss. */
|
||||
extras: DashboardSnapshotExtras | null;
|
||||
@@ -1496,6 +1509,14 @@ export async function buildDashboardSnapshot(
|
||||
weightTargetOverride,
|
||||
weightTrendReferenceKg(slim.summaries.WEIGHT),
|
||||
),
|
||||
directions: resolveSummaryVerdicts(slim.summaries, {
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
}),
|
||||
},
|
||||
extras: extrasResult?.extras ?? null,
|
||||
medsToday,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* response the synchronous route has always given.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { hasPreference } from "@/lib/http/prefer";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
import type { AiRunFailure } from "./store";
|
||||
@@ -20,14 +21,7 @@ import {
|
||||
* is what a client that has not adopted runs relies on.
|
||||
*/
|
||||
export function prefersRespondAsync(request: Request): boolean {
|
||||
const header = request.headers.get("prefer");
|
||||
if (!header) return false;
|
||||
return header
|
||||
.split(",")
|
||||
.some(
|
||||
(preference) =>
|
||||
preference.split(";")[0]?.trim().toLowerCase() === "respond-async",
|
||||
);
|
||||
return hasPreference(request, "respond-async");
|
||||
}
|
||||
|
||||
/** The 202 for a queued run, with the poll address and the applied preference. */
|
||||
|
||||
@@ -34,6 +34,9 @@ function sourceRow() {
|
||||
hourlyCumulative: CURVE,
|
||||
dayTotal: CURVE[23],
|
||||
sampleCount: 96,
|
||||
hourlyIncrements: [] as number[],
|
||||
basis: "PER_SAMPLE",
|
||||
bucketCount: null,
|
||||
timezone: "Europe/Berlin",
|
||||
createdAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T02:00:00.000Z"),
|
||||
@@ -57,6 +60,9 @@ function entry(
|
||||
hourlyCumulative: CURVE,
|
||||
dayTotal: CURVE[23],
|
||||
sampleCount: 96,
|
||||
hourlyIncrements: [],
|
||||
basis: "PER_SAMPLE",
|
||||
bucketCount: null,
|
||||
timezone: "Europe/Berlin",
|
||||
...overrides,
|
||||
};
|
||||
@@ -164,4 +170,62 @@ describe("intraday profile backup", () => {
|
||||
).rejects.toThrow(/23 hourly slots, not 24/);
|
||||
expect(created, "a row every reader drops is worse than none").toEqual([]);
|
||||
});
|
||||
|
||||
// v1.43 (ios #77) — the posted hours travel with the day.
|
||||
it("carries the posted hours, the basis and the hour count both ways", async () => {
|
||||
const hours = Array.from({ length: 24 }, (_, h) => (h < 8 ? -1 : 100));
|
||||
const built = await buildIntradayProfileBackupSection(
|
||||
{
|
||||
intradayCumulativeProfile: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
...sourceRow(),
|
||||
hourlyIncrements: hours,
|
||||
basis: "HOUR_BUCKETS",
|
||||
bucketCount: 16,
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as never,
|
||||
"user-A",
|
||||
);
|
||||
expect(built.intradayProfiles[0]).toMatchObject({
|
||||
hourlyIncrements: hours,
|
||||
basis: "HOUR_BUCKETS",
|
||||
bucketCount: 16,
|
||||
});
|
||||
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
await restoreIntradayProfileData(tx(created), "user-B", {
|
||||
intradayProfiles: [built.intradayProfiles[0]],
|
||||
});
|
||||
expect(created[0]).toMatchObject({
|
||||
hourlyIncrements: hours,
|
||||
basis: "HOUR_BUCKETS",
|
||||
bucketCount: 16,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores a day that so far holds only a few posted hours", async () => {
|
||||
const hours = Array.from({ length: 24 }, (_, h) => (h === 9 ? 120 : -1));
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
await restoreIntradayProfileData(tx(created), "user-B", {
|
||||
intradayProfiles: [
|
||||
entry({
|
||||
hourlyCumulative: [],
|
||||
dayTotal: 0,
|
||||
sampleCount: 0,
|
||||
hourlyIncrements: hours,
|
||||
bucketCount: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(created[0]).toMatchObject({ hourlyCumulative: [], bucketCount: 1 });
|
||||
|
||||
await expect(
|
||||
restoreIntradayProfileData(tx([]), "user-B", {
|
||||
intradayProfiles: [entry({ hourlyIncrements: hours.slice(0, 3) })],
|
||||
}),
|
||||
).rejects.toThrow(/3 posted-hour slots/);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,18 @@ export interface IntradayProfileBackupEntry {
|
||||
hourlyCumulative: number[];
|
||||
dayTotal: number;
|
||||
sampleCount: number;
|
||||
/**
|
||||
* v1.43 (ios #77) — the hours the phone posted for the day, 24 slots with
|
||||
* -1 for an hour not posted, or empty for a day folded from single samples
|
||||
* only. A day with fewer than four posted hours keeps its posted hours here
|
||||
* while its curve is still the fold's, or empty when the drain never saw
|
||||
* the day.
|
||||
*/
|
||||
hourlyIncrements: number[];
|
||||
/** What `hourlyCumulative` was built from. */
|
||||
basis: "PER_SAMPLE" | "HOUR_BUCKETS";
|
||||
/** Posted hours behind the day; null when none were posted. */
|
||||
bucketCount: number | null;
|
||||
/**
|
||||
* The IANA zone the day was cut on. Carried because the reader compares it
|
||||
* against the account's current zone and drops the mismatched days; a
|
||||
@@ -113,6 +125,9 @@ export async function buildIntradayProfileBackupSection(
|
||||
hourlyCumulative: row.hourlyCumulative,
|
||||
dayTotal: row.dayTotal,
|
||||
sampleCount: row.sampleCount,
|
||||
hourlyIncrements: row.hourlyIncrements,
|
||||
basis: row.basis === "HOUR_BUCKETS" ? "HOUR_BUCKETS" : "PER_SAMPLE",
|
||||
bucketCount: row.bucketCount,
|
||||
timezone: row.timezone,
|
||||
})),
|
||||
};
|
||||
@@ -180,7 +195,24 @@ export async function restoreIntradayProfileData(
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
if (profile.hourlyCumulative.length !== PROFILE_BACKUP_HOURS) {
|
||||
const increments = profile.hourlyIncrements ?? [];
|
||||
if (increments.length !== 0 && increments.length !== PROFILE_BACKUP_HOURS) {
|
||||
throw new Error(
|
||||
`Intraday profile for ${profile.type} on ${profile.dateKey} carries ` +
|
||||
`${increments.length} posted-hour slots, not ${PROFILE_BACKUP_HOURS} or none.`,
|
||||
);
|
||||
}
|
||||
// A day that so far holds only a few posted hours has no curve yet; the
|
||||
// reader skips it until it does, and the hours are what it is restored
|
||||
// for. Every other row must carry a full day.
|
||||
const hoursOnly =
|
||||
profile.hourlyCumulative.length === 0 &&
|
||||
increments.length === PROFILE_BACKUP_HOURS &&
|
||||
(profile.basis ?? "PER_SAMPLE") === "PER_SAMPLE";
|
||||
if (
|
||||
!hoursOnly &&
|
||||
profile.hourlyCumulative.length !== PROFILE_BACKUP_HOURS
|
||||
) {
|
||||
throw new Error(
|
||||
`Intraday profile for ${profile.type} on ${profile.dateKey} carries ` +
|
||||
`${profile.hourlyCumulative.length} hourly slots, not ${PROFILE_BACKUP_HOURS}. ` +
|
||||
@@ -200,6 +232,9 @@ export async function restoreIntradayProfileData(
|
||||
hourlyCumulative: profile.hourlyCumulative,
|
||||
dayTotal: profile.dayTotal,
|
||||
sampleCount: profile.sampleCount,
|
||||
hourlyIncrements: profile.hourlyIncrements ?? [],
|
||||
basis: profile.basis ?? "PER_SAMPLE",
|
||||
bucketCount: profile.bucketCount ?? null,
|
||||
timezone: profile.timezone,
|
||||
...(profile.createdAt
|
||||
? { createdAt: new Date(profile.createdAt) }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasPreference, RETURN_REPRESENTATION } from "../prefer";
|
||||
|
||||
function req(prefer?: string): Request {
|
||||
return new Request("http://localhost/x", {
|
||||
headers: prefer === undefined ? {} : { prefer },
|
||||
});
|
||||
}
|
||||
|
||||
describe("hasPreference", () => {
|
||||
it("is false without the header", () => {
|
||||
expect(hasPreference(req(), RETURN_REPRESENTATION)).toBe(false);
|
||||
});
|
||||
|
||||
it("finds a valued preference among several, with parameters and case", () => {
|
||||
expect(
|
||||
hasPreference(
|
||||
req("respond-async, Return=Representation; foo=bar"),
|
||||
RETURN_REPRESENTATION,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasPreference(req("return = representation"), "return=representation"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not take a different value of the same preference", () => {
|
||||
expect(hasPreference(req("return=minimal"), RETURN_REPRESENTATION)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a bare token", () => {
|
||||
expect(hasPreference(req("respond-async; wait=10"), "respond-async")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* RFC 7240 `Prefer` header reading. A preference is the token before its
|
||||
* first `;` (parameters follow it), several preferences are comma-separated,
|
||||
* and a token without `=` stands alone (`respond-async`) while one with `=`
|
||||
* carries a value (`return=representation`). Compared case-insensitively.
|
||||
*/
|
||||
export function hasPreference(request: Request, preference: string): boolean {
|
||||
const header = request.headers.get("prefer");
|
||||
if (!header) return false;
|
||||
const wanted = preference.toLowerCase();
|
||||
return header.split(",").some(
|
||||
(entry) =>
|
||||
entry
|
||||
.split(";")[0]
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s*=\s*/, "=") === wanted,
|
||||
);
|
||||
}
|
||||
|
||||
/** `Prefer: return=representation`: answer a write with what it changed. */
|
||||
export const RETURN_REPRESENTATION = "return=representation";
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoodAggregateEntry } from "@/lib/insights/mood-aggregates";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import {
|
||||
computeMoodPatterns,
|
||||
moodPatternsWire,
|
||||
PATTERN_MIN_DAYS_PER_SIDE,
|
||||
weekdayOfKey,
|
||||
} from "../mood-pattern-cards";
|
||||
|
||||
const TODAY = "2026-10-11"; // a Sunday
|
||||
|
||||
/** A deterministic pseudo-random stream (mulberry32). */
|
||||
function rng(seed: number): () => number {
|
||||
let a = seed;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function entry(
|
||||
date: string,
|
||||
score: number,
|
||||
extra: Partial<MoodAggregateEntry> = {},
|
||||
): MoodAggregateEntry {
|
||||
return {
|
||||
date,
|
||||
score,
|
||||
tags: null,
|
||||
moodLoggedAt: new Date(`${date}T18:00:00.000Z`),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function days(n: number, score: (date: string, i: number) => number) {
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const date = shiftDateKey(TODAY, -(n - 1 - i));
|
||||
return entry(date, score(date, i));
|
||||
});
|
||||
}
|
||||
|
||||
function run(entries: MoodAggregateEntry[], noteDays = new Set<string>()) {
|
||||
return computeMoodPatterns({
|
||||
entries,
|
||||
noteDays,
|
||||
todayKey: TODAY,
|
||||
windowDays: 365,
|
||||
});
|
||||
}
|
||||
|
||||
describe("computeMoodPatterns", () => {
|
||||
it("finds a clear weekend effect and names it", () => {
|
||||
const r = rng(7);
|
||||
const entries = days(120, (date) =>
|
||||
Math.min(
|
||||
5,
|
||||
Math.max(1, (weekdayOfKey(date) >= 5 ? 4.2 : 3) + (r() - 0.5)),
|
||||
),
|
||||
);
|
||||
const patterns = run(entries);
|
||||
const weekend = patterns.find((p) => p.kind === "weekend");
|
||||
expect(weekend).toBeDefined();
|
||||
expect(weekend).toMatchObject({
|
||||
id: "weekend",
|
||||
direction: "higher",
|
||||
strength: "strong",
|
||||
});
|
||||
expect(weekend!.sampleSize.subject).toBeGreaterThanOrEqual(
|
||||
PATTERN_MIN_DAYS_PER_SIDE,
|
||||
);
|
||||
expect(weekend!.values.subject).toBeGreaterThan(weekend!.values.comparison);
|
||||
// Saturday or Sunday is the best weekday, and only one is published.
|
||||
const best = patterns.filter((p) => p.kind === "best_weekday");
|
||||
expect(best.length).toBeLessThanOrEqual(1);
|
||||
if (best[0]) expect(best[0].values.weekday).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("finds nothing in noise", () => {
|
||||
for (const seed of [1, 2, 3, 4, 5]) {
|
||||
const r = rng(seed);
|
||||
const entries = days(180, () => 1 + Math.round(r() * 4));
|
||||
expect(run(entries), `seed ${seed}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds every comparison to eight days a side", () => {
|
||||
// Twelve days, all with a strong weekend split: too few weekend days.
|
||||
const r = rng(16);
|
||||
const entries = days(
|
||||
12,
|
||||
(date) => (weekdayOfKey(date) >= 5 ? 4.6 : 1.4) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
expect(run(entries).find((p) => p.kind === "weekend")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("compares days with a note against days without", () => {
|
||||
const r = rng(11);
|
||||
const entries = days(
|
||||
90,
|
||||
(_, i) => (i % 3 === 0 ? 2 : 4) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
const noteDays = new Set(
|
||||
entries.filter((_, i) => i % 3 === 0).map((e) => e.date),
|
||||
);
|
||||
const notes = run(entries, noteDays).find((p) => p.kind === "notes");
|
||||
expect(notes).toMatchObject({ id: "notes", direction: "lower" });
|
||||
});
|
||||
|
||||
it("compares days with several entries against single-entry days", () => {
|
||||
const r = rng(12);
|
||||
const base = days(
|
||||
90,
|
||||
(_, i) => (i % 2 === 0 ? 4.5 : 3) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
const extra = base
|
||||
.filter((_, i) => i % 2 === 0)
|
||||
.map((e) => entry(e.date, e.score));
|
||||
const multi = run([...base, ...extra]).find(
|
||||
(p) => p.kind === "multiple_entries",
|
||||
);
|
||||
expect(multi).toMatchObject({ direction: "higher" });
|
||||
});
|
||||
|
||||
it("reads a tag's association with the NEXT day", () => {
|
||||
const r = rng(13);
|
||||
const tag = {
|
||||
key: "alcohol",
|
||||
categoryKey: "health",
|
||||
labelKey: "mood.tag.alcohol",
|
||||
icon: null,
|
||||
};
|
||||
const tagged = new Set<number>();
|
||||
const entries = days(120, (_, i) => {
|
||||
// The day after a tagged day is low.
|
||||
return tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
|
||||
});
|
||||
entries.forEach((e, i) => {
|
||||
if (i % 4 === 0) {
|
||||
tagged.add(i);
|
||||
e.structuredTags = [tag];
|
||||
}
|
||||
});
|
||||
// Recompute the scores now that the tag days are known.
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
entries[i].score = tagged.has(i - 1) ? 2 + r() * 0.4 : 4 + r() * 0.4;
|
||||
}
|
||||
const delayed = run(entries).find((p) => p.kind === "delayed_tag");
|
||||
expect(delayed).toMatchObject({
|
||||
id: "delayed_tag:alcohol",
|
||||
direction: "lower",
|
||||
values: { tagKey: "alcohol" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the day after a below-median day", () => {
|
||||
const r = rng(14);
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
let prevLow = false;
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
const date = shiftDateKey(TODAY, -(149 - i));
|
||||
// Persistent mood: a low day is followed by another low-ish day.
|
||||
const low: boolean = r() < (prevLow ? 0.85 : 0.15);
|
||||
entries.push(entry(date, low ? 2 + r() * 0.5 : 4 + r() * 0.5));
|
||||
prevLow = low;
|
||||
}
|
||||
const previous = run(entries).find((p) => p.kind === "previous_day");
|
||||
expect(previous).toMatchObject({ id: "previous_day", direction: "lower" });
|
||||
});
|
||||
|
||||
it("only counts days inside the window", () => {
|
||||
const r = rng(15);
|
||||
const entries = days(
|
||||
200,
|
||||
(date) => (weekdayOfKey(date) >= 5 ? 4.5 : 2.5) + (r() - 0.5) * 0.4,
|
||||
);
|
||||
const recent = computeMoodPatterns({
|
||||
entries,
|
||||
noteDays: new Set(),
|
||||
todayKey: TODAY,
|
||||
windowDays: 30,
|
||||
}).find((p) => p.kind === "weekend");
|
||||
expect(recent).toBeDefined();
|
||||
expect(recent!.sampleSize.subject).toBeGreaterThanOrEqual(8);
|
||||
expect(recent!.sampleSize.subject + recent!.sampleSize.comparison).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moodPatternsWire", () => {
|
||||
it("names a catalogue tag in the reader's language and a custom one verbatim, and drops q", () => {
|
||||
const wire = moodPatternsWire(
|
||||
[
|
||||
{
|
||||
id: "delayed_tag:alcohol",
|
||||
kind: "delayed_tag",
|
||||
strength: "moderate",
|
||||
direction: "lower",
|
||||
values: {
|
||||
subject: 2.1,
|
||||
comparison: 4,
|
||||
delta: -1.9,
|
||||
tagKey: "alcohol",
|
||||
},
|
||||
sampleSize: { subject: 20, comparison: 80 },
|
||||
tag: { labelKey: "mood.tag.alcohol", label: null },
|
||||
qValue: 0.01,
|
||||
},
|
||||
{
|
||||
id: "delayed_tag:custom:x",
|
||||
kind: "delayed_tag",
|
||||
strength: "weak",
|
||||
direction: "higher",
|
||||
values: { subject: 4, comparison: 3, delta: 1, tagKey: "custom:x" },
|
||||
sampleSize: { subject: 9, comparison: 50 },
|
||||
tag: { labelKey: "custom:x", label: "Choir" },
|
||||
qValue: 0.08,
|
||||
},
|
||||
],
|
||||
(key) => `t:${key}`,
|
||||
);
|
||||
expect(wire[0].values.tagLabel).toBe("t:mood.tag.alcohol");
|
||||
expect(wire[1].values.tagLabel).toBe("Choir");
|
||||
expect(wire[0]).not.toHaveProperty("qValue");
|
||||
expect(wire[0]).not.toHaveProperty("tag");
|
||||
expect(moodPatternsWire(undefined, (k) => k)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@
|
||||
* statistics helpers below are exported so the unit test can assert the
|
||||
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
|
||||
*/
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
type TrendVerdict,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
@@ -91,6 +95,14 @@ export interface VitalsBaselineValue {
|
||||
* band is already computed from (no extra read).
|
||||
*/
|
||||
series: number[];
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — which way a change of this vital counts for
|
||||
* the person, with the band's centre placed against their own target or a
|
||||
* profile default (`src/lib/targets/trend-verdict.ts`). The tile colours
|
||||
* its delta from this instead of a fixed polarity. Absent when the caller
|
||||
* passed no profile facts to judge with.
|
||||
*/
|
||||
direction?: TrendVerdict;
|
||||
}
|
||||
|
||||
/** Caller-supplied profile (read once per request, never re-fetched here). */
|
||||
@@ -109,6 +121,13 @@ export interface BaselineProfile {
|
||||
* metric (weight ÷ height²); `null` when the profile has no height.
|
||||
*/
|
||||
heightCm?: number | null;
|
||||
/**
|
||||
* v1.43 — the date of birth and the person's own targets, which the trend
|
||||
* verdict on `VITALS_BASELINE` is judged with. Optional: a caller that does
|
||||
* not read them gets no verdict rather than a wrong one.
|
||||
*/
|
||||
dateOfBirth?: Date | null;
|
||||
thresholdsJson?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,13 +146,20 @@ export async function loadBaselineProfile(
|
||||
): Promise<BaselineProfile> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dateOfBirth: true, gender: true, heightCm: true },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
const sex = toProfileSex(user?.gender);
|
||||
return {
|
||||
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
|
||||
sex,
|
||||
heightCm: user?.heightCm ?? null,
|
||||
dateOfBirth: user?.dateOfBirth ?? null,
|
||||
thresholdsJson: user?.thresholdsJson ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,7 +337,7 @@ export async function readDayMeanSeries(
|
||||
*/
|
||||
export async function computeVitalsBaseline(
|
||||
userId: string,
|
||||
_profile: BaselineProfile,
|
||||
profile: BaselineProfile,
|
||||
opts: VitalsBaselineOpts,
|
||||
): Promise<Derived<VitalsBaselineValue>> {
|
||||
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
||||
@@ -403,8 +429,22 @@ export async function computeVitalsBaseline(
|
||||
// means the band is built from, capped to the recent window.
|
||||
const series = points.slice(-SPARKLINE_MAX_POINTS).map((p) => p.mean);
|
||||
|
||||
// Judged only when the caller read the facts it is judged with; the
|
||||
// nightly engines that never show a delta pass a bare profile.
|
||||
const direction =
|
||||
profile.dateOfBirth !== undefined
|
||||
? resolveTrendVerdict(type, {
|
||||
profile: {
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
gender: profile.sex,
|
||||
heightCm: profile.heightCm ?? null,
|
||||
},
|
||||
thresholdsJson: profile.thresholdsJson ?? null,
|
||||
referenceValue: band.center,
|
||||
})
|
||||
: undefined;
|
||||
return buildOk<VitalsBaselineValue>({
|
||||
value: { type, ...band, series },
|
||||
value: { type, ...band, series, ...(direction ? { direction } : {}) },
|
||||
coverage: cov,
|
||||
confidence,
|
||||
provenance: { inputs: [String(type)], source, windowDays, computedAt },
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
* importing from here unchanged.
|
||||
*/
|
||||
|
||||
import {
|
||||
computeMoodPatterns,
|
||||
moodPatternsWire,
|
||||
type MoodPatternFinding,
|
||||
} from "@/lib/insights/mood-pattern-cards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
significantPearsonCorrelation,
|
||||
@@ -512,6 +517,13 @@ export interface MoodAggregates {
|
||||
* `stability` and does not publish this map.
|
||||
*/
|
||||
stabilityByWindow: Record<MoodStabilityWindow, MoodStability | null>;
|
||||
/**
|
||||
* v1.43 (ios #116 item 6) — the gated mood patterns per selectable window
|
||||
* (`mood-pattern-cards.ts`). Cached with the aggregate like the stability
|
||||
* map; the route serves the chosen window as `patterns`. Optional so an
|
||||
* aggregate cached before it existed still reads (as none found).
|
||||
*/
|
||||
patternsByWindow?: Record<MoodStabilityWindow, MoodPatternFinding[]>;
|
||||
tags: TagSummaryRow[];
|
||||
/**
|
||||
* v1.38 — how the day's pleasantness sat on days carrying each context
|
||||
@@ -579,6 +591,11 @@ export function computeMoodAggregates(args: {
|
||||
* calendar day. Defaults to Berlin for legacy / test callers.
|
||||
*/
|
||||
tz?: string;
|
||||
/**
|
||||
* v1.43 — the mood days that carry a note, for the `notes` pattern. Read
|
||||
* as a separate projection so the note text itself is never loaded here.
|
||||
*/
|
||||
noteDays?: ReadonlySet<string>;
|
||||
}): MoodAggregates {
|
||||
const { entries, measurements, now } = args;
|
||||
const userPriorityJson = args.userPriorityJson ?? null;
|
||||
@@ -633,6 +650,18 @@ export function computeMoodAggregates(args: {
|
||||
),
|
||||
]),
|
||||
) as Record<MoodStabilityWindow, MoodStability | null>;
|
||||
const todayKey = dayOffsetToDayKey(now, 0, tz);
|
||||
const patternsByWindow = Object.fromEntries(
|
||||
MOOD_STABILITY_WINDOWS.map((days) => [
|
||||
days,
|
||||
computeMoodPatterns({
|
||||
entries,
|
||||
noteDays: args.noteDays ?? new Set<string>(),
|
||||
todayKey,
|
||||
windowDays: days,
|
||||
}),
|
||||
]),
|
||||
) as Record<MoodStabilityWindow, MoodPatternFinding[]>;
|
||||
const tags = computeTagSummary(entries, now);
|
||||
const structuredTags = computeStructuredTagSummary(entries, now);
|
||||
const tagInfluence = computeTagInfluence(entries, now);
|
||||
@@ -715,6 +744,7 @@ export function computeMoodAggregates(args: {
|
||||
timeOfDay,
|
||||
stability,
|
||||
stabilityByWindow,
|
||||
patternsByWindow,
|
||||
tags,
|
||||
structuredTags,
|
||||
tagInfluence,
|
||||
@@ -921,9 +951,20 @@ export async function fetchMoodAggregates(
|
||||
});
|
||||
}
|
||||
|
||||
const [userPriorityJson, tz] = await Promise.all([
|
||||
// v1.43 — which mood days carry a note, without reading a single note.
|
||||
const [userPriorityJson, tz, noteRows] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
resolveUserTimezone(userId),
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: windowCutoff },
|
||||
OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }],
|
||||
},
|
||||
select: { date: true },
|
||||
distinct: ["date"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const aggregate = computeMoodAggregates({
|
||||
@@ -932,6 +973,7 @@ export async function fetchMoodAggregates(
|
||||
now,
|
||||
userPriorityJson,
|
||||
tz,
|
||||
noteDays: new Set(noteRows.map((row) => row.date)),
|
||||
});
|
||||
|
||||
const tagEvidence = aggregate.tagMetricCrosstab.map((row) => ({
|
||||
@@ -995,8 +1037,10 @@ export async function fetchMoodAggregates(
|
||||
export function moodInsightsWire(
|
||||
result: MoodAggregates,
|
||||
stabilityWindowDays: MoodStabilityWindow,
|
||||
/** Names a pattern's tag in the reader's language (catalogue tags). */
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
const { stabilityByWindow, ...wire } = result;
|
||||
const { stabilityByWindow, patternsByWindow, ...wire } = result;
|
||||
return {
|
||||
...wire,
|
||||
stability:
|
||||
@@ -1004,5 +1048,8 @@ export function moodInsightsWire(
|
||||
? result.stability
|
||||
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
|
||||
stabilityWindowDays,
|
||||
// v1.43 — the patterns of the same window; an aggregate cached before
|
||||
// they existed reads as none found until its next rebuild.
|
||||
patterns: moodPatternsWire(patternsByWindow?.[stabilityWindowDays], t),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Mood patterns the server finds and publishes (v1.43, ios #116 item 6).
|
||||
*
|
||||
* The native client used to compute six pattern cards itself (the day after
|
||||
* a low day, weekends, the best weekday, days with a note, delayed tag
|
||||
* effects, several entries a day) on the device calendar and without any
|
||||
* statistical gate, and removed them. They are computed here instead, on the
|
||||
* account's mood day (`MoodEntry.date`, anchored to the entry's own zone),
|
||||
* and only the ones that pass the gate are published:
|
||||
*
|
||||
* - at least `PATTERN_MIN_DAYS_PER_SIDE` days on each side of a comparison;
|
||||
* - a Welch two-sample t-test on the two groups of daily means (the same
|
||||
* test the tag influence board uses);
|
||||
* - Benjamini-Hochberg over every comparison tested in the window, kept at
|
||||
* q ≤ `PATTERN_FDR_Q` (the false discovery rate the mood crosstabs use).
|
||||
*
|
||||
* An empty list means nothing passed, not that something failed. Every
|
||||
* comparison is a daily-mean comparison, so a day with five entries weighs
|
||||
* as much as a day with one. Observational only: the copy a client renders
|
||||
* says "tends to", never "because".
|
||||
*
|
||||
* Pure; the caller hands over the entries and which days carry a note.
|
||||
*/
|
||||
import { welchTTest } from "@/lib/insights/correlations";
|
||||
import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
|
||||
import type {
|
||||
MoodAggregateEntry,
|
||||
StructuredTagRef,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
export const PATTERN_MIN_DAYS_PER_SIDE = 8;
|
||||
export const PATTERN_FDR_Q = 0.1;
|
||||
|
||||
export type MoodPatternKind =
|
||||
| "previous_day"
|
||||
| "weekend"
|
||||
| "best_weekday"
|
||||
| "notes"
|
||||
| "delayed_tag"
|
||||
| "multiple_entries";
|
||||
|
||||
/** A pattern as the aggregate caches it: the tag's label is resolved at the wire. */
|
||||
export interface MoodPatternFinding {
|
||||
id: string;
|
||||
kind: MoodPatternKind;
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
direction: "higher" | "lower" | "none";
|
||||
values: {
|
||||
subject: number;
|
||||
comparison: number;
|
||||
delta: number;
|
||||
weekday?: number;
|
||||
tagKey?: string;
|
||||
};
|
||||
sampleSize: { subject: number; comparison: number };
|
||||
/** For `delayed_tag`: how to name the tag (custom label first). */
|
||||
tag?: { labelKey: string; label: string | null };
|
||||
/** BH-adjusted q, kept for ordering and tests; not on the wire. */
|
||||
qValue: number;
|
||||
}
|
||||
|
||||
/** The wire shape (`MoodPattern` in OpenAPI). */
|
||||
export interface MoodPatternWire {
|
||||
id: string;
|
||||
kind: MoodPatternKind;
|
||||
strength: "weak" | "moderate" | "strong";
|
||||
direction: "higher" | "lower" | "none";
|
||||
values: {
|
||||
subject: number;
|
||||
comparison: number;
|
||||
delta: number;
|
||||
weekday?: number;
|
||||
tagKey?: string;
|
||||
tagLabel?: string;
|
||||
};
|
||||
sampleSize: { subject: number; comparison: number };
|
||||
}
|
||||
|
||||
interface MoodDay {
|
||||
date: string;
|
||||
mean: number;
|
||||
entries: number;
|
||||
hasNote: boolean;
|
||||
tags: Map<string, StructuredTagRef>;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: string;
|
||||
kind: MoodPatternKind;
|
||||
subject: number[];
|
||||
comparison: number[];
|
||||
weekday?: number;
|
||||
tag?: StructuredTagRef;
|
||||
}
|
||||
|
||||
/** 0 = Monday … 6 = Sunday, read off the day key itself. */
|
||||
export function weekdayOfKey(dateKey: string): number {
|
||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||
return (d.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
function collapseDays(
|
||||
entries: readonly MoodAggregateEntry[],
|
||||
noteDays: ReadonlySet<string>,
|
||||
): Map<string, MoodDay> {
|
||||
const days = new Map<
|
||||
string,
|
||||
{ sum: number; count: number; tags: Map<string, StructuredTagRef> }
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
const day = days.get(entry.date) ?? {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
tags: new Map<string, StructuredTagRef>(),
|
||||
};
|
||||
day.sum += entry.score;
|
||||
day.count += 1;
|
||||
for (const ref of entry.structuredTags ?? []) day.tags.set(ref.key, ref);
|
||||
days.set(entry.date, day);
|
||||
}
|
||||
const out = new Map<string, MoodDay>();
|
||||
for (const [date, day] of days) {
|
||||
out.set(date, {
|
||||
date,
|
||||
mean: day.sum / day.count,
|
||||
entries: day.count,
|
||||
hasNote: noteDays.has(date),
|
||||
tags: day.tags,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function mean(values: readonly number[]): number {
|
||||
return values.reduce((s, v) => s + v, 0) / values.length;
|
||||
}
|
||||
|
||||
/** Cohen's d on the pooled SD, banded at the conventional 0.5 / 0.8. */
|
||||
function strengthOf(
|
||||
subject: readonly number[],
|
||||
comparison: readonly number[],
|
||||
): MoodPatternFinding["strength"] {
|
||||
const ms = mean(subject);
|
||||
const mc = mean(comparison);
|
||||
const vs =
|
||||
subject.reduce((s, v) => s + (v - ms) ** 2, 0) / (subject.length - 1);
|
||||
const vc =
|
||||
comparison.reduce((s, v) => s + (v - mc) ** 2, 0) / (comparison.length - 1);
|
||||
const pooled =
|
||||
((subject.length - 1) * vs + (comparison.length - 1) * vc) /
|
||||
(subject.length + comparison.length - 2);
|
||||
const d = pooled > 0 ? Math.abs(ms - mc) / Math.sqrt(pooled) : 0;
|
||||
if (d >= 0.8) return "strong";
|
||||
if (d >= 0.5) return "moderate";
|
||||
return "weak";
|
||||
}
|
||||
|
||||
function candidates(days: Map<string, MoodDay>): Candidate[] {
|
||||
const list = [...days.values()].sort((a, b) =>
|
||||
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
|
||||
);
|
||||
const out: Candidate[] = [];
|
||||
|
||||
// The day after a day below the person's own median, against the day after
|
||||
// one above it. Days on the median itself belong to neither side.
|
||||
const med = list.length > 0 ? median(list.map((d) => d.mean)) : 0;
|
||||
const afterLow: number[] = [];
|
||||
const afterHigh: number[] = [];
|
||||
for (const day of list) {
|
||||
const prev = days.get(shiftDateKey(day.date, -1));
|
||||
if (!prev) continue;
|
||||
if (prev.mean < med) afterLow.push(day.mean);
|
||||
else if (prev.mean > med) afterHigh.push(day.mean);
|
||||
}
|
||||
out.push({
|
||||
id: "previous_day",
|
||||
kind: "previous_day",
|
||||
subject: afterLow,
|
||||
comparison: afterHigh,
|
||||
});
|
||||
|
||||
// Saturday and Sunday against Monday to Friday.
|
||||
out.push({
|
||||
id: "weekend",
|
||||
kind: "weekend",
|
||||
subject: list.filter((d) => weekdayOfKey(d.date) >= 5).map((d) => d.mean),
|
||||
comparison: list.filter((d) => weekdayOfKey(d.date) < 5).map((d) => d.mean),
|
||||
});
|
||||
|
||||
// Each weekday against the rest. All seven are tested so the selection of
|
||||
// the best one is paid for in the false discovery control; only the best
|
||||
// can be published.
|
||||
for (let weekday = 0; weekday < 7; weekday += 1) {
|
||||
out.push({
|
||||
id: `weekday:${weekday}`,
|
||||
kind: "best_weekday",
|
||||
weekday,
|
||||
subject: list
|
||||
.filter((d) => weekdayOfKey(d.date) === weekday)
|
||||
.map((d) => d.mean),
|
||||
comparison: list
|
||||
.filter((d) => weekdayOfKey(d.date) !== weekday)
|
||||
.map((d) => d.mean),
|
||||
});
|
||||
}
|
||||
|
||||
out.push({
|
||||
id: "notes",
|
||||
kind: "notes",
|
||||
subject: list.filter((d) => d.hasNote).map((d) => d.mean),
|
||||
comparison: list.filter((d) => !d.hasNote).map((d) => d.mean),
|
||||
});
|
||||
|
||||
out.push({
|
||||
id: "multiple_entries",
|
||||
kind: "multiple_entries",
|
||||
subject: list.filter((d) => d.entries >= 2).map((d) => d.mean),
|
||||
comparison: list.filter((d) => d.entries === 1).map((d) => d.mean),
|
||||
});
|
||||
|
||||
// A tag's association with the NEXT day's mood: the day after a tagged day
|
||||
// against the day after an untagged one. Only consecutive logged days count.
|
||||
const tagRefs = new Map<string, StructuredTagRef>();
|
||||
for (const day of list) {
|
||||
for (const [key, ref] of day.tags)
|
||||
if (!tagRefs.has(key)) tagRefs.set(key, ref);
|
||||
}
|
||||
for (const [key, ref] of tagRefs) {
|
||||
const afterTag: number[] = [];
|
||||
const afterOther: number[] = [];
|
||||
for (const day of list) {
|
||||
const prev = days.get(shiftDateKey(day.date, -1));
|
||||
if (!prev) continue;
|
||||
if (prev.tags.has(key)) afterTag.push(day.mean);
|
||||
else afterOther.push(day.mean);
|
||||
}
|
||||
out.push({
|
||||
id: `delayed_tag:${key}`,
|
||||
kind: "delayed_tag",
|
||||
tag: ref,
|
||||
subject: afterTag,
|
||||
comparison: afterOther,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gated patterns over the entries whose mood day falls inside the
|
||||
* trailing `windowDays` ending at `todayKey`.
|
||||
*/
|
||||
export function computeMoodPatterns(args: {
|
||||
entries: readonly MoodAggregateEntry[];
|
||||
noteDays: ReadonlySet<string>;
|
||||
todayKey: string;
|
||||
windowDays: number;
|
||||
}): MoodPatternFinding[] {
|
||||
const fromKey = shiftDateKey(args.todayKey, -(args.windowDays - 1));
|
||||
const inWindow = args.entries.filter(
|
||||
(e) => e.date >= fromKey && e.date <= args.todayKey,
|
||||
);
|
||||
const days = collapseDays(inWindow, args.noteDays);
|
||||
|
||||
const tested: Array<{ c: Candidate; p: number }> = [];
|
||||
for (const c of candidates(days)) {
|
||||
if (
|
||||
c.subject.length < PATTERN_MIN_DAYS_PER_SIDE ||
|
||||
c.comparison.length < PATTERN_MIN_DAYS_PER_SIDE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const welch = welchTTest(c.subject, c.comparison);
|
||||
// No spread on either side: nothing a test can stand behind.
|
||||
if (welch.status !== "ok") continue;
|
||||
tested.push({ c, p: welch.pValue });
|
||||
}
|
||||
if (tested.length === 0) return [];
|
||||
|
||||
const q = benjaminiHochberg(tested.map((t) => t.p));
|
||||
const passed: MoodPatternFinding[] = [];
|
||||
tested.forEach(({ c }, i) => {
|
||||
if (q[i] > PATTERN_FDR_Q) return;
|
||||
const subject = mean(c.subject);
|
||||
const comparison = mean(c.comparison);
|
||||
const delta = subject - comparison;
|
||||
passed.push({
|
||||
id: c.kind === "best_weekday" ? "best_weekday" : c.id,
|
||||
kind: c.kind,
|
||||
strength: strengthOf(c.subject, c.comparison),
|
||||
direction: delta > 0 ? "higher" : delta < 0 ? "lower" : "none",
|
||||
values: {
|
||||
subject: round(subject, 2),
|
||||
comparison: round(comparison, 2),
|
||||
delta: round(delta, 2),
|
||||
...(c.weekday !== undefined ? { weekday: c.weekday } : {}),
|
||||
...(c.tag ? { tagKey: c.tag.key } : {}),
|
||||
},
|
||||
sampleSize: {
|
||||
subject: c.subject.length,
|
||||
comparison: c.comparison.length,
|
||||
},
|
||||
...(c.tag
|
||||
? { tag: { labelKey: c.tag.labelKey, label: c.tag.label ?? null } }
|
||||
: {}),
|
||||
qValue: q[i],
|
||||
});
|
||||
});
|
||||
|
||||
// One best weekday at most: the highest one that passed and is above the
|
||||
// rest. A weekday that is only significantly LOWER is not a best weekday.
|
||||
const weekdays = passed
|
||||
.filter((p) => p.kind === "best_weekday" && p.direction === "higher")
|
||||
.sort((a, b) => b.values.subject - a.values.subject);
|
||||
const kept = passed.filter((p) => p.kind !== "best_weekday");
|
||||
if (weekdays.length > 0) kept.push(weekdays[0]);
|
||||
|
||||
const STRENGTH_RANK = { strong: 0, moderate: 1, weak: 2 } as const;
|
||||
return kept.sort(
|
||||
(a, b) =>
|
||||
STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength] ||
|
||||
Math.abs(b.values.delta) - Math.abs(a.values.delta) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** The wire form: the tag named in the reader's language, no internal fields. */
|
||||
export function moodPatternsWire(
|
||||
findings: readonly MoodPatternFinding[] | undefined,
|
||||
t: (key: string) => string,
|
||||
): MoodPatternWire[] {
|
||||
return (findings ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
kind: f.kind,
|
||||
strength: f.strength,
|
||||
direction: f.direction,
|
||||
values: {
|
||||
...f.values,
|
||||
...(f.tag ? { tagLabel: f.tag.label ?? t(f.tag.labelKey) } : {}),
|
||||
},
|
||||
sampleSize: f.sampleSize,
|
||||
}));
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*
|
||||
* Read-only. A connection that does not exist resolves to null.
|
||||
*/
|
||||
import { BACKFILL_LEDGER_PREFIX } from "./healthkit-type-sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import type { IntegrationKey } from "./status";
|
||||
@@ -194,7 +195,12 @@ export async function loadConnectionFacts(
|
||||
: null,
|
||||
wanted.has("apple-health")
|
||||
? prisma.healthKitTypeSync.findMany({
|
||||
where: { userId },
|
||||
// A backfill's own ledger row is a catch-up, not a live arrival,
|
||||
// and must not make the connection look alive (ios #77).
|
||||
where: {
|
||||
userId,
|
||||
NOT: { type: { startsWith: BACKFILL_LEDGER_PREFIX } },
|
||||
},
|
||||
select: { type: true, lastReceivedAt: true, lastNewSampleAt: true },
|
||||
orderBy: { lastReceivedAt: "desc" },
|
||||
})
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
type MetricFreshnessEntry,
|
||||
} from "./sync-verdict";
|
||||
|
||||
/** The `syncTrigger` values the Apple Health ingest routes accept. */
|
||||
/**
|
||||
* The `syncTrigger` values that describe a live sync: the ones a batch can
|
||||
* leave behind as a type's `lastTrigger`.
|
||||
*/
|
||||
export const HEALTHKIT_SYNC_TRIGGERS = [
|
||||
"foreground",
|
||||
"background",
|
||||
@@ -35,8 +38,39 @@ export const HEALTHKIT_SYNC_TRIGGERS = [
|
||||
|
||||
export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number];
|
||||
|
||||
/**
|
||||
* v1.43 (ios #77) — a history catch-up (the 30-day baseline backfill, a
|
||||
* "sync everything" walk). Accepted on the two ingest routes when the server
|
||||
* says so (`features.syncTriggerBackfill`). It is not a live sync, so it is
|
||||
* never stored as `lastTrigger` and moves none of the live stamps; it moves
|
||||
* only `lastBackfillAt`.
|
||||
*/
|
||||
export const BACKFILL_SYNC_TRIGGER = "backfill" as const;
|
||||
|
||||
/** Every `syncTrigger` value the Apple Health ingest routes accept. */
|
||||
export const HEALTHKIT_REQUEST_SYNC_TRIGGERS = [
|
||||
...HEALTHKIT_SYNC_TRIGGERS,
|
||||
BACKFILL_SYNC_TRIGGER,
|
||||
] as const;
|
||||
|
||||
export type HealthKitRequestSyncTrigger =
|
||||
(typeof HEALTHKIT_REQUEST_SYNC_TRIGGERS)[number];
|
||||
|
||||
const TRIGGERS: ReadonlySet<string> = new Set<string>(HEALTHKIT_SYNC_TRIGGERS);
|
||||
|
||||
/**
|
||||
* A backfill arrival is kept as its own ledger row, keyed `backfill:<type>`,
|
||||
* beside the live row of the same type. Its `last_received_at` is the
|
||||
* backfill's time; it carries no trigger and no sample stamps. Keeping it in
|
||||
* a row of its own is what lets a catch-up leave every live column of the type
|
||||
* exactly as it was, and every live reader skips the prefix.
|
||||
*/
|
||||
export const BACKFILL_LEDGER_PREFIX = "backfill:";
|
||||
|
||||
export function isBackfillLedgerKey(type: string): boolean {
|
||||
return type.startsWith(BACKFILL_LEDGER_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger key for Apple Health medication doses, which are not a
|
||||
* `MeasurementType`. Also the `type` of their `metricFreshness` entry.
|
||||
@@ -72,7 +106,7 @@ export async function recordHealthKitTypeArrivals(
|
||||
input: {
|
||||
userId: string;
|
||||
counts: ReadonlyMap<HealthKitArrivalType, HealthKitTypeArrival>;
|
||||
trigger: HealthKitSyncTrigger | null;
|
||||
trigger: HealthKitRequestSyncTrigger | null;
|
||||
at?: Date;
|
||||
},
|
||||
): Promise<void> {
|
||||
@@ -88,6 +122,27 @@ export async function recordHealthKitTypeArrivals(
|
||||
// serialised in the process's local zone and these are zone-less UTC
|
||||
// columns (same reasoning as the access-token sunset in refresh-token.ts).
|
||||
const at = (input.at ?? new Date()).toISOString();
|
||||
if (input.trigger === BACKFILL_SYNC_TRIGGER) {
|
||||
// v1.43 — a catch-up moves only its own row; the live row of each type
|
||||
// keeps its arrival, trigger and sample stamps untouched.
|
||||
const keys = types.map((type) => `${BACKFILL_LEDGER_PREFIX}${type}`);
|
||||
try {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "healthkit_type_syncs"
|
||||
("user_id", "type", "last_received_at")
|
||||
SELECT ${input.userId}, u.t, ${at}::timestamptz AT TIME ZONE 'UTC'
|
||||
FROM unnest(${keys}::text[]) AS u(t)
|
||||
ON CONFLICT ("user_id", "type") DO UPDATE SET
|
||||
"last_received_at" = EXCLUDED."last_received_at"
|
||||
`;
|
||||
} catch (error) {
|
||||
logCaught("healthkit.type_sync.record_failed", error, {
|
||||
types: types.length,
|
||||
backfill: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const background = input.trigger === "background";
|
||||
try {
|
||||
await tx.$executeRaw`
|
||||
@@ -120,10 +175,13 @@ export async function recordHealthKitTypeArrivals(
|
||||
|
||||
/** One type's arrival facts, as the Apple Health status read serves them. */
|
||||
export interface HealthKitTypeArrivalRecord {
|
||||
lastReceivedAt: string;
|
||||
/** Null for a type that so far arrived only through a backfill. */
|
||||
lastReceivedAt: string | null;
|
||||
lastTrigger: HealthKitSyncTrigger | null;
|
||||
lastNewSampleAt: string | null;
|
||||
lastBackgroundAt: string | null;
|
||||
/** v1.43 — when a `backfill` batch last carried the type; null when none has. */
|
||||
lastBackfillAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,16 +203,30 @@ export async function getHealthKitTypeArrivals(
|
||||
},
|
||||
});
|
||||
const out = new Map<string, HealthKitTypeArrivalRecord>();
|
||||
const empty = (): HealthKitTypeArrivalRecord => ({
|
||||
lastReceivedAt: null,
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
lastBackgroundAt: null,
|
||||
lastBackfillAt: null,
|
||||
});
|
||||
for (const row of rows) {
|
||||
out.set(row.type, {
|
||||
lastReceivedAt: row.lastReceivedAt.toISOString(),
|
||||
lastTrigger:
|
||||
row.lastTrigger && TRIGGERS.has(row.lastTrigger)
|
||||
? (row.lastTrigger as HealthKitSyncTrigger)
|
||||
: null,
|
||||
lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null,
|
||||
lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null,
|
||||
});
|
||||
if (isBackfillLedgerKey(row.type)) {
|
||||
const type = row.type.slice(BACKFILL_LEDGER_PREFIX.length);
|
||||
const record = out.get(type) ?? empty();
|
||||
record.lastBackfillAt = row.lastReceivedAt.toISOString();
|
||||
out.set(type, record);
|
||||
continue;
|
||||
}
|
||||
const record = out.get(row.type) ?? empty();
|
||||
record.lastReceivedAt = row.lastReceivedAt.toISOString();
|
||||
record.lastTrigger =
|
||||
row.lastTrigger && TRIGGERS.has(row.lastTrigger)
|
||||
? (row.lastTrigger as HealthKitSyncTrigger)
|
||||
: null;
|
||||
record.lastNewSampleAt = row.lastNewSampleAt?.toISOString() ?? null;
|
||||
record.lastBackgroundAt = row.lastBackgroundAt?.toISOString() ?? null;
|
||||
out.set(row.type, record);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -195,6 +267,8 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry {
|
||||
lastNewSampleAt?: string | null;
|
||||
/** When a `background` sync last carried it; a later foreground one keeps it. */
|
||||
lastBackgroundAt?: string | null;
|
||||
/** v1.43 — when a `backfill` batch last carried it. */
|
||||
lastBackfillAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +294,7 @@ export function withHealthKitArrivals(
|
||||
lastTrigger: arrival?.lastTrigger ?? null,
|
||||
lastNewSampleAt: arrival?.lastNewSampleAt ?? null,
|
||||
lastBackgroundAt: arrival?.lastBackgroundAt ?? null,
|
||||
lastBackfillAt: arrival?.lastBackfillAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
classifyHourBucket,
|
||||
cumulativeFromIncrements,
|
||||
HOUR_NOT_POSTED,
|
||||
isStartOfLocalHour,
|
||||
parseHourBucketId,
|
||||
postedHours,
|
||||
} from "../intraday-hour-buckets";
|
||||
|
||||
const BERLIN = "Europe/Berlin";
|
||||
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||
|
||||
describe("parseHourBucketId", () => {
|
||||
it("reads the hour form", () => {
|
||||
expect(
|
||||
parseHourBucketId(
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14T09",
|
||||
),
|
||||
).toEqual({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
dateKey: "2026-10-14",
|
||||
hour: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14",
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-10-14T09",
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-10-14T09:10:00.000Z",
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14T24",
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-02-30T09",
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-10-14T9",
|
||||
"uuid-1234",
|
||||
])("leaves %s to its own path", (id) => {
|
||||
expect(parseHourBucketId(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStartOfLocalHour", () => {
|
||||
const id = { hkIdentifier: "x", dateKey: "2026-10-14", hour: 9 };
|
||||
it("accepts the first instant of the local hour only", () => {
|
||||
// 09:00 in Berlin (CEST, UTC+2) is 07:00Z.
|
||||
expect(
|
||||
isStartOfLocalHour(new Date("2026-10-14T07:00:00.000Z"), id, BERLIN),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStartOfLocalHour(new Date("2026-10-14T09:00:00.000Z"), id, BERLIN),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isStartOfLocalHour(new Date("2026-10-14T07:00:00.001Z"), id, BERLIN),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isStartOfLocalHour(new Date("2026-10-14T07:30:00.000Z"), id, BERLIN),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts either start of a repeated autumn hour and no start of a skipped spring hour", () => {
|
||||
const repeated = { hkIdentifier: "x", dateKey: "2026-10-25", hour: 2 };
|
||||
expect(
|
||||
isStartOfLocalHour(
|
||||
new Date("2026-10-25T00:00:00.000Z"),
|
||||
repeated,
|
||||
BERLIN,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStartOfLocalHour(
|
||||
new Date("2026-10-25T01:00:00.000Z"),
|
||||
repeated,
|
||||
BERLIN,
|
||||
),
|
||||
).toBe(true);
|
||||
const skipped = { hkIdentifier: "x", dateKey: "2026-03-29", hour: 2 };
|
||||
expect(
|
||||
isStartOfLocalHour(new Date("2026-03-29T01:00:00.000Z"), skipped, BERLIN),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyHourBucket", () => {
|
||||
const steps = (dateKey: string, hour: number) => ({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
dateKey,
|
||||
hour,
|
||||
});
|
||||
|
||||
it("maps the four summed identifiers", () => {
|
||||
expect(
|
||||
classifyHourBucket(
|
||||
steps("2026-10-14", 9),
|
||||
new Date("2026-10-14T07:00:00Z"),
|
||||
BERLIN,
|
||||
NOW,
|
||||
),
|
||||
).toMatchObject({ ok: true, type: "ACTIVITY_STEPS" });
|
||||
});
|
||||
|
||||
it("refuses another identifier", () => {
|
||||
expect(
|
||||
classifyHourBucket(
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
dateKey: "2026-10-14",
|
||||
hour: 9,
|
||||
},
|
||||
new Date("2026-10-14T07:00:00Z"),
|
||||
BERLIN,
|
||||
NOW,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "hour_bucket_unsupported_type" });
|
||||
});
|
||||
|
||||
it("refuses a start that is not the local hour's start", () => {
|
||||
// 09:00Z is 11:00 in Berlin, not 09:00.
|
||||
expect(
|
||||
classifyHourBucket(
|
||||
steps("2026-10-14", 9),
|
||||
new Date("2026-10-14T09:00:00Z"),
|
||||
BERLIN,
|
||||
NOW,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "hour_bucket_misaligned" });
|
||||
});
|
||||
|
||||
it("refuses a day more than 400 days back", () => {
|
||||
expect(
|
||||
classifyHourBucket(
|
||||
steps("2025-09-01", 9),
|
||||
new Date("2025-09-01T07:00:00Z"),
|
||||
BERLIN,
|
||||
NOW,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "hour_bucket_too_old" });
|
||||
expect(
|
||||
classifyHourBucket(
|
||||
steps("2025-09-11", 9),
|
||||
new Date("2025-09-11T07:00:00Z"),
|
||||
BERLIN,
|
||||
NOW,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cumulativeFromIncrements", () => {
|
||||
it("adds only posted hours and keeps the running total flat across gaps", () => {
|
||||
const increments = new Array<number>(24).fill(HOUR_NOT_POSTED);
|
||||
increments[8] = 100;
|
||||
increments[9] = 0;
|
||||
increments[12] = 50;
|
||||
const cumulative = cumulativeFromIncrements(increments);
|
||||
expect(cumulative[7]).toBe(0);
|
||||
expect(cumulative[8]).toBe(100);
|
||||
expect(cumulative[11]).toBe(100);
|
||||
expect(cumulative[23]).toBe(150);
|
||||
expect(postedHours(increments)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
|
||||
|
||||
import {
|
||||
computeSeriesChange,
|
||||
seriesDayValues,
|
||||
seriesReferenceValue,
|
||||
} from "../series-change";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const upBad = resolveTrendVerdict("RESTING_HEART_RATE", {
|
||||
profile: { dateOfBirth: null, gender: null, heightCm: null },
|
||||
thresholdsJson: null,
|
||||
referenceValue: null,
|
||||
});
|
||||
|
||||
describe("seriesDayValues", () => {
|
||||
it("sums a summed type per local day and averages the rest", () => {
|
||||
const points = [
|
||||
{ at: "2026-09-01T06:00:00.000Z", value: 1000 },
|
||||
{ at: "2026-09-01T20:00:00.000Z", value: 2000 },
|
||||
// 23:30 UTC is already the next day in Berlin.
|
||||
{ at: "2026-09-01T23:30:00.000Z", value: 500 },
|
||||
];
|
||||
expect(seriesDayValues("ACTIVITY_STEPS", points, TZ)).toEqual([
|
||||
{ day: "2026-09-01", value: 3000 },
|
||||
{ day: "2026-09-02", value: 500 },
|
||||
]);
|
||||
expect(seriesDayValues("WEIGHT", points, TZ)[0]).toEqual({
|
||||
day: "2026-09-01",
|
||||
value: 1500,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSeriesChange", () => {
|
||||
it("is null under two days", () => {
|
||||
expect(
|
||||
computeSeriesChange([{ day: "2026-09-01", value: 60 }], upBad),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("compares the last day with the first and judges the change", () => {
|
||||
expect(
|
||||
computeSeriesChange(
|
||||
[
|
||||
{ day: "2026-09-01", value: 60 },
|
||||
{ day: "2026-09-02", value: 70 },
|
||||
{ day: "2026-09-03", value: 64.123 },
|
||||
],
|
||||
upBad,
|
||||
),
|
||||
).toEqual({ from: 60, to: 64.12, delta: 4.12, sentiment: "negative" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("seriesReferenceValue", () => {
|
||||
it("averages the last seven days", () => {
|
||||
const days = Array.from({ length: 10 }, (_, i) => ({
|
||||
day: `2026-09-${String(i + 1).padStart(2, "0")}`,
|
||||
value: i + 1,
|
||||
}));
|
||||
expect(seriesReferenceValue(days)).toBe(7);
|
||||
expect(seriesReferenceValue([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { locales as LOCALES } from "@/lib/i18n/config";
|
||||
|
||||
import { resolveSeriesReferenceLines } from "../series-reference-lines";
|
||||
|
||||
const en = getServerTranslator("en");
|
||||
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
|
||||
const MAN_41 = {
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
};
|
||||
|
||||
function lines(
|
||||
kind: Parameters<typeof resolveSeriesReferenceLines>[0],
|
||||
opts: {
|
||||
profile?: typeof NO_PROFILE | typeof MAN_41;
|
||||
thresholdsJson?: unknown;
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
} = {},
|
||||
) {
|
||||
return resolveSeriesReferenceLines(kind, {
|
||||
profile: opts.profile ?? NO_PROFILE,
|
||||
thresholdsJson: opts.thresholdsJson ?? null,
|
||||
glucoseUnit: opts.glucoseUnit ?? "mg/dL",
|
||||
t: (key) => en.t(key),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveSeriesReferenceLines", () => {
|
||||
it("draws glucose 70 / 180 mg/dL, converted for an mmol/L account", () => {
|
||||
expect(lines("glucose").map((l) => [l.kind, l.value])).toEqual([
|
||||
["lower", 70],
|
||||
["upper", 180],
|
||||
]);
|
||||
expect(
|
||||
lines("glucose", { glucoseUnit: "mmol/L" }).map((l) => [l.kind, l.value]),
|
||||
).toEqual([
|
||||
["lower", 3.9],
|
||||
["upper", 10],
|
||||
]);
|
||||
});
|
||||
|
||||
it("draws the ESH 140 / 90 lines without a profile or a target", () => {
|
||||
expect(
|
||||
lines("bloodPressure").map((l) => [
|
||||
l.appliesTo,
|
||||
l.kind,
|
||||
l.value,
|
||||
l.source,
|
||||
]),
|
||||
).toEqual([
|
||||
["value", "upper", 140, "clinical"],
|
||||
["secondary", "upper", 90, "clinical"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the clinical edge with the person's band", () => {
|
||||
const own = lines("bloodPressure", {
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
});
|
||||
expect(own.map((l) => [l.appliesTo, l.kind, l.value, l.source])).toEqual([
|
||||
["value", "lower", 115, "personal"],
|
||||
["value", "upper", 125, "personal"],
|
||||
["secondary", "upper", 90, "clinical"],
|
||||
]);
|
||||
// An age turns the diastolic side into the profile band too.
|
||||
const aged = lines("bloodPressure", { profile: MAN_41 });
|
||||
expect(aged.every((l) => l.source === "personal")).toBe(true);
|
||||
expect(aged.map((l) => l.value)).toEqual([120, 129, 70, 79]);
|
||||
});
|
||||
|
||||
it("draws the SpO2 floor, or the person's band instead", () => {
|
||||
expect(lines("oxygenSaturation")).toMatchObject([
|
||||
{ value: 92, kind: "lower", source: "clinical" },
|
||||
]);
|
||||
expect(
|
||||
lines("oxygenSaturation", {
|
||||
thresholdsJson: { OXYGEN_SATURATION: { min: 90, max: 96 } },
|
||||
}).map((l) => l.value),
|
||||
).toEqual([90, 96]);
|
||||
});
|
||||
|
||||
it("draws the resting band on the resting chart only", () => {
|
||||
expect(lines("restingHeartRate", { profile: MAN_41 })).toHaveLength(2);
|
||||
expect(lines("pulse", { profile: MAN_41 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty when there is nothing to draw", () => {
|
||||
expect(lines("heartRateVariability")).toEqual([]);
|
||||
expect(lines("steps")).toEqual([]);
|
||||
expect(lines("weight", { profile: MAN_41 })).toEqual([]);
|
||||
});
|
||||
|
||||
it("names every label key in every bundle, and labels in the account language", () => {
|
||||
const keys = new Set([
|
||||
...lines("bloodPressure").map((l) => l.labelKey),
|
||||
...lines("bloodPressure", { profile: MAN_41 }).map((l) => l.labelKey),
|
||||
...lines("glucose").map((l) => l.labelKey),
|
||||
]);
|
||||
expect(keys.size).toBe(4);
|
||||
for (const locale of LOCALES) {
|
||||
const t = getServerTranslator(locale);
|
||||
for (const key of keys) expect(t.t(key)).not.toBe(key);
|
||||
}
|
||||
const de = getServerTranslator("de");
|
||||
const german = resolveSeriesReferenceLines("glucose", {
|
||||
profile: NO_PROFILE,
|
||||
thresholdsJson: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
t: (key) => de.t(key),
|
||||
});
|
||||
expect(german[0].label).toBe("Klinische Untergrenze");
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,14 @@ export interface CumulativeDayFold {
|
||||
export interface CumulativeDayProfile extends CumulativeDayFold {
|
||||
/** Whether this came off the table or was folded live from raw rows. */
|
||||
origin: "stored" | "live";
|
||||
/**
|
||||
* v1.43 (ios #77) — what the running totals were built from: the fold of
|
||||
* single samples, or the hours the phone posted (`intraday-hour-buckets`).
|
||||
* `sampleCount` is the number of posted hours for the latter.
|
||||
*/
|
||||
basis: "PER_SAMPLE" | "HOUR_BUCKETS";
|
||||
/** Posted hours behind the day; null for a fold of single samples. */
|
||||
bucketCount: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,9 +215,26 @@ export async function mergeIntradayCumulativeProfile(
|
||||
|
||||
const existing = await tx.intradayCumulativeProfile.findUnique({
|
||||
where: { userId_type_dateKey: key },
|
||||
select: { hourlyCumulative: true, sampleCount: true, timezone: true },
|
||||
select: {
|
||||
hourlyCumulative: true,
|
||||
sampleCount: true,
|
||||
timezone: true,
|
||||
basis: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.43 (ios #77) — a day built from the hours the phone posted keeps that
|
||||
// shape: the posted hours already account for every sample, so adding a
|
||||
// fold of the same samples would count the day twice. The fold is dropped,
|
||||
// exactly as the samples it came from are about to be.
|
||||
if (
|
||||
existing !== null &&
|
||||
existing.basis === "HOUR_BUCKETS" &&
|
||||
existing.timezone === increment.timezone
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reusable =
|
||||
existing !== null &&
|
||||
existing.timezone === increment.timezone &&
|
||||
@@ -227,6 +252,11 @@ export async function mergeIntradayCumulativeProfile(
|
||||
dayTotal: hourlyCumulative[PROFILE_HOURS - 1],
|
||||
sampleCount,
|
||||
timezone: increment.timezone,
|
||||
// A profile cut on another clock is replaced whole, its posted hours
|
||||
// included (`basis` stays the default for a day under the threshold).
|
||||
...(existing !== null && existing.timezone !== increment.timezone
|
||||
? { hourlyIncrements: [], bucketCount: null, basis: "PER_SAMPLE" }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await tx.intradayCumulativeProfile.upsert({
|
||||
@@ -300,6 +330,8 @@ export async function readCumulativeDayProfiles(args: {
|
||||
hourlyCumulative: true,
|
||||
dayTotal: true,
|
||||
sampleCount: true,
|
||||
basis: true,
|
||||
bucketCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -316,6 +348,8 @@ export async function readCumulativeDayProfiles(args: {
|
||||
dayTotal: row.dayTotal,
|
||||
sampleCount: row.sampleCount,
|
||||
origin: "stored",
|
||||
basis: row.basis === "HOUR_BUCKETS" ? "HOUR_BUCKETS" : "PER_SAMPLE",
|
||||
bucketCount: row.bucketCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +360,14 @@ export async function readCumulativeDayProfiles(args: {
|
||||
if (dateKey < startDateKey) break;
|
||||
if (byDay.has(dateKey)) continue;
|
||||
const folded = await foldLiveDay(db, userId, type, dateKey, timezone);
|
||||
if (folded) byDay.set(dateKey, { ...folded, origin: "live" });
|
||||
if (folded) {
|
||||
byDay.set(dateKey, {
|
||||
...folded,
|
||||
origin: "live",
|
||||
basis: "PER_SAMPLE",
|
||||
bucketCount: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDay.values()].sort((a, b) => (a.dateKey < b.dateKey ? 1 : -1));
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Hour buckets for the summed intraday types (v1.43, ios #77).
|
||||
*
|
||||
* The phone can hand over a day of steps, active energy, walking distance or
|
||||
* flights as twenty-four hourly sums (`stats:<HK>:<YYYY-MM-DD>T<HH>`, the
|
||||
* local hour in the account's zone) instead of the minute-level samples the
|
||||
* nightly drain folds. That matters for history: a thirty-day backfill of
|
||||
* hourly sums makes the same-time baseline usable on day one, where the
|
||||
* drain only ever sees samples that are still on the phone's recent window.
|
||||
*
|
||||
* Such a row is NEVER a measurement. The day's total stays the
|
||||
* `stats:<HK>:<YYYY-MM-DD>` row the phone keeps posting; an hourly row stored
|
||||
* as a measurement would be summed into that day a second time. Each hour
|
||||
* lands as one slot of `IntradayCumulativeProfile.hourlyIncrements` instead,
|
||||
* and once a day holds `HOUR_BUCKET_MIN_FOR_PROFILE` hours its running totals
|
||||
* (`hourlyCumulative`) are rebuilt from the posted hours and the profile's
|
||||
* `basis` reads `HOUR_BUCKETS`. Below that a day keeps the per-sample fold,
|
||||
* if it has one.
|
||||
*
|
||||
* An hour not posted holds `HOUR_NOT_POSTED` (-1) in its slot; every value a
|
||||
* posted hour can carry is zero or more, so the sentinel cannot collide with
|
||||
* a reading, and `bucketCount` is the number of slots at zero or above.
|
||||
*/
|
||||
import type {
|
||||
MeasurementType,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
import {
|
||||
CUMULATIVE_PROFILE_RETENTION_DAYS,
|
||||
PROFILE_HOURS,
|
||||
} from "./intraday-cumulative-profile";
|
||||
|
||||
/** The HealthKit identifiers the hour form is defined for, and their types. */
|
||||
export const HOUR_BUCKET_TYPES: Readonly<Record<string, MeasurementType>> = {
|
||||
HKQuantityTypeIdentifierStepCount: "ACTIVITY_STEPS",
|
||||
HKQuantityTypeIdentifierActiveEnergyBurned: "ACTIVE_ENERGY_BURNED",
|
||||
HKQuantityTypeIdentifierDistanceWalkingRunning: "WALKING_RUNNING_DISTANCE",
|
||||
HKQuantityTypeIdentifierFlightsClimbed: "FLIGHTS_CLIMBED",
|
||||
};
|
||||
|
||||
/** Hours a day needs before its profile is built from buckets. */
|
||||
export const HOUR_BUCKET_MIN_FOR_PROFILE = 4;
|
||||
|
||||
/** The oldest hour accepted: the profile's own retention bound. */
|
||||
export const HOUR_BUCKET_MAX_AGE_DAYS = CUMULATIVE_PROFILE_RETENTION_DAYS;
|
||||
|
||||
/** The slot value of an hour the phone has not posted. */
|
||||
export const HOUR_NOT_POSTED = -1;
|
||||
|
||||
export const HOUR_BUCKET_SKIP_REASONS = {
|
||||
misaligned: "hour_bucket_misaligned",
|
||||
unsupportedType: "hour_bucket_unsupported_type",
|
||||
tooOld: "hour_bucket_too_old",
|
||||
} as const;
|
||||
|
||||
export type HourBucketSkipReason =
|
||||
(typeof HOUR_BUCKET_SKIP_REASONS)[keyof typeof HOUR_BUCKET_SKIP_REASONS];
|
||||
|
||||
/**
|
||||
* The heart-rate identifier keeps its own bucket contract (ten-minute ISO
|
||||
* buckets, `malformed_hr_bucket_id`), so the hour form is not claimed for it.
|
||||
*/
|
||||
const HEART_RATE_HK = "HKQuantityTypeIdentifierHeartRate";
|
||||
|
||||
const HOUR_FORM = /^stats:([A-Za-z0-9]+):(\d{4}-\d{2}-\d{2})T(\d{2})$/;
|
||||
|
||||
export interface HourBucketId {
|
||||
hkIdentifier: string;
|
||||
dateKey: string;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `stats:<HK>:<YYYY-MM-DD>T<HH>`. Null for anything else, the day form
|
||||
* and the heart-rate buckets included, so every other external id keeps the
|
||||
* path it had.
|
||||
*/
|
||||
export function parseHourBucketId(externalId: string): HourBucketId | null {
|
||||
const match = HOUR_FORM.exec(externalId);
|
||||
if (!match) return null;
|
||||
const [, hkIdentifier, dateKey, hh] = match;
|
||||
if (hkIdentifier === HEART_RATE_HK) return null;
|
||||
const hour = Number(hh);
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour >= PROFILE_HOURS) return null;
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
const probe = new Date(Date.UTC(y, m - 1, d));
|
||||
if (
|
||||
probe.getUTCFullYear() !== y ||
|
||||
probe.getUTCMonth() !== m - 1 ||
|
||||
probe.getUTCDate() !== d
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { hkIdentifier, dateKey, hour };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `startDate` is the first instant of that local hour in the
|
||||
* account's zone. Read through the zone rather than computed from it, so a
|
||||
* repeated autumn hour accepts either of its two starts and a spring hour
|
||||
* that does not exist accepts none.
|
||||
*/
|
||||
export function isStartOfLocalHour(
|
||||
startDate: Date,
|
||||
id: HourBucketId,
|
||||
timezone: string,
|
||||
): boolean {
|
||||
if (startDate.getUTCMilliseconds() !== 0) return false;
|
||||
const wall = wallClockInTz(startDate, timezone);
|
||||
const key = `${String(wall.year).padStart(4, "0")}-${String(wall.month).padStart(2, "0")}-${String(wall.day).padStart(2, "0")}`;
|
||||
return (
|
||||
key === id.dateKey &&
|
||||
wall.hour === id.hour &&
|
||||
wall.minute === 0 &&
|
||||
wall.second === 0
|
||||
);
|
||||
}
|
||||
|
||||
export type HourBucketVerdict =
|
||||
| { ok: true; type: MeasurementType; id: HourBucketId }
|
||||
| { ok: false; reason: HourBucketSkipReason };
|
||||
|
||||
/** Decide whether one parsed hour row may be stored, and as which type. */
|
||||
export function classifyHourBucket(
|
||||
id: HourBucketId,
|
||||
startDate: Date,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): HourBucketVerdict {
|
||||
const type = HOUR_BUCKET_TYPES[id.hkIdentifier];
|
||||
if (!type)
|
||||
return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.unsupportedType };
|
||||
const oldest = shiftDateKey(
|
||||
userDayKey(now, timezone),
|
||||
-HOUR_BUCKET_MAX_AGE_DAYS,
|
||||
);
|
||||
if (id.dateKey < oldest) {
|
||||
return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.tooOld };
|
||||
}
|
||||
if (!isStartOfLocalHour(startDate, id, timezone)) {
|
||||
return { ok: false, reason: HOUR_BUCKET_SKIP_REASONS.misaligned };
|
||||
}
|
||||
return { ok: true, type, id };
|
||||
}
|
||||
|
||||
/** Running totals from posted hours; an hour not posted adds nothing. */
|
||||
export function cumulativeFromIncrements(
|
||||
increments: readonly number[],
|
||||
): number[] {
|
||||
const out = new Array<number>(PROFILE_HOURS);
|
||||
let running = 0;
|
||||
for (let h = 0; h < PROFILE_HOURS; h += 1) {
|
||||
const v = increments[h];
|
||||
if (v !== undefined && v >= 0) running += v;
|
||||
out[h] = running;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function postedHours(increments: readonly number[]): number {
|
||||
return increments.filter((v) => v >= 0).length;
|
||||
}
|
||||
|
||||
/** One accepted hour, as the batch hands it over. */
|
||||
export interface HourBucketWrite {
|
||||
/** The entry's index in the batch, for its per-entry status. */
|
||||
index: number;
|
||||
type: MeasurementType;
|
||||
hkIdentifier: string;
|
||||
dateKey: string;
|
||||
hour: number;
|
||||
/** The hour's sum in the canonical unit of `type`. */
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a batch's hours, one transaction per (type, day). Returns the
|
||||
* per-entry status: `inserted` for the first value of a slot, `updated` when
|
||||
* it replaced one. A later entry for the same slot in one batch wins, and the
|
||||
* earlier ones read `updated` too, since the slot already held a value when
|
||||
* they were applied in order.
|
||||
*
|
||||
* Serialised against the nightly drain's merge for the same day by the
|
||||
* drain's own advisory lock (keyed on the day's `stats:` id), so a bucket
|
||||
* write and a per-sample fold never interleave.
|
||||
*/
|
||||
export async function writeHourBuckets(
|
||||
db: PrismaClient,
|
||||
userId: string,
|
||||
timezone: string,
|
||||
writes: readonly HourBucketWrite[],
|
||||
): Promise<Map<number, "inserted" | "updated">> {
|
||||
const statuses = new Map<number, "inserted" | "updated">();
|
||||
const groups = new Map<string, HourBucketWrite[]>();
|
||||
for (const w of writes) {
|
||||
const key = `${w.type}::${w.dateKey}`;
|
||||
const list = groups.get(key);
|
||||
if (list) list.push(w);
|
||||
else groups.set(key, [w]);
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const { type, dateKey, hkIdentifier } = group[0];
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('drain-cumulative:' || ${userId} || ':' || ${`stats:${hkIdentifier}:${dateKey}`}, 0))
|
||||
`;
|
||||
const key = { userId, type, dateKey };
|
||||
const existing = await tx.intradayCumulativeProfile.findUnique({
|
||||
where: { userId_type_dateKey: key },
|
||||
select: {
|
||||
hourlyIncrements: true,
|
||||
hourlyCumulative: true,
|
||||
dayTotal: true,
|
||||
sampleCount: true,
|
||||
timezone: true,
|
||||
basis: true,
|
||||
},
|
||||
});
|
||||
// Hours cut on another clock mean other hours: start the day over.
|
||||
const sameZone = existing !== null && existing.timezone === timezone;
|
||||
const increments =
|
||||
sameZone && existing.hourlyIncrements.length === PROFILE_HOURS
|
||||
? [...existing.hourlyIncrements]
|
||||
: new Array<number>(PROFILE_HOURS).fill(HOUR_NOT_POSTED);
|
||||
for (const w of group) {
|
||||
statuses.set(w.index, increments[w.hour] >= 0 ? "updated" : "inserted");
|
||||
increments[w.hour] = w.value;
|
||||
}
|
||||
const bucketCount = postedHours(increments);
|
||||
const fromBuckets = bucketCount >= HOUR_BUCKET_MIN_FOR_PROFILE;
|
||||
const cumulative = cumulativeFromIncrements(increments);
|
||||
// Below the threshold the day keeps whatever the per-sample fold left
|
||||
// (nothing, for a day the drain never saw): the reader skips a profile
|
||||
// without 24 running totals.
|
||||
const keepFold = sameZone && !fromBuckets;
|
||||
const data = {
|
||||
hourlyIncrements: increments,
|
||||
bucketCount,
|
||||
basis: fromBuckets ? "HOUR_BUCKETS" : "PER_SAMPLE",
|
||||
timezone,
|
||||
hourlyCumulative: fromBuckets
|
||||
? cumulative
|
||||
: keepFold
|
||||
? existing.hourlyCumulative
|
||||
: [],
|
||||
dayTotal: fromBuckets
|
||||
? cumulative[PROFILE_HOURS - 1]
|
||||
: keepFold
|
||||
? existing.dayTotal
|
||||
: 0,
|
||||
sampleCount: fromBuckets
|
||||
? bucketCount
|
||||
: keepFold
|
||||
? existing.sampleCount
|
||||
: 0,
|
||||
} satisfies Prisma.IntradayCumulativeProfileUncheckedUpdateInput;
|
||||
await tx.intradayCumulativeProfile.upsert({
|
||||
where: { userId_type_dateKey: key },
|
||||
create: { ...key, ...data },
|
||||
update: data,
|
||||
});
|
||||
});
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The first-to-last change of a chart window, by day (v1.43, ios #116
|
||||
* item 3).
|
||||
*
|
||||
* A chart's change is read off day values, not off single points: the first
|
||||
* reading of a window can be an outlier minute of a dense series, and a day
|
||||
* of steps is its total, not its last sample. Each local day of the window is
|
||||
* reduced with the type's own day statistic (the total for a summed type, the
|
||||
* mean of the hours' means for pulse, the mean otherwise), and the change is
|
||||
* the last day against the first. Under two days there is no change to show.
|
||||
*
|
||||
* Pure: the series route hands over the points it already serves, in the
|
||||
* unit it serves them in.
|
||||
*/
|
||||
import { dayValue } from "@/lib/measurements/day-mean";
|
||||
import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import {
|
||||
trendSentimentOf,
|
||||
type TrendSentiment,
|
||||
type TrendVerdict,
|
||||
} from "@/lib/targets/trend-verdict";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export interface SeriesChange {
|
||||
from: number;
|
||||
to: number;
|
||||
delta: number;
|
||||
sentiment: TrendSentiment;
|
||||
}
|
||||
|
||||
export interface SeriesDayValue {
|
||||
day: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** One value per local day, oldest first. */
|
||||
export function seriesDayValues(
|
||||
type: string,
|
||||
points: ReadonlyArray<{ at: string; value: number }>,
|
||||
timezone: string,
|
||||
): SeriesDayValue[] {
|
||||
const byDay = new Map<string, Array<{ value: number; measuredAt: Date }>>();
|
||||
for (const point of points) {
|
||||
const measuredAt = new Date(point.at);
|
||||
if (!Number.isFinite(point.value) || Number.isNaN(measuredAt.getTime())) {
|
||||
continue;
|
||||
}
|
||||
const day = userDayKey(measuredAt, timezone);
|
||||
const list = byDay.get(day);
|
||||
if (list) list.push({ value: point.value, measuredAt });
|
||||
else byDay.set(day, [{ value: point.value, measuredAt }]);
|
||||
}
|
||||
const summed = isCumulativeDaySumType(type);
|
||||
const out: SeriesDayValue[] = [];
|
||||
for (const [day, rows] of byDay) {
|
||||
const value = summed
|
||||
? rows.reduce((sum, r) => sum + r.value, 0)
|
||||
: dayValue(type, rows, timezone);
|
||||
if (value !== null) out.push({ day, value });
|
||||
}
|
||||
return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
}
|
||||
|
||||
export function computeSeriesChange(
|
||||
days: readonly SeriesDayValue[],
|
||||
verdict: TrendVerdict | null,
|
||||
): SeriesChange | null {
|
||||
if (days.length < 2) return null;
|
||||
const from = round2(days[0].value);
|
||||
const to = round2(days[days.length - 1].value);
|
||||
const delta = round2(to - from);
|
||||
return { from, to, delta, sentiment: trendSentimentOf(delta, verdict) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The value a window's verdict places against the band: the mean of its last
|
||||
* seven day values (the dashboard places the seven-day average the same way),
|
||||
* so one day near a band edge does not flip the colour.
|
||||
*/
|
||||
export function seriesReferenceValue(
|
||||
days: readonly SeriesDayValue[],
|
||||
): number | null {
|
||||
if (days.length === 0) return null;
|
||||
const recent = days.slice(-7);
|
||||
return recent.reduce((sum, d) => sum + d.value, 0) / recent.length;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Horizontal reference lines for a measurement chart (v1.43, ios #116 item 4).
|
||||
*
|
||||
* The native client drew fixed threshold lines (blood pressure 140/60, pulse
|
||||
* 100/60, glucose 70/180 mg/dL, fever 37.5, SpO2 95) that knew neither the
|
||||
* series' unit nor the person's targets, and removed them. The server knows
|
||||
* both, so it resolves the lines here and `GET /api/measurements/series`
|
||||
* serves them in the unit of its own points.
|
||||
*
|
||||
* Two sources, one rule: a personal line (the person's own target from
|
||||
* `/targets`, else the profile default the target cards use: the age-based
|
||||
* blood pressure band, the age- and sex-based resting pulse band, the
|
||||
* sex-based body fat band) replaces the clinical line for the same edge.
|
||||
* The clinical lines are the ones a guideline names:
|
||||
*
|
||||
* - blood pressure 140 / 90 mmHg, the ESH 2023 hypertension threshold;
|
||||
* - glucose 70 and 180 mg/dL, the CGM time-in-range band (ATTD consensus,
|
||||
* Battelino et al. 2019), converted for an mmol/L account;
|
||||
* - SpO2 lower edge 92 %, the red-flag floor in `clinical-floors.ts`.
|
||||
*
|
||||
* Without either, the list is empty: nothing to draw is an answer, not an
|
||||
* error.
|
||||
*/
|
||||
import type {
|
||||
ThresholdMetric,
|
||||
UserProfileForRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { TIME_IN_RANGE_MGDL } from "@/lib/analytics/glucose-metrics";
|
||||
import { SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { REFERENCE_RANGES } from "@/lib/reference-ranges";
|
||||
import { resolvePersonalBand } from "@/lib/targets/trend-verdict";
|
||||
|
||||
export type SeriesKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation"
|
||||
| "restingHeartRate"
|
||||
| "heartRateVariability"
|
||||
| "vo2Max";
|
||||
|
||||
export interface SeriesReferenceLine {
|
||||
value: number;
|
||||
kind: "lower" | "upper";
|
||||
source: "personal" | "clinical";
|
||||
/** `secondary` is the diastolic line of a blood pressure chart. */
|
||||
appliesTo: "value" | "secondary";
|
||||
labelKey: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SeriesReferenceContext {
|
||||
/** The profile of the record on screen. */
|
||||
profile: UserProfileForRange;
|
||||
thresholdsJson: unknown;
|
||||
glucoseUnit: GlucoseUnit;
|
||||
/** Server translator in the account's language. */
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/** The ESH 2023 hypertension threshold, systolic from the shipped bands. */
|
||||
const ESH_HYPERTENSION_SYS =
|
||||
REFERENCE_RANGES.BLOOD_PRESSURE.bands.find((b) => b.label === "Hypertension")
|
||||
?.low ?? 140;
|
||||
/** Its diastolic counterpart (ESH 2023 grade 1 starts at 140 / 90). */
|
||||
const ESH_HYPERTENSION_DIA = 90;
|
||||
|
||||
/** Which `/targets` threshold a kind's personal lines come from. */
|
||||
const PERSONAL_METRIC: Partial<Record<SeriesKind, ThresholdMetric>> = {
|
||||
weight: "WEIGHT",
|
||||
bodyFat: "BODY_FAT",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
// A resting band on the resting chart only: raw heart rate includes every
|
||||
// workout, where the resting band says nothing.
|
||||
restingHeartRate: "PULSE",
|
||||
};
|
||||
|
||||
function personalBand(
|
||||
metric: ThresholdMetric,
|
||||
ctx: SeriesReferenceContext,
|
||||
): { min: number; max: number } | null {
|
||||
return (
|
||||
resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson)?.band ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function line(
|
||||
ctx: SeriesReferenceContext,
|
||||
value: number,
|
||||
kind: "lower" | "upper",
|
||||
source: "personal" | "clinical",
|
||||
appliesTo: "value" | "secondary",
|
||||
): SeriesReferenceLine {
|
||||
const labelKey =
|
||||
source === "personal"
|
||||
? kind === "upper"
|
||||
? "series.reference.targetUpper"
|
||||
: "series.reference.targetLower"
|
||||
: kind === "upper"
|
||||
? "series.reference.clinicalUpper"
|
||||
: "series.reference.clinicalLower";
|
||||
return { value, kind, source, appliesTo, labelKey, label: ctx.t(labelKey) };
|
||||
}
|
||||
|
||||
/** Both edges of a personal band. */
|
||||
function bandLines(
|
||||
ctx: SeriesReferenceContext,
|
||||
band: { min: number; max: number },
|
||||
appliesTo: "value" | "secondary",
|
||||
): SeriesReferenceLine[] {
|
||||
return [
|
||||
line(ctx, band.min, "lower", "personal", appliesTo),
|
||||
line(ctx, band.max, "upper", "personal", appliesTo),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveSeriesReferenceLines(
|
||||
kind: SeriesKind,
|
||||
ctx: SeriesReferenceContext,
|
||||
): SeriesReferenceLine[] {
|
||||
switch (kind) {
|
||||
case "bloodPressure": {
|
||||
const sys = personalBand("BLOOD_PRESSURE_SYS", ctx);
|
||||
const dia = personalBand("BLOOD_PRESSURE_DIA", ctx);
|
||||
return [
|
||||
...(sys
|
||||
? bandLines(ctx, sys, "value")
|
||||
: [line(ctx, ESH_HYPERTENSION_SYS, "upper", "clinical", "value")]),
|
||||
...(dia
|
||||
? bandLines(ctx, dia, "secondary")
|
||||
: [
|
||||
line(ctx, ESH_HYPERTENSION_DIA, "upper", "clinical", "secondary"),
|
||||
]),
|
||||
];
|
||||
}
|
||||
case "glucose":
|
||||
// The series mixes every meal context, so no single context's target
|
||||
// is the person's line here; the time-in-range band is.
|
||||
return [
|
||||
line(
|
||||
ctx,
|
||||
convertGlucose(TIME_IN_RANGE_MGDL.low, ctx.glucoseUnit),
|
||||
"lower",
|
||||
"clinical",
|
||||
"value",
|
||||
),
|
||||
line(
|
||||
ctx,
|
||||
convertGlucose(TIME_IN_RANGE_MGDL.high, ctx.glucoseUnit),
|
||||
"upper",
|
||||
"clinical",
|
||||
"value",
|
||||
),
|
||||
];
|
||||
case "oxygenSaturation": {
|
||||
const own = personalBand("OXYGEN_SATURATION", ctx);
|
||||
if (own) return bandLines(ctx, own, "value");
|
||||
return [line(ctx, SPO2_RED_FLAG_PCT, "lower", "clinical", "value")];
|
||||
}
|
||||
default: {
|
||||
const metric = PERSONAL_METRIC[kind];
|
||||
if (!metric) return [];
|
||||
const band = personalBand(metric, ctx);
|
||||
return band ? bandLines(ctx, band, "value") : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,34 @@ const cycleDeleteOutcomeEnvelope = dataEnvelope(
|
||||
"CycleDeleteOutcomeEnvelope",
|
||||
);
|
||||
|
||||
/**
|
||||
* `Prefer: return=representation` on the two deletes that can remove a start.
|
||||
* A factory, so each operation carries its own copy: a shared object becomes a
|
||||
* YAML anchor and renumbers every other anchor in the document.
|
||||
*/
|
||||
const preferReturnRepresentationParameter = () => ({
|
||||
name: "Prefer",
|
||||
in: "header" as const,
|
||||
required: false,
|
||||
schema: { type: "string" as const, example: "return=representation" },
|
||||
description:
|
||||
"v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before.",
|
||||
});
|
||||
|
||||
/** The 200 a delete answers with when the request preferred a representation. */
|
||||
const cycleDeleteOutcomeResponse = () => ({
|
||||
description:
|
||||
"v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back.",
|
||||
content: {
|
||||
"application/json": { schema: cycleDeleteOutcomeEnvelope },
|
||||
},
|
||||
headers: z.object({
|
||||
"Preference-Applied": z.literal("return=representation").meta({
|
||||
description: "The route honoured `Prefer: return=representation`.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const cyclePeriodResponse = z.object({
|
||||
cycle: menstrualCycleDto.nullable(),
|
||||
dayLog: cycleDayLogDto.nullable(),
|
||||
@@ -340,7 +368,7 @@ const cyclePeriodResponse = z.object({
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.',
|
||||
'v1.43 — a later start within the period length that this start folded in (it stops being a start of its own), or null. Present on every `action: "start"` answer; null on `end`.',
|
||||
),
|
||||
});
|
||||
|
||||
@@ -787,7 +815,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.array(cycleRestoredStart)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — starts given back because this edit cleared the flow on a start day.",
|
||||
"v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. Absent on older servers.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
@@ -808,16 +836,11 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Soft-delete a cycle day-log (v1.15.0)",
|
||||
description:
|
||||
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
|
||||
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
parameters: [preferReturnRepresentationParameter()],
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
|
||||
content: {
|
||||
"application/json": { schema: cycleDeleteOutcomeEnvelope },
|
||||
},
|
||||
},
|
||||
"200": cycleDeleteOutcomeResponse(),
|
||||
"204": { description: "Soft-deleted (no body)." },
|
||||
"404": {
|
||||
description: "Day-log not found / not owned.",
|
||||
@@ -970,16 +993,11 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Cycle"],
|
||||
summary: "Soft-delete a menstrual cycle (v1.15.0)",
|
||||
description:
|
||||
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
|
||||
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
parameters: [preferReturnRepresentationParameter()],
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
|
||||
content: {
|
||||
"application/json": { schema: cycleDeleteOutcomeEnvelope },
|
||||
},
|
||||
},
|
||||
"200": cycleDeleteOutcomeResponse(),
|
||||
"204": { description: "Soft-deleted (no body)." },
|
||||
"404": {
|
||||
description: "Cycle not found / not owned.",
|
||||
|
||||
@@ -412,7 +412,7 @@ export const derivedMetricResponse = z
|
||||
.record(z.string(), z.unknown())
|
||||
.nullable()
|
||||
.describe(
|
||||
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
|
||||
"Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.",
|
||||
),
|
||||
coverage: derivedCoverage,
|
||||
confidence: derivedConfidence
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
seriesChange,
|
||||
seriesReferenceLine,
|
||||
trendVerdict,
|
||||
recordBenchmark,
|
||||
} from "./trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
@@ -93,10 +92,11 @@ const batchEntrySchema = z
|
||||
.min(1)
|
||||
.max(120)
|
||||
.describe(
|
||||
"Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" +
|
||||
"Dedup key for the `(userId, type, source, externalId)` composite index. Four shapes:\n" +
|
||||
"• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" +
|
||||
"• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" +
|
||||
"• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `<bucket-start>` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.\n" +
|
||||
"• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>T<HH>` — v1.43 (ios #77), only when `GET /api/auth/me` reports `features.cumulativeHourBuckets: true`: one local hour's SUM of steps, active energy, walking and running distance or flights climbed, the date and the zero-padded hour being the local hour in the account's zone (`timezone` on `/api/auth/me`). `value` is the hour's sum in the unit of the day form, `startDate` the first instant of that local hour, `endDate` one hour later. Such a row is never a measurement and never changes the day total, which stays the day form: it feeds the intraday profile behind the same-time baseline (a day with at least four posted hours is built from them). A re-post overwrites (`updated`). Skipped with `hour_bucket_misaligned` when `startDate` is not the start of that local hour, `hour_bucket_unsupported_type` for any other identifier or a `MANUAL` / bridge entry, `hour_bucket_too_old` when the day is more than 400 days back.\n" +
|
||||
"The id MUST be stable across app restarts. An object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.",
|
||||
),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
@@ -133,10 +133,10 @@ const batchPayloadSchema = z
|
||||
// used in attribution — sending it or omitting it produces byte-identical
|
||||
// row outcomes. Omit on any client that does not track its wake reason.
|
||||
syncTrigger: z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.enum(["foreground", "background", "push", "manual", "backfill"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -157,7 +157,7 @@ const batchEntryResult = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate.",
|
||||
"Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate. `hour_bucket_misaligned`, `hour_bucket_unsupported_type`, `hour_bucket_too_old` (since v1.43): an hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` row the server refused, see `externalId`; permanent for the row as sent.",
|
||||
),
|
||||
convertedValue: z
|
||||
.number()
|
||||
@@ -595,86 +595,8 @@ export const sleepRhythmResponse = z
|
||||
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
|
||||
});
|
||||
|
||||
// ── Personal records ─────────────────────────────────────────────────
|
||||
//
|
||||
// `GET /api/personal-records` shipped as a schema-only contract so the native
|
||||
// client could build its query path against a stable shape before the
|
||||
// detection worker landed. It answers the stored rows verbatim — including
|
||||
// `userId` and the provenance foreign key — because the handler passes the
|
||||
// Prisma result straight to `apiSuccess` with no projection.
|
||||
|
||||
const personalRecordQuery = z
|
||||
.object({
|
||||
metricType: measurementTypeEnum
|
||||
.optional()
|
||||
.describe(
|
||||
"Restrict to one measurement type. A value outside the enum is DROPPED rather than refused — the read then covers every metric, which is indistinguishable from omitting the parameter.",
|
||||
),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.optional()
|
||||
.default(100)
|
||||
.describe(
|
||||
"Row cap, 1..500, default 100. A non-integer, zero, negative or non-numeric value falls back to the default and a value above the ceiling clamps to 500 — none of them 422.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "PersonalRecordQuery" });
|
||||
|
||||
const personalRecordResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The RECORD owner's id, which is not the caller's id on a delegated read.",
|
||||
),
|
||||
metricType: measurementTypeEnum,
|
||||
metricSlot: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Per-sport-type bucket for workout-driven records (e.g. `running_5km_time`). Null for a plain measurement-driven record, where the metric type alone is the dimension.",
|
||||
),
|
||||
direction: z
|
||||
.enum(["MAX", "MIN"])
|
||||
.describe(
|
||||
"Which way is better for this metric: `MAX` for steps / VO2 max / HRV / distance / daylight, `MIN` for resting heart rate / body fat / audio exposure.",
|
||||
),
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
achievedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"When the record was set. The list orders on this, newest first.",
|
||||
),
|
||||
sourceMeasurementId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The measurement row that achieved the record. Nulled rather than cascaded when that measurement is deleted, so the historical fact survives its source.",
|
||||
),
|
||||
source: measurementSourceEnum,
|
||||
externalId: z.string().nullable(),
|
||||
createdAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.",
|
||||
),
|
||||
benchmark: recordBenchmark
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "PersonalRecord",
|
||||
description:
|
||||
"One personal record: the best value a metric (optionally within a per-sport slot) has reached, in the direction that counts as better for it.",
|
||||
});
|
||||
// `GET /api/personal-records` is documented once, in `./awards.ts`, beside the
|
||||
// other read of earned bests.
|
||||
|
||||
// ── Time-series adapter (iOS chart source) ───────────────────────────
|
||||
//
|
||||
@@ -1255,28 +1177,4 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/personal-records": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "List the record's personal records",
|
||||
description:
|
||||
"Returns the account's personal-record rows, `achievedAt` descending. Both query parameters are FORGIVING rather than strict: an unrecognised `metricType` is dropped (the read falls back to every metric) and an unusable `limit` clamps to the default instead of 422-ing, so a loosely-typed client filter cannot take the page down. Rows are the stored entities verbatim, including `userId` and the provenance `sourceMeasurementId`. Delegable at READ level over the `measurements` section. Not module-gated: the list spans metrics from several modules and is filtered by the caller's `metricType`, not by the account's module map — a record achieved under a metric whose module is now off is still returned. Cookie or Bearer auth.",
|
||||
requestParams: { query: personalRecordQuery },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The personal-record rows (possibly empty).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(personalRecordResource),
|
||||
"PersonalRecordListEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -199,10 +199,10 @@ const bulkIntakePayload = z
|
||||
.object({
|
||||
entries: z.array(bulkIntakeEntry).min(1).max(500),
|
||||
syncTrigger: z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.enum(["foreground", "background", "push", "manual", "backfill"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.',
|
||||
'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry\'s `lastBackfillAt` and leaves its live stamps and the sync checkpoint alone. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ageAt, resolveRecordBenchmark } from "../record-benchmark";
|
||||
|
||||
const DOB_1981 = new Date("1981-06-15T00:00:00.000Z");
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
const profile = {
|
||||
dateOfBirth: DOB_1981,
|
||||
sex: "MALE" as const,
|
||||
heightCm: 180,
|
||||
weightKg: 80,
|
||||
};
|
||||
|
||||
function record(
|
||||
metricType: Parameters<typeof resolveRecordBenchmark>[0]["metricType"],
|
||||
unit: string,
|
||||
achievedAt = "2026-07-01T08:00:00.000Z",
|
||||
metricSlot: string | null = null,
|
||||
) {
|
||||
return { metricType, unit, metricSlot, achievedAt: at(achievedAt) };
|
||||
}
|
||||
|
||||
describe("ageAt", () => {
|
||||
it("counts whole years up to the day before the birthday", () => {
|
||||
expect(ageAt(DOB_1981, at("2026-06-14T23:00:00.000Z"))).toBe(44);
|
||||
expect(ageAt(DOB_1981, at("2026-06-15T00:00:00.000Z"))).toBe(45);
|
||||
expect(ageAt(null, at("2026-06-15T00:00:00.000Z"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRecordBenchmark", () => {
|
||||
it("gives a 45-year-old man's VO2 max record the FRIEND band in its unit", () => {
|
||||
const band = resolveRecordBenchmark(
|
||||
record("VO2_MAX", "mL/(kg·min)"),
|
||||
profile,
|
||||
);
|
||||
expect(band).not.toBeNull();
|
||||
expect(band!.unit).toBe("mL/(kg·min)");
|
||||
expect(band!.source).toBe("friend_2015");
|
||||
expect(band!.sourceLabel).toContain("Kaminsky");
|
||||
// Between the 40s (35–45) and 50s (31–41) bands.
|
||||
expect(band!.low).toBeGreaterThanOrEqual(31);
|
||||
expect(band!.low).toBeLessThanOrEqual(35);
|
||||
expect(band!.high).toBeGreaterThanOrEqual(41);
|
||||
expect(band!.high).toBeLessThanOrEqual(45);
|
||||
});
|
||||
|
||||
it("uses the age the record was set at, not today's", () => {
|
||||
const old = resolveRecordBenchmark(
|
||||
record("VO2_MAX", "mL/(kg·min)", "2004-07-01T08:00:00.000Z"),
|
||||
profile,
|
||||
);
|
||||
// 23 then: below the 20s bracket centre, so the 20s male band itself.
|
||||
expect(old).toMatchObject({ low: 42, high: 53 });
|
||||
});
|
||||
|
||||
it("returns null for a metric without a cited norm", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("ACTIVITY_STEPS", "steps"), profile),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without a date of birth", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), {
|
||||
...profile,
|
||||
dateOfBirth: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a sex-split table without a sex", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), {
|
||||
...profile,
|
||||
sex: undefined,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a record stored in another unit, never converted on a guess", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("GRIP_STRENGTH", "lb"), profile),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a spelling of the canonical unit", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("RESPIRATORY_RATE", "count/min"), profile),
|
||||
).toMatchObject({ unit: "count/min", source: "who_pals" });
|
||||
});
|
||||
|
||||
it("returns null for a per-sport slot", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(
|
||||
record("WALKING_RUNNING_DISTANCE", "m", undefined, "running_5km_time"),
|
||||
profile,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("places a six-minute walk between 80 % of the predicted distance and the prediction", () => {
|
||||
const band = resolveRecordBenchmark(
|
||||
record("SIX_MINUTE_WALK_DISTANCE", "m"),
|
||||
profile,
|
||||
);
|
||||
// Enright & Sherrill, man, 45 y, 180 cm, 80 kg.
|
||||
const predicted = 7.57 * 180 - 5.02 * 45 - 1.76 * 80 - 309;
|
||||
expect(band).toEqual({
|
||||
low: Math.round(predicted * 0.8),
|
||||
high: Math.round(predicted),
|
||||
unit: "m",
|
||||
source: "enright_sherrill_1998",
|
||||
sourceLabel: "Enright & Sherrill 1998",
|
||||
});
|
||||
expect(
|
||||
resolveRecordBenchmark(record("SIX_MINUTE_WALK_DISTANCE", "m"), {
|
||||
...profile,
|
||||
weightKg: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The comparison band published beside a personal record (v1.43, ios #116
|
||||
* item 5).
|
||||
*
|
||||
* The native client used to place a record against hard-coded population
|
||||
* values that knew neither the person's age nor their sex. The server owns the
|
||||
* cited norms (`lookupNormalRange` in `src/lib/insights/derived/norms.ts`, the
|
||||
* Enright & Sherrill equation for the six-minute walk), so it resolves the band
|
||||
* here and the clients show it as delivered, or nothing when it is null.
|
||||
*
|
||||
* The band is for the age the person had when the record was set: a record
|
||||
* from ten years ago is compared with what was typical then, not with today's
|
||||
* bracket. Null whenever no honest band exists: a metric without a cited norm,
|
||||
* no date of birth, a sex the table has no row for, a per-sport workout slot,
|
||||
* or a record stored in a unit other than the norm's (the band is never
|
||||
* converted on a guess).
|
||||
*
|
||||
* Pure: the caller reads the profile and the weight the walk equation needs.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
lookupNormalRange,
|
||||
predictSixMinuteWalkDistance,
|
||||
type NormSex,
|
||||
} from "@/lib/insights/derived/norms";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
export interface RecordBenchmark {
|
||||
low: number;
|
||||
high: number;
|
||||
unit: string;
|
||||
/** Stable identifier of the cited norm. */
|
||||
source: string;
|
||||
/** The citation as a reader sees it. */
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
/** The profile facts the norms are keyed on. */
|
||||
export interface RecordBenchmarkProfile {
|
||||
dateOfBirth: Date | null;
|
||||
sex: NormSex;
|
||||
heightCm: number | null;
|
||||
/** Latest weight in kg, for the six-minute-walk equation only. */
|
||||
weightKg: number | null;
|
||||
}
|
||||
|
||||
interface NormCitation {
|
||||
metricId: MetricStatusMetricId;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
const CITED_NORMS: Partial<Record<MeasurementType, NormCitation>> = {
|
||||
VO2_MAX: {
|
||||
metricId: "VO2_MAX",
|
||||
source: "friend_2015",
|
||||
sourceLabel: "FRIEND registry (Kaminsky et al. 2015)",
|
||||
},
|
||||
RESTING_HEART_RATE: {
|
||||
metricId: "RESTING_HEART_RATE",
|
||||
source: "fleming_2011",
|
||||
sourceLabel: "Fleming et al. 2011, AHA",
|
||||
},
|
||||
RESPIRATORY_RATE: {
|
||||
metricId: "RESPIRATORY_RATE",
|
||||
source: "who_pals",
|
||||
sourceLabel: "WHO / PALS reference",
|
||||
},
|
||||
GRIP_STRENGTH: {
|
||||
metricId: "GRIP_STRENGTH",
|
||||
source: "dodds_2014",
|
||||
sourceLabel: "Dodds et al. 2014, EWGSOP2",
|
||||
},
|
||||
};
|
||||
|
||||
const SIX_MINUTE_WALK: { source: string; sourceLabel: string } = {
|
||||
source: "enright_sherrill_1998",
|
||||
sourceLabel: "Enright & Sherrill 1998",
|
||||
};
|
||||
|
||||
/**
|
||||
* The share of the predicted distance the six-minute-walk card already calls
|
||||
* "green" (`six-minute-walk.ts`). The band runs from there to the predicted
|
||||
* distance, so the record and the card speak about the same expectation.
|
||||
*/
|
||||
const SIX_MINUTE_WALK_LOW_SHARE = 0.8;
|
||||
|
||||
/** Spellings of one canonical unit that writers have used. */
|
||||
const UNIT_ALIASES: Record<string, readonly string[]> = {
|
||||
"breaths/min": ["count/min", "/min", "breaths/min", "brpm"],
|
||||
};
|
||||
|
||||
function unitKey(unit: string): string {
|
||||
return unit.toLowerCase().replace(/[^a-z0-9%]/g, "");
|
||||
}
|
||||
|
||||
function sameUnit(type: MeasurementType, stored: string): boolean {
|
||||
const canonical = getUnitForType(type);
|
||||
const accepted = [canonical, ...(UNIT_ALIASES[canonical] ?? [])];
|
||||
return accepted.some((u) => unitKey(u) === unitKey(stored));
|
||||
}
|
||||
|
||||
/** Whole years between a date of birth and an instant. */
|
||||
export function ageAt(dateOfBirth: Date | null, at: Date): number | null {
|
||||
if (!dateOfBirth || Number.isNaN(dateOfBirth.getTime())) return null;
|
||||
let age = at.getUTCFullYear() - dateOfBirth.getUTCFullYear();
|
||||
const beforeBirthday =
|
||||
at.getUTCMonth() < dateOfBirth.getUTCMonth() ||
|
||||
(at.getUTCMonth() === dateOfBirth.getUTCMonth() &&
|
||||
at.getUTCDate() < dateOfBirth.getUTCDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function resolveRecordBenchmark(
|
||||
record: {
|
||||
metricType: MeasurementType;
|
||||
metricSlot: string | null;
|
||||
unit: string;
|
||||
achievedAt: Date;
|
||||
},
|
||||
profile: RecordBenchmarkProfile,
|
||||
): RecordBenchmark | null {
|
||||
// A per-sport slot (a 5 km time) has no population norm here.
|
||||
if (record.metricSlot !== null) return null;
|
||||
if (!sameUnit(record.metricType, record.unit)) return null;
|
||||
const age = ageAt(profile.dateOfBirth, record.achievedAt);
|
||||
// Every band below is age-stratified; without an age none is honest.
|
||||
if (age === null) return null;
|
||||
|
||||
if (record.metricType === "SIX_MINUTE_WALK_DISTANCE") {
|
||||
const predicted = predictSixMinuteWalkDistance(
|
||||
age,
|
||||
profile.heightCm,
|
||||
profile.weightKg,
|
||||
profile.sex,
|
||||
);
|
||||
if (predicted === null) return null;
|
||||
return {
|
||||
low: Math.round(predicted * SIX_MINUTE_WALK_LOW_SHARE),
|
||||
high: Math.round(predicted),
|
||||
unit: record.unit,
|
||||
...SIX_MINUTE_WALK,
|
||||
};
|
||||
}
|
||||
|
||||
const citation = CITED_NORMS[record.metricType];
|
||||
if (!citation) return null;
|
||||
const band = lookupNormalRange(citation.metricId, age, profile.sex);
|
||||
if (!band) return null;
|
||||
return {
|
||||
low: round1(band.low),
|
||||
high: round1(band.high),
|
||||
unit: record.unit,
|
||||
source: citation.source,
|
||||
sourceLabel: citation.sourceLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a record of this type can carry a band at all (query planning). */
|
||||
export function mayCarryBenchmark(type: MeasurementType): boolean {
|
||||
return type === "SIX_MINUTE_WALK_DISTANCE" || type in CITED_NORMS;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolvePersonalBand,
|
||||
resolveSummaryVerdicts,
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOf,
|
||||
trendSentimentOfTrend,
|
||||
} from "../trend-verdict";
|
||||
|
||||
const NO_PROFILE = { dateOfBirth: null, gender: null, heightCm: null };
|
||||
const MAN_41 = {
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
};
|
||||
const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
|
||||
|
||||
function verdict(
|
||||
key: string,
|
||||
referenceValue: number | null,
|
||||
thresholdsJson: unknown = null,
|
||||
profile: typeof NO_PROFILE | typeof MAN_41 = NO_PROFILE,
|
||||
) {
|
||||
return resolveTrendVerdict(key, { profile, thresholdsJson, referenceValue });
|
||||
}
|
||||
|
||||
describe("resolveTrendVerdict against the person's own target", () => {
|
||||
it("calls a rising blood pressure above the target a setback", () => {
|
||||
const v = verdict("BLOOD_PRESSURE_SYS", 132, OWN_BP);
|
||||
expect(v).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "personal_target",
|
||||
});
|
||||
expect(trendSentimentOf(4, v)).toBe("negative");
|
||||
});
|
||||
|
||||
it("calls the same rise below the target progress", () => {
|
||||
const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP);
|
||||
expect(v).toEqual({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
basis: "personal_target",
|
||||
});
|
||||
expect(trendSentimentOf(4, v)).toBe("positive");
|
||||
});
|
||||
|
||||
it("holds inside the target, with the edges counted as inside", () => {
|
||||
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP)).toMatchObject({
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
});
|
||||
const v = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
|
||||
expect(trendSentimentOf(0, v)).toBe("positive");
|
||||
expect(trendSentimentOf(3, v)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("judges the BLOOD_PRESSURE target tile by the systolic band", () => {
|
||||
expect(verdict("BLOOD_PRESSURE", 132, OWN_BP).direction).toBe("up-bad");
|
||||
});
|
||||
|
||||
it("reads a sleep target in hours against a reference in minutes", () => {
|
||||
const own = { SLEEP_DURATION: { min: 7, max: 8 } };
|
||||
expect(verdict("SLEEP_DURATION", 6 * 60, own)).toMatchObject({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
});
|
||||
expect(verdict("SLEEP_DURATION", 7.5 * 60, own).direction).toBe("hold");
|
||||
});
|
||||
|
||||
it("ignores a malformed target and falls back", () => {
|
||||
expect(
|
||||
verdict("BLOOD_PRESSURE_SYS", 132, {
|
||||
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTrendVerdict against a profile default", () => {
|
||||
it("uses the age-based blood pressure band", () => {
|
||||
expect(verdict("BLOOD_PRESSURE_SYS", 125, null, MAN_41)).toEqual({
|
||||
direction: "hold",
|
||||
targetPosition: "inside",
|
||||
basis: "profile_default",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the age- and sex-based resting pulse band for both pulse keys", () => {
|
||||
for (const key of ["PULSE", "RESTING_HEART_RATE"]) {
|
||||
expect(verdict(key, 95, null, MAN_41)).toMatchObject({
|
||||
direction: "up-bad",
|
||||
targetPosition: "above",
|
||||
basis: "profile_default",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not call a band nobody is personalised for a profile default", () => {
|
||||
// No age: the pulse band is the same for everyone.
|
||||
expect(verdict("RESTING_HEART_RATE", 95)).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
// No sex: no body fat band.
|
||||
expect(verdict("BODY_FAT", 30).basis).toBe("metric_default");
|
||||
});
|
||||
|
||||
it("prefers the person's own target over the profile default", () => {
|
||||
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe(
|
||||
"personal_target",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTrendVerdict metric defaults", () => {
|
||||
it.each([
|
||||
["ACTIVITY_STEPS", "up-good"],
|
||||
["HEART_RATE_VARIABILITY", "up-good"],
|
||||
["HRV_RMSSD", "up-good"],
|
||||
["VO2_MAX", "up-good"],
|
||||
["MOOD", "up-good"],
|
||||
["BODY_FAT", "up-bad"],
|
||||
["RESTING_HEART_RATE", "up-bad"],
|
||||
["RESPIRATORY_RATE", "neutral"],
|
||||
["TOTAL_BODY_WATER", "neutral"],
|
||||
["BLOOD_GLUCOSE_FASTING", "neutral"],
|
||||
])("%s defaults to %s", (key, direction) => {
|
||||
expect(verdict(key, null)).toEqual({
|
||||
direction,
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps weight's own rule: only a stored target counts", () => {
|
||||
expect(verdict("WEIGHT", 70, null, MAN_41)).toEqual({
|
||||
direction: "up-bad",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
});
|
||||
expect(verdict("WEIGHT", 60, { WEIGHT: { min: 65, max: 72 } })).toEqual({
|
||||
direction: "up-good",
|
||||
targetPosition: "below",
|
||||
basis: "personal_target",
|
||||
});
|
||||
// BMI follows the weight it is derived from.
|
||||
expect(verdict("BMI", 60, { WEIGHT: { min: 65, max: 72 } }).direction).toBe(
|
||||
"up-good",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trend sentiment", () => {
|
||||
it("applies the noise floor and treats no verdict as neutral", () => {
|
||||
const upBad = verdict("RESTING_HEART_RATE", null);
|
||||
expect(trendSentimentOf(0.01, upBad)).toBe("neutral");
|
||||
expect(trendSentimentOf(-2, upBad)).toBe("positive");
|
||||
expect(trendSentimentOf(2, null)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("reads a trend word the way it reads a signed change", () => {
|
||||
const upGood = verdict("ACTIVITY_STEPS", null);
|
||||
expect(trendSentimentOfTrend("up", upGood)).toBe("positive");
|
||||
expect(trendSentimentOfTrend("down", upGood)).toBe("negative");
|
||||
expect(trendSentimentOfTrend("flat", upGood)).toBe("neutral");
|
||||
expect(trendSentimentOfTrend("unknown", upGood)).toBe("neutral");
|
||||
expect(trendSentimentOfTrend(null, upGood)).toBe("neutral");
|
||||
const hold = verdict("BLOOD_PRESSURE_SYS", 120, OWN_BP);
|
||||
expect(trendSentimentOfTrend("stable", hold)).toBe("positive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePersonalBand", () => {
|
||||
it("is null when neither an own target nor a personalised default exists", () => {
|
||||
expect(resolvePersonalBand("ACTIVITY_STEPS", NO_PROFILE, null)).toBeNull();
|
||||
expect(resolvePersonalBand("BLOOD_PRESSURE_DIA", MAN_41, null)).toEqual({
|
||||
band: { min: 70, max: 79 },
|
||||
basis: "profile_default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSummaryVerdicts", () => {
|
||||
it("places each tile's seven-day average and adds the summaryless keys", () => {
|
||||
const out = resolveSummaryVerdicts(
|
||||
{
|
||||
BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 },
|
||||
ACTIVITY_STEPS: { avg7: null, latest: 9000 },
|
||||
},
|
||||
{ profile: NO_PROFILE, thresholdsJson: OWN_BP },
|
||||
);
|
||||
expect(out.BLOOD_PRESSURE_SYS.direction).toBe("up-bad");
|
||||
expect(out.ACTIVITY_STEPS.direction).toBe("up-good");
|
||||
expect(out.MOOD.direction).toBe("up-good");
|
||||
expect(out.BLOOD_PRESSURE_IN_TARGET.direction).toBe("up-good");
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,11 @@ import { buildSleepTarget } from "./sleep-builder";
|
||||
import { buildTargetPageSummary } from "./summary-builder";
|
||||
import { buildVitalTargets } from "./vitals-builder";
|
||||
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
|
||||
import {
|
||||
resolveTrendVerdict,
|
||||
trendSentimentOfTrend,
|
||||
type TrendVerdictContext,
|
||||
} from "./trend-verdict";
|
||||
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
@@ -362,6 +367,18 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.43 (ios #116 item 3) — every item carries which way its change
|
||||
// counts, judged with the same rule the dashboard tiles use.
|
||||
const verdictTargets = withTargetVerdicts(targets, {
|
||||
profile: {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
},
|
||||
thresholdsJson: dbUser?.thresholdsJson ?? null,
|
||||
});
|
||||
targets.splice(0, targets.length, ...verdictTargets);
|
||||
|
||||
const pageSummary = buildTargetPageSummary(targets);
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
@@ -393,3 +410,35 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the verdict over each target item. The reference is the item's latest
|
||||
* value, else its thirty-day average, in the unit the item is served in:
|
||||
* sleep is served in hours and judged in minutes, and BMI is judged by the
|
||||
* weight it is derived from.
|
||||
*/
|
||||
export function withTargetVerdicts(
|
||||
targets: readonly TargetItem[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): TargetItem[] {
|
||||
const weight = targets.find((item) => item.type === "WEIGHT");
|
||||
const weightReference = weight ? (weight.current ?? weight.average30) : null;
|
||||
return targets.map((item) => {
|
||||
const own = item.current ?? item.average30;
|
||||
const reference =
|
||||
item.type === "BMI"
|
||||
? weightReference
|
||||
: own !== null && item.type === "SLEEP_DURATION"
|
||||
? own * 60
|
||||
: own;
|
||||
const direction = resolveTrendVerdict(item.type, {
|
||||
...ctx,
|
||||
referenceValue: reference,
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
direction,
|
||||
trendSentiment: trendSentimentOfTrend(item.trend, direction),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Which way a change of a metric counts as progress, for this person (v1.43,
|
||||
* ios #116 item 3). The generalisation of `resolveWeightTrend`.
|
||||
*
|
||||
* Every surface that shows a change (a dashboard arrow, a seven-day delta, a
|
||||
* target tile, a chart's first-to-last change) used to carry its own fixed
|
||||
* polarity: a rising blood pressure turned orange whatever the person's
|
||||
* target was, and the native client kept a third copy of the table. The
|
||||
* verdict is resolved here, once, and published on every route that serves a
|
||||
* change; clients colour from it and never decide it themselves.
|
||||
*
|
||||
* The rule:
|
||||
*
|
||||
* - a band applies (the person's own target from `/targets`, else a
|
||||
* profile-derived default: the age-based blood pressure target, the
|
||||
* age- and sex-based resting pulse band, the sex-based body fat band):
|
||||
* below the band → a rise is progress (`up-good`)
|
||||
* above the band → a fall is progress (`up-bad`)
|
||||
* inside the band → holding is progress (`hold`)
|
||||
* - no band, or no value to place: the metric's own default direction.
|
||||
*
|
||||
* Weight keeps its own rule (`weight-trend.ts`): only a target the person set
|
||||
* counts, never the height-derived band, because a band nobody chose says
|
||||
* nothing about which way they want to move.
|
||||
*
|
||||
* Pure and client-safe: no DB read, no clock beyond the age the profile
|
||||
* helpers compute.
|
||||
*/
|
||||
import {
|
||||
getEffectiveRange,
|
||||
resolveThresholdOverride,
|
||||
resolveWeightTargetOverride,
|
||||
type ThresholdMetric,
|
||||
type UserProfileForRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import {
|
||||
getTrendSentiment,
|
||||
type TrendDirectionSentiment,
|
||||
type TrendSentimentDirection,
|
||||
} from "@/lib/insights/trend-sentiment";
|
||||
import { binaryReferenceSex } from "@/lib/profile/sex";
|
||||
|
||||
import { resolveWeightTrend } from "./weight-trend";
|
||||
|
||||
export type TrendTargetPosition = "below" | "inside" | "above";
|
||||
export type TrendVerdictBasis =
|
||||
"personal_target" | "profile_default" | "metric_default";
|
||||
|
||||
export interface TrendVerdict {
|
||||
direction: TrendDirectionSentiment;
|
||||
targetPosition: TrendTargetPosition | null;
|
||||
basis: TrendVerdictBasis;
|
||||
}
|
||||
|
||||
export type TrendSentiment = TrendSentimentDirection;
|
||||
|
||||
/**
|
||||
* What a verdict can be asked for: a measurement type, or one of the derived
|
||||
* keys a target tile or dashboard card carries (`BLOOD_PRESSURE` is the
|
||||
* systolic side of the pair, `BMI` follows weight, `MOOD` / `MOOD_SCORE` the
|
||||
* mood mean, `BLOOD_PRESSURE_IN_TARGET` and `MEDICATION_COMPLIANCE` a share).
|
||||
*/
|
||||
export type TrendVerdictKey = string;
|
||||
|
||||
/**
|
||||
* The metric's own direction when no band applies. A rise is progress for the
|
||||
* capacity and activity measures, a setback for the load measures, and
|
||||
* carries no judgement for everything else (body water, bone mass, breathing
|
||||
* rate, skin temperature, intake, …).
|
||||
*/
|
||||
const METRIC_DEFAULT_DIRECTION: Readonly<
|
||||
Record<string, Exclude<TrendDirectionSentiment, "hold">>
|
||||
> = {
|
||||
ACTIVITY_STEPS: "up-good",
|
||||
HEART_RATE_VARIABILITY: "up-good",
|
||||
HRV_RMSSD: "up-good",
|
||||
VO2_MAX: "up-good",
|
||||
OXYGEN_SATURATION: "up-good",
|
||||
MUSCLE_MASS: "up-good",
|
||||
SLEEP_DURATION: "up-good",
|
||||
SIX_MINUTE_WALK_DISTANCE: "up-good",
|
||||
GRIP_STRENGTH: "up-good",
|
||||
MOOD: "up-good",
|
||||
MOOD_SCORE: "up-good",
|
||||
BLOOD_PRESSURE_IN_TARGET: "up-good",
|
||||
MEDICATION_COMPLIANCE: "up-good",
|
||||
RESTING_HEART_RATE: "up-bad",
|
||||
PULSE: "up-bad",
|
||||
BODY_FAT: "up-bad",
|
||||
BLOOD_PRESSURE: "up-bad",
|
||||
BLOOD_PRESSURE_SYS: "up-bad",
|
||||
BLOOD_PRESSURE_DIA: "up-bad",
|
||||
BODY_TEMPERATURE: "up-bad",
|
||||
BLOOD_GLUCOSE: "up-bad",
|
||||
};
|
||||
|
||||
/** The `/targets` threshold a key is judged against, when it has one. */
|
||||
const THRESHOLD_FOR_KEY: Readonly<Record<string, ThresholdMetric>> = {
|
||||
BLOOD_PRESSURE: "BLOOD_PRESSURE_SYS",
|
||||
BLOOD_PRESSURE_SYS: "BLOOD_PRESSURE_SYS",
|
||||
BLOOD_PRESSURE_DIA: "BLOOD_PRESSURE_DIA",
|
||||
// The resting pulse card and the dashboard pulse tile share one target.
|
||||
PULSE: "PULSE",
|
||||
RESTING_HEART_RATE: "PULSE",
|
||||
BODY_FAT: "BODY_FAT",
|
||||
SLEEP_DURATION: "SLEEP_DURATION",
|
||||
ACTIVITY_STEPS: "ACTIVITY_STEPS",
|
||||
TOTAL_BODY_WATER: "TOTAL_BODY_WATER",
|
||||
BONE_MASS: "BONE_MASS",
|
||||
OXYGEN_SATURATION: "OXYGEN_SATURATION",
|
||||
};
|
||||
|
||||
/**
|
||||
* Stored values the threshold is not written in. Sleep is stored in minutes
|
||||
* and targeted in hours; every other threshold shares the stored unit.
|
||||
*/
|
||||
const TO_THRESHOLD_UNIT: Readonly<Record<string, (value: number) => number>> = {
|
||||
SLEEP_DURATION: (minutes) => minutes / 60,
|
||||
};
|
||||
|
||||
export interface TrendVerdictContext {
|
||||
/** The profile of the RECORD on screen, not of the viewer. */
|
||||
profile: UserProfileForRange;
|
||||
/** `User.thresholdsJson` of the same record, unparsed. */
|
||||
thresholdsJson: unknown;
|
||||
/**
|
||||
* The value placed against the band, in the stored (canonical) unit of the
|
||||
* key: usually the seven-day average, else the latest reading. Null when
|
||||
* there is nothing to place, which leaves the metric's default.
|
||||
*/
|
||||
referenceValue: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the profile carries what the default band of this metric is
|
||||
* personalised from. Without it the band is the same for everyone, which is a
|
||||
* metric default and not a statement about this person.
|
||||
*/
|
||||
function hasProfileDefault(
|
||||
metric: ThresholdMetric,
|
||||
profile: UserProfileForRange,
|
||||
): boolean {
|
||||
switch (metric) {
|
||||
case "BLOOD_PRESSURE_SYS":
|
||||
case "BLOOD_PRESSURE_DIA":
|
||||
return profile.dateOfBirth != null;
|
||||
case "PULSE": {
|
||||
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
|
||||
return age != null && age >= 20;
|
||||
}
|
||||
case "BODY_FAT":
|
||||
return binaryReferenceSex(profile.gender) !== null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The band a person's values are judged against: their own target, else the
|
||||
* profile default when the profile carries what it is personalised from.
|
||||
* Null when neither applies. Shared with the chart reference lines, so the
|
||||
* line drawn and the verdict coloured come from the same band.
|
||||
*/
|
||||
export function resolvePersonalBand(
|
||||
metric: ThresholdMetric,
|
||||
profile: UserProfileForRange,
|
||||
thresholdsJson: unknown,
|
||||
): {
|
||||
band: { min: number; max: number };
|
||||
basis: Exclude<TrendVerdictBasis, "metric_default">;
|
||||
} | null {
|
||||
const own = resolveThresholdOverride(thresholdsJson, metric);
|
||||
if (own) return { band: own, basis: "personal_target" };
|
||||
if (!hasProfileDefault(metric, profile)) return null;
|
||||
const range = getEffectiveRange(metric, profile, null).range;
|
||||
return range
|
||||
? {
|
||||
band: { min: range.greenMin, max: range.greenMax },
|
||||
basis: "profile_default",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function metricDefault(key: string): TrendVerdict {
|
||||
return {
|
||||
direction: METRIC_DEFAULT_DIRECTION[key] ?? "neutral",
|
||||
targetPosition: null,
|
||||
basis: "metric_default",
|
||||
};
|
||||
}
|
||||
|
||||
/** Place a value against a band; the edges count as inside. */
|
||||
export function placeAgainstBand(
|
||||
band: { min: number; max: number },
|
||||
value: number,
|
||||
): TrendTargetPosition {
|
||||
if (value < band.min) return "below";
|
||||
if (value > band.max) return "above";
|
||||
return "inside";
|
||||
}
|
||||
|
||||
const DIRECTION_BY_POSITION: Record<
|
||||
TrendTargetPosition,
|
||||
TrendDirectionSentiment
|
||||
> = {
|
||||
below: "up-good",
|
||||
above: "up-bad",
|
||||
inside: "hold",
|
||||
};
|
||||
|
||||
export function resolveTrendVerdict(
|
||||
key: TrendVerdictKey,
|
||||
ctx: TrendVerdictContext,
|
||||
): TrendVerdict {
|
||||
const reference =
|
||||
ctx.referenceValue !== null && Number.isFinite(ctx.referenceValue)
|
||||
? ctx.referenceValue
|
||||
: null;
|
||||
|
||||
// Weight, and BMI which moves exactly when weight does: only the person's
|
||||
// own target counts (see the module comment). For `BMI` the reference is
|
||||
// the WEIGHT it is derived from, in kg, since the target is a weight.
|
||||
if (key === "WEIGHT" || key === "BMI") {
|
||||
const target = resolveWeightTargetOverride(ctx.thresholdsJson);
|
||||
const judgement = resolveWeightTrend(target, reference);
|
||||
return {
|
||||
direction: judgement.direction,
|
||||
targetPosition: judgement.targetPosition,
|
||||
basis:
|
||||
judgement.targetPosition !== null
|
||||
? "personal_target"
|
||||
: "metric_default",
|
||||
};
|
||||
}
|
||||
|
||||
const metric = THRESHOLD_FOR_KEY[key];
|
||||
if (!metric || reference === null) return metricDefault(key);
|
||||
|
||||
const personal = resolvePersonalBand(metric, ctx.profile, ctx.thresholdsJson);
|
||||
if (!personal) return metricDefault(key);
|
||||
|
||||
const toUnit = TO_THRESHOLD_UNIT[key];
|
||||
const position = placeAgainstBand(
|
||||
personal.band,
|
||||
toUnit ? toUnit(reference) : reference,
|
||||
);
|
||||
return {
|
||||
direction: DIRECTION_BY_POSITION[position],
|
||||
targetPosition: position,
|
||||
basis: personal.basis,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivered change judged by a verdict, with the noise floor the arrows
|
||||
* have always used (`getTrendSentiment`). No verdict reads neutral.
|
||||
*/
|
||||
export function trendSentimentOf(
|
||||
change: number | null | undefined,
|
||||
verdict: TrendVerdict | null,
|
||||
): TrendSentiment {
|
||||
return getTrendSentiment(change, verdict?.direction ?? "neutral");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same judgement for a change that is only known by its direction (the
|
||||
* `trend` of a dashboard card or a target tile). A flat or unknown trend is
|
||||
* no change: inside a band that is the one case where it is news.
|
||||
*/
|
||||
export function trendSentimentOfTrend(
|
||||
trend: "up" | "down" | "flat" | "stable" | "unknown" | null | undefined,
|
||||
verdict: TrendVerdict | null,
|
||||
): TrendSentiment {
|
||||
if (trend === "up") return trendSentimentOf(1, verdict);
|
||||
if (trend === "down") return trendSentimentOf(-1, verdict);
|
||||
if (trend === "flat" || trend === "stable")
|
||||
return trendSentimentOf(0, verdict);
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/** Keys that carry a verdict without a measurement summary of their own. */
|
||||
export const SUMMARYLESS_VERDICT_KEYS = [
|
||||
"MOOD",
|
||||
"BLOOD_PRESSURE_IN_TARGET",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One verdict per summarised type, each placed with the value its tile shows
|
||||
* as the reference (the seven-day average, else the latest reading), plus the
|
||||
* summaryless keys at their metric default. The dashboard snapshot publishes
|
||||
* this as `tiles.directions`.
|
||||
*/
|
||||
export function resolveSummaryVerdicts(
|
||||
summaries: Readonly<
|
||||
Record<string, { avg7: number | null; latest: number | null } | undefined>
|
||||
>,
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
): Record<string, TrendVerdict> {
|
||||
const out: Record<string, TrendVerdict> = {};
|
||||
for (const [type, summary] of Object.entries(summaries)) {
|
||||
if (!summary) continue;
|
||||
out[type] = resolveTrendVerdict(type, {
|
||||
...ctx,
|
||||
referenceValue: summary.avg7 ?? summary.latest ?? null,
|
||||
});
|
||||
}
|
||||
for (const key of SUMMARYLESS_VERDICT_KEYS) {
|
||||
out[key] ??= resolveTrendVerdict(key, { ...ctx, referenceValue: null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
|
||||
import type { TrendSentiment, TrendVerdict } from "./trend-verdict";
|
||||
|
||||
export type TargetTrend = "up" | "down" | "stable" | null;
|
||||
export type DayBand = "in" | "near" | "out";
|
||||
@@ -35,6 +36,13 @@ export interface TargetItem extends TargetConsistency {
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
/**
|
||||
* v1.43 (ios #116 item 3) — which way a change of this target counts for
|
||||
* the person (`src/lib/targets/trend-verdict.ts`), and that verdict
|
||||
* applied to `trend`. Set on every item by `buildTargetsResponse`.
|
||||
*/
|
||||
direction?: TrendVerdict | null;
|
||||
trendSentiment?: TrendSentiment | null;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
|
||||
@@ -210,6 +210,69 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// A qualitative marker is minted with an empty unit and its readings are
|
||||
// stored under it; the file the account's own backup wrote must parse.
|
||||
describe("units of qualitative lab markers", () => {
|
||||
const base = {
|
||||
schemaVersion: "1",
|
||||
exportedAt: "2026-05-08T07:00:00.000Z",
|
||||
userId: "u1",
|
||||
};
|
||||
const qualitativeMarker = { id: "bm-1", name: "Urine glucose", unit: "" };
|
||||
const qualitativeReading = {
|
||||
analyte: "Urine glucose",
|
||||
value: null,
|
||||
valueText: "negative",
|
||||
unit: "",
|
||||
takenAt: "2026-04-01T09:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
biomarkerId: "bm-1",
|
||||
biomarkerName: "Urine glucose",
|
||||
};
|
||||
|
||||
it("accepts an empty unit on a qualitative marker and its reading", () => {
|
||||
const r = backupPayloadSchema.safeParse({
|
||||
...base,
|
||||
biomarkers: [qualitativeMarker],
|
||||
labResults: [qualitativeReading],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still needs the unit on a numeric reading", () => {
|
||||
const r = backupPayloadSchema.safeParse({
|
||||
...base,
|
||||
labResults: [
|
||||
{
|
||||
...qualitativeReading,
|
||||
value: 5,
|
||||
valueText: null,
|
||||
biomarkerId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error?.issues.map((i) => i.path.join("."))).toEqual([
|
||||
"labResults.0.unit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses an empty unit on a marker a numeric reading is filed under", () => {
|
||||
const r = backupPayloadSchema.safeParse({
|
||||
...base,
|
||||
biomarkers: [qualitativeMarker],
|
||||
labResults: [
|
||||
qualitativeReading,
|
||||
{ ...qualitativeReading, value: 5, valueText: null, unit: "mg/dL" },
|
||||
],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error?.issues.map((i) => i.path.join("."))).toEqual([
|
||||
"biomarkers.0.unit",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips an illness episode with a flare referencing its parent + nested day-logs", () => {
|
||||
const r = backupPayloadSchema.safeParse({
|
||||
schemaVersion: "1",
|
||||
|
||||
@@ -993,6 +993,12 @@ const intradayProfileBackupSchema = z
|
||||
hourlyCumulative: z.array(z.number()),
|
||||
dayTotal: z.number(),
|
||||
sampleCount: z.number().int(),
|
||||
// v1.43 (ios #77) — the hours the phone posted, what the curve was built
|
||||
// from, and how many hours there were. Defaulted so a file written before
|
||||
// the hours existed restores as a fold of single samples, which it was.
|
||||
hourlyIncrements: z.array(z.number()).default([]),
|
||||
basis: z.enum(["PER_SAMPLE", "HOUR_BUCKETS"]).default("PER_SAMPLE"),
|
||||
bucketCount: z.number().int().nonnegative().nullable().default(null),
|
||||
// The zone the day was cut on. The reader compares it against the
|
||||
// account's current zone and drops the mismatched days, so a restore that
|
||||
// dropped this column would make old curves look comparable when their
|
||||
@@ -1120,7 +1126,10 @@ const labResultBackupSchema = z
|
||||
analyte: z.string().min(1),
|
||||
value: z.number().nullable().optional(),
|
||||
valueText: z.string().nullable().optional(),
|
||||
unit: z.string().min(1),
|
||||
// A qualitative reading ("positive", "trace") has no unit and is stored
|
||||
// under its marker's empty one (`POST /api/labs`); the refine below keeps
|
||||
// the unit required for every numeric reading.
|
||||
unit: z.string(),
|
||||
referenceLow: z.number().nullable().optional(),
|
||||
referenceHigh: z.number().nullable().optional(),
|
||||
// Optional so a backup written before these columns existed still
|
||||
@@ -1142,13 +1151,29 @@ const labResultBackupSchema = z
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.superRefine((reading, ctx) => {
|
||||
const qualitative =
|
||||
(reading.value === null || reading.value === undefined) &&
|
||||
typeof reading.valueText === "string" &&
|
||||
reading.valueText.length > 0;
|
||||
if (reading.unit.length === 0 && !qualitative) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["unit"],
|
||||
message: "A numeric lab reading needs its unit",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const biomarkerBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
name: z.string().min(1),
|
||||
unit: z.string().min(1),
|
||||
// Empty for a qualitative marker, minted from a reading with no unit. The
|
||||
// payload refine below refuses an empty unit on a marker a numeric
|
||||
// reading is filed under.
|
||||
unit: z.string(),
|
||||
lowerBound: z.number().nullable().optional(),
|
||||
upperBound: z.number().nullable().optional(),
|
||||
panel: z.string().nullable().optional(),
|
||||
@@ -2246,6 +2271,30 @@ export const backupPayloadSchema = z
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((payload, ctx) => {
|
||||
// A marker without a unit is a qualitative one. A numeric reading filed
|
||||
// under it would restore as a number with no unit, so the file is refused
|
||||
// naming the marker rather than restored into that.
|
||||
const numericByMarker = new Set<string>();
|
||||
for (const reading of payload.labResults) {
|
||||
if (reading.value === null || reading.value === undefined) continue;
|
||||
if (reading.biomarkerId) numericByMarker.add(`id:${reading.biomarkerId}`);
|
||||
if (reading.biomarkerName) {
|
||||
numericByMarker.add(`name:${reading.biomarkerName}`);
|
||||
}
|
||||
}
|
||||
payload.biomarkers.forEach((marker, index) => {
|
||||
if (marker.unit.length > 0) return;
|
||||
if (
|
||||
(marker.id && numericByMarker.has(`id:${marker.id}`)) ||
|
||||
numericByMarker.has(`name:${marker.name}`)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["biomarkers", index, "unit"],
|
||||
message: `Biomarker "${marker.name}" has numeric readings and needs its unit`,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return;
|
||||
payload.measurements.forEach((measurement, index) => {
|
||||
if (!measurement.id) {
|
||||
|
||||
Reference in new issue
Block a user