mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(daily): speak about today only from today's readings
A statement that says "today", or compares today against a baseline,
now only rests on a reading taken on the current calendar day in the
reader's zone. Yesterday morning's pulse read at nine in the evening
was narrated as today's: the signals of the day accepted any reading
from the last two weeks, and the Today overview, the coincident
deviation flag and the health status counted yesterday as current.
- computeSignalsOfDay drops a metric with no reading today, so the
briefing prompt and its content hash never see a stale value as the
day's; the prompt says so in both languages.
- The digest serves no cached briefing written on an earlier day and
drops a reading-backed signal whose metric was not measured today,
so the web hero, the iOS topSignal and the morning push agree.
- The Today overview's vitals line and deterministic lead, the
coincident flag and the health status deviations use the new
isFromToday rule (calendar day, not elapsed hours).
Signal deltas now carry each metric's display precision: the prompt
input is rounded (whole bpm and mmHg, one decimal of kg), and the
delta string under the hero lead is re-read at that precision in the
reader's locale ("+34 bpm", not "+33,72 bpm").
This commit is contained in:
1 parent
6cea4be2a1
commit
aa222f5b02
17 files changed
+645
-75
No files matched your search
@@ -293,6 +293,10 @@ gib die obige Verweigerung zurück.`,
|
||||
briefing is stronger with one or two sharp signals than three
|
||||
weak ones. When "signalsOfDay" is absent or flat, omit the field
|
||||
(or set null) — do NOT manufacture a signal from a quiet metric.
|
||||
Every "signalsOfDay" entry comes from a reading taken TODAY in the
|
||||
reader's own timezone. A metric missing from the block was not
|
||||
measured today: never call its last reading "today's", and never
|
||||
build a signal or a "today" sentence from it.
|
||||
- keyFindings: 0-5 short rows — the longer-horizon trend list
|
||||
below the signals. Each row has tone ("good" | "watch" |
|
||||
"info"), a headline (≤ 60 chars), a one-sentence detail, an
|
||||
@@ -504,6 +508,10 @@ gib die obige Verweigerung zurück.`,
|
||||
zwei scharfe Signale sind stärker als drei schwache. Fehlt
|
||||
"signalsOfDay" oder ist flach, lass das Feld weg (oder setze null)
|
||||
— erfinde KEIN Signal aus einer ruhigen Metrik.
|
||||
Jeder "signalsOfDay"-Eintrag stammt aus einer Messung von HEUTE in
|
||||
der Zeitzone der Person. Eine Metrik, die im Block fehlt, wurde
|
||||
heute nicht gemessen: nenne ihren letzten Wert nie "heute" und baue
|
||||
daraus weder ein Signal noch einen Satz über heute.
|
||||
- keyFindings: 0-5 kurze Zeilen — die Trend-Liste mit längerem
|
||||
Horizont unter den Signalen. Jede Zeile hat tone ("good" | "watch"
|
||||
| "info"), eine headline (≤ 60 Zeichen), ein detail im Einzelsatz,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
import {
|
||||
briefingForToday,
|
||||
formatSignalDelta,
|
||||
} from "@/lib/daily/briefing-today";
|
||||
|
||||
describe("formatSignalDelta", () => {
|
||||
it("reads a pulse difference in whole beats (de)", () => {
|
||||
expect(
|
||||
formatSignalDelta("+33,72 bpm vs. dein 30-Tage-Mittel", "pulse", "de"),
|
||||
).toBe("+34 bpm vs. dein 30-Tage-Mittel");
|
||||
});
|
||||
|
||||
it("reads a pulse difference in whole beats (en)", () => {
|
||||
expect(
|
||||
formatSignalDelta("+33.72 bpm vs your 30-day average", "pulse", "en"),
|
||||
).toBe("+34 bpm vs your 30-day average");
|
||||
});
|
||||
|
||||
it("reads weight at one decimal in both languages", () => {
|
||||
expect(
|
||||
formatSignalDelta("−0,37 kg seit letzter Woche", "weight", "de"),
|
||||
).toBe("−0,4 kg seit letzter Woche");
|
||||
expect(formatSignalDelta("-0.37 kg since last week", "weight", "en")).toBe(
|
||||
"-0.4 kg since last week",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a figure already at its precision, and a grouped thousand, alone", () => {
|
||||
expect(
|
||||
formatSignalDelta("+6 mmHg vs your 30-day average", "bp", "en"),
|
||||
).toBe("+6 mmHg vs your 30-day average");
|
||||
expect(formatSignalDelta("+1,2 kg", "weight", "de")).toBe("+1,2 kg");
|
||||
// "1.234" is a thousand in German, never a decimal to round.
|
||||
expect(formatSignalDelta("1.234 bpm", "pulse", "de")).toBe("1.234 bpm");
|
||||
});
|
||||
|
||||
it("drops the sign of a difference that rounds to nothing", () => {
|
||||
expect(formatSignalDelta("+0.3 bpm vs your normal", "pulse", "en")).toBe(
|
||||
"0 bpm vs your normal",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a metric that is not a single reading verbatim", () => {
|
||||
expect(formatSignalDelta("−42,5 Min.", "sleep", "de")).toBe("−42,5 Min.");
|
||||
});
|
||||
});
|
||||
|
||||
const BRIEFING: DailyBriefing = {
|
||||
paragraph: "Dein Puls ist heute deutlich erhöht.",
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "pulse",
|
||||
tone: "watch",
|
||||
headline: "Puls ist heute deutlich erhöht",
|
||||
nudge: "Gönn dir heute etwas Ruhe.",
|
||||
delta: "+33,72 bpm vs. dein 30-Tage-Mittel",
|
||||
},
|
||||
{
|
||||
sourceMetric: "sleep",
|
||||
tone: "info",
|
||||
headline: "Kürzere Nacht",
|
||||
nudge: "Heute etwas früher ins Bett.",
|
||||
delta: null,
|
||||
},
|
||||
],
|
||||
keyFindings: [],
|
||||
};
|
||||
|
||||
/** 2026-10-05 21:00 in Berlin, the time the defect was seen. */
|
||||
const BERLIN = {
|
||||
timezone: "Europe/Berlin",
|
||||
todayLocalDate: "2026-10-05",
|
||||
language: "de" as const,
|
||||
};
|
||||
|
||||
describe("briefingForToday", () => {
|
||||
it("drops a pulse signal whose last reading was yesterday at 08:50", () => {
|
||||
const out = briefingForToday(BRIEFING, {
|
||||
...BERLIN,
|
||||
updatedAt: "2026-10-05T04:00:00Z",
|
||||
lastSeenAt: (type) => (type === "PULSE" ? "2026-10-04T06:50:00Z" : null),
|
||||
});
|
||||
expect(out?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual(["sleep"]);
|
||||
});
|
||||
|
||||
it("keeps a pulse signal measured today, with its delta in whole beats", () => {
|
||||
const out = briefingForToday(BRIEFING, {
|
||||
...BERLIN,
|
||||
updatedAt: "2026-10-05T04:00:00Z",
|
||||
lastSeenAt: (type) => (type === "PULSE" ? "2026-10-05T06:50:00Z" : null),
|
||||
});
|
||||
expect(out?.signalsOfDay?.[0]?.delta).toBe(
|
||||
"+34 bpm vs. dein 30-Tage-Mittel",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves no briefing written on an earlier day, just after midnight", () => {
|
||||
// Written 23:30 on the 4th, read 00:10 on the 5th (Berlin).
|
||||
expect(
|
||||
briefingForToday(BRIEFING, {
|
||||
...BERLIN,
|
||||
updatedAt: "2026-10-04T21:30:00Z",
|
||||
lastSeenAt: () => "2026-10-04T21:00:00Z",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("decides the day in the reader's zone", () => {
|
||||
// 05:00 UTC on the 5th is the evening of the 4th in Los Angeles.
|
||||
const ctx = {
|
||||
updatedAt: "2026-10-05T05:00:00Z",
|
||||
lastSeenAt: () => "2026-10-05T05:00:00Z",
|
||||
language: "en" as const,
|
||||
};
|
||||
expect(
|
||||
briefingForToday(BRIEFING, {
|
||||
...ctx,
|
||||
timezone: "America/Los_Angeles",
|
||||
todayLocalDate: "2026-10-05",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
briefingForToday(BRIEFING, {
|
||||
...ctx,
|
||||
timezone: "UTC",
|
||||
todayLocalDate: "2026-10-05",
|
||||
})?.signalsOfDay?.length,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -343,7 +343,51 @@ describe("deterministic lead — strongest signal", () => {
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a vital that is not from today or yesterday", () => {
|
||||
it("says nothing about today from yesterday's reading", () => {
|
||||
// A pulse from yesterday morning, read at nine in the evening: neither
|
||||
// the lead nor the vitals line may call it today's.
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [
|
||||
vital({
|
||||
type: "PULSE",
|
||||
daysAgo: 1,
|
||||
direction: "above",
|
||||
value: 98,
|
||||
high: 72,
|
||||
valueLabel: "98 bpm",
|
||||
rangeLabel: "58 to 72 bpm",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toBeNull();
|
||||
expect(o.today.some((f) => f.kind === "vitals")).toBe(false);
|
||||
});
|
||||
|
||||
it("speaks about a vital measured today", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [
|
||||
vital({
|
||||
type: "PULSE",
|
||||
daysAgo: 0,
|
||||
direction: "above",
|
||||
value: 98,
|
||||
high: 72,
|
||||
valueLabel: "98 bpm",
|
||||
rangeLabel: "58 to 72 bpm",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.source).toBe("signal");
|
||||
expect(o.lead?.text).toContain("98 bpm");
|
||||
});
|
||||
|
||||
it("ignores a vital from days ago", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [vital({ daysAgo: 4, direction: "above", value: 70 })],
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The cached briefing, reduced to what is still true about today.
|
||||
*
|
||||
* The briefing is model text written at one moment and served for up to a
|
||||
* day, and the content-hash gate may keep the same text across several. The
|
||||
* hero reads it as the day's lead, its top signal as "pulse is up today", and
|
||||
* the morning push repeats both. Two things made that wrong in practice:
|
||||
*
|
||||
* - A briefing written on an earlier calendar day speaks about that day.
|
||||
* Served after midnight it narrates yesterday's readings as today's.
|
||||
* - A signal whose metric has no reading today is not a signal of the day,
|
||||
* whatever the text says. The prompt input is filtered the same way
|
||||
* (`computeSignalsOfDay`), so this only catches text cached before that,
|
||||
* or a cache that outlived the reading it was written from.
|
||||
*
|
||||
* Both are decided here, on the server, so every client (the web hero, the
|
||||
* iOS app reading `topSignal`, the push line) gets the same answer. The
|
||||
* delta string is model text as well: its figures are re-read at the
|
||||
* metric's display precision, so a pulse difference reads "+34 bpm", never
|
||||
* "+33.72 bpm".
|
||||
*/
|
||||
import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* The measurement type a briefing signal is a reading of. Metrics absent
|
||||
* here (sleep, steps, mood, compliance, derived scores) are not a single
|
||||
* dated reading and keep their signal; the prompt input already decides
|
||||
* those.
|
||||
*/
|
||||
export const SIGNAL_READING_TYPE: Partial<
|
||||
Record<DailyBriefingSignal["sourceMetric"], string>
|
||||
> = {
|
||||
bp: "BLOOD_PRESSURE_SYS",
|
||||
weight: "WEIGHT",
|
||||
pulse: "PULSE",
|
||||
resting_hr: "RESTING_HEART_RATE",
|
||||
hrv: "HEART_RATE_VARIABILITY",
|
||||
body_temp: "BODY_TEMPERATURE",
|
||||
};
|
||||
|
||||
/** The locale's decimal separator ("," for de, "." for en). */
|
||||
function decimalSeparator(locale: Locale): string {
|
||||
return (
|
||||
new Intl.NumberFormat(resolveIntlLocale(locale))
|
||||
.formatToParts(1.5)
|
||||
.find((part) => part.type === "decimal")?.value ?? "."
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the figures in a signal's delta string at the metric's display
|
||||
* precision.
|
||||
*
|
||||
* Only a number written with the locale's own decimal separator is touched,
|
||||
* and only when it carries more fraction digits than the metric is shown
|
||||
* with: "1.234" in German is a thousand, not a decimal, and is left alone.
|
||||
* A figure that rounds to zero loses its sign ("+0.3 bpm" reads "0 bpm").
|
||||
* A metric with no reading type keeps its delta verbatim.
|
||||
*/
|
||||
export function formatSignalDelta(
|
||||
delta: string,
|
||||
sourceMetric: DailyBriefingSignal["sourceMetric"],
|
||||
locale: Locale,
|
||||
): string {
|
||||
const type = SIGNAL_READING_TYPE[sourceMetric];
|
||||
if (!type) return delta;
|
||||
// Every reading type above reads at the same precision in both unit
|
||||
// systems (whole bpm / mmHg / ms, one decimal of kg / lb / °C / °F).
|
||||
const decimals = vitalDisplayDecimals(type, 1);
|
||||
const sep = decimalSeparator(locale);
|
||||
const number = new Intl.NumberFormat(resolveIntlLocale(locale), {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: false,
|
||||
});
|
||||
const pattern = new RegExp(
|
||||
`([+\\-−±]?)(\\d+)${escapeRegExp(sep)}(\\d+)(?![\\d])`,
|
||||
"g",
|
||||
);
|
||||
return delta.replace(pattern, (match, sign: string, int: string, frac) => {
|
||||
if ((frac as string).length <= decimals) return match;
|
||||
const value = Number(`${int}.${frac as string}`);
|
||||
const rounded = Math.round(value * 10 ** decimals) / 10 ** decimals;
|
||||
return `${rounded === 0 ? "" : sign}${number.format(rounded)}`;
|
||||
});
|
||||
}
|
||||
|
||||
export interface BriefingForTodayContext {
|
||||
/** When the cached briefing was written (ISO), or null when unknown. */
|
||||
updatedAt: string | null;
|
||||
/** The newest reading of a measurement type (ISO), or null when none. */
|
||||
lastSeenAt: (type: string) => string | null;
|
||||
/** The reader's zone and today's day key in it. */
|
||||
timezone: string;
|
||||
todayLocalDate: string;
|
||||
/** The reader's language, already resolved; deltas are re-read in it. */
|
||||
language: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* The briefing as the Today surfaces may use it: null when it was written on
|
||||
* an earlier calendar day, otherwise with every reading-backed signal whose
|
||||
* metric was not measured today removed and every remaining delta re-read at
|
||||
* its metric's precision.
|
||||
*/
|
||||
export function briefingForToday(
|
||||
briefing: DailyBriefing | null,
|
||||
ctx: BriefingForTodayContext,
|
||||
): DailyBriefing | null {
|
||||
if (!briefing) return null;
|
||||
if (
|
||||
ctx.updatedAt !== null &&
|
||||
userDayKey(new Date(ctx.updatedAt), ctx.timezone) !== ctx.todayLocalDate
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!briefing.signalsOfDay) return briefing;
|
||||
const measuredToday = (type: string) => {
|
||||
const at = ctx.lastSeenAt(type);
|
||||
return (
|
||||
at !== null &&
|
||||
userDayKey(new Date(at), ctx.timezone) === ctx.todayLocalDate
|
||||
);
|
||||
};
|
||||
const signalsOfDay = briefing.signalsOfDay
|
||||
.filter((signal) => {
|
||||
const type = SIGNAL_READING_TYPE[signal.sourceMetric];
|
||||
return type === undefined || measuredToday(type);
|
||||
})
|
||||
.map((signal) =>
|
||||
signal.delta
|
||||
? {
|
||||
...signal,
|
||||
delta: formatSignalDelta(
|
||||
signal.delta,
|
||||
signal.sourceMetric,
|
||||
ctx.language,
|
||||
),
|
||||
}
|
||||
: signal,
|
||||
);
|
||||
return { ...briefing, signalsOfDay };
|
||||
}
|
||||
@@ -82,6 +82,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision";
|
||||
import { briefingForToday } from "@/lib/daily/briefing-today";
|
||||
import type {
|
||||
DateFormatPreference,
|
||||
TimeFormatPreference,
|
||||
@@ -328,24 +330,6 @@ async function gatherScoreDays(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display decimals per vital. The transform's own `decimals` covers the
|
||||
* converted units (lb, °F, mmol/L); the rest are whole numbers on every
|
||||
* device and in every chart that shows them.
|
||||
*/
|
||||
function vitalDecimals(type: string, transformDecimals: number): number {
|
||||
switch (type) {
|
||||
case "BODY_TEMPERATURE":
|
||||
case "SKIN_TEMPERATURE":
|
||||
case "WEIGHT":
|
||||
return 1;
|
||||
case "BLOOD_GLUCOSE":
|
||||
return transformDecimals;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one vital's value and range in the reader's units and locale. */
|
||||
function toTodayVital(
|
||||
vital: ExtrasVital,
|
||||
@@ -354,7 +338,7 @@ function toTodayVital(
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): TodayVital {
|
||||
const transform = getReadingTransform(vital.type, units);
|
||||
const decimals = vitalDecimals(vital.type, transform.decimals);
|
||||
const decimals = vitalDisplayDecimals(vital.type, transform.decimals);
|
||||
const number = new Intl.NumberFormat(resolveIntlLocale(locale), {
|
||||
maximumFractionDigits: decimals,
|
||||
minimumFractionDigits: decimals,
|
||||
@@ -950,7 +934,17 @@ export async function loadDailyDigest(
|
||||
snapshot.layout.enabledHeroItemKinds ??
|
||||
PRIORITY_ITEM_KINDS,
|
||||
score,
|
||||
briefing: snapshot.briefing,
|
||||
// Only what is still true about today: a briefing written on an
|
||||
// earlier day is not served as today's read, and a signal whose
|
||||
// metric has no reading today is dropped (`briefing-today.ts`).
|
||||
briefing: briefingForToday(snapshot.briefing, {
|
||||
updatedAt: snapshot.briefingUpdatedAt,
|
||||
lastSeenAt: (type) =>
|
||||
snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null,
|
||||
timezone: user.timezone,
|
||||
todayLocalDate,
|
||||
language: resolvedLocale,
|
||||
}),
|
||||
medsToday: snapshot.medsToday,
|
||||
sleepLastSeenDaysAgo,
|
||||
morningRefreshedToday,
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||||
import { isFromToday } from "@/lib/insights/measurement-freshness";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
|
||||
@@ -486,11 +486,16 @@ function buildSignalLine(
|
||||
return keptDelta ? { headline: null, delta: keptDelta } : null;
|
||||
}
|
||||
|
||||
/** Vitals that may speak about today: module on, reading current. */
|
||||
/**
|
||||
* Vitals that may speak about today: module on, and a reading taken today in
|
||||
* the reader's zone. Yesterday's reading is not today's, however recent: at
|
||||
* nine in the evening a pulse from the previous morning would be narrated as
|
||||
* the day's, on a day with no reading at all.
|
||||
*/
|
||||
function currentVitals(input: TodayOverviewInput): TodayVital[] {
|
||||
return input.vitals.filter(
|
||||
(v) =>
|
||||
isCurrentForTodayClaim(v.daysAgo) &&
|
||||
isFromToday(v.daysAgo) &&
|
||||
(v.moduleKey === null || moduleOn(input.modules, v.moduleKey)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ describe("summariseHealthStatus", () => {
|
||||
expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" });
|
||||
});
|
||||
|
||||
it("does not present yesterday's reading as now", () => {
|
||||
const s = summariseHealthStatus([vital(true, "above", 1)], []);
|
||||
expect(s.present).toBe(false);
|
||||
expect(s.deviations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops a vital flagged outside but with an 'in' direction", () => {
|
||||
const s = summariseHealthStatus([vital(true, "in")], []);
|
||||
expect(s.present).toBe(false);
|
||||
|
||||
@@ -7,16 +7,20 @@ const DAY = 86_400_000;
|
||||
/**
|
||||
* A day of pulse is the mean of its hours' means and a window the mean of its
|
||||
* days (`day-mean.ts`). Two days: a workout hour of twelve readings at 150 and
|
||||
* three resting hours at 60 (day value 82.5), then a day with one reading at
|
||||
* 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings.
|
||||
* three resting hours at 60 (day value 82.5), then today with one reading at
|
||||
* 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings; the
|
||||
* pulse signal states them at its display precision, whole beats.
|
||||
*/
|
||||
|
||||
/** 21:00 UTC: late enough that this morning's reading is today's. */
|
||||
const NOW = Date.UTC(2026, 9, 5, 21, 0);
|
||||
function rows(now: number) {
|
||||
const today = new Date(now);
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) -
|
||||
daysAgo * DAY;
|
||||
const out: Array<{ value: number; measuredAt: Date }> = [];
|
||||
const workout = midnight(3);
|
||||
const workout = midnight(1);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
out.push({
|
||||
value: 150,
|
||||
@@ -26,13 +30,13 @@ function rows(now: number) {
|
||||
for (const h of [12, 14, 16]) {
|
||||
out.push({ value: 60, measuredAt: new Date(workout + h * 3_600_000) });
|
||||
}
|
||||
out.push({ value: 60, measuredAt: new Date(midnight(2) + 8 * 3_600_000) });
|
||||
out.push({ value: 60, measuredAt: new Date(midnight(0) + 8 * 3_600_000) });
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("the pulse signal of the day", () => {
|
||||
it("weighs each day once in its 7- and 30-day means", () => {
|
||||
const now = Date.now();
|
||||
const now = NOW;
|
||||
const pulse = rows(now);
|
||||
const [signal] = computeSignalsOfDay(
|
||||
(type) => (type === "PULSE" ? pulse : []),
|
||||
@@ -40,13 +44,13 @@ describe("the pulse signal of the day", () => {
|
||||
"UTC",
|
||||
);
|
||||
expect(signal.metric).toBe("pulse");
|
||||
expect(signal.avg7).toBe(71.25);
|
||||
expect(signal.avg30).toBe(71.25);
|
||||
expect(signal.deltaVs7).toBe(-11.25);
|
||||
expect(signal.avg7).toBe(71);
|
||||
expect(signal.avg30).toBe(71);
|
||||
expect(signal.deltaVs7).toBe(-11);
|
||||
});
|
||||
|
||||
it("leaves every other metric on the plain mean of its readings", () => {
|
||||
const now = Date.now();
|
||||
const now = NOW;
|
||||
const weight = rows(now);
|
||||
const [signal] = computeSignalsOfDay(
|
||||
(type) => (type === "WEIGHT" ? weight : []),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeSignalsOfDay } from "../signals-of-day";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* Thirty days of pulse around 64 bpm, one reading a day at 08:50 local, the
|
||||
* newest one at `newest`. Enough history for a signal; the only question is
|
||||
* whether the newest reading is today's.
|
||||
*/
|
||||
function pulseUpTo(newest: Date) {
|
||||
const out: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let d = 20; d >= 1; d -= 1) {
|
||||
out.push({
|
||||
value: 62 + (d % 5),
|
||||
measuredAt: new Date(newest.getTime() - d * DAY),
|
||||
});
|
||||
}
|
||||
out.push({ value: 98, measuredAt: newest });
|
||||
return out;
|
||||
}
|
||||
|
||||
function signals(newest: Date, now: Date, tz: string) {
|
||||
const pulse = pulseUpTo(newest);
|
||||
return computeSignalsOfDay(
|
||||
(type) => (type === "PULSE" ? pulse : []),
|
||||
now.getTime(),
|
||||
tz,
|
||||
);
|
||||
}
|
||||
|
||||
describe("signals of the day are about today", () => {
|
||||
it("drops a pulse last measured yesterday morning, read at 21:00", () => {
|
||||
// 2026-10-04 08:50 and 2026-10-05 21:00 in Berlin (UTC+2).
|
||||
const newest = new Date("2026-10-04T06:50:00Z");
|
||||
const now = new Date("2026-10-05T19:00:00Z");
|
||||
expect(signals(newest, now, "Europe/Berlin")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a pulse measured this morning", () => {
|
||||
const newest = new Date("2026-10-05T06:50:00Z");
|
||||
const now = new Date("2026-10-05T19:00:00Z");
|
||||
const [signal] = signals(newest, now, "Europe/Berlin");
|
||||
expect(signal?.metric).toBe("pulse");
|
||||
expect(signal?.latestDaysAgo).toBe(0);
|
||||
// Whole beats, as pulse is shown everywhere else.
|
||||
expect(Number.isInteger(signal?.deltaVs30)).toBe(true);
|
||||
expect(Number.isInteger(signal?.avg30)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats 23:50 as yesterday at 00:10, and 00:05 as today", () => {
|
||||
const now = new Date("2026-10-05T22:10:00Z"); // 00:10 on the 6th in Berlin
|
||||
expect(
|
||||
signals(new Date("2026-10-05T21:50:00Z"), now, "Europe/Berlin"),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
signals(new Date("2026-10-05T22:05:00Z"), now, "Europe/Berlin")[0]
|
||||
?.metric,
|
||||
).toBe("pulse");
|
||||
});
|
||||
|
||||
it("reads today in the reader's zone, not in UTC", () => {
|
||||
// 05:00 UTC is 22:00 the previous evening in Los Angeles; 18:00 UTC the
|
||||
// same calendar day in UTC, but the next one in Los Angeles.
|
||||
const newest = new Date("2026-10-05T05:00:00Z");
|
||||
const now = new Date("2026-10-05T18:00:00Z");
|
||||
expect(signals(newest, now, "UTC")[0]?.metric).toBe("pulse");
|
||||
expect(signals(newest, now, "America/Los_Angeles")).toEqual([]);
|
||||
});
|
||||
|
||||
it("is quiet when there is nothing today, even on a sizeable deviation", () => {
|
||||
const newest = new Date(Date.UTC(2026, 9, 3, 8));
|
||||
const now = new Date(newest.getTime() + 2 * DAY);
|
||||
expect(signals(newest, now, "UTC")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,23 @@ describe("computeCoincidentDeviation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not call yesterday's outliers today's, read the next evening", async () => {
|
||||
// The outliers were taken 2026-06-02 07:00 UTC; read on the 3rd at
|
||||
// 21:00 in Berlin there is no reading today, so nothing contributes.
|
||||
findMany.mockImplementation(firingRows);
|
||||
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
||||
now: new Date("2026-06-03T19:00:00Z"),
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.value.contributing).toEqual([]);
|
||||
expect(result.value.fired).toBe(false);
|
||||
// The vitals keep their standing and their age for the anatomy view.
|
||||
expect(result.value.vitals.every((v) => v.daysAgo === 1)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not fire when vitals stay inside their bands", async () => {
|
||||
const flat = (base: number): Array<{ value: number; measuredAt: Date }> =>
|
||||
Array.from({ length: 10 }, (_, i) => ({
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
} from "@/lib/measurements/value-domain";
|
||||
import {
|
||||
dayKeyAgeInDays,
|
||||
isCurrentForTodayClaim,
|
||||
isFromToday,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
@@ -422,14 +422,14 @@ export async function computeCoincidentDeviation(
|
||||
}
|
||||
|
||||
// Everything this flag says is about TODAY — the card is literally called
|
||||
// "signals of the day" and every line it renders is present tense. A vital
|
||||
// whose freshest reading is older than that cannot contribute to it: the
|
||||
// "signals of the day" and its lines say "today". A vital whose freshest
|
||||
// reading is from an earlier calendar day cannot contribute to it: the
|
||||
// reading describes the day it was taken on, and saying otherwise tells the
|
||||
// reader something about a day on which nothing was measured. Stale vitals
|
||||
// stay in `vitals` with their age, so the anatomy view still lists them and
|
||||
// nothing silently disappears.
|
||||
const contributing = vitals.filter(
|
||||
(v) => v.outside && isCurrentForTodayClaim(v.daysAgo),
|
||||
(v) => v.outside && isFromToday(v.daysAgo),
|
||||
);
|
||||
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
* factor alone. A metric reader on mg/dL gets every number back unchanged,
|
||||
* with a unit label added.
|
||||
*/
|
||||
import {
|
||||
roundToDisplay,
|
||||
vitalDisplayDecimals,
|
||||
} from "@/lib/measurements/vital-precision";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
@@ -61,19 +65,29 @@ export function signalInReaderUnits(
|
||||
const type = SIGNAL_TYPE[signal.metric];
|
||||
if (!type) return signal;
|
||||
const t = getReadingTransform(type, units);
|
||||
// A signal is a statement the model repeats to the reader, so its figures
|
||||
// carry the precision the reading is shown at. The metric branch of a
|
||||
// transform is the identity and does not round, which handed the model
|
||||
// "+0.37 kg" to restate.
|
||||
const decimals = vitalDisplayDecimals(type, t.decimals);
|
||||
const round = (value: Num): Num =>
|
||||
typeof value === "number" ? roundToDisplay(value, decimals) : value;
|
||||
return {
|
||||
...signal,
|
||||
unit: t.displayUnit,
|
||||
latest: applyDisplayTransform(signal.latest, t),
|
||||
avg7: abs(signal.avg7, t),
|
||||
avg30: abs(signal.avg30, t),
|
||||
deltaVs7: delta(signal.deltaVs7, t),
|
||||
deltaVs30: delta(signal.deltaVs30, t),
|
||||
spread30: delta(signal.spread30, t),
|
||||
latest: roundToDisplay(applyDisplayTransform(signal.latest, t), decimals),
|
||||
avg7: round(abs(signal.avg7, t)),
|
||||
avg30: round(abs(signal.avg30, t)),
|
||||
deltaVs7: round(delta(signal.deltaVs7, t)),
|
||||
deltaVs30: round(delta(signal.deltaVs30, t)),
|
||||
spread30: round(delta(signal.spread30, t)),
|
||||
recentAnomaly: signal.recentAnomaly
|
||||
? {
|
||||
...signal.recentAnomaly,
|
||||
value: applyDisplayTransform(signal.recentAnomaly.value, t),
|
||||
value: roundToDisplay(
|
||||
applyDisplayTransform(signal.recentAnomaly.value, t),
|
||||
decimals,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -1901,7 +1901,14 @@ export async function extractFeatures(
|
||||
// in-memory measurement set (no extra DB round-trip). Lands inside the
|
||||
// compacted features payload, so it feeds both the briefing prompt AND the
|
||||
// content-hash gate — a fresh daily signal forces the briefing to refresh.
|
||||
const signalsOfDay = computeSignalsOfDay(byType, now, pulseTz);
|
||||
// Each signal is a statement about today, which is a calendar day in the
|
||||
// reader's own zone, so the zone is resolved for every account here, not
|
||||
// only for the ones with pulse.
|
||||
const signalsOfDay = computeSignalsOfDay(
|
||||
byType,
|
||||
now,
|
||||
await resolveUserTimezone(userId),
|
||||
);
|
||||
if (signalsOfDay.length > 0) {
|
||||
features.signalsOfDay = signalsOfDay;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||||
import { isFromToday } from "@/lib/insights/measurement-freshness";
|
||||
|
||||
/** One vital sitting outside its personal band today. */
|
||||
export interface HealthStatusDeviation {
|
||||
@@ -66,14 +66,12 @@ export function summariseHealthStatus(
|
||||
): HealthStatusSummary {
|
||||
const deviations: HealthStatusDeviation[] = vitals
|
||||
// Present tense, so present readings only. "Your pulse is above your usual
|
||||
// range" describes now; a reading from last week describes last week, and
|
||||
// the card carried no date to say which. A vital whose freshest reading is
|
||||
// outside the claim window is left out rather than dressed as current —
|
||||
// range ... now 95" describes today; a reading from yesterday describes
|
||||
// yesterday, and the card carries no date to say which. A vital whose
|
||||
// freshest reading is not from today is left out rather than dressed as
|
||||
// current —
|
||||
// the changepoint shifts below are dated by construction and unaffected.
|
||||
.filter(
|
||||
(v) =>
|
||||
v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo),
|
||||
)
|
||||
.filter((v) => v.outside && v.direction !== "in" && isFromToday(v.daysAgo))
|
||||
.map((v) => ({
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
|
||||
@@ -42,6 +42,27 @@ export function isCurrentForTodayClaim(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reading was taken today, on the reader's own calendar.
|
||||
*
|
||||
* Stricter than {@link isCurrentForTodayClaim}, and the rule for every
|
||||
* sentence that says "today" in so many words or compares today against a
|
||||
* baseline: the Today overview on the dashboard, the signals of the day,
|
||||
* the "vitals outside their range today" card. A reading from yesterday
|
||||
* morning, read at nine in the evening, is not today's pulse, and saying so
|
||||
* describes a day on which nothing was measured. A metric with no reading
|
||||
* today is left out of those statements rather than worded with its age:
|
||||
* the quieter of the two answers, and the one that never asks for a
|
||||
* measurement nobody planned to take.
|
||||
*
|
||||
* `daysAgo` is the calendar distance in the reader's zone (as
|
||||
* {@link dayKeyAgeInDays} computes it), never elapsed hours: 23:40 and
|
||||
* 00:10 are different days, 00:10 and 23:50 the same one.
|
||||
*/
|
||||
export function isFromToday(daysAgo: number | null | undefined): boolean {
|
||||
return daysAgo === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`).
|
||||
*
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
*/
|
||||
import { trendSlope } from "@/lib/analytics/trends";
|
||||
import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features";
|
||||
import { dayKeyAgeInDays } from "@/lib/insights/measurement-freshness";
|
||||
import {
|
||||
roundToDisplay,
|
||||
vitalDisplayDecimals,
|
||||
} from "@/lib/measurements/vital-precision";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.18.7 — one present-focused signal feeding the daily briefing. Every
|
||||
@@ -26,7 +32,10 @@ export interface SignalOfDay {
|
||||
unit?: string;
|
||||
/** Freshest reading (the "now" value the briefing leads with). */
|
||||
latest: number;
|
||||
/** Days since the freshest reading. */
|
||||
/**
|
||||
* Calendar days between the freshest reading and today in the reader's
|
||||
* zone. Always 0: a metric with no reading today yields no signal.
|
||||
*/
|
||||
latestDaysAgo: number;
|
||||
/** Trailing-7d mean. */
|
||||
avg7: number | null;
|
||||
@@ -88,26 +97,51 @@ function r2(v: number): number {
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
|
||||
/** How one signal is read: its zone, and the precision its figures carry. */
|
||||
interface SignalRead {
|
||||
/** The reader's zone; "today" is the calendar day in it. */
|
||||
tz: string;
|
||||
/** For pulse, the zone its day means are read in (`day-mean.ts`). */
|
||||
day?: { type: string; tz: string };
|
||||
/**
|
||||
* Display precision of a unit-insensitive metric (bpm, mmHg), applied
|
||||
* here. Weight and glucose stay canonical and are rounded where they are
|
||||
* converted into the reader's unit (`signalInReaderUnits`).
|
||||
*/
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one signal from a single metric's measurement records. Returns null
|
||||
* when there is no fresh reading or fewer than three points in 30 days — a
|
||||
* sparse metric cannot carry an honest "today vs your normal" read.
|
||||
* when the metric has no reading today, or fewer than three points in 30
|
||||
* days — a sparse metric cannot carry an honest "today vs your normal" read.
|
||||
*
|
||||
* "Today" is the calendar day in the reader's zone. Every signal is a
|
||||
* present-tense statement about the day ("pulse is up today"), so a reading
|
||||
* from yesterday morning read at nine in the evening is not one: it would
|
||||
* narrate a day on which nothing was measured. A metric that was not
|
||||
* measured today simply has no signal; the briefing's trend sections still
|
||||
* carry its history, dated.
|
||||
*/
|
||||
function buildSignal(
|
||||
metric: SignalOfDay["metric"],
|
||||
label: string,
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
unit?: string,
|
||||
day?: { type: string; tz: string },
|
||||
unit: string | undefined,
|
||||
read: SignalRead,
|
||||
): SignalOfDay | null {
|
||||
if (records.length === 0) return null;
|
||||
const newest = records[records.length - 1];
|
||||
const latestDaysAgo = Math.round(
|
||||
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||||
const latestDaysAgo = dayKeyAgeInDays(
|
||||
userDayKey(newest.measuredAt, read.tz),
|
||||
userDayKey(new Date(now), read.tz),
|
||||
);
|
||||
// A reading older than two weeks is not a "signal of the day".
|
||||
if (latestDaysAgo > 14) return null;
|
||||
if (latestDaysAgo !== 0) return null;
|
||||
const { day } = read;
|
||||
const shown = (v: number) =>
|
||||
read.decimals === undefined ? r2(v) : roundToDisplay(v, read.decimals);
|
||||
const shownOrNull = (v: number | null) => (v === null ? null : shown(v));
|
||||
|
||||
const win30 = records.filter(
|
||||
(rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000,
|
||||
@@ -119,8 +153,8 @@ function buildSignal(
|
||||
const avg7 = avgInWindow(records, now, 7, 0, day);
|
||||
const avg30 = avgInWindow(records, now, 30, 0, day);
|
||||
const spread30 = stdDev(win30.map((rec) => rec.value));
|
||||
const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
|
||||
const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
|
||||
const deltaVs7 = avg7 !== null ? shown(newest.value - avg7) : null;
|
||||
const deltaVs30 = avg30 !== null ? shown(newest.value - avg30) : null;
|
||||
const outsideNormalSwing =
|
||||
avg30 !== null && spread30 !== null && spread30 > 0
|
||||
? Math.abs(newest.value - avg30) > spread30
|
||||
@@ -152,7 +186,7 @@ function buildSignal(
|
||||
bestAbs = abs;
|
||||
recentAnomaly = {
|
||||
kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
|
||||
value: r2(rec.value),
|
||||
value: shown(rec.value),
|
||||
anomalyDaysAgo: Math.round(
|
||||
(now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
@@ -166,13 +200,13 @@ function buildSignal(
|
||||
metric,
|
||||
label,
|
||||
...(unit ? { unit } : {}),
|
||||
latest: r2(newest.value),
|
||||
latest: shown(newest.value),
|
||||
latestDaysAgo,
|
||||
avg7,
|
||||
avg30,
|
||||
avg7: shownOrNull(avg7),
|
||||
avg30: shownOrNull(avg30),
|
||||
deltaVs7,
|
||||
deltaVs30,
|
||||
spread30,
|
||||
spread30: shownOrNull(spread30),
|
||||
outsideNormalSwing,
|
||||
emergingTrend,
|
||||
recentAnomaly,
|
||||
@@ -190,13 +224,17 @@ function buildSignal(
|
||||
export function computeSignalsOfDay(
|
||||
byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
/** The zone pulse days are read in (`day-mean.ts`). */
|
||||
pulseTz: string = "UTC",
|
||||
/** The reader's zone: today's calendar day, and pulse's day means. */
|
||||
tz: string = "UTC",
|
||||
): SignalOfDay[] {
|
||||
const candidates: SignalOfDay[] = [];
|
||||
const push = (s: SignalOfDay | null) => {
|
||||
if (s) candidates.push(s);
|
||||
};
|
||||
const whole = (type: string) => ({
|
||||
tz,
|
||||
decimals: vitalDisplayDecimals(type, 0),
|
||||
});
|
||||
|
||||
// Systolic carries the BP signal (the headline number clinicians read first).
|
||||
push(
|
||||
@@ -206,10 +244,22 @@ export function computeSignalsOfDay(
|
||||
byType("BLOOD_PRESSURE_SYS"),
|
||||
now,
|
||||
"mmHg",
|
||||
whole("BLOOD_PRESSURE_SYS"),
|
||||
),
|
||||
);
|
||||
// Absent data simply produces no signal.
|
||||
push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
|
||||
push(
|
||||
buildSignal(
|
||||
"glucose",
|
||||
"blood glucose",
|
||||
byType("BLOOD_GLUCOSE"),
|
||||
now,
|
||||
undefined,
|
||||
{
|
||||
tz,
|
||||
},
|
||||
),
|
||||
);
|
||||
push(
|
||||
buildSignal(
|
||||
"resting_hr",
|
||||
@@ -217,18 +267,21 @@ export function computeSignalsOfDay(
|
||||
byType("RESTING_HEART_RATE"),
|
||||
now,
|
||||
"bpm",
|
||||
whole("RESTING_HEART_RATE"),
|
||||
),
|
||||
);
|
||||
push(
|
||||
buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm", {
|
||||
type: "PULSE",
|
||||
tz: pulseTz,
|
||||
...whole("PULSE"),
|
||||
day: { type: "PULSE", tz },
|
||||
}),
|
||||
);
|
||||
// Weight and glucose are canonical here and carry no unit: the reader's
|
||||
// unit is attached where the signal is converted for the prompt
|
||||
// (`featuresInReaderUnits`), so no canonical symbol can leak into one.
|
||||
push(buildSignal("weight", "weight", byType("WEIGHT"), now));
|
||||
push(
|
||||
buildSignal("weight", "weight", byType("WEIGHT"), now, undefined, { tz }),
|
||||
);
|
||||
// Sleep is stored one row per stage per night, so a raw "latest" point
|
||||
// would mis-sum; the sleep aggregates carry that signal already. Steps
|
||||
// ingest as many intraday `stats:`-prefixed samples, so the newest raw row
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Decimal places a vital reading is shown at, in the unit it is shown in.
|
||||
*
|
||||
* The transform's own `decimals` covers the converted units (lb, °F,
|
||||
* mmol/L); body temperature and weight read at one decimal; the rest
|
||||
* (pulse, blood pressure, HRV, SpO2, respiratory rate, the cumulative
|
||||
* same-hours metrics) are whole numbers on every device and in every chart
|
||||
* that shows them.
|
||||
*
|
||||
* One answer for every server-side text that prints a vital or a difference
|
||||
* of one: the Today overview's value and range labels, the signals of the
|
||||
* day the briefing prompt is given, and the delta under the hero lead. A
|
||||
* difference is read at the precision of the readings it was taken from, so
|
||||
* "+34 bpm", never "+33.72 bpm".
|
||||
*/
|
||||
export function vitalDisplayDecimals(
|
||||
type: string,
|
||||
transformDecimals: number,
|
||||
): number {
|
||||
switch (type) {
|
||||
case "BODY_TEMPERATURE":
|
||||
case "SKIN_TEMPERATURE":
|
||||
case "WEIGHT":
|
||||
return 1;
|
||||
case "BLOOD_GLUCOSE":
|
||||
return transformDecimals;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Round to `decimals` fraction digits. Non-finite input passes through. */
|
||||
export function roundToDisplay(value: number, decimals: number): number {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
const scale = 10 ** decimals;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
Reference in new issue
Block a user