mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(coach): read the cycle contrast and the metric table in the reader's units
The cycle block's headline contrast labelled weight in kg and the temperatures in °C, and left a glucose contrast without any unit. The metric table, which the person sees and whose summary the model reads, listed weight in kilograms, temperatures in degrees Celsius, walking speed in metres per second and distance in metres. The cycle contrast now converts both means and the gap into the reader's units (the gap by the factor alone) and names the unit in its sentence. The table resolves each column's unit and decimals from the reader's preferences and converts every folded period value once; the snapshot carries the preferences it was written in, so the table and the snapshot of a turn always agree. Refs #1067
This commit is contained in:
1 parent
ca5ce518d8
commit
d11c165675
12 files changed
+232
-28
No files matched your search
@@ -11,6 +11,7 @@
|
||||
* Dates are fixed and `now` is injected, so the fixture cannot slide out of its
|
||||
* window as the calendar moves.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -118,6 +119,7 @@ function snapshot(sections: Record<string, unknown>): CoachSnapshotResult {
|
||||
sections,
|
||||
provenance: { windows: [], metrics: [] },
|
||||
referenceGrounding: null,
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -191,3 +191,32 @@ describe("buildCycleSnapshotBlock", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCycleSnapshotBlock — the reader's units", () => {
|
||||
it("states a weight contrast in pounds for an imperial reader", async () => {
|
||||
prismaMock.cycleProfile.findUnique.mockResolvedValue(profile());
|
||||
prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles());
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
unitPreference: "imperial",
|
||||
});
|
||||
const weight = rhrMeasurements().map((row) => ({
|
||||
...row,
|
||||
type: "WEIGHT",
|
||||
value: (row.value as number) + 10,
|
||||
}));
|
||||
prismaMock.measurement.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(weight);
|
||||
const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW);
|
||||
const insight = block!.phaseInsight!;
|
||||
expect(insight.metric).toBe("weight");
|
||||
expect(insight.unit).toBe("lb");
|
||||
// Luteal ~73 kg, follicular ~67 kg: both means and the gap in pounds.
|
||||
expect(insight.lutealAvg).toBeGreaterThan(155);
|
||||
expect(insight.follicularAvg).toBeGreaterThan(145);
|
||||
expect(insight.delta).toBeGreaterThan(12);
|
||||
expect(insight.interpretation).toMatch(/runs about [\d.]+ lb higher/);
|
||||
expect(insight.interpretation).not.toContain("kg");
|
||||
});
|
||||
});
|
||||
@@ -660,6 +660,10 @@ describe("buildCoachSnapshot", () => {
|
||||
"timelineRecentDays": 14
|
||||
}
|
||||
}",
|
||||
"units": {
|
||||
"glucoseUnit": "mg/dL",
|
||||
"system": "metric",
|
||||
},
|
||||
}
|
||||
`);
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* structured `{ present: false }` for an absent domain — the hallucination
|
||||
* audit asserts the model can always tell "no data" from "data".
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
@@ -86,6 +87,7 @@ function snapshot(
|
||||
sections,
|
||||
provenance: { windows: [], metrics: [] },
|
||||
referenceGrounding,
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* render a compact, brand-free, grounding-honest block. The model reads this to
|
||||
* know what is fetchable so it never invents a metric.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
@@ -37,6 +38,7 @@ function snapshot(
|
||||
...(counts ? { counts: counts as never } : {}),
|
||||
},
|
||||
referenceGrounding: null,
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
* day" claim.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getReadingTransform,
|
||||
isUnitSensitiveType,
|
||||
resolveUnitPreferences,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
|
||||
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
|
||||
import {
|
||||
@@ -60,13 +68,16 @@ const METRIC_LABEL: Record<string, string> = {
|
||||
skinTemperature: "skin temperature",
|
||||
};
|
||||
|
||||
/**
|
||||
* Labels for the display kinds no preference changes. Weight, the
|
||||
* temperatures and glucose are read in the reader's units instead, through
|
||||
* the measurement type's transform (see `headlineInReaderUnits`).
|
||||
*/
|
||||
const UNIT_LABEL: Record<string, string> = {
|
||||
hours: "h",
|
||||
steps: "steps",
|
||||
bpm: "bpm",
|
||||
ms: "ms",
|
||||
kg: "kg",
|
||||
celsius: "°C",
|
||||
};
|
||||
|
||||
interface PhaseInsightBlock {
|
||||
@@ -108,12 +119,42 @@ export interface CycleSnapshotBlock {
|
||||
phaseInsight: PhaseInsightBlock | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline contrast's figures in the reader's units. The two means take
|
||||
* the full transform, the difference the factor alone; a kind no preference
|
||||
* touches keeps its numbers and its fixed label.
|
||||
*/
|
||||
function headlineInReaderUnits(
|
||||
row: PhaseMetricCrosstabRow,
|
||||
units: UnitPreferences,
|
||||
): { lutealAvg: number; follicularAvg: number; delta: number; unit: string } {
|
||||
const type = PHASE_CROSSTAB_METRICS[row.metricKey].type;
|
||||
if (!isUnitSensitiveType(type)) {
|
||||
return {
|
||||
lutealAvg: row.lutealAvg,
|
||||
follicularAvg: row.follicularAvg,
|
||||
delta: row.delta,
|
||||
unit: UNIT_LABEL[row.display] ?? "",
|
||||
};
|
||||
}
|
||||
const transform = getReadingTransform(type, units);
|
||||
return {
|
||||
lutealAvg: applyDisplayTransform(row.lutealAvg, transform),
|
||||
follicularAvg: applyDisplayTransform(row.follicularAvg, transform),
|
||||
delta: applyDisplayTransformDelta(row.delta, transform),
|
||||
unit: transform.displayUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Descriptive, never-causal interpretation of the headline contrast. */
|
||||
function interpretHeadline(row: PhaseMetricCrosstabRow): string {
|
||||
function interpretHeadline(
|
||||
row: PhaseMetricCrosstabRow,
|
||||
shown: { delta: number; unit: string },
|
||||
): string {
|
||||
const metric = METRIC_LABEL[row.metricKey] ?? row.metricKey;
|
||||
const dir = row.delta >= 0 ? "higher" : "lower";
|
||||
const mag = Math.abs(row.delta);
|
||||
const unit = UNIT_LABEL[row.display] ?? "";
|
||||
const dir = shown.delta >= 0 ? "higher" : "lower";
|
||||
const mag = Math.abs(shown.delta);
|
||||
const unit = shown.unit;
|
||||
return `Your ${metric} runs about ${mag}${unit ? " " + unit : ""} ${dir} on luteal-phase days than follicular-phase days in your own data — a descriptive pattern, not a cause.`;
|
||||
}
|
||||
|
||||
@@ -212,7 +253,11 @@ export async function buildCycleSnapshotBlock(
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { sourcePriorityJson: true },
|
||||
select: {
|
||||
sourcePriorityJson: true,
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -301,18 +346,24 @@ export async function buildCycleSnapshotBlock(
|
||||
|
||||
let phaseInsight: PhaseInsightBlock | null = null;
|
||||
if (headlineRow) {
|
||||
const cfg = PHASE_CROSSTAB_METRICS[headlineRow.metricKey];
|
||||
const shown = headlineInReaderUnits(
|
||||
headlineRow,
|
||||
resolveUnitPreferences({
|
||||
unitPreference: userRow?.unitPreference,
|
||||
glucoseUnit: userRow?.glucoseUnit,
|
||||
}),
|
||||
);
|
||||
phaseInsight = {
|
||||
metric: METRIC_LABEL[headlineRow.metricKey] ?? headlineRow.metricKey,
|
||||
unit: UNIT_LABEL[cfg.display] ?? "",
|
||||
lutealAvg: headlineRow.lutealAvg,
|
||||
follicularAvg: headlineRow.follicularAvg,
|
||||
delta: headlineRow.delta,
|
||||
unit: shown.unit,
|
||||
lutealAvg: shown.lutealAvg,
|
||||
follicularAvg: shown.follicularAvg,
|
||||
delta: shown.delta,
|
||||
confidence: headlineRow.confidence,
|
||||
qValue: headlineRow.qValue,
|
||||
lutealDays: headlineRow.lutealDays,
|
||||
follicularDays: headlineRow.follicularDays,
|
||||
interpretation: interpretHeadline(headlineRow),
|
||||
interpretation: interpretHeadline(headlineRow, shown),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* turn belongs to, the older tools' projections, and the table never in
|
||||
* what the model reads.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
@@ -59,6 +60,7 @@ function snapshot(sections: Record<string, unknown>): CoachSnapshotResult {
|
||||
sections,
|
||||
provenance: { windows: [], metrics: [] },
|
||||
referenceGrounding: null,
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* own days, one row per period with absence kept as null, weeks folded the
|
||||
* way the chart folds them, and the bounded summary the model reads.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readDailySeries = vi.fn();
|
||||
@@ -172,6 +173,7 @@ describe("readMetricTable", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table).not.toBeNull();
|
||||
@@ -214,6 +216,7 @@ describe("readMetricTable", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
const chart = bucketTimeSeries(
|
||||
@@ -243,6 +246,7 @@ describe("readMetricTable", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table).toBeNull();
|
||||
@@ -263,6 +267,7 @@ describe("readMetricTable", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
const rows = new Map(table!.rows.map((row) => [row[0], row]));
|
||||
@@ -298,6 +303,7 @@ describe("summariseTable", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
}))!;
|
||||
}
|
||||
@@ -388,6 +394,7 @@ describe("totals and levels", () => {
|
||||
timeZone: tz,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: WEST_NOW,
|
||||
});
|
||||
}
|
||||
@@ -408,6 +415,7 @@ describe("totals and levels", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table!.columns[1]).toMatchObject({
|
||||
@@ -439,6 +447,7 @@ describe("totals and levels", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table!.columns[1].labelKey).toBe("coach.result.column.mean");
|
||||
@@ -536,6 +545,7 @@ describe("totals and levels", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
const day = table!.rows.find((row) => row[0] === "2026-09-25")!;
|
||||
@@ -556,6 +566,7 @@ describe("totals and levels", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table!.columns[1].decimals).toBe(2);
|
||||
@@ -581,6 +592,7 @@ describe("totals and levels", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table!.columns.map((c) => c.key)).toEqual([
|
||||
@@ -606,3 +618,50 @@ describe("totals and levels", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMetricTable — the reader's units", () => {
|
||||
const read = (
|
||||
metric: "weight" | "distance" | "walking_speed",
|
||||
units: { system: "metric" | "imperial"; glucoseUnit: "mg/dL" },
|
||||
) =>
|
||||
readMetricTable({
|
||||
userId: "u1",
|
||||
metric,
|
||||
window: "last7days",
|
||||
period: "current",
|
||||
granularity: undefined,
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
it("states weight in pounds for an imperial reader", async () => {
|
||||
readDailySeries.mockResolvedValue([dayRow("WEIGHT", "2026-09-27", 80)]);
|
||||
const table = await read("weight", {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(table!.columns[1]).toMatchObject({ unit: "lb", decimals: 1 });
|
||||
expect(table!.rows.at(-1)).toEqual(["2026-09-27", 176.4, 1]);
|
||||
});
|
||||
|
||||
it("states walking distance in miles and kilometres, never in metres", async () => {
|
||||
readDailySeries.mockResolvedValue([
|
||||
dayRow("WALKING_RUNNING_DISTANCE", "2026-09-27", 8000),
|
||||
]);
|
||||
const imperial = await read("distance", {
|
||||
system: "imperial",
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(imperial!.columns[1]).toMatchObject({ unit: "mi", decimals: 2 });
|
||||
expect(imperial!.rows.at(-1)).toEqual(["2026-09-27", 4.97, 1]);
|
||||
const metric = await read("distance", {
|
||||
system: "metric",
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(metric!.columns[1]).toMatchObject({ unit: "km" });
|
||||
expect(metric!.rows.at(-1)).toEqual(["2026-09-27", 8, 1]);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,13 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { bucketTimeSeries } from "@/lib/charts/bucket-time-series";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getReadingTransform,
|
||||
hasDisplayTransform,
|
||||
type DisplayTransform,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
|
||||
import {
|
||||
COACH_RESULT_COLUMN_KEYS,
|
||||
@@ -212,10 +219,16 @@ export function periodKeys(
|
||||
/** One value series of a table: its column and its values by day. */
|
||||
interface DaySeries {
|
||||
column: Omit<CoachResultColumn, "label">;
|
||||
/** Local day key → the day's value. */
|
||||
/** Local day key → the day's value, canonical. */
|
||||
byDay: Map<string, number>;
|
||||
/** How days fold into a week or month: a total sums, a level averages. */
|
||||
fold: "sum" | "mean";
|
||||
/**
|
||||
* The reader's-unit transform, applied once to each folded period value
|
||||
* (the column's unit and decimals already name it). Absent for a series
|
||||
* no preference touches.
|
||||
*/
|
||||
transform?: DisplayTransform;
|
||||
}
|
||||
|
||||
/** Per-day values and reading counts for a metric, before bucketing. */
|
||||
@@ -227,7 +240,6 @@ interface MetricDays {
|
||||
|
||||
const UNIT_TOKENS: Readonly<Record<string, string>> = {
|
||||
minutes: "min",
|
||||
celsius: "°C",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -281,6 +293,7 @@ const SERIES_SUFFIX: Readonly<Partial<Record<MeasurementType, string>>> = {
|
||||
type MeasurementColumn = DaySeries["column"] & {
|
||||
type: MeasurementType;
|
||||
suffix?: string;
|
||||
transform?: DisplayTransform;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -302,6 +315,7 @@ function measurementColumns(
|
||||
metric: CoachScopeSource,
|
||||
types: readonly MeasurementType[],
|
||||
granularity: CoachResultGranularity,
|
||||
units: UnitPreferences,
|
||||
): MeasurementColumn[] {
|
||||
if (metric === "bp") {
|
||||
return [
|
||||
@@ -330,16 +344,24 @@ function measurementColumns(
|
||||
? COACH_RESULT_COLUMN_KEYS.total
|
||||
: COACH_RESULT_COLUMN_KEYS.mean;
|
||||
return types.map((type) => {
|
||||
const unit = unitTokenFor(type);
|
||||
const suffix = types.length > 1 ? (SERIES_SUFFIX[type] ?? type) : undefined;
|
||||
// A mass, length, temperature, speed or distance is read in the
|
||||
// reader's units, at the decimals that unit is read at.
|
||||
const transform = hasDisplayTransform(type)
|
||||
? getReadingTransform(type, units)
|
||||
: undefined;
|
||||
const unit = transform ? transform.displayUnit : unitTokenFor(type);
|
||||
return {
|
||||
key: suffix ? suffix.toLowerCase() : "value",
|
||||
kind: "number" as const,
|
||||
labelKey,
|
||||
...(unit ? { unit } : {}),
|
||||
decimals: decimalsForType(type, unit ?? ""),
|
||||
decimals: transform
|
||||
? transform.decimals
|
||||
: decimalsForType(type, unit ?? ""),
|
||||
type,
|
||||
...(suffix ? { suffix } : {}),
|
||||
...(transform ? { transform } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -374,10 +396,11 @@ async function readMeasurementDays(args: {
|
||||
granularity: CoachResultGranularity;
|
||||
range: TableRange;
|
||||
timeZone: string;
|
||||
units: UnitPreferences;
|
||||
}): Promise<MetricDays & { suffixes: Map<string, string> }> {
|
||||
const { userId, metric, granularity, range, timeZone } = args;
|
||||
const { userId, metric, granularity, range, timeZone, units } = args;
|
||||
const types = COACH_SOURCE_MEASUREMENT_TYPES[metric];
|
||||
const columns = measurementColumns(metric, types, granularity);
|
||||
const columns = measurementColumns(metric, types, granularity, units);
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const rowsByType = await Promise.all(
|
||||
columns.map((column) =>
|
||||
@@ -403,12 +426,13 @@ async function readMeasurementDays(args: {
|
||||
counts.set(day, (counts.get(day) ?? 0) + count);
|
||||
}
|
||||
if (column.suffix) suffixes.set(column.key, column.suffix);
|
||||
const { type: _type, suffix: _suffix, ...rest } = column;
|
||||
const { type: _type, suffix: _suffix, transform, ...rest } = column;
|
||||
return {
|
||||
column: rest,
|
||||
byDay,
|
||||
fold: total ? ("sum" as const) : ("mean" as const),
|
||||
counts,
|
||||
...(transform ? { transform } : {}),
|
||||
};
|
||||
});
|
||||
const { ordered, counts } = orderAndCount(metric, read);
|
||||
@@ -443,14 +467,15 @@ async function readMeasurementMonths(args: {
|
||||
userId: string;
|
||||
metric: CoachScopeSource;
|
||||
range: TableRange;
|
||||
units: UnitPreferences;
|
||||
}): Promise<{
|
||||
series: DaySeries[];
|
||||
periods: PeriodValues;
|
||||
suffixes: Map<string, string>;
|
||||
}> {
|
||||
const { userId, metric, range } = args;
|
||||
const { userId, metric, range, units } = args;
|
||||
const types = COACH_SOURCE_MEASUREMENT_TYPES[metric];
|
||||
const columns = measurementColumns(metric, types, "month");
|
||||
const columns = measurementColumns(metric, types, "month", units);
|
||||
const total = aggregationKind(metric) === "total";
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
// The first whole UTC month of the range: a bucket that starts before
|
||||
@@ -512,7 +537,7 @@ async function readMeasurementMonths(args: {
|
||||
const suffixes = new Map<string, string>();
|
||||
const read = columns.map((column, index) => {
|
||||
if (column.suffix) suffixes.set(column.key, column.suffix);
|
||||
const { type: _type, suffix: _suffix, ...rest } = column;
|
||||
const { type: _type, suffix: _suffix, transform, ...rest } = column;
|
||||
const byDay = new Map<string, number>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const [month, { value, count }] of byType[index]) {
|
||||
@@ -524,6 +549,7 @@ async function readMeasurementMonths(args: {
|
||||
byDay,
|
||||
fold: total ? ("sum" as const) : ("mean" as const),
|
||||
counts,
|
||||
...(transform ? { transform } : {}),
|
||||
};
|
||||
});
|
||||
const { ordered, counts } = orderAndCount(metric, read);
|
||||
@@ -743,9 +769,14 @@ export async function readMetricTable(args: {
|
||||
timeZone: string;
|
||||
locale: Locale;
|
||||
ref: string;
|
||||
/**
|
||||
* The reader's units. The person sees the table and the model reads its
|
||||
* summary, so a mass, temperature, speed or distance is stated in them.
|
||||
*/
|
||||
units: UnitPreferences;
|
||||
now?: Date;
|
||||
}): Promise<CoachResultTable | null> {
|
||||
const { userId, metric, window, timeZone, locale, ref } = args;
|
||||
const { userId, metric, window, timeZone, locale, ref, units } = args;
|
||||
const now = args.now ?? new Date();
|
||||
const period = window === "allTime" ? "current" : args.period;
|
||||
const granularity = effectiveGranularity(window, args.granularity);
|
||||
@@ -765,7 +796,7 @@ export async function readMetricTable(args: {
|
||||
series = days.series;
|
||||
folded = foldIntoPeriods({ days, granularity, timeZone });
|
||||
} else if (window === "allTime") {
|
||||
const read = await readMeasurementMonths({ userId, metric, range });
|
||||
const read = await readMeasurementMonths({ userId, metric, range, units });
|
||||
series = read.series;
|
||||
folded = read.periods;
|
||||
suffixes = read.suffixes;
|
||||
@@ -777,6 +808,7 @@ export async function readMetricTable(args: {
|
||||
granularity,
|
||||
range,
|
||||
timeZone,
|
||||
units,
|
||||
});
|
||||
series = read.series;
|
||||
folded = foldIntoPeriods({ days: read, granularity, timeZone });
|
||||
@@ -826,9 +858,10 @@ export async function readMetricTable(args: {
|
||||
const cells = values.get(key);
|
||||
return [
|
||||
key,
|
||||
...series.map((_, index) => {
|
||||
...series.map((s, index) => {
|
||||
const value = cells?.[index];
|
||||
return value === undefined || !Number.isFinite(value) ? null : value;
|
||||
if (value === undefined || !Number.isFinite(value)) return null;
|
||||
return s.transform ? applyDisplayTransform(value, s.transform) : value;
|
||||
}),
|
||||
cells ? (counts.get(key) ?? 0) : null,
|
||||
];
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveUnitPreferences } from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
resolveUnitPreferences,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import type { SleepStageRow } from "@/lib/analytics/sleep-night";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -126,6 +129,12 @@ export interface CoachSnapshotResult {
|
||||
* from `src/lib/reference-ranges.ts`; carries no commercial brand name.
|
||||
*/
|
||||
referenceGrounding: string | null;
|
||||
/**
|
||||
* The reader's unit choices the snapshot was written in. A tool that reads
|
||||
* beyond the snapshot (the metric table) states its figures in the same
|
||||
* units, so a reply never mixes the two.
|
||||
*/
|
||||
units: UnitPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1243,6 +1252,7 @@ async function buildCoachSnapshotImpl(
|
||||
counts: Object.keys(counts).length > 0 ? counts : undefined,
|
||||
},
|
||||
referenceGrounding,
|
||||
units,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
* still gets a correct, distinct build (the rare case); the common path
|
||||
* collapses to one build.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -839,6 +840,7 @@ async function getMetricTable(
|
||||
timeZone,
|
||||
locale: turn?.locale ?? "en",
|
||||
ref: "r0",
|
||||
units: gate.units,
|
||||
now: turn?.now,
|
||||
});
|
||||
if (!table) {
|
||||
@@ -862,6 +864,7 @@ async function getMetricTable(
|
||||
timeZone,
|
||||
locale: turn?.locale ?? "en",
|
||||
ref: "r0",
|
||||
units: gate.units,
|
||||
now: turn?.now,
|
||||
});
|
||||
comparison = current ? compareWithCurrent(table, current) : null;
|
||||
@@ -1067,6 +1070,8 @@ async function withProjectedTable(
|
||||
timeZone: await resolveUserTimezone(userId),
|
||||
locale: turn.locale,
|
||||
ref: "r0",
|
||||
// Sleep is read in minutes for every reader; no preference applies.
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: turn.now,
|
||||
});
|
||||
table =
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Then pins that `show_result` reaches a stored table only inside the
|
||||
* conversation it was written to.
|
||||
*/
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -106,6 +107,7 @@ describe("get_metric_table — the chart's numbers", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
expect(table).not.toBeNull();
|
||||
@@ -162,6 +164,7 @@ describe("get_metric_table — the chart's numbers", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
});
|
||||
const range = resolveTableRange({
|
||||
@@ -280,6 +283,7 @@ describe("get_metric_table — all time", () => {
|
||||
timeZone: WEST,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: WEST_NOW,
|
||||
});
|
||||
|
||||
@@ -338,6 +342,7 @@ describe("show_result — only within the conversation", () => {
|
||||
timeZone: TZ,
|
||||
locale: "en",
|
||||
ref: "r1",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
now: NOW,
|
||||
})) as CoachResultTable;
|
||||
const {
|
||||
|
||||
Reference in new issue
Block a user