diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts index 800cba90b..06634c8c7 100644 --- a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -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): CoachSnapshotResult { sections, provenance: { windows: [], metrics: [] }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts index 25869b76d..9b4aa84fc 100644 --- a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -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"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 4ca883b76..29611e146 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -660,6 +660,10 @@ describe("buildCoachSnapshot", () => { "timelineRecentDays": 14 } }", + "units": { + "glucoseUnit": "mg/dL", + "system": "metric", + }, } `); } finally { diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index ff751c4ab..e7d22ab59 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -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, }; } diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts index f4d9a3947..2206540fb 100644 --- a/src/lib/ai/coach/__tests__/tools-inventory.test.ts +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -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, }; } diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index 510638c23..fe6ee6202 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -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 = { 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 = { 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), }; } diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index 3ea6c0338..e7d2fe1f6 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -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): CoachSnapshotResult { sections, provenance: { windows: [], metrics: [] }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts b/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts index 3441728d8..715886bac 100644 --- a/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts +++ b/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts @@ -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]); + }); +}); diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts index 23d230ed9..1ea4b50a2 100644 --- a/src/lib/ai/coach/results/metric-table-tool.ts +++ b/src/lib/ai/coach/results/metric-table-tool.ts @@ -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; - /** Local day key → the day's value. */ + /** Local day key → the day's value, canonical. */ byDay: Map; /** 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> = { minutes: "min", - celsius: "°C", }; /** @@ -281,6 +293,7 @@ const SERIES_SUFFIX: Readonly>> = { 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 }> { - 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; }> { - 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(); 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(); const counts = new Map(); 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 { - 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, ]; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 4f7ed8b79..666ae143c 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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, }; } diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index ed4b09da2..814842567 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -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 = diff --git a/tests/integration/coach-metric-table.test.ts b/tests/integration/coach-metric-table.test.ts index 803e9e041..479725ea0 100644 --- a/tests/integration/coach-metric-table.test.ts +++ b/tests/integration/coach-metric-table.test.ts @@ -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 {