Merge pull request #255 from MBombeck/release/v1.11.4

release/v1.11.4 — sleep totals, trend captions, and a round of fixes
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-04 17:46:08 +02:00
commit af040e7480
41 files changed
+3178 -239

No files matched your search

+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [1.11.4] — 2026-06-04 — sleep totals, trend captions, and a round of fixes
### Fixed
- **Sleep now shows the whole night.** The sleep tile and sleep chart previously showed a single sleep stage's minutes; they now show the night's total time asleep, grouped by sleep session so a night that crosses midnight counts as one night, with the unit shown explicitly. When two sources report the same night, the higher-priority one is used rather than adding them together.
- **The daily briefing no longer makes the Insights overview wait.** The overview reads the existing briefing immediately and refreshes it in the background instead of blocking the page on generation.
- **Recent achievements no longer flash in and vanish.** Cards that depend on your layout or your data now wait for that to load before appearing, so nothing shows up only to be retracted a moment later.
- **Charts with only a day or two of data now show those points** instead of withholding them behind a "more days needed" card. A short series still notes that more days will fill out the trend.
- **A reading logged by hand and mirrored to Apple Health no longer appears twice.** The same physical reading arriving from both sources is merged into one.
- Long wellness-score labels (for example "Sleep score") no longer clip.
- The mood-by-time-of-day chart no longer overflows its card.
- The Trends row cards are now equal height and the mood card's axis labels stay inside the card.
### Changed
- **The Trends row now describes the actual trend.** When there is no written summary yet, each trend card states the direction and size of the change over the period (for example "rising over 30 days") drawn from your own data, instead of always showing "awaiting more data."
- **Settings → Advanced:** the erase-data and delete-account buttons now sit beside their descriptions, matching the rest of the app.
## [1.11.3] — 2026-06-04 — WHOOP body data, quality-of-life polish, fuller translations
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.11.3
version: 1.11.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+9
View File
@@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.",
"noDataInRangeTitle": "Keine Daten in diesem Zeitraum",
"noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.",
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
@@ -2811,6 +2812,14 @@
"veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.",
"fired": "Dein Gerät hat dieses Ereignis erkannt."
}
},
"trendDescriptor": {
"rising": "Über 30 Tage steigend ({delta}{unit}).",
"falling": "Über 30 Tage fallend ({delta}{unit}).",
"stable": "Über die letzten 30 Tage stabil.",
"moodImproved": "Stimmung über 30 Tage leicht verbessert.",
"moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.",
"moodStable": "Stimmung über die letzten 30 Tage stabil."
}
},
"thresholds": {
+9
View File
@@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.",
"noDataInRangeTitle": "No data in this range",
"noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.",
"sparseDataCaption": "More days will fill out the trend.",
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
@@ -2811,6 +2812,14 @@
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
},
"trendDescriptor": {
"rising": "Rising over 30 days ({delta}{unit}).",
"falling": "Falling over 30 days ({delta}{unit}).",
"stable": "Stable over the last 30 days.",
"moodImproved": "Mood trended slightly higher over 30 days.",
"moodDeclined": "Mood trended slightly lower over 30 days.",
"moodStable": "Mood held steady over the last 30 days."
}
},
"thresholds": {
+9
View File
@@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.",
"noDataInRangeTitle": "Sin datos en este rango",
"noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.",
"sparseDataCaption": "Más días completarán la tendencia.",
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
@@ -2811,6 +2812,14 @@
"veryLow": "Tu dispositivo detectó una estabilidad al caminar muy baja.",
"fired": "Tu dispositivo señaló este evento."
}
},
"trendDescriptor": {
"rising": "Al alza en 30 días ({delta}{unit}).",
"falling": "A la baja en 30 días ({delta}{unit}).",
"stable": "Estable en los últimos 30 días.",
"moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.",
"moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.",
"moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días."
}
},
"thresholds": {
+9
View File
@@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.",
"noDataInRangeTitle": "Aucune donnée sur cette période",
"noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.",
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
@@ -2811,6 +2812,14 @@
"veryLow": "Votre appareil a détecté une très faible stabilité à la marche.",
"fired": "Votre appareil a signalé cet événement."
}
},
"trendDescriptor": {
"rising": "En hausse sur 30 jours ({delta}{unit}).",
"falling": "En baisse sur 30 jours ({delta}{unit}).",
"stable": "Stable sur les 30 derniers jours.",
"moodImproved": "Humeur en légère hausse sur 30 jours.",
"moodDeclined": "Humeur en légère baisse sur 30 jours.",
"moodStable": "Humeur stable sur les 30 derniers jours."
}
},
"thresholds": {
+9
View File
@@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.",
"noDataInRangeTitle": "Nessun dato in questo intervallo",
"noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.",
"sparseDataCaption": "Più giorni completeranno il trend.",
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
@@ -2811,6 +2812,14 @@
"veryLow": "Il tuo dispositivo ha rilevato una stabilità nella camminata molto bassa.",
"fired": "Il tuo dispositivo ha segnalato questo evento."
}
},
"trendDescriptor": {
"rising": "In aumento su 30 giorni ({delta}{unit}).",
"falling": "In calo su 30 giorni ({delta}{unit}).",
"stable": "Stabile negli ultimi 30 giorni.",
"moodImproved": "Umore in lieve miglioramento su 30 giorni.",
"moodDeclined": "Umore in lieve calo su 30 giorni.",
"moodStable": "Umore stabile negli ultimi 30 giorni."
}
},
"thresholds": {
+9
View File
@@ -1438,6 +1438,7 @@
"needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.",
"noDataInRangeTitle": "Brak danych w tym zakresie",
"noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.",
"sparseDataCaption": "Więcej dni dopełni trend.",
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
@@ -2811,6 +2812,14 @@
"veryLow": "Twoje urządzenie wykryło bardzo niską stabilność chodu.",
"fired": "Twoje urządzenie zgłosiło to zdarzenie."
}
},
"trendDescriptor": {
"rising": "Rośnie w ciągu 30 dni ({delta}{unit}).",
"falling": "Spada w ciągu 30 dni ({delta}{unit}).",
"stable": "Stabilnie przez ostatnie 30 dni.",
"moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.",
"moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.",
"moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni."
}
},
"thresholds": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.11.3",
"version": "1.11.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -3,7 +3,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: vi.fn() },
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
// the night-total sleep tile.
measurement: { groupBy: vi.fn(), findMany: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
// v1.4.39 W-SERVER-FIX-2 — the dashboard route now backfills
@@ -34,6 +36,13 @@ vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
// v1.11.4 — the route loads the user's source-priority ladder to collapse a
// dual-source sleep night; pin it to the defaults (null) so the test stays
// hermetic.
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -82,6 +91,10 @@ beforeEach(() => {
// per-type all-time count + most-recent timestamp. Default to an
// empty aggregate so legacy tests keep their "no data" expectations.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
// v1.11.4 — the sleep tile reads the raw per-stage SLEEP_DURATION rows
// via `measurement.findMany` to reconstruct the night total. Default to
// no rows so legacy tests keep their "no sleep" expectation.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
@@ -443,6 +456,50 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// tile must SUM the asleep stages of the latest night and convert to
// hours, NOT surface a single stage. Excludes IN_BED + AWAKE.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 5 },
_max: { measuredAt: wake },
},
] as never);
// Raw per-stage rows for the night (the sleep findMany read).
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
{ value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
metrics: Array<{
id: string;
latestValue: number | null;
unit: string | null;
sleepStages: Record<string, number> | null;
}>;
};
};
const sleep = body.data.metrics.find((m) => m.id === "sleep");
expect(sleep, "sleep tile must be emitted").toBeDefined();
// Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410 min → 6.83 h.
// (IN_BED 480 + AWAKE 20 excluded.)
expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2);
expect(sleep?.unit).toBe("h");
// Stage breakdown is exposed in hours for a future detail view.
expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2);
expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2);
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
});
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
+133
View File
@@ -57,6 +57,13 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
summarizeSleepNights,
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { SleepStage } from "@/generated/prisma/client";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -96,6 +103,24 @@ interface MetricCard {
* without another wire-shape change.
*/
unitKey: string;
/**
* v1.11.4 — explicit unit token for clients that need to know the
* value's unit without inferring it from the metric kind. Most tiles
* leave this `null` (the `unitKey` i18n key already carries the
* display unit). The `sleep` tile sets it to `"h"` because its
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
* below for why the night total replaced the single-stage value.
*/
unit: string | null;
/**
* v1.11.4 — per-stage minutes for the headline night, sleep tile only.
* `null` for every other kind and for a sleep night with no
* stage-tagged rows (a legacy bare-duration night). Additive: a future
* sleep detail view can render the breakdown without changing the
* headline `latestValue`.
*/
sleepStages: Partial<Record<SleepStage, number>> | null;
trend: "up" | "down" | "flat" | "unknown";
sparkline: number[];
updatedAt: string | null;
@@ -158,6 +183,24 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
};
/**
* v1.11.4 — sleep sparkline = the trailing-7 nights' TIME-ASLEEP in
* hours, reconstructed per night from the raw stage rows. The generic
* DAY-bucket rollup sparkline can't be used for sleep because it means
* across the per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE
* row average to 150) rather than summing them into a night total.
*/
function buildSleepSparkline(
rows: SleepStageRow[],
tz: string,
priorityJson: unknown,
): number[] {
return reconstructSleepNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100)
.slice(-SPARK_DAYS);
}
function trendOf(values: number[]): MetricCard["trend"] {
if (values.length < 2) return "unknown";
const first = values[0];
@@ -420,6 +463,7 @@ async function buildDashboardSummary(
todaysIntakes,
streakActivity,
measurementStreakDays,
sleepStageRows,
] = await Promise.all([
time("latestEver", () =>
prisma.$queryRaw<LatestEverRow[]>`
@@ -508,8 +552,34 @@ async function buildDashboardSummary(
AND m."deleted_at" IS NULL
`,
),
// v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total
// tile. `SLEEP_DURATION` is stored one row per STAGE per night
// (minutes), so the single-most-recent row (the `latestEver` path
// above) is just ONE stage, not the night. The sleep tile instead
// sums the asleep stages of the latest night via
// `summarizeSleepNights`. Bounded to the streak window (≈ a year of
// ~5 rows/night) so the read stays small; the headline only needs
// the most-recent night but the window also feeds the iOS sparkline
// night totals.
time("sleepNights", () =>
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: streakWindowStart },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
}),
),
]);
// v1.11.4 — the user's sleep source-priority ladder, used to collapse a
// dual-source night (e.g. WHOOP + Apple Health) to one canonical source
// before the per-night reconstruction sums it.
const sleepPriorityJson = await loadUserSourcePriority(userId);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
@@ -594,6 +664,17 @@ async function buildDashboardSummary(
return sparkByType.get(type) ?? [];
}
// v1.11.4 — collapse the per-stage SLEEP_DURATION rows into per-night
// asleep totals. The sleep tile's headline is last night's TIME ASLEEP
// (CORE/light + DEEP + REM, excluding IN_BED + AWAKE), emitted in HOURS
// with an explicit `unit: "h"`, replacing the single-stage minutes the
// `latestEver` read used to surface.
const sleepSummary = summarizeSleepNights(
sleepStageRows as SleepStageRow[],
userTz,
sleepPriorityJson,
);
const metrics: MetricCard[] = [];
// v1.4.33 maintainer-item-1 — every emitted card now carries
@@ -614,6 +695,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.weight,
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -652,6 +735,8 @@ async function buildDashboardSummary(
latestValue: latestSys?.value ?? null,
secondaryValue: latestDia?.value ?? null,
unitKey: METRIC_UNIT_KEYS.bloodPressure,
unit: null,
sleepStages: null,
trend: trendOf(sysSpark),
sparkline: sysSpark,
updatedAt:
@@ -679,6 +764,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.pulse,
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -704,6 +791,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.bodyFat,
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -730,6 +819,48 @@ async function buildDashboardSummary(
const latest = latestOf(type);
const meta = metaForType(type);
if (!latest && meta.allTimeCount === 0) continue;
// v1.11.4 — sleep is night-aggregated, not single-stage. Emit the
// latest night's TIME ASLEEP in HOURS (float) with an explicit
// `unit: "h"`, the per-stage breakdown, and a sparkline of the
// trailing nights' asleep hours. Every other kind keeps the
// single-row latest value + canonical-unit i18n key.
if (kind === "sleep") {
const night = sleepSummary.latestNight;
const toHours = (minutes: number): number =>
Math.round((minutes / 60) * 100) / 100;
// Sparkline = trailing nights' asleep hours from the night
// reconstruction (the DAY-bucket rollup sparkline blends stages
// and would be misleading for sleep).
const nightSpark = buildSleepSparkline(
sleepStageRows as SleepStageRow[],
userTz,
sleepPriorityJson,
);
const stageHours: Partial<Record<SleepStage, number>> | null = night
? Object.fromEntries(
Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]),
)
: null;
metrics.push({
id: kind,
kind,
titleKey: METRIC_TITLE_KEYS[kind],
latestValue: night ? toHours(night.asleepMinutes) : null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS[kind],
unit: "h",
sleepStages:
stageHours && Object.keys(stageHours).length > 0 ? stageHours : null,
trend: trendOf(nightSpark),
sparkline: nightSpark,
updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
allTimeCount: meta.allTimeCount,
lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
});
continue;
}
const spark = sparkOf(type);
metrics.push({
id: kind,
@@ -738,6 +869,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS[kind],
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -76,6 +76,16 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
// The read-only GET probes the provider chain (no completion) and
// enqueues an out-of-band warm on a stale / missing cache.
vi.mock("@/lib/insights/status-provider", () => ({
hasUsableStatusProvider: vi.fn(async () => true),
}));
vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
enqueueForceWarm: vi.fn(async () => undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
@@ -99,10 +109,12 @@ vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
import { POST, resolveInsightsRateLimit } from "../route";
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
import { clearLastWorkingProviderCache } from "@/lib/ai/provider-runner";
import {
extractFeatures,
@@ -404,3 +416,61 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy();
});
});
describe("GET /api/insights/generate — read-only advisor read", () => {
it("serves the cached briefing without ever calling the provider", async () => {
const cached = { dailyBriefing: { paragraph: "ok", keyFindings: [] } };
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(cached),
locale: "en",
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { insights: unknown; cached: boolean };
};
expect(body.data.cached).toBe(true);
expect(body.data.insights).toEqual(cached);
// No completion is ever run on the read path.
expect(resolveProvider).not.toHaveBeenCalled();
// A fresh cache (just now) does not trigger a warm.
expect(enqueueForceWarm).not.toHaveBeenCalled();
});
it("enqueues an out-of-band warm when the cache is stale and a provider exists", async () => {
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: stale,
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
locale: "en",
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
expect(enqueueForceWarm).toHaveBeenCalledWith({
userId: "u-1",
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
});
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: null,
insightsCachedText: null,
locale: "en",
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { insights: unknown; cached: boolean };
};
expect(body.data.cached).toBe(false);
expect(body.data.insights).toBeNull();
expect(enqueueForceWarm).not.toHaveBeenCalled();
});
});
+88
View File
@@ -41,6 +41,18 @@ import { requireAssistantSurface } from "@/lib/feature-flags";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
export const dynamic = "force-dynamic";
/**
* Briefing freshness window for the read-only GET. A cached briefing read
* this recently is fresh and triggers no warm; older / missing enqueues an
* out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path
* honours so the two stay consistent.
*/
const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000;
const DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR = 10;
@@ -184,6 +196,82 @@ async function buildComparisonSnapshotForUser(
return { baseline, metrics };
}
/**
* GET /api/insights/generate — read-only advisor read.
*
* The advisor consumers (hero strip, daily-briefing card, trends row) used
* to mount against the POST handler, which generates inline on a cache miss
* and blocks the page-load path on the full provider chain (up to the
* client's 8 s abort). This read-only GET serves the cached payload from
* `User.insightsCachedText` immediately — NEVER calling the provider — and,
* when the cache is stale / missing AND a provider is configured, enqueues
* an out-of-band warm (the same `insight-pregenerate` queue the nightly
* cron and the "prepare assessments" button use). The next read reflects
* the fresh briefing. User-initiated regeneration stays on the POST path.
*
* `userId` is narrowed from the session / Bearer — never a body field.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Same surface gate as the POST + the read-only status routes: a user
// with assessments enabled but Coach disabled still reads the cached
// briefing.
await requireAssistantSurface("coach");
const userId = user.id;
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: {
insightsCachedAt: true,
insightsCachedText: true,
locale: true,
},
});
const cachedAt = dbUser?.insightsCachedAt ?? null;
const isFresh =
cachedAt !== null &&
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
// provider-less account costs one cheap chain-resolve and shows the
// empty / connect-AI state instead of a wasted enqueue).
let revalidating = false;
if (!isFresh && (await hasUsableStatusProvider(userId))) {
const locale = (dbUser?.locale ?? user.locale) === "en" ? "en" : "de";
void enqueueForceWarm({ userId, locale });
revalidating = true;
}
if (dbUser?.insightsCachedText) {
try {
const cached = JSON.parse(dbUser.insightsCachedText);
const legacyPayload = isLegacyInsightPayload(cached);
annotate({
action: { name: "insights.generate.read" },
meta: { cached: true, legacyPayload, revalidating },
});
return apiSuccess({
insights: cached,
cached: true,
cachedAt,
legacyPayload,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
// warm enqueue above (when a provider exists) repairs it for the
// next read.
}
}
annotate({
action: { name: "insights.generate.read" },
meta: { cached: false, revalidating },
});
return apiSuccess({ insights: null, cached: false, legacyPayload: false });
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.4.31 — the advisor + daily briefing surfaces ride this
+126
View File
@@ -41,6 +41,13 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
import {
MEASURED_AT_TOLERANCE_MS,
isMergeableSource,
isSameReadingAcrossSource,
oppositeMergeSource,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -287,6 +294,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
let insertedCount = 0;
let updatedCount = 0;
let duplicateCount = 0;
// v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the
// MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain
// composite-key duplicate). Surfaced as a dedicated wide-event count so
// an operator can see how often the standalone-pair mirror collapses.
let crossSourceMergedCount = 0;
if (prepared.length > 0) {
// Pre-flight duplicate detection so we can return per-entry status
@@ -320,6 +332,78 @@ async function postBatch(request: NextRequest): Promise<Response> {
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
);
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
// mirror duplicate. When the iOS app logs a manual reading offline it
// mirrors that reading into HealthKit; on pairing, adopt-on-pair
// uploads it as MANUAL and HealthKit background sync independently
// re-ingests the mirrored sample as APPLE_HEALTH. The two carry
// different externalIds + sources, so the composite unique index lets
// both land — duplicating the whole hand-logged history on first pair.
// We collapse them at ingest: an incoming MANUAL / APPLE_HEALTH row is
// dropped as a `duplicate` when a same-reading row of the OPPOSITE
// source already exists (same type + value + measuredAt within a tight
// ±2 s window). `stats:*` cumulative rows are excluded — those are
// per-day aggregates the observer overwrites in place, never
// hand-entered point readings. See `cross-source-merge.ts` for the
// full rule + why first-physical-reading-wins (the value is identical
// between the two rows, so only the source label differs).
//
// Pull the opposite-source candidate rows in one widened query: for
// every mergeable, non-`stats:*` incoming row, look for an opposite-
// source row of the same type whose measuredAt falls inside the
// tolerance window. Compared in JS because the value match needs a
// float epsilon the SQL `IN` can't express.
const mergeProbes = prepared.filter(
(p) =>
isMergeableSource(p.row.source as string) &&
!isStatsExternalId(p.row.externalId as string),
);
const crossSourceCandidates: MergeCandidate[] = [];
if (mergeProbes.length > 0) {
const candidateRows = await prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
OR: mergeProbes.map((p) => {
const measuredAt = p.row.measuredAt as Date;
return {
type: p.row.type as MeasurementType,
source: oppositeMergeSource(
p.row.source as "MANUAL" | "APPLE_HEALTH",
),
measuredAt: {
gte: new Date(measuredAt.getTime() - MEASURED_AT_TOLERANCE_MS),
lte: new Date(measuredAt.getTime() + MEASURED_AT_TOLERANCE_MS),
},
};
}),
},
select: { type: true, source: true, value: true, measuredAt: true },
});
crossSourceCandidates.push(...candidateRows);
}
// Rows already chosen for insert earlier in THIS batch — so a MANUAL
// and an APPLE_HEALTH same-reading arriving in the SAME batch also
// collapse (the DB probe above only sees rows from prior batches).
const inBatchCandidates: MergeCandidate[] = [];
function hasSameReadingSibling(p: Prepared): boolean {
const incoming = {
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
measuredAt: p.row.measuredAt as Date,
};
if (!isMergeableSource(incoming.source)) return false;
for (const candidate of crossSourceCandidates) {
if (isSameReadingAcrossSource(incoming, candidate)) return true;
}
for (const candidate of inBatchCandidates) {
if (isSameReadingAcrossSource(incoming, candidate)) return true;
}
return false;
}
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
@@ -336,9 +420,35 @@ async function postBatch(request: NextRequest): Promise<Response> {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
} else if (
!isStatsExternalId(p.row.externalId as string) &&
hasSameReadingSibling(p)
) {
// v1.11.4 (iOS #2) — cross-source same-reading collapse. The
// physical reading already exists under the opposite client
// source; drop this mirror copy. Status stays `duplicate` so the
// iOS sync cursor checkpoints past it exactly as it does for a
// composite-key duplicate; the `reason` distinguishes it for ops.
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "cross_source_merge",
};
duplicateCount += 1;
crossSourceMergedCount += 1;
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p.row);
// Track this row so a same-reading sibling later in the SAME
// batch collapses against it.
if (isMergeableSource(p.row.source as string)) {
inBatchCandidates.push({
type: p.row.type as MeasurementType,
source: p.row.source as string,
value: p.row.value as number,
measuredAt: p.row.measuredAt as Date,
});
}
}
}
@@ -463,6 +573,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
// v1.11.4 (iOS #2) — dedicated annotation for the MANUAL↔APPLE_HEALTH
// same-reading collapse so an operator can grep
// `measurement.batch.cross-source-merge` to confirm the standalone-pair
// mirror duplicate is being absorbed (a healthy first-pair flow). Only
// fires when at least one row was merged so the baseline ingest trace
// is unchanged for the common case.
if (crossSourceMergedCount > 0) {
annotate({
action: { name: "measurement.batch.cross-source-merge" },
meta: {
merged: crossSourceMergedCount,
processed: entries.length,
},
});
}
// v1.4.25 W16c — kick off PR detection for this user. We always
// enqueue when at least one row was written (or the batch had any
// measurements to consider) so a single off-day reading still gets
@@ -10,8 +10,22 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night;
// pin it so the night grouping is deterministic.
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
// v1.11.4 — the sleep branch loads the user's source-priority ladder to
// collapse a dual-source night; pin it to the defaults so the test stays
// hermetic (no DB user read).
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -117,6 +131,48 @@ describe("GET /api/measurements/series", () => {
expect(body.data.points[0].secondary).toBeNull();
});
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// series must return ONE point per night carrying the TIME-ASLEEP
// total in hours, not a point per stage.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{ id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" },
{ id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
] as never);
const res = await GET(req("kind=sleep&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
unit: string;
points: Array<{
value: number;
sleepStages: Record<string, number> | null;
}>;
};
};
expect(body.data.unit).toBe("h");
// One point for the single night.
expect(body.data.points).toHaveLength(1);
// Time asleep = CORE + DEEP + REM = 410 min → 6.83 h (IN_BED + AWAKE
// excluded).
expect(body.data.points[0].value).toBeCloseTo(410 / 60, 2);
expect(body.data.points[0].sleepStages?.CORE).toBeCloseTo(4, 2);
});
it("returns an explicit unit for a non-sleep kind (v1.11.4)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "w1", value: 78.4, measuredAt: new Date() },
] as never);
const res = await GET(req("kind=weight&days=7"));
const body = (await res.json()) as { data: { unit: string } };
expect(body.data.unit).toBe("kg");
});
it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => {
// v1.5.5 — the previous 365-day cap rejected the iOS app's
// "Alle"-range request with a 422, painting an error banner on
+109 -2
View File
@@ -21,7 +21,17 @@ import {
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType } from "@/generated/prisma/client";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
* window is capped to one year even when the client requests the 3650-day
* "Alle" range. Matches the slim slice's `withSleepNightTotals` sleep read.
*/
const SLEEP_SERIES_MAX_DAYS = 365;
const kindEnum = z.enum([
"weight",
@@ -71,11 +81,41 @@ const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
vo2Max: "VO2_MAX",
};
/**
* v1.11.4 — explicit per-kind unit token returned at the top level so
* the client never has to infer the value's unit. `bloodPressure` reuses
* the systolic type's unit (mmHg, same for both lines). `sleep` is
* overridden at request time to `"h"` because the route returns per-night
* TIME-ASLEEP in hours rather than the canonical per-stage minutes.
*/
const SERIES_UNIT: Record<z.infer<typeof kindEnum>, string> = {
weight: "kg",
bloodPressure: "mmHg",
pulse: "bpm",
bodyFat: "%",
glucose: "mg/dL",
sleep: "h",
steps: "steps",
totalBodyWater: "kg",
boneMass: "kg",
oxygenSaturation: "%",
restingHeartRate: "bpm",
heartRateVariability: "ms",
vo2Max: "mL/(kg·min)",
};
interface SeriesPoint {
id: string;
at: string;
value: number;
secondary: number | null;
/**
* v1.11.4 — per-stage minutes for a sleep night point. `null` for
* every non-sleep kind. Lets a sleep detail view render the night's
* stage breakdown without a second fetch; the headline `value` stays
* the night's TIME-ASLEEP total.
*/
sleepStages?: Partial<Record<SleepStage, number>> | null;
}
function stdDev(values: number[]): number {
@@ -136,8 +176,74 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { kind, days } = parsed.data;
const since = new Date(Date.now() - days * 86_400_000);
// v1.11.4 — explicit unit token so the client never infers the unit
// from the kind. Sleep is special-cased below (per-night TIME-ASLEEP
// in HOURS); every other kind carries its canonical stored unit.
let unit = SERIES_UNIT[kind];
let points: SeriesPoint[] = [];
if (kind === "bloodPressure") {
if (kind === "sleep") {
// SLEEP_DURATION is stored one row per STAGE per night (minutes).
// Collapse the stage rows into ONE point per night carrying the
// night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE),
// converted to HOURS. The single-stage rows the legacy path returned
// made the chart show one fragment per stage instead of a nightly
// trend. The per-night reconstruction clusters stages into sessions
// (so a midnight-spanning night stays one point) and collapses a
// dual-source night to one canonical source via the user's `sleep`
// priority ladder.
//
// v1.11.4 — sleep is the one kind read row-per-stage (≈5 rows/night ×
// source-count) rather than from the day-bucketed rollup, so an
// unbounded "Alle" (days = 3650) request on a multi-year dual-source
// account would walk a six-figure row set into JS. Cap the sleep read
// at SLEEP_SERIES_MAX_DAYS (365 d) — the same one-year bound the slim
// slice's `withSleepNightTotals` uses — so the window stays small
// regardless of the requested range. Other kinds read from the rollup
// tier and keep the full 3650-day range.
const sleepSince = new Date(
Date.now() - Math.min(days, SLEEP_SERIES_MAX_DAYS) * 86_400_000,
);
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
]);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
measuredAt: { gte: sleepSince },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: {
id: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
},
});
unit = "h";
points = reconstructSleepNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => {
const stageHours = Object.fromEntries(
Object.entries(n.stages).map(([s, m]) => [
s,
Math.round((m / 60) * 100) / 100,
]),
) as Partial<Record<SleepStage, number>>;
return {
id: `sleep:${n.night}`,
at: n.measuredAt.toISOString(),
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
secondary: null,
sleepStages:
Object.keys(stageHours).length > 0 ? stageHours : null,
};
});
} else if (kind === "bloodPressure") {
const [sys, dia] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -213,6 +319,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({
kind,
unit,
points,
stats: summary
? {
+69 -12
View File
@@ -151,6 +151,39 @@ interface RangeDisplayConfig {
range: TrafficRange | null;
}
/**
* v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep
* tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes;
* the tile renders hours. Every value-bearing field (latest / min / max /
* mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides
* by 60; the slope tuples scale their `slope` (per-day rate) by the same
* factor so the trend arrow stays consistent; count / direction /
* confidence / anomalyCount are unit-free and pass through.
*/
function toHoursSummary(s: DataSummary): DataSummary {
const h = (v: number | null | undefined): number | null =>
v == null ? null : Math.round((v / 60) * 100) / 100;
const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] =>
slope == null
? null
: { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 };
return {
...s,
latest: h(s.latest),
min: h(s.min),
max: h(s.max),
mean: h(s.mean),
median: h(s.median),
avg7: h(s.avg7),
avg30: h(s.avg30),
avg30LastMonth: h(s.avg30LastMonth),
avg30LastYear: h(s.avg30LastYear),
slope7: scaleSlope(s.slope7),
slope30: scaleSlope(s.slope30),
slope90: scaleSlope(s.slope90),
};
}
function getHourForTimeZone(timeZone?: string): number {
const now = new Date();
if (!timeZone) return now.getHours();
@@ -445,6 +478,15 @@ export default function DashboardPage() {
const p = data?.summaries?.PULSE;
const bf = data?.summaries?.BODY_FAT;
const sleepSummary = data?.summaries?.SLEEP_DURATION;
// v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep
// totals in MINUTES (the server collapses the per-stage rows into one
// night value; see `summaries-slice.ts`). The sleep tile renders hours,
// so convert every value field minutes→hours here while keeping the
// staleness / count metadata untouched. This is the web-parity twin of
// the iOS dashboard-summary route which already emits `unit:"h"`.
const sleepSummaryHours = sleepSummary
? toHoursSummary(sleepSummary)
: undefined;
const stepsSummary = data?.summaries?.ACTIVITY_STEPS;
// v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics
// auto-populates this summary because the route iterates over the
@@ -571,15 +613,30 @@ export default function DashboardPage() {
const showSleepChart = isChartVisible("sleep") && hasSleep;
const showStepsChart = isChartVisible("steps") && hasSteps;
const showMedicationsCard = isChartVisible("medications");
// `layoutData` is undefined until the real layout (snapshot or legacy
// widgets) resolves; `resolveDashboardLayout(undefined)` falls back to
// DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default.
// Gating layout-toggle-only cards on that fallback flashed them in for
// the load window and then retracted them for any user who had turned
// the widget OFF (their real layout arriving after first paint). The
// data-driven tiles tolerate the fallback because they also gate on a
// data floor; the achievements + recent-workouts cards have no floor,
// so wait for the real layout before committing them.
const layoutResolved = layoutData != null;
// v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself
// self-handles the empty state (CTA → /achievements), so we only need
// the layout-toggle gate here. No data-floor check (the empty card is
// intentional — the maintainer wants the user to discover the feature).
const showAchievementsCard = isChartVisible("achievements");
// self-handles the loading skeleton + empty state (CTA → /achievements),
// so we only need the layout-toggle gate here. No data-floor check (the
// empty card is intentional — the maintainer wants the user to discover
// the feature).
const showAchievementsCard = layoutResolved && isChartVisible("achievements");
// v1.4.32 — recent workouts dashboard tile. Self-gates on the
// workouts query response so we only need the layout toggle here;
// the tile renders an Apple-Health-onboarding hint when empty.
const showRecentWorkoutsTile = isChartVisible("recentWorkouts");
// the tile renders an Apple-Health-onboarding hint when empty. Same
// layout-resolved gate as the achievements card — default-visible with
// no data floor, so it would otherwise flash in then retract for a user
// who hid it.
const showRecentWorkoutsTile =
layoutResolved && isChartVisible("recentWorkouts");
// Glucose widget — visible iff layout enables it AND at least one reading exists.
// Glucose has no separate chart slot today, so the tile flag is the
@@ -1109,16 +1166,16 @@ export default function DashboardPage() {
<TrendCard
key="sleep"
label={t("dashboard.sleepShort") ?? "Sleep"}
latest={sleepSummary?.latest ?? null}
latest={sleepSummaryHours?.latest ?? null}
unit="h"
avg7={sleepSummary?.avg7 ?? null}
avg30={sleepSummary?.avg30 ?? null}
slope30={sleepSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sleepSummary)}
avg7={sleepSummaryHours?.avg7 ?? null}
avg30={sleepSummaryHours?.avg30 ?? null}
slope30={sleepSummaryHours?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sleepSummaryHours)}
icon={Moon}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sleepSummary)}
compareDelta={tileCompareDelta(sleepSummaryHours)}
staleDays={tileStaleDays("SLEEP_DURATION")}
/>
),
@@ -2,38 +2,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.43 W2-CHART-GATE — pin the empty-state copy split.
* Sparse-data render contract.
*
* Pre-v1.4.43 the chart painted "Erfasse mehr Messungen, um die
* Trendlinie freizuschalten" whenever `chartData.length < 3` — but
* `chartData` is daily-aggregated, so a user with many measurements
* on < 3 distinct days saw the misleading "log more measurements"
* hint despite having logged plenty. The gate now keys on the raw
* measurement count surfaced via the `rawCount` property the
* queryFn stashes on its return array. When `rawCount >= 3` the
* "need more days" copy paints; otherwise the legacy "log more"
* copy stays.
* The metric chart no longer withholds real data behind a "more days
* needed" card. Any non-empty window paints the available points — a
* single marker for one day, a line for two — and adds a subtle inline
* caption (`chart-sparse-caption`) when fewer than three daily points
* exist so the user understands more days fill out the trend. Only a
* genuinely empty window (zero daily points) paints the no-data card.
*/
function buildData(
rows: Array<{ measuredAt: string; value: number; count?: number }>,
rawCount: number,
): unknown[] {
// Re-use the same array-with-stashed-property shape the real
// queryFn produces. The chart's `useMemo(() => …, [data])` reads
// `(data as ChartDataPoint[] & { rawCount?: number }).rawCount`.
const points = rows.map((r) => ({
// The queryFn returns daily-aggregated points; mirror that shape so
// the chart's `useMemo(() => …, [data])` derives the same chartData.
return rows.map((r) => ({
date: r.measuredAt.slice(0, 10),
timestamp: new Date(r.measuredAt).getTime(),
PULSE: r.value,
}));
Object.defineProperty(points, "rawCount", {
value: rawCount,
enumerable: false,
configurable: false,
writable: false,
});
return points;
}
vi.mock("@/hooks/use-auth", () => ({
@@ -44,78 +32,83 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
describe("<HealthChart> — empty-state gate by raw count", () => {
async function renderChart(data: unknown[]): Promise<string> {
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
vi.doUnmock("@tanstack/react-query");
return html;
}
describe("<HealthChart> — sparse-data render contract", () => {
beforeEach(() => {
vi.resetModules();
});
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
// Two distinct days with 50 raw rows total — the daily-aggregated
// chartData collapses to 2 points but the user logged plenty.
const data = buildData(
[
it("renders the chart (not a withholding card) with a sparse caption for two distinct days", async () => {
const html = await renderChart(
buildData([
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
],
50,
]),
);
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
expect(html).toContain("Mehr Messtage erforderlich");
// The chart paints the available points + a subtle caption …
expect(html).toContain('data-slot="chart-sparse-caption"');
expect(html).toContain("Mehr Messtage füllen den Trend.");
// … and does NOT fall back to the old withholding empty-state copy.
expect(html).not.toContain('data-slot="chart-empty-state"');
expect(html).not.toContain("Mehr Messtage erforderlich");
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
vi.doUnmock("@tanstack/react-query");
});
it("renders no-data copy when rawCount < 3", async () => {
const data = buildData(
[{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }],
1,
it("renders the single marker + sparse caption for one day instead of a bare hint", async () => {
const html = await renderChart(
buildData([{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }]),
);
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
// A single point still renders (Recharts paints the marker) and the
// caption stays — no withholding card for one real reading.
expect(html).toContain('data-slot="chart-sparse-caption"');
expect(html).toContain("Mehr Messtage füllen den Trend.");
expect(html).not.toContain('data-slot="chart-empty-state"');
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
});
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
it("renders the no-data card with no sparse caption when the window is empty", async () => {
const html = await renderChart(buildData([]));
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
expect(html).toContain('data-slot="chart-empty-state"');
expect(html).toContain("Für den gewählten Bereich liegen keine Messungen");
expect(html).not.toContain('data-slot="chart-sparse-caption"');
});
it("omits the sparse caption once three or more distinct days exist", async () => {
const html = await renderChart(
buildData([
{ measuredAt: "2026-05-18T09:00:00.000Z", value: 68 },
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
]),
);
expect(html).toContain("Erfasse mindestens 3 Einträge");
expect(html).not.toContain("Mehr Messtage erforderlich");
vi.doUnmock("@tanstack/react-query");
expect(html).not.toContain('data-slot="chart-sparse-caption"');
expect(html).not.toContain('data-slot="chart-empty-state"');
});
});
@@ -10,19 +10,12 @@ import { renderToStaticMarkup } from "react-dom/server";
* The fix paints a "no data in this range" empty state via
* `<ChartEmptyState>` so the dashboard composition stays intact and
* the user knows the chart loaded but the selected window holds no
* measurements. The copy is distinct from the < 3-points "Mehr
* Messtage erforderlich" branch so the two situations don't blur.
* measurements. The copy is distinct from the sparse-data caption
* (one / two daily points) so the two situations don't blur.
*/
function buildData(rawCount: number): unknown[] {
const points: unknown[] = [];
Object.defineProperty(points, "rawCount", {
value: rawCount,
enumerable: false,
configurable: false,
writable: false,
});
return points;
function buildData(): unknown[] {
return [];
}
vi.mock("@/hooks/use-auth", () => ({
@@ -39,7 +32,7 @@ describe("<HealthChart> — empty-window state (W11-M6)", () => {
});
it("renders the no-data-in-range copy when data resolves to an empty array", async () => {
const data = buildData(0);
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
@@ -67,8 +60,9 @@ describe("<HealthChart> — empty-window state (W11-M6)", () => {
// dashboard isn't missing a tile. The pre-fix `return null` would
// have stripped this entirely.
expect(html).toContain("Pulse");
// The < 3-points branch must NOT paint here — empty-window and
// The sparse-data caption must NOT paint here — empty-window and
// sparse-data are different situations with distinct copy.
expect(html).not.toContain('data-slot="chart-sparse-caption"');
expect(html).not.toContain("Mehr Messtage erforderlich");
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
+20 -71
View File
@@ -622,14 +622,6 @@ export function HealthChart({
}
>();
// v1.4.43 W2-CHART-GATE — accumulate the raw measurement count
// across every fetched type so the empty-state copy can
// distinguish "user logged < 3 measurements" from "user logged
// many measurements but on < 3 distinct days". The chartData
// length collapses to daily buckets and would otherwise paint
// the "log more" hint even when the user already logged 50
// entries on 2 days.
let rawMeasurementCount = 0;
async function fetchMeasurementsByType(type: string) {
const typeParams = new URLSearchParams();
typeParams.set("type", type);
@@ -684,12 +676,6 @@ export function HealthChart({
continue;
}
// v1.4.43 W2-CHART-GATE — rollup / server-aggregated rows
// carry the underlying `count`; raw rows do not, so each
// counts as one. Sum across types since the gate is the
// total user-logged measurements in the window.
rawMeasurementCount += measurement.count ?? 1;
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
@@ -757,30 +743,11 @@ export function HealthChart({
})
.sort((a, b) => a.timestamp - b.timestamp);
// v1.4.43 W2-CHART-GATE — stash the raw measurement count on
// the returned array as a non-enumerable property so the gate
// logic can distinguish "few measurements" from "few days" in
// the empty-state copy. Non-enumerable keeps the array shape
// intact for every downstream consumer that iterates / spreads.
Object.defineProperty(allData, "rawCount", {
value: rawMeasurementCount,
enumerable: false,
configurable: false,
writable: false,
});
return allData;
},
enabled: isAuthenticated,
});
// v1.4.43 W2-CHART-GATE — surface the stashed raw count.
const rawCount = useMemo<number>(() => {
if (!data) return 0;
const value = (data as ChartDataPoint[] & { rawCount?: number }).rawCount;
return typeof value === "number" ? value : 0;
}, [data]);
const chartData = useMemo(() => {
if (!data?.length) return data;
@@ -1370,48 +1337,17 @@ export function HealthChart({
) : !chartData?.length ? (
// v1.4.43 W11-M6 — empty-window state.
//
// Pre-fix the chart silently `return null`ed when `chartData`
// resolved empty (after-load, no points in the selected
// range). Paired with the < 3-points hint, the user's
// five visible chart states were:
//
// - skeleton (loading)
// - 1-2 daily points / many raw → "Mehr Messtage erforderlich"
// - 1-2 daily points / < 3 raw → "Erfasse mindestens 3 Einträge"
// - ≥ 3 daily points → line chart
// - 0 daily points → NOTHING
//
// The "nothing" branch read as a broken widget on the
// dashboard. Paint a distinct empty state explaining the
// selected range is empty so the user reaches for the range
// tabs or the quick-add instead of suspecting a bug.
// The chart only withholds rendering when the selected range
// holds zero daily points. Any real data — even a single day —
// renders the points below; a sparse-data caption (rather than
// a withholding card) explains that more days fill out the
// trend. The genuinely-empty window paints a distinct empty
// state so the user reaches for the range tabs or the quick-add
// instead of suspecting a broken widget.
<ChartEmptyState
title={t("charts.noDataInRangeTitle")}
description={t("charts.noDataInRangeDescription")}
/>
) : (chartData?.length ?? 0) < 3 ? (
// v1.4.16 B1a — sparse-data placeholder. <3 daily points is too
// few to render a meaningful trend; paint a friendly hint
// instead so the dashboard doesn't look broken.
//
// v1.4.43 W2-CHART-GATE — split the copy on the raw measurement
// count. A user with 50 BP readings on 2 calendar days has
// `chartData.length = 2` (daily-aggregated) but `rawCount = 50`,
// so the legacy "log more measurements" hint was misleading.
// When the user has logged enough raw measurements but only
// hit < 3 distinct days, paint the "need more days" copy
// instead so the next action is clear.
rawCount >= 3 ? (
<ChartEmptyState
title={t("charts.needMoreDistinctDaysTitle")}
description={t("charts.needMoreDistinctDaysDescription")}
/>
) : (
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
/>
)
) : (
<div className={`relative ${chartHeightClass}`}>
{visibleBands.length > 0 ? (
@@ -1903,6 +1839,19 @@ export function HealthChart({
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Sparse-data caption. With fewer than three daily points the
chart still paints every reading (a single marker for one
day, a line for two), and this subtle note sets the
expectation that more days fill out the trend — rather than
withholding the data behind a placeholder card. */}
{!mini && (chartData?.length ?? 0) < 3 ? (
<p
className="text-muted-foreground mt-2 text-center text-xs"
data-slot="chart-sparse-caption"
>
{t("charts.sparseDataCaption")}
</p>
) : null}
</div>
)}
</div>
@@ -18,21 +18,24 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
let mockData: {
summary: unknown;
achievements: AchievementProgress[];
metrics: unknown;
} = {
let mockData:
| {
summary: unknown;
achievements: AchievementProgress[];
metrics: unknown;
}
| undefined = {
summary: {},
achievements: [],
metrics: {},
};
let mockIsPending = false;
// v1.4.34 IW-F-Perf — the card now reads through the shared
// `useAchievementsQuery` hook; the test mocks the hook directly so the
// network layer + TanStack provider scaffolding stays out of scope.
vi.mock("@/lib/queries/use-achievements-query", () => ({
useAchievementsQuery: () => ({ data: mockData }),
useAchievementsQuery: () => ({ data: mockData, isPending: mockIsPending }),
}));
import {
@@ -50,6 +53,7 @@ function render() {
beforeEach(() => {
vi.clearAllMocks();
mockIsPending = false;
});
const baseAchievement: AchievementProgress = {
@@ -127,6 +131,22 @@ describe("pickRecentUnlocks", () => {
});
describe("<RecentAchievementsCard>", () => {
it("renders a skeleton (no content, no empty state) while the query is pending", () => {
// The flash this guards against: before the query settles the card
// used to paint the empty / content branch and then retract it once
// the real payload arrived. The skeleton commits to neither.
mockIsPending = true;
mockData = undefined;
const html = render();
expect(html).toContain('data-slot="recent-achievements-skeleton"');
// Neither the empty-state copy nor any content item paints while
// pending — nothing to retract.
expect(html).not.toContain("No achievements yet");
expect(html).not.toContain('data-slot="recent-achievement-item"');
// The card header still anchors the footprint.
expect(html).toContain("Recent unlocks");
});
it("shows the empty-state CTA when no unlocks exist", () => {
mockData = { summary: {}, achievements: [], metrics: {} };
const html = render();
@@ -81,7 +81,14 @@ const RECENT_LIMIT = 3;
export function RecentAchievementsCard() {
const { t } = useTranslations();
const { data } = useAchievementsQuery();
// `isPending` (no data yet, fetch in flight or gated) drives the
// loading branch. Rendering the empty / content branch before the
// query settles caused an appear-then-retract flash on a cold load:
// the card painted its "no achievements yet" empty state (or stale
// content) for the fetch window, then swapped once the real payload
// landed. A skeleton holds the card's footprint without committing to
// a content shape it may have to retract.
const { data, isPending } = useAchievementsQuery();
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
@@ -105,7 +112,26 @@ export function RecentAchievementsCard() {
</Link>
</div>
{recent.length === 0 ? (
{isPending ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
className="space-y-2 motion-reduce:animate-none"
>
{Array.from({ length: RECENT_LIMIT }).map((_, idx) => (
<li
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-muted/60 h-7 w-7 shrink-0 animate-pulse rounded-md motion-reduce:animate-none" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="bg-muted/60 h-3 w-1/2 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-2.5 w-3/4 animate-pulse rounded motion-reduce:animate-none" />
</div>
</li>
))}
</ul>
) : recent.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t("achievements.dashboardCard.empty")}
</p>
@@ -0,0 +1,189 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.11.4 item J — `<TrendDescriptorCaption>` is the deterministic
* fallback the Trends row shows when the AI advisor produced no
* annotation (cold briefing). It reads the SAME series the mini-chart
* plots and renders a neutral direction + magnitude descriptor, or the
* real "Awaiting more data" hint only when the series is genuinely too
* sparse.
*
* The component reads its series through `useQuery`. We pre-seed the
* QueryClient cache (matching the factory keys) with `staleTime:
* Infinity` so the SSR render resolves synchronously off the cache, and
* stub `useAuth` authenticated so the query is enabled.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
import { TrendDescriptorCaption } from "../trend-annotation";
function seededClient(
seed: (client: QueryClient) => void,
): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
seed(client);
return client;
}
function render(client: QueryClient, node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
function numericSeries(values: number[]): {
value: number;
measuredAt: string;
}[] {
return values.map((value, i) => ({
value,
measuredAt: new Date(Date.UTC(2026, 0, 1 + i, 12)).toISOString(),
}));
}
describe("<TrendDescriptorCaption>", () => {
it("renders a rising numeric descriptor with the signed magnitude + unit", () => {
const client = seededClient((c) =>
c.setQueryData(queryKeys.insightsTrendSeries("BLOOD_PRESSURE_SYS"), [
...numericSeries([120, 124, 128]).map((r) => ({
value: r.value,
measuredAt: r.measuredAt,
})),
]),
);
const html = render(
client,
<TrendDescriptorCaption
metric="bp"
emptyMetric="bp"
kind="numeric"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-descriptor"/);
expect(html).toMatch(/data-metric="bp"/);
expect(html).toContain("Rising over 30 days");
expect(html).toContain("+8 mmHg");
// Observational + neutral — no causal / medical framing leaks in.
expect(html).not.toContain("Awaiting more data");
});
it("renders a falling weight descriptor", () => {
const client = seededClient((c) =>
c.setQueryData(
queryKeys.insightsTrendSeries("WEIGHT"),
numericSeries([82.4, 81.5, 81.0]),
),
);
const html = render(
client,
<TrendDescriptorCaption
metric="weight"
emptyMetric="weight"
kind="numeric"
types={["WEIGHT"]}
/>,
);
expect(html).toContain("Falling over 30 days");
expect(html).toContain("−1.4 kg");
});
it("renders a stable descriptor when the move is within the metric floor", () => {
const client = seededClient((c) =>
c.setQueryData(
queryKeys.insightsTrendSeries("WEIGHT"),
numericSeries([81.0, 81.1]),
),
);
const html = render(
client,
<TrendDescriptorCaption
metric="weight"
emptyMetric="weight"
kind="numeric"
types={["WEIGHT"]}
/>,
);
expect(html).toContain("Stable over the last 30 days");
});
it("renders the mood descriptor in plain improved/declined terms (no raw delta)", () => {
// Mood analytics returns the full history; the descriptor filters to
// the trailing 30-day window client-side, so seed two recent days.
const dayKey = (daysAgo: number): string =>
new Date(Date.now() - daysAgo * 86_400_000).toISOString().slice(0, 10);
const client = seededClient((c) =>
c.setQueryData(queryKeys.moodAnalytics(), {
entries: [
{ date: dayKey(20), score: 3 },
{ date: dayKey(2), score: 4 },
],
}),
);
const html = render(
client,
<TrendDescriptorCaption
metric="mood"
emptyMetric="mood"
kind="mood"
types={[]}
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-descriptor"/);
expect(html).toContain("Mood trended slightly higher over 30 days");
// Categorical scale — no raw numeric point delta in the copy.
expect(html).not.toMatch(/\+\d/);
});
it("falls back to the real empty hint when the series is too sparse", () => {
const client = seededClient((c) =>
c.setQueryData(
queryKeys.insightsTrendSeries("WEIGHT"),
numericSeries([81.0]),
),
);
const html = render(
client,
<TrendDescriptorCaption
metric="weight"
emptyMetric="weight"
kind="numeric"
types={["WEIGHT"]}
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-empty"/);
expect(html).toContain("Awaiting more data");
});
it("localises the descriptor (German)", () => {
const client = seededClient((c) =>
c.setQueryData(
queryKeys.insightsTrendSeries("BLOOD_PRESSURE_SYS"),
numericSeries([120, 128]),
),
);
const html = render(
client,
<TrendDescriptorCaption
metric="bp"
emptyMetric="bp"
kind="numeric"
types={["BLOOD_PRESSURE_SYS"]}
/>,
"de",
);
expect(html).toContain("steigend");
expect(html).toContain("+8 mmHg");
});
});
@@ -128,6 +128,26 @@ describe("<TrendsRow>", () => {
}
});
it("bounds the chart slot so the mood card's axis labels stay contained (item I)", () => {
// v1.11.4 item I — the mood mini chart is a `<Card>` (extra header
// + padding chrome) wrapping a categorical y-axis + a date x-axis;
// its tick labels used to bleed past the 180 px envelope and
// overlap the caption below. The slot now (a) clips with
// `overflow-hidden` so nothing escapes the envelope and (b) drives
// both mini charts' internal band down to 120 px via
// `[--chart-height:120px]` so the mood card's full envelope
// (band + header + axis labels) fits inside 180 px and the three
// tiles share one chart-band baseline.
const html = render(<TrendsRow />);
const slots =
html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? [];
expect(slots.length).toBe(3);
for (const slot of slots) {
expect(slot).toMatch(/overflow-hidden/);
expect(slot).toMatch(/\[--chart-height:120px\]/);
}
});
it("renders annotations when supplied", () => {
const html = render(
<TrendsRow
@@ -59,11 +59,23 @@ function RingTile({
data-metric={metricSlot}
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-col items-center gap-1.5">
<ScoreRing score={score} band={band} size="sm" label={label} />
<div className="flex min-w-0 flex-col items-center gap-1.5">
{/* The metric title rides an HTML caption below the ring, not the
in-SVG `label` slot: a long localised title ("Schlaf-Score",
"Bereitschaft") overflowed the small ring's centred SVG text and
the recharts `overflow:hidden` clipped its leading glyph. As HTML
it wraps within the tile and never truncates. The ring keeps just
the number centred. */}
<ScoreRing score={score} band={band} size="sm" />
<span
data-slot="wellness-score-label"
className="text-foreground max-w-full text-center text-xs font-medium text-balance"
>
{label}
</span>
<span
data-slot="wellness-score-band-word"
className="text-muted-foreground text-xs font-medium"
className="text-muted-foreground text-center text-xs"
>
{bandWord}
</span>
@@ -77,7 +77,12 @@ export function MoodTimeOfDayChart({
return (
<div className="space-y-2">
<div className="aspect-[3/2] min-h-[160px] w-full">
{/* A fixed height, not `aspect-[3/2]`: this card spans the full overview
width (its weekday/distribution siblings sit in a 2-col grid and are
width-constrained), so an aspect ratio derived the height off the full
card width and ballooned the chart to ~800px on a wide viewport. A
bounded height keeps it the same size as the sibling charts. */}
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
@@ -1,9 +1,21 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import {
computeTrendDescriptor,
moodDescriptorCopy,
numericDescriptorCopy,
MOOD_DESCRIPTOR_CONFIG,
TREND_SLOT_DESCRIPTOR_META,
type TrendDescriptorPoint,
} from "@/lib/insights/trend-descriptor";
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
@@ -188,3 +200,177 @@ export function TrendAnnotation({
</TrendCaptionCard>
);
}
// ── v1.11.4 item J — deterministic Trends-row caption ──────────────────
/** Trailing window the descriptor reads. Matches the Trends row's
* "Last 30 days at a glance" subtitle. */
const DESCRIPTOR_WINDOW_DAYS = 30;
interface MeasurementApiRow {
value: number;
measuredAt: string;
}
interface MoodAnalyticsRow {
date: string;
score: number;
}
function dayKeyToTimestamp(dayKey: string): number {
const [y, m, d] = dayKey.split("-").map(Number);
return Date.UTC(y, m - 1, d, 12, 0, 0);
}
/**
* Caption that prefers a deterministic, rule-based trend descriptor over
* the static "Awaiting more data" hint.
*
* Precedence (item J):
* 1. `pending` → shimmer (advisor in flight) — handled by the parent,
* which keeps rendering `<TrendAnnotation status="pending">` so this
* component only mounts on the resolved path.
* 2. advisor annotation present → the parent renders `<TrendAnnotation>`
* with the AI sentence; this component is not mounted.
* 3. NO advisor annotation but a computable series → this component
* shows the deterministic descriptor (direction + magnitude over the
* window) derived from the SAME series the mini-chart plots.
* 4. series too sparse (< 2 points) → the real "not enough data yet"
* empty hint, keeping the `trend-annotation-empty` contract so the
* row reads honestly when there genuinely isn't a trend to describe.
*
* Tone is observational + neutral by construction (see
* `trend-descriptor.ts`): direction + magnitude only, no value judgement.
*/
export function TrendDescriptorCaption({
metric,
emptyMetric,
kind,
types,
}: {
/** Stable slot id (`TrendChartConfig.metric`) — drives the descriptor
* config + the `data-metric` test hook. */
metric: string;
/** Which empty-state copy to show when the series is too sparse. */
emptyMetric: TrendAnnotationProps["metric"];
/** `mood` reads the categorical mood analytics; everything else reads
* the numeric measurement series for its primary type. */
kind: "mood" | "numeric";
/** Measurement types for the numeric path. The descriptor reads the
* FIRST type (the chart's primary line, e.g. systolic for BP). */
types: string[];
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const isMood = kind === "mood";
const primaryType = types[0] ?? "";
// Mood reuses the exact `moodAnalytics()` cache slot the MoodChart
// already populates, so the descriptor reads from the same series with
// zero extra round-trip. Numeric metrics take a small, dedicated
// 30-day daily-aggregate read keyed under `trend-series` (bounded to
// ≤ 30 rollup rows) rather than re-deriving the chart's heavyweight,
// state-dependent `chart-data` key.
const moodQuery = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async () => {
const res = await fetch("/api/mood/analytics");
if (!res.ok) throw new Error("Failed to fetch mood analytics");
const json = await res.json();
return json.data as { entries: MoodAnalyticsRow[] };
},
enabled: isAuthenticated && isMood,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const numericQuery = useQuery({
queryKey: queryKeys.insightsTrendSeries(primaryType),
queryFn: async () => {
const to = new Date();
const from = new Date(
to.getTime() - DESCRIPTOR_WINDOW_DAYS * 86_400_000,
);
const params = new URLSearchParams({
type: primaryType,
sortBy: "measuredAt",
sortDir: "asc",
from: from.toISOString(),
to: to.toISOString(),
limit: "5000",
aggregate: "daily",
source: "rollup",
});
const res = await fetch(`/api/measurements?${params}`);
if (!res.ok) throw new Error("Failed to fetch measurement series");
const json = await res.json();
return (json.data?.measurements ?? []) as MeasurementApiRow[];
},
enabled: isAuthenticated && !isMood && primaryType.length > 0,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const points = useMemo<TrendDescriptorPoint[]>(() => {
if (isMood) {
// Mood analytics returns the full history; the MoodChart mini
// defaults to its trailing 30-point window, so mirror that here by
// taking the last `DESCRIPTOR_WINDOW_DAYS` daily entries rather than
// a wall-clock date filter (keeps the memo pure — no `Date.now()`).
const entries = moodQuery.data?.entries ?? [];
return entries.slice(-DESCRIPTOR_WINDOW_DAYS).map((row) => ({
timestamp: dayKeyToTimestamp(row.date),
value: row.score,
}));
}
// The numeric series is already server-bounded to the trailing
// 30-day window by the fetch, so every returned row is in-window.
return (numericQuery.data ?? []).map((row) => ({
timestamp: new Date(row.measuredAt).getTime(),
value: row.value,
}));
}, [isMood, moodQuery.data, numericQuery.data]);
const descriptor = useMemo(() => {
const config = isMood
? MOOD_DESCRIPTOR_CONFIG
: TREND_SLOT_DESCRIPTOR_META[metric]?.config;
return computeTrendDescriptor(points, config);
}, [points, isMood, metric]);
// Tier 4 — genuinely too few points (also covers the in-flight window
// before the small series lands). Surface the real empty hint so the
// caption reads honestly when there is no trend to describe. The
// advisor-pending shimmer is the parent's job; this fallback path only
// mounts when the advisor produced no annotation, so a brief empty
// hint during the small series fetch is the right transient state.
if (!descriptor) {
return (
<p
data-slot="trend-annotation-empty"
data-metric={metric}
className="text-muted-foreground line-clamp-3 text-xs italic"
>
{t(EMPTY_KEY[emptyMetric])}
</p>
);
}
// Tier 3 — deterministic descriptor. Mood uses the categorical
// "improved / declined / stable" copy; every numeric metric uses the
// "{delta}{unit}" template. `numericDescriptorCopy` returns null for a
// slot with no numeric meta, in which case we fall back to mood-style
// copy defensively (should not happen for the legacy triple).
const copy = isMood
? moodDescriptorCopy(descriptor)
: (numericDescriptorCopy(metric, descriptor) ?? moodDescriptorCopy(descriptor));
return (
<TrendCaptionCard
slot="trend-annotation-descriptor"
metric={metric}
text={t(copy.key, copy.params)}
/>
);
}
+63 -8
View File
@@ -14,6 +14,7 @@ import {
import {
TrendAnnotation,
TrendCaptionCard,
TrendDescriptorCaption,
type TrendAnnotationConfidenceBand,
type TrendAnnotationStatus,
} from "./trend-annotation";
@@ -142,6 +143,45 @@ export function TrendsRow({
const annotationFor = (key: TrendAnnotationKey): string | null =>
annotations?.[key] ?? null;
// v1.11.4 item J — three-tier caption precedence for the legacy triple
// (the only slots that carry an advisor annotation):
// 1. advisor in flight → `<TrendAnnotation status="pending">`
// shimmer (loading flag).
// 2. advisor annotation present → the AI sentence.
// 3. NO annotation (cold briefing) → a deterministic, rule-based
// descriptor computed from the SAME series the mini-chart plots
// (`<TrendDescriptorCaption>`), instead of the old static
// "Awaiting more data" hint. That component itself falls back to
// the real empty hint only when the series is genuinely too sparse.
const renderLegacyCaption = (config: TrendChartConfig) => {
const annotationKey = config.annotationKey as TrendAnnotationKey;
const annotation = annotationFor(annotationKey);
const status = statusFor(annotation);
// Tiers 1 + 2 — keep the existing pending shimmer / AI prose path.
if (status === "pending" || status === "generated") {
return (
<TrendAnnotation
metric={annotationKey}
annotation={annotation}
confidence={confidence?.[annotationKey]}
status={status}
/>
);
}
// Tier 3 — deterministic descriptor (falls through to the real empty
// hint internally when the series is too sparse).
return (
<TrendDescriptorCaption
metric={config.metric}
emptyMetric={annotationKey}
kind={config.kind === "mood" ? "mood" : "numeric"}
types={config.types}
/>
);
};
return (
<section
data-slot="trends-row"
@@ -199,20 +239,35 @@ export function TrendsRow({
{/* v1.4.28 R3c-Insights — fixed chart slot. The mini
chart paints its own band; this wrapper pins the
total chart-envelope height so every tile lines up on
a single baseline regardless of the chart kind. */}
a single baseline regardless of the chart kind.
v1.11.4 item I — the slot is now the hard bound for
every chart kind. Two changes close the mood-card
overflow (its categorical y-axis + date x-axis bled
the tick labels out the bottom, overlapping the
caption, and the `<Card>` shell ran taller than the
flat `<HealthChart mini>` siblings):
- `overflow-hidden` clips anything the inner chart
paints past the 180 px envelope, so a stray axis
label can never escape into the caption row.
- `[--chart-height:120px]` drives BOTH mini charts'
internal band (they read `h-[var(--chart-height,
140px)]`) down to a shared 120 px. 120 px + the
mood `<Card>` header/padding chrome (~36 px) + the
x-axis tick band (~16 px) lands inside the 180 px
envelope, so the mood card no longer overflows and
the three tiles share one chart-band baseline. No
chart-component edit, no token churn — the bound
lives entirely on this slot. */}
<div
data-slot="trends-row-chart-slot"
className="h-[180px] shrink-0"
className="h-[180px] shrink-0 overflow-hidden [--chart-height:120px]"
>
<MetricChart config={config} title={title} />
</div>
{config.annotationKey ? (
<TrendAnnotation
metric={config.annotationKey}
annotation={annotationFor(config.annotationKey)}
confidence={confidence?.[config.annotationKey]}
status={statusFor(annotationFor(config.annotationKey))}
/>
renderLegacyCaption(config)
) : (
// v1.8.6 W8 — additive metrics carry no advisor
// annotation. Paint the metric's standard one-line
@@ -73,12 +73,21 @@ async function fetchAdvisor(
);
let res: Response;
try {
res = await fetch("/api/insights/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options.force ? { force: true } : {}),
signal: controller.signal,
});
// Read path (no `force`): the GET serves the cached briefing read-only
// and enqueues an out-of-band warm on a stale / missing cache — it
// never blocks the page-load path on the provider chain. Only the
// user-initiated regenerate (`force`) POSTs to generate inline.
res = options.force
? await fetch("/api/insights/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true }),
signal: controller.signal,
})
: await fetch("/api/insights/generate", {
method: "GET",
signal: controller.signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Graceful empty payload — the UI surfaces the empty / regen
+25 -17
View File
@@ -276,17 +276,23 @@ function DataResetCard() {
GitHub-style (red CTA only) rather than red-on-red-on-red.
The protective gate (confirmation dialog) is unchanged; this
is purely a visual-tone fix per the v1.4.43 audit. */}
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.dangerZoneDescription")}
</p>
<div className="mt-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.dangerZoneDescription")}
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={deleting}>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="w-full shrink-0 sm:w-auto"
>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -389,20 +395,22 @@ function AccountDeleteCard() {
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-account-delete-card"
>
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
<div className="mt-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="w-full shrink-0 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
@@ -0,0 +1,246 @@
import { describe, it, expect } from "vitest";
import type {
MeasurementSource,
SleepStage,
} from "@/generated/prisma/client";
import {
reconstructSleepNights,
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
/** Build a stage row with a fixed wall-clock instant. */
function row(
iso: string,
stage: SleepStage | null,
minutes: number,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes };
}
/** Build a stage row tagged with an ingest source. */
function srcRow(
iso: string,
stage: SleepStage | null,
minutes: number,
source: MeasurementSource,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes, source };
}
describe("reconstructSleepNights", () => {
it("sums asleep stages into one night, excluding IN_BED + AWAKE", () => {
// One night written as five per-stage rows (WHOOP / Apple shape), all
// on the same UTC calendar day so the night-key collapses them.
const rows: SleepStageRow[] = [
row("2026-06-04T00:00:00.000Z", "IN_BED", 480),
row("2026-06-04T00:30:00.000Z", "CORE", 240),
row("2026-06-04T02:00:00.000Z", "DEEP", 90),
row("2026-06-04T04:00:00.000Z", "REM", 80),
row("2026-06-04T05:00:00.000Z", "AWAKE", 20),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
const main = nights[0];
expect(main.night).toBe("2026-06-04");
// Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410. AWAKE excluded.
expect(main.asleepMinutes).toBe(410);
// AWAKE recorded but not counted as asleep.
expect(main.awakeMinutes).toBe(20);
// IN_BED row present → in-bed total surfaced.
expect(main.inBedMinutes).toBe(480);
expect(main.stages.CORE).toBe(240);
expect(main.stages.DEEP).toBe(90);
expect(main.stages.REM).toBe(80);
});
it("groups all stages of one night by the user's tz calendar day", () => {
// In a non-UTC zone (Auckland = UTC+12 in June) a night whose stages
// straddle UTC midnight still collapses to one local day. These
// instants are all 2026-06-04 LOCAL (12:00–17:00 UTC = 00:00–05:00
// Jun 4 Auckland) but straddle UTC midnight if keyed naively.
const rows: SleepStageRow[] = [
row("2026-06-03T12:00:00.000Z", "IN_BED", 480), // 00:00 Jun 4 NZST
row("2026-06-03T13:00:00.000Z", "CORE", 240), // 01:00 Jun 4
row("2026-06-03T15:00:00.000Z", "DEEP", 90), // 03:00 Jun 4
row("2026-06-03T17:00:00.000Z", "REM", 80), // 05:00 Jun 4
];
const nights = reconstructSleepNights(rows, "Pacific/Auckland");
expect(nights).toHaveLength(1);
expect(nights[0].night).toBe("2026-06-04");
expect(nights[0].asleepMinutes).toBe(410);
expect(nights[0].inBedMinutes).toBe(480);
});
it("treats a bare SLEEP_DURATION row (no stage) as the night total", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", null, 423),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(423);
expect(nights[0].inBedMinutes).toBeNull();
});
it("keeps a night that straddles LOCAL midnight as ONE night (HIGH)", () => {
// Berlin = UTC+2 in June. Asleep 22:30 → 06:15 LOCAL across CONTIGUOUS
// stage segments (each ends where the next begins). The stage END instants
// land on BOTH sides of local midnight, so a per-stage day key would split
// the night in two and the headline would lose the pre-midnight sleep.
// Session clustering must collapse them into one night keyed by the LOCAL
// WAKE DAY (Jun 4, the morning the user wakes).
const rows: SleepStageRow[] = [
// IN_BED spans the whole night (22:30 → 06:15 local = 465 min).
row("2026-06-04T04:15:00.000Z", "IN_BED", 465),
row("2026-06-03T21:30:00.000Z", "CORE", 60), // 22:30 → 23:30 Jun 3
row("2026-06-03T23:00:00.000Z", "DEEP", 90), // 23:30 → 01:00 (→ Jun 4)
row("2026-06-04T01:00:00.000Z", "REM", 120), // 01:00 → 03:00 Jun 4
row("2026-06-04T04:15:00.000Z", "CORE", 195), // 03:00 → 06:15 Jun 4
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(1);
// Keyed by the wake day, not the fall-asleep day.
expect(nights[0].night).toBe("2026-06-04");
// Whole night summed: CORE 60 + DEEP 90 + REM 120 + CORE 195 = 465.
expect(nights[0].asleepMinutes).toBe(465);
expect(nights[0].inBedMinutes).toBe(465);
});
it("keeps a night that straddles a DST spring-forward as one night", () => {
// Europe/Berlin springs forward 2026-03-29 02:00 → 03:00 local (UTC+1 →
// UTC+2). A night asleep ~23:00 Mar 28 → ~07:00 Mar 29 local crosses the
// skipped hour. The absolute-time gap clustering is DST-immune, and the
// wake-day key resolves on the real instant, so it stays one night keyed
// to the wake day (Mar 29).
// Contiguous segments chained in UTC across the spring-forward seam.
const rows: SleepStageRow[] = [
row("2026-03-28T22:40:00.000Z", "CORE", 100), // 23:00 → 23:40 Mar 28 (UTC+1)
row("2026-03-29T00:30:00.000Z", "DEEP", 110), // 23:40 → 01:30 Mar 29 (UTC+1)
// 01:55 UTC = 03:55 Mar 29 local AFTER the spring-forward (UTC+2).
row("2026-03-29T01:55:00.000Z", "REM", 85), // 01:30 → 03:55 Mar 29 (DST seam)
row("2026-03-29T05:00:00.000Z", "CORE", 185), // 03:55 → 07:00 Mar 29 (UTC+2)
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(1);
expect(nights[0].night).toBe("2026-03-29");
// 100 + 110 + 85 + 185 = 480 asleep minutes, summed across the DST seam.
expect(nights[0].asleepMinutes).toBe(480);
});
it("keeps a daytime nap separable from the following overnight night", () => {
// A 15:00 nap and a 19:40→06:00 overnight block are > 3 h apart, so they
// are two distinct sessions — the nap is NOT lumped into the night.
const rows: SleepStageRow[] = [
row("2026-06-03T13:00:00.000Z", "CORE", 45), // 14:15 → 15:00 Jun 3 nap
// Overnight, contiguous: ~19:40 Jun 3 → 06:00 Jun 4 local.
row("2026-06-03T21:00:00.000Z", "CORE", 200), // 19:40 → 23:00 Jun 3
row("2026-06-03T23:00:00.000Z", "DEEP", 120), // 23:00 → 01:00 Jun 4
row("2026-06-04T01:00:00.000Z", "REM", 120), // 01:00 → 03:00 Jun 4
row("2026-06-04T04:00:00.000Z", "CORE", 180), // 03:00 → 06:00 Jun 4
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(2);
// Nap keyed to its own wake day (Jun 3); overnight to Jun 4.
expect(nights[0].night).toBe("2026-06-03");
expect(nights[0].asleepMinutes).toBe(45);
expect(nights[1].night).toBe("2026-06-04");
expect(nights[1].asleepMinutes).toBe(620);
});
it("collapses a dual-source night to one canonical source (MEDIUM-1)", () => {
// WHOOP + Apple Health both report the SAME night. The default `sleep`
// ladder is WHOOP > APPLE_HEALTH > WITHINGS, so only WHOOP's stages are
// summed — no double-count, no blend.
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
// Apple Health's parallel rows for the same night — must be dropped.
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "REM", 80, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// WHOOP only: 240 + 90 + 90 = 420, NOT the ~825 blend of both sources.
expect(nights[0].asleepMinutes).toBe(420);
expect(nights[0].stages.CORE).toBe(240);
});
it("honours a per-user ladder that prefers Apple Health over WHOOP", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 80, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
// Apple Health wins under the override: 230 + 80 = 310.
expect(nights[0].asleepMinutes).toBe(310);
});
});
describe("summarizeSleepNights", () => {
it("summarises per-night totals, not per-stage rows", () => {
const rows: SleepStageRow[] = [
// Night A — 2026-06-03: 200 + 100 = 300 asleep min.
row("2026-06-03T01:00:00.000Z", "CORE", 200),
row("2026-06-03T03:00:00.000Z", "DEEP", 100),
// Night B — 2026-06-04: 240 + 90 + 90 = 420 asleep min.
row("2026-06-04T01:00:00.000Z", "CORE", 240),
row("2026-06-04T03:00:00.000Z", "DEEP", 90),
row("2026-06-04T04:30:00.000Z", "REM", 90),
];
const { summary, latestNight } = summarizeSleepNights(rows, "UTC");
// count = nights, NOT stage rows (5).
expect(summary.count).toBe(2);
// latest = most-recent night total (minutes), not a single stage.
expect(summary.latest).toBe(420);
expect(summary.min).toBe(300);
expect(summary.max).toBe(420);
expect(summary.mean).toBe(360);
expect(latestNight?.night).toBe("2026-06-04");
expect(latestNight?.asleepMinutes).toBe(420);
});
it("drops nights with zero asleep minutes (IN_BED / AWAKE only)", () => {
const rows: SleepStageRow[] = [
row("2026-06-03T23:00:00.000Z", "IN_BED", 60),
row("2026-06-03T23:30:00.000Z", "AWAKE", 60),
];
const { summary, latestNight } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(0);
expect(latestNight).toBeNull();
});
it("returns an empty summary for no rows", () => {
const { summary, latestNight } = summarizeSleepNights([], "UTC");
expect(summary.count).toBe(0);
expect(summary.latest).toBeNull();
expect(latestNight).toBeNull();
});
it("the latest night is the most-recent COMPLETE midnight-spanning night", () => {
// The headline reads `latestNight.asleepMinutes`. With a midnight-spanning
// last night, the old per-stage keying would have made the latest "night"
// the post-midnight fragment only. After the fix the latest night carries
// the full asleep total.
const rows: SleepStageRow[] = [
// Older complete night → Jun 3 (contiguous).
row("2026-06-02T23:00:00.000Z", "CORE", 200), // 21:00 → 01:00 Jun 3
row("2026-06-03T01:00:00.000Z", "DEEP", 120), // 01:00 → 03:00 Jun 3
// Last night, asleep before local midnight → Jun 4 (contiguous).
row("2026-06-03T21:00:00.000Z", "CORE", 150), // 20:30 → 23:00 Jun 3
row("2026-06-03T23:30:00.000Z", "DEEP", 150), // 23:00 → 01:30 Jun 4
row("2026-06-04T04:00:00.000Z", "REM", 270), // 23:30 → 06:00 Jun 4
];
const { summary, latestNight } = summarizeSleepNights(rows, "Europe/Berlin");
expect(summary.count).toBe(2);
expect(latestNight?.night).toBe("2026-06-04");
// Full last night = 150 + 150 + 270 = 570 (not just a post-midnight slice).
expect(latestNight?.asleepMinutes).toBe(570);
expect(summary.latest).toBe(570);
});
});
+307
View File
@@ -0,0 +1,307 @@
/**
* v1.11.4 — per-night sleep aggregation (iOS #1, HIGH).
*
* `SLEEP_DURATION` is stored in MINUTES, one row per sleep STAGE per
* night (IN_BED / AWAKE / ASLEEP / REM / CORE / DEEP) since v1.4.23. The
* value is NOT cumulative — a single stage row carries only that stage's
* minutes. Every "last night's sleep" surface therefore has to SUM the
* stage rows belonging to one night before it shows a headline number;
* reading the single most-recent row gives one stage, not the night.
*
* This helper is the one shared place that turns the raw per-stage rows
* into per-night totals. The headline "time asleep" is light(CORE) +
* deep + REM (+ legacy bare `ASLEEP`); IN_BED and AWAKE are excluded
* from the asleep total, matching the AASM convention already encoded in
* `sleep-score.ts`'s `ASLEEP_STAGES`. Bare rows with no `sleepStage`
* (legacy / manual) count as that night's asleep total.
*
* Night grouping
* --------------
* A "night" is one SLEEP SESSION, not a calendar day. `measuredAt` is the
* stage segment's END instant (`apple-health-mapping.ts` sets
* `takenAt = endDate`), and a device writes one row per stage segment, so a
* normal overnight sleep (asleep before local midnight) spreads its segments
* across BOTH sides of midnight. Keying each segment by its own calendar day
* would split one physical night into two partial nights and undercount the
* "last night" headline.
*
* Instead we cluster the segments into sessions by time gap: sort by
* `measuredAt`, and start a new session whenever the gap from the previous
* segment exceeds `SESSION_GAP_MS` (3 h). Stage segments inside one sleep
* session are contiguous (minutes to ~2 h apart), so a > 3 h gap reliably
* marks the boundary between a daytime nap and the overnight session, or
* between two genuine sessions in one day — they stay separable. The gap is
* measured on the absolute UTC instant, so it is immune to DST shifts.
*
* Each session is keyed by the LOCAL WAKE DAY — `userDayKey` of the session's
* LAST segment (its end instant) in the user's timezone. This matches the
* Apple Health convention (a sleep session is attributed to the date you wake
* up) and keeps the keying tz/DST-correct because it runs on the real instant.
*
* Source de-dup
* -------------
* A user paired to more than one sleep source (e.g. WHOOP + Apple Health)
* gets per-stage rows for the SAME night from each source. Summing them all
* would ~double the night total. Before summing a session we collapse it to
* ONE source using the user's `sleep` source-priority ladder
* (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > WITHINGS): pick the
* highest-ranked source present in the session, falling back to the source
* with the most asleep minutes (then source name) when none of the session's
* sources sits on the ladder. Only that source's segments are summed — sources
* are never blended within a night. This mirrors the per-(type, day) collapse
* the rest of the v1.11.x numeric surfaces apply.
*
* Unit
* ----
* Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit
* (`getUnitForType` → "minutes") — so the existing web consumers that
* already treat the summary as minutes (e.g. the insights overview's
* `formatHoursMinutes`) keep working unchanged; they now average per-
* night totals instead of per-stage rows, which is strictly more
* correct. Callers that want hours convert at the edge (`/ 60`).
*/
import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/resolver";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"ASLEEP",
"REM",
"CORE",
"DEEP",
]);
/**
* Gap (ms) between consecutive stage segments that starts a new sleep
* session. Stage segments within one night are contiguous (minutes to a
* couple of hours apart); a > 3 h gap separates a nap from the overnight
* block, or two genuine sessions in one day.
*/
const SESSION_GAP_MS = 3 * 60 * 60 * 1000;
export interface SleepStageRow {
value: number;
measuredAt: Date;
sleepStage: SleepStage | null;
/**
* Ingest source of the stage row. Used to collapse a multi-source night
* (e.g. WHOOP + Apple Health) to one canonical source before summing.
* Optional so legacy callers and fixtures that don't carry a source still
* type-check (a missing source is treated as a single anonymous source —
* the de-dup is a no-op for single-source nights).
*/
source?: MeasurementSource | null;
}
/** One reconstructed night: the asleep total + the per-stage breakdown. */
export interface SleepNight {
/** Wake-day key (YYYY-MM-DD) in the user's timezone. */
night: string;
/** Latest stage instant in the night — the night's representative timestamp. */
measuredAt: Date;
/** Time asleep in minutes = CORE + DEEP + REM (+ legacy bare ASLEEP). */
asleepMinutes: number;
/** In-bed minutes when an IN_BED row exists, else null. */
inBedMinutes: number | null;
/** Awake-in-bed minutes when an AWAKE row exists, else null. */
awakeMinutes: number | null;
/** Per-stage minutes for the night (only stages the device reported). */
stages: Partial<Record<SleepStage, number>>;
}
/** Sentinel for rows that carry no source — collapses to one bucket. */
const NO_SOURCE = "__none__";
/**
* Pick the one canonical source for a session. The user's `sleep`
* source-priority ladder wins (lowest ladder index = highest priority).
* Sources absent from the ladder fall back to "most asleep minutes", then
* a stable source-name tiebreak. Single-source (or source-less) sessions
* resolve to that one bucket with no work.
*/
function pickSessionSource(
rows: SleepStageRow[],
sleepLadder: readonly MeasurementSource[],
): string {
const asleepBySource = new Map<string, number>();
for (const r of rows) {
const src = r.source ?? NO_SOURCE;
const stage = r.sleepStage;
const counts = stage == null || ASLEEP_STAGES.has(stage);
if (!counts) continue;
const minutes = Number.isFinite(r.value) ? r.value : 0;
asleepBySource.set(src, (asleepBySource.get(src) ?? 0) + minutes);
}
// No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set
// of all present sources so the session still resolves to a single bucket.
const sources =
asleepBySource.size > 0
? [...asleepBySource.keys()]
: [...new Set(rows.map((r) => r.source ?? NO_SOURCE))];
if (sources.length <= 1) return sources[0] ?? NO_SOURCE;
const rankOf = (src: string): number => {
const i = sleepLadder.indexOf(src as MeasurementSource);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
return sources.sort((a, b) => {
const ra = rankOf(a);
const rb = rankOf(b);
if (ra !== rb) return ra - rb;
// Neither (or both) on the ladder — most asleep minutes wins, then name.
const ma = asleepBySource.get(a) ?? 0;
const mb = asleepBySource.get(b) ?? 0;
if (ma !== mb) return mb - ma;
return a < b ? -1 : 1;
})[0];
}
/**
* Group raw per-stage rows into per-night totals. Stages are clustered into
* sleep SESSIONS by time gap (a > `SESSION_GAP_MS` gap starts a new session),
* each session keyed by the LOCAL WAKE DAY of its last segment, then collapsed
* to ONE source via the user's `sleep` priority ladder before summing.
*
* Pure — the caller does the bounded DB read. `priorityJson` is the user's
* persisted `sourcePriorityJson` (or null for the defaults). Nights are
* returned sorted ascending by key so the last element is the most recent
* night.
*/
export function reconstructSleepNights(
rows: SleepStageRow[],
tz: string,
priorityJson: unknown = null,
): SleepNight[] {
if (rows.length === 0) return [];
const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep");
// Cluster into sessions by the gap between a segment's START and the latest
// END seen so far in the current session. `measuredAt` is the segment END;
// the segment START is `end − value minutes`. A new session begins only when
// the next segment STARTS more than SESSION_GAP_MS after the running end —
// so a single long stage block (a 4 h CORE) does NOT split a night, and two
// sources' interleaved/overlapping rows for the same night stay together
// (their gap is ≤ 0). Gaps are absolute-time, so the clustering is DST-immune.
// Sort by START so contiguous segments compare end-to-start in order.
const startOf = (r: SleepStageRow): number =>
r.measuredAt.getTime() -
(Number.isFinite(r.value) ? r.value : 0) * 60_000;
const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b));
const sessions: SleepStageRow[][] = [];
let current: SleepStageRow[] = [];
let sessionEnd = Number.NEGATIVE_INFINITY;
for (const r of sorted) {
const start = startOf(r);
const end = r.measuredAt.getTime();
if (current.length > 0 && start - sessionEnd > SESSION_GAP_MS) {
sessions.push(current);
current = [];
sessionEnd = Number.NEGATIVE_INFINITY;
}
current.push(r);
if (end > sessionEnd) sessionEnd = end;
}
if (current.length > 0) sessions.push(current);
// Two sessions can land on the same wake day (e.g. a genuine second sleep);
// merge their rows under one key after the source-collapse so the night key
// stays unique. Collapse each session to its canonical source first.
const byNight = new Map<string, SleepStageRow[]>();
for (const session of sessions) {
const canonical = pickSessionSource(session, sleepLadder);
const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical);
const pool = kept.length > 0 ? kept : session;
// The wake day is the LATEST segment END in the session (Apple Health
// attributes a session to the morning you wake up).
const wakeInstant = pool.reduce(
(max, r) => (r.measuredAt.getTime() > max.getTime() ? r.measuredAt : max),
pool[0].measuredAt,
);
const key = userDayKey(wakeInstant, tz);
const list = byNight.get(key) ?? [];
list.push(...kept);
byNight.set(key, list);
}
const nights: SleepNight[] = [];
for (const [night, nightRows] of byNight) {
let asleep = 0;
let inBed = 0;
let awake = 0;
let sawInBed = false;
let sawAwake = false;
let latest = nightRows[0].measuredAt;
const stages: Partial<Record<SleepStage, number>> = {};
for (const r of nightRows) {
const minutes = Number.isFinite(r.value) ? r.value : 0;
if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt;
const stage = r.sleepStage;
if (stage) {
stages[stage] = (stages[stage] ?? 0) + minutes;
}
if (stage === "IN_BED") {
inBed += minutes;
sawInBed = true;
} else if (stage === "AWAKE") {
awake += minutes;
sawAwake = true;
} else if (stage && ASLEEP_STAGES.has(stage)) {
asleep += minutes;
} else if (stage == null) {
// Bare SLEEP_DURATION row (no stage) — the night's total asleep.
asleep += minutes;
}
}
nights.push({
night,
measuredAt: latest,
asleepMinutes: asleep,
inBedMinutes: sawInBed ? inBed : null,
awakeMinutes: sawAwake ? awake : null,
stages,
});
}
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
}
export interface SleepNightSummary {
/**
* A `DataSummary` whose every value is a per-night TIME-ASLEEP total
* (minutes). Drop-in replacement for the per-stage `summaries.
* SLEEP_DURATION` the slim slice produced. Null when no night has any
* asleep minutes in the supplied rows.
*/
summary: DataSummary;
/** The most recent night (for the dashboard/series headline), or null. */
latestNight: SleepNight | null;
}
/**
* Reconstruct nights from raw rows and summarise the per-night asleep
* totals. Nights with zero asleep minutes are dropped (an IN_BED-only or
* AWAKE-only fragment is not a scorable night). The returned `summary`
* uses one DataPoint per night so `summarize()`'s windowed avg7 / avg30
* become "average nightly sleep over the trailing N days".
*/
export function summarizeSleepNights(
rows: SleepStageRow[],
tz: string,
priorityJson: unknown = null,
): SleepNightSummary {
const nights = reconstructSleepNights(rows, tz, priorityJson).filter(
(n) => n.asleepMinutes > 0,
);
const dataPoints = nights.map((n) => ({
date: n.measuredAt,
value: n.asleepMinutes,
}));
return {
summary: summarize(dataPoints),
latestNight: nights.length > 0 ? nights[nights.length - 1] : null,
};
}
+60 -2
View File
@@ -72,6 +72,11 @@ import {
probeRollupCoverage,
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -239,7 +244,7 @@ export async function computeSummariesSlice(
// brand-new-type case stays correct.
const coverage = await probeRollupCoverage(userId);
if (isFullyCovered(coverage)) {
return computeFromRollups(userId);
return withSleepNightTotals(userId, await computeFromRollups(userId));
}
// v1.4.38.7 — annotate the live fallback with the per-type
// coverage map so the operator can see WHICH type stranded the
@@ -261,7 +266,60 @@ export async function computeSummariesSlice(
},
},
});
return computeFromLiveAggregate(userId);
return withSleepNightTotals(userId, await computeFromLiveAggregate(userId));
}
/**
* v1.11.4 — replace the per-stage `summaries.SLEEP_DURATION` with a
* per-NIGHT summary (iOS #1, HIGH).
*
* `SLEEP_DURATION` is stored one row per sleep STAGE per night (minutes).
* The slim slice's generic aggregate therefore treats each stage as an
* independent reading: `latest` is the most-recent STAGE (not the night),
* `mean` / `avg7` / `avg30` average across stage rows. For the dashboard
* sleep tile and the insights overview that is wrong — they want the
* per-night TIME-ASLEEP total.
*
* This post-pass reads the SLEEP_DURATION rows once and overwrites the
* summary with one computed over per-night asleep totals (one DataPoint
* per night). The unit stays MINUTES (canonical), so existing consumers
* that already treat the summary as minutes keep working — they now
* average nights instead of stages, which is strictly more correct. The
* read is bounded to a year (~5 rows/night) and is skipped entirely when
* the user has logged no sleep.
*/
async function withSleepNightTotals(
userId: string,
slice: SummariesSlice,
): Promise<SummariesSlice> {
const existing = slice.summaries.SLEEP_DURATION;
// No sleep data at all → nothing to recompute (the empty summary stays).
if (!existing || existing.count === 0) return slice;
const since = new Date(Date.now() - 365 * DAY_MS);
const rows = (await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
})) as SleepStageRow[];
if (rows.length === 0) return slice;
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const { summary } = summarizeSleepNights(rows, tz, priorityJson);
// Preserve the slope tuples the night summary doesn't compute (the
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /
// slope30 / slope90 from the night DataPoints, so use them directly.
slice.summaries.SLEEP_DURATION = summary;
return slice;
}
/**
+14
View File
@@ -523,6 +523,20 @@ function buildMetricStates(
const summary = summaries[type];
const lastSeen = lastSeenByType[type];
if (!summary || summary.latest === null || !lastSeen) continue;
// v1.11.4 — `summaries.SLEEP_DURATION.latest` now carries last night's
// TIME-ASLEEP total in MINUTES (the slim slice collapses the per-stage
// rows). Emit the cold-launch seed in HOURS with an explicit `unit:
// "h"` so it matches the `/api/dashboard/summary` sleep tile, which
// already emits hours. Every other metric passes through with its
// canonical stored unit.
if (type === "SLEEP_DURATION") {
out[metricKindRaw] = {
value: Math.round((summary.latest / 60) * 100) / 100,
measuredAt: lastSeen.lastSeenAt,
unit: "h",
};
continue;
}
out[metricKindRaw] = {
value: summary.latest,
measuredAt: lastSeen.lastSeenAt,
@@ -0,0 +1,192 @@
import { describe, it, expect } from "vitest";
import {
computeTrendDescriptor,
numericDescriptorCopy,
moodDescriptorCopy,
MOOD_DESCRIPTOR_CONFIG,
TREND_SLOT_DESCRIPTOR_META,
} from "../trend-descriptor";
/**
* v1.11.4 item J — deterministic Trends-row caption descriptor.
*
* The helper turns the same series the mini-chart plots into a neutral,
* observational direction + magnitude. These tests pin the direction
* classification, the stability floor, the first-vs-last delta, and the
* per-metric copy resolution (numeric template vs categorical mood copy).
*/
function series(values: number[]): { timestamp: number; value: number }[] {
// One point per day, ascending; timestamps don't affect direction
// (first-vs-last) but anchor the chronological sort.
return values.map((value, i) => ({
timestamp: Date.UTC(2026, 0, 1 + i, 12),
value,
}));
}
describe("computeTrendDescriptor", () => {
it("returns null for fewer than two points", () => {
expect(computeTrendDescriptor([])).toBeNull();
expect(computeTrendDescriptor(series([120]))).toBeNull();
});
it("classifies a clear upward move as rising with a signed delta", () => {
const d = computeTrendDescriptor(series([120, 124, 128]), {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
});
expect(d).not.toBeNull();
expect(d?.direction).toBe("rising");
expect(d?.delta).toBe(8);
expect(d?.magnitude).toBe(8);
expect(d?.pointCount).toBe(3);
});
it("classifies a clear downward move as falling with a negative delta", () => {
const d = computeTrendDescriptor(series([82.4, 81.5, 81.0]), {
absoluteFloor: 0.3,
relativeFloor: 0.01,
decimals: 1,
});
expect(d?.direction).toBe("falling");
expect(d?.delta).toBe(-1.4);
expect(d?.magnitude).toBe(1.4);
});
it("classifies a within-floor move as stable", () => {
// +1 mmHg over the window is inside the 2-mmHg absolute floor.
const d = computeTrendDescriptor(series([120, 121]), {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
});
expect(d?.direction).toBe("stable");
expect(d?.delta).toBe(1);
});
it("uses the relative floor when it exceeds the absolute floor", () => {
// 2 % of 10000 steps = 200; a +150 move reads stable.
const d = computeTrendDescriptor(series([10000, 10150]), {
absoluteFloor: 0,
relativeFloor: 0.02,
decimals: 0,
});
expect(d?.direction).toBe("stable");
});
it("sorts unordered points chronologically before first-vs-last", () => {
const unordered = [
{ timestamp: Date.UTC(2026, 0, 3, 12), value: 128 },
{ timestamp: Date.UTC(2026, 0, 1, 12), value: 120 },
{ timestamp: Date.UTC(2026, 0, 2, 12), value: 124 },
];
const d = computeTrendDescriptor(unordered, {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
});
expect(d?.direction).toBe("rising");
expect(d?.delta).toBe(8);
});
it("drops non-finite points", () => {
const d = computeTrendDescriptor(
[
{ timestamp: Date.UTC(2026, 0, 1, 12), value: 120 },
{ timestamp: Date.UTC(2026, 0, 2, 12), value: Number.NaN },
{ timestamp: Date.UTC(2026, 0, 3, 12), value: 128 },
],
{ absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 },
);
expect(d?.pointCount).toBe(2);
expect(d?.direction).toBe("rising");
});
});
describe("numericDescriptorCopy", () => {
it("resolves the rising template with a signed delta and spaced unit", () => {
const d = computeTrendDescriptor(series([120, 128]), {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
})!;
const copy = numericDescriptorCopy("bp", d);
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.delta).toBe("+8");
expect(copy?.params.unit).toBe(" mmHg");
});
it("renders a unit-less metric without a dangling space", () => {
const d = computeTrendDescriptor(series([8000, 9500]), {
absoluteFloor: 300,
relativeFloor: 0.05,
decimals: 0,
})!;
const copy = numericDescriptorCopy("steps", d);
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.unit).toBe("");
});
it("uses a minus sign glyph for a falling delta", () => {
const d = computeTrendDescriptor(series([82.4, 81.0]), {
absoluteFloor: 0.3,
relativeFloor: 0.01,
decimals: 1,
})!;
const copy = numericDescriptorCopy("weight", d);
expect(copy?.key).toBe("insights.trendDescriptor.falling");
expect(copy?.params.delta).toBe("−1.4");
});
it("returns null for a slot with no numeric meta (mood)", () => {
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
expect(numericDescriptorCopy("mood", d)).toBeNull();
});
it("covers every numeric slot in the descriptor meta", () => {
// Each meta-backed slot resolves a copy key — guards against a slot
// added to the chart selector without a descriptor config.
for (const metric of Object.keys(TREND_SLOT_DESCRIPTOR_META)) {
const d = computeTrendDescriptor(series([1, 100]), {
absoluteFloor: 0,
relativeFloor: 0,
decimals: 1,
})!;
expect(numericDescriptorCopy(metric, d)).not.toBeNull();
}
});
});
describe("moodDescriptorCopy", () => {
it("maps rising mood to the improved copy", () => {
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
expect(d.direction).toBe("rising");
expect(moodDescriptorCopy(d).key).toBe(
"insights.trendDescriptor.moodImproved",
);
});
it("maps falling mood to the declined copy", () => {
const d = computeTrendDescriptor(series([4, 3]), MOOD_DESCRIPTOR_CONFIG)!;
expect(d.direction).toBe("falling");
expect(moodDescriptorCopy(d).key).toBe(
"insights.trendDescriptor.moodDeclined",
);
});
it("maps a within-floor mood move to the stable copy", () => {
// 0.2 points is inside the 0.3 mood floor.
const d = computeTrendDescriptor(series([3.5, 3.7]), MOOD_DESCRIPTOR_CONFIG)!;
expect(d.direction).toBe("stable");
expect(moodDescriptorCopy(d).key).toBe(
"insights.trendDescriptor.moodStable",
);
});
it("carries no interpolation params (no raw point delta in copy)", () => {
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
expect(moodDescriptorCopy(d).params).toEqual({});
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* v1.11.4 item J — deterministic, rule-based trend descriptor for the
* Insights "Trends" row captions.
*
* Background. Each Trends-row card carries a one-line caption below its
* mini-chart. The first choice is the AI advisor's `trendAnnotations`
* sentence; but on a cold briefing (web-only account, pre-briefing
* cached payload, or the advisor still generating) that annotation is
* null and every card fell back to "Awaiting more data" — static and
* meaningless even when the user has a full 30-day series painted right
* above the caption.
*
* This module computes a FACTUAL descriptor from the same series the
* mini-chart already renders: direction (rising / falling / stable) plus
* the first-vs-last magnitude over the window. It is the second tier in
* the caption precedence the renderer applies:
*
* 1. advisor annotation present → show the AI sentence.
* 2. else, descriptor computable → show this deterministic descriptor.
* 3. else (truly too few points) → show the "not enough data yet" hint.
*
* Tone is hard-constrained: OBSERVATIONAL and NEUTRAL — direction +
* magnitude only. No causal, diagnostic, or medical claim; no value
* judgement (a rising weight and a rising step count read identically
* here — the row is descriptive, the colour-sentiment lives elsewhere on
* the dedicated metric pages). Mirrors the disclaimer ethos.
*
* Pure + deterministic so the logic is unit-testable in isolation and
* the client component stays a thin consumer.
*/
/**
* A single chronological observation feeding the descriptor. `value` is
* the metric's display-scaled numeric value (the same value the chart
* plots); ordering is by `timestamp` ascending.
*/
export interface TrendDescriptorPoint {
timestamp: number;
value: number;
}
export type TrendDescriptorDirection = "rising" | "falling" | "stable";
export interface TrendDescriptor {
direction: TrendDescriptorDirection;
/**
* Signed first-vs-last delta over the window, in the metric's display
* unit. Always finite. `stable` carries the (small) delta too so the
* caller can still surface a magnitude when it wants to.
*/
delta: number;
/** Absolute delta, convenience for template interpolation. */
magnitude: number;
/** Number of points the descriptor was computed from (>= 2). */
pointCount: number;
}
/**
* How a metric decides "stable" vs a real move, and how its delta is
* rounded for display. The stability floor is the larger of an absolute
* floor and a fraction of the starting value, so a metric reads "stable"
* when the window move is within its day-to-day noise band rather than
* flagging every wobble as a trend.
*/
export interface TrendDescriptorConfig {
/** Absolute noise floor in the metric's unit (e.g. 2 mmHg for BP). */
absoluteFloor: number;
/** Relative noise floor as a fraction of |first value| (e.g. 0.02 = 2 %). */
relativeFloor: number;
/** Decimal places to round the displayed delta to. */
decimals: number;
}
const DEFAULT_CONFIG: TrendDescriptorConfig = {
absoluteFloor: 0,
relativeFloor: 0.03,
decimals: 1,
};
function roundTo(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
/**
* Compute a deterministic trend descriptor from a chronological series.
*
* Returns `null` when the series carries fewer than two finite points —
* the genuine "not enough data yet" case the renderer surfaces as a
* real empty hint. Otherwise resolves the first-vs-last delta and
* classifies direction against the metric's stability floor.
*/
export function computeTrendDescriptor(
points: ReadonlyArray<TrendDescriptorPoint>,
config: Partial<TrendDescriptorConfig> = {},
): TrendDescriptor | null {
const cfg = { ...DEFAULT_CONFIG, ...config };
const finite = points
.filter((p) => Number.isFinite(p.value) && Number.isFinite(p.timestamp))
.slice()
.sort((a, b) => a.timestamp - b.timestamp);
if (finite.length < 2) return null;
const first = finite[0].value;
const last = finite[finite.length - 1].value;
const delta = roundTo(last - first, cfg.decimals);
// Stability floor: the larger of the absolute floor and a fraction of
// the starting magnitude. A move inside the floor reads as "stable".
const floor = Math.max(cfg.absoluteFloor, Math.abs(first) * cfg.relativeFloor);
let direction: TrendDescriptorDirection;
if (Math.abs(last - first) <= floor) {
direction = "stable";
} else {
direction = last > first ? "rising" : "falling";
}
return {
direction,
delta,
magnitude: Math.abs(delta),
pointCount: finite.length,
};
}
/**
* Per-Trends-row-slot descriptor configuration + the display unit the
* caption interpolates. Keyed by `TrendChartConfig.metric` (the stable
* slot id), so a slot the row can chart always resolves a config here.
*
* `mood` is intentionally ABSENT: mood rides a categorical 1–5 scale, so
* its descriptor is phrased in plain "improved / declined / stable"
* terms (no raw unit), handled by {@link MOOD_DESCRIPTOR_CONFIG} +
* dedicated copy rather than the numeric `{delta} {unit}` template.
*/
export interface TrendSlotDescriptorMeta {
/** Display unit interpolated into the numeric descriptor template. */
unit: string;
config: TrendDescriptorConfig;
}
export const TREND_SLOT_DESCRIPTOR_META: Record<
string,
TrendSlotDescriptorMeta
> = {
bp: { unit: "mmHg", config: { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 } },
weight: { unit: "kg", config: { absoluteFloor: 0.3, relativeFloor: 0.01, decimals: 1 } },
pulse: { unit: "bpm", config: { absoluteFloor: 2, relativeFloor: 0.03, decimals: 0 } },
sleep: { unit: "h", config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 } },
steps: { unit: "", config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 0 } },
hrv: { unit: "ms", config: { absoluteFloor: 3, relativeFloor: 0.05, decimals: 0 } },
resting_hr: { unit: "bpm", config: { absoluteFloor: 2, relativeFloor: 0.03, decimals: 0 } },
active_energy: { unit: "kcal", config: { absoluteFloor: 50, relativeFloor: 0.05, decimals: 0 } },
flights: { unit: "", config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 } },
distance: { unit: "km", config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 } },
vo2_max: { unit: "", config: { absoluteFloor: 0.5, relativeFloor: 0.02, decimals: 1 } },
body_temp: { unit: "°C", config: { absoluteFloor: 0.2, relativeFloor: 0.005, decimals: 1 } },
};
/**
* Mood descriptor config. Mood is a 1–5 categorical score; a window move
* of ≥ 0.3 points reads as a real shift in plain terms.
*/
export const MOOD_DESCRIPTOR_CONFIG: TrendDescriptorConfig = {
absoluteFloor: 0.3,
relativeFloor: 0,
decimals: 1,
};
/**
* The i18n template key + interpolation params for a resolved numeric
* descriptor. The renderer calls `t(key, params)` to produce the
* caption sentence. The metric name itself is NOT interpolated — the
* chart title above the caption already names the metric, so the
* sentence stays "Rising over 30 days (+8 mmHg)" rather than repeating
* the label.
*/
export interface TrendDescriptorCopy {
key: string;
params: Record<string, string | number>;
}
/**
* Resolve the copy for a NUMERIC metric slot. Returns `null` when the
* slot has no numeric descriptor meta (e.g. `mood`, which uses
* {@link moodDescriptorCopy} instead) — the caller then falls through to
* its own path.
*/
export function numericDescriptorCopy(
metric: string,
descriptor: TrendDescriptor,
): TrendDescriptorCopy | null {
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
if (!meta) return null;
// Signed, formatted magnitude (e.g. "+8", "-1.4"). `stable` still
// carries its small delta so the caption can read "stable (±0 …)" if a
// future copy wants it; today the stable template omits the magnitude.
const sign = descriptor.delta > 0 ? "+" : descriptor.delta < 0 ? "−" : "";
const formatted = `${sign}${descriptor.magnitude}`;
const directionKey: Record<TrendDescriptorDirection, string> = {
rising: "insights.trendDescriptor.rising",
falling: "insights.trendDescriptor.falling",
stable: "insights.trendDescriptor.stable",
};
return {
key: directionKey[descriptor.direction],
params: {
delta: formatted,
// Unit carries its OWN leading space when present so the template
// reads "({delta}{unit})" → "(+8 mmHg)" with a unit, "(+1200)"
// without one — no dangling space inside the parens for unit-less
// metrics (steps, flights).
unit: meta.unit ? ` ${meta.unit}` : "",
},
};
}
/**
* Resolve the copy for the MOOD slot. Mood reads on a 1–5 categorical
* scale, so the descriptor is phrased as "slightly improved / declined /
* stable" rather than a raw point delta — observational and neutral, no
* value judgement beyond the plain direction word.
*/
export function moodDescriptorCopy(
descriptor: TrendDescriptor,
): TrendDescriptorCopy {
const directionKey: Record<TrendDescriptorDirection, string> = {
rising: "insights.trendDescriptor.moodImproved",
falling: "insights.trendDescriptor.moodDeclined",
stable: "insights.trendDescriptor.moodStable",
};
return { key: directionKey[descriptor.direction], params: {} };
}
@@ -0,0 +1,185 @@
/**
* v1.11.4 (iOS #2) — unit coverage for the MANUAL↔APPLE_HEALTH
* same-reading merge predicate. The route integration test exercises the
* end-to-end ingest path; this suite pins the pure matching rule:
* - cross-source same reading → match
* - same-source / server-source pair → no match (no over-merge)
* - different value or out-of-tolerance timestamp → no match
*/
import { describe, expect, it } from "vitest";
import {
MEASURED_AT_TOLERANCE_MS,
isMergeableSource,
isSameReadingAcrossSource,
measuredAtMatch,
oppositeMergeSource,
valuesMatch,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import type { MeasurementType } from "@/generated/prisma/client";
const T0 = new Date("2026-06-04T07:30:00.000Z");
function candidate(over: Partial<MergeCandidate> = {}): MergeCandidate {
return {
type: "WEIGHT" as MeasurementType,
source: "MANUAL",
value: 81.4,
measuredAt: T0,
...over,
};
}
function incoming(
over: Partial<{
type: MeasurementType;
source: string;
value: number;
measuredAt: Date;
}> = {},
) {
return {
type: "WEIGHT" as MeasurementType,
source: "APPLE_HEALTH",
value: 81.4,
measuredAt: T0,
...over,
};
}
describe("isMergeableSource", () => {
it("accepts only the two client-facing mirror sources", () => {
expect(isMergeableSource("MANUAL")).toBe(true);
expect(isMergeableSource("APPLE_HEALTH")).toBe(true);
expect(isMergeableSource("WITHINGS")).toBe(false);
expect(isMergeableSource("IMPORT")).toBe(false);
expect(isMergeableSource("COMPUTED")).toBe(false);
expect(isMergeableSource("WHOOP")).toBe(false);
});
});
describe("oppositeMergeSource", () => {
it("flips MANUAL ↔ APPLE_HEALTH", () => {
expect(oppositeMergeSource("MANUAL")).toBe("APPLE_HEALTH");
expect(oppositeMergeSource("APPLE_HEALTH")).toBe("MANUAL");
});
});
describe("valuesMatch", () => {
it("matches identical and sub-epsilon values", () => {
expect(valuesMatch(81.4, 81.4)).toBe(true);
expect(valuesMatch(81.4, 81.4 + 1e-12)).toBe(true);
expect(valuesMatch(0.97, 0.97)).toBe(true);
});
it("rejects genuinely different values", () => {
expect(valuesMatch(81.4, 81.5)).toBe(false);
expect(valuesMatch(120, 121)).toBe(false);
});
});
describe("measuredAtMatch", () => {
it("matches inside the ±tolerance window", () => {
expect(measuredAtMatch(T0, T0)).toBe(true);
expect(
measuredAtMatch(T0, new Date(T0.getTime() + MEASURED_AT_TOLERANCE_MS)),
).toBe(true);
expect(
measuredAtMatch(T0, new Date(T0.getTime() - MEASURED_AT_TOLERANCE_MS)),
).toBe(true);
});
it("rejects timestamps beyond the tolerance window", () => {
expect(
measuredAtMatch(
T0,
new Date(T0.getTime() + MEASURED_AT_TOLERANCE_MS + 1),
),
).toBe(false);
// A whole minute apart — two distinct readings, never the mirror pair.
expect(measuredAtMatch(T0, new Date(T0.getTime() + 60_000))).toBe(false);
});
});
describe("isSameReadingAcrossSource", () => {
it("matches a MANUAL candidate against an incoming APPLE_HEALTH mirror", () => {
expect(isSameReadingAcrossSource(incoming(), candidate())).toBe(true);
});
it("matches an APPLE_HEALTH candidate against an incoming MANUAL mirror", () => {
expect(
isSameReadingAcrossSource(
incoming({ source: "MANUAL" }),
candidate({ source: "APPLE_HEALTH" }),
),
).toBe(true);
});
it("matches within the timestamp tolerance", () => {
expect(
isSameReadingAcrossSource(
incoming({ measuredAt: new Date(T0.getTime() + 1_500) }),
candidate(),
),
).toBe(true);
});
it("does NOT match a same-source pair (no over-merge)", () => {
// Two APPLE_HEALTH readings — keep the existing per-source contract.
expect(
isSameReadingAcrossSource(
incoming({ source: "APPLE_HEALTH" }),
candidate({ source: "APPLE_HEALTH" }),
),
).toBe(false);
// Two MANUAL readings.
expect(
isSameReadingAcrossSource(
incoming({ source: "MANUAL" }),
candidate({ source: "MANUAL" }),
),
).toBe(false);
});
it("does NOT match when a server-owned source is involved", () => {
expect(
isSameReadingAcrossSource(
incoming({ source: "APPLE_HEALTH" }),
candidate({ source: "WITHINGS" }),
),
).toBe(false);
// Incoming WITHINGS could never reach this route, but the predicate
// must still refuse it.
expect(
isSameReadingAcrossSource(
incoming({ source: "WITHINGS" }),
candidate({ source: "APPLE_HEALTH" }),
),
).toBe(false);
});
it("does NOT match a different measurement type", () => {
expect(
isSameReadingAcrossSource(
incoming({ type: "PULSE" as MeasurementType }),
candidate({ type: "WEIGHT" as MeasurementType }),
),
).toBe(false);
});
it("does NOT match a different value (two real readings, same minute)", () => {
expect(
isSameReadingAcrossSource(incoming({ value: 81.4 }), candidate({ value: 82.0 })),
).toBe(false);
});
it("does NOT match a timestamp beyond the tolerance window", () => {
expect(
isSameReadingAcrossSource(
incoming({ measuredAt: new Date(T0.getTime() + 60_000) }),
candidate(),
),
).toBe(false);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
* mirror duplicate.
*
* ## The problem
* When the iOS app runs standalone/offline and the user logs a MANUAL
* measurement, iOS mirrors that reading into HealthKit too. On pairing a
* server, two independent upload paths then reach
* `POST /api/measurements/batch` for the SAME physical reading:
* 1. adopt-on-pair backfill uploads it as `source = MANUAL`
* (externalId = the app's local id), and
* 2. HealthKit background sync re-ingests the mirrored sample and
* uploads it as `source = APPLE_HEALTH` (externalId = HKSample.uuid).
*
* The two carry different `externalId`s and different `source`s, so the
* `(userId, type, source, externalId)` unique index treats them as two
* distinct rows — duplicating the whole manually-logged history on first
* pair.
*
* ## The rule
* Treat a MANUAL row and an APPLE_HEALTH row as the SAME physical
* reading when they share:
* - the same `userId` (multi-tenant scoping — always narrowed), AND
* - the same `type`, AND
* - the same `value` (within a tiny float epsilon — both rows are the
* same hand-entered number run through the identical
* `mapAppleHealthEntry` conversion, so equality is exact in practice;
* the epsilon only guards against sub-ulp drift), AND
* - a `measuredAt` within `MEASURED_AT_TOLERANCE_MS` of each other.
*
* The merge is deliberately ONE-SIDED in scope: it only fires for the
* MANUAL ↔ APPLE_HEALTH cross-source pair. MANUAL ↔ MANUAL,
* APPLE_HEALTH ↔ APPLE_HEALTH, and any pair involving a server-owned
* source (WITHINGS / IMPORT / COMPUTED / WHOOP) keep the existing
* first-write-wins / per-source contracts untouched. Two genuinely
* distinct readings at the same minute from two devices therefore never
* collapse — they are same-source or involve a server source, both of
* which fall outside this rule.
*
* ## Ingest-time, first-physical-reading-wins
* The collapse runs at INGEST time, not read time: read-time dedup would
* have to live in the rollup tier and every analytics path forever.
* Ingest-time keeps the storage clean and the rule contained.
*
* Because the two uploads can arrive in either order and minutes apart
* in SEPARATE batches, the collapse compares each incoming row against
* the rows already PERSISTED for the user (cross-batch) as well as the
* rows already chosen for insert earlier in the SAME batch.
*
* The winner is whichever physical reading landed first. The `value` is
* identical between the two rows by construction, so the only difference
* the surviving row carries is its `source` label — the numeric series
* the user sees is the same either way. A source-priority-aware REPLACE
* (overwrite the loser's source/row with the higher-ranked source) was
* considered and rejected for a patch: it would require mutating or
* re-sourcing an existing row (rollup re-source + sync-version churn) for
* zero change to the displayed value. First-physical-reading-wins is the
* natural extension of the endpoint's existing first-write-wins
* philosophy across the MANUAL↔APPLE_HEALTH boundary.
*
* `stats:*` cumulative externalIds are explicitly OUT of scope — those
* are per-day aggregate rows the iOS observer overwrites in place
* (issue #213), never hand-entered point readings, so the cross-source
* mirror duplicate cannot arise for them.
*/
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Timestamp tolerance for the same-reading match. A reading the user
* hand-enters and the app mirrors to HealthKit shares the same instant;
* the HealthKit re-ingest may round to the second or carry a sub-second
* drift, so a tight ±2 s window absorbs that without any chance of
* catching a separate genuine reading — a user does not hand-enter two
* distinct readings of the same type within two seconds.
*/
export const MEASURED_AT_TOLERANCE_MS = 2_000;
/**
* Relative epsilon for the value match. Both rows run the same
* `convertToDbUnit` over the same source number, so the stored floats
* are bit-identical in practice; the epsilon is a defensive guard
* against any future conversion that introduces sub-ulp rounding. Scaled
* to the magnitude of the value so it stays meaningful for both a
* 0.97-fraction SpO2 and a 12 000-step count.
*/
const VALUE_RELATIVE_EPSILON = 1e-9;
/** The two client-facing sources that can mirror the same reading. */
type MergeableSource = "MANUAL" | "APPLE_HEALTH";
/** The opposite member of the MANUAL ↔ APPLE_HEALTH pair. */
export function oppositeMergeSource(source: MergeableSource): MergeableSource {
return source === "MANUAL" ? "APPLE_HEALTH" : "MANUAL";
}
/**
* True when `source` participates in the cross-source merge. Only the
* two client-facing mirror sources qualify; server-owned sources are
* untouched.
*/
export function isMergeableSource(source: string): source is MergeableSource {
return source === "MANUAL" || source === "APPLE_HEALTH";
}
/** Are two values the same reading within the relative epsilon? */
export function valuesMatch(a: number, b: number): boolean {
if (a === b) return true;
const scale = Math.max(Math.abs(a), Math.abs(b), 1);
return Math.abs(a - b) <= VALUE_RELATIVE_EPSILON * scale;
}
/** Are two instants within the same-reading tolerance window? */
export function measuredAtMatch(a: Date, b: Date): boolean {
return Math.abs(a.getTime() - b.getTime()) <= MEASURED_AT_TOLERANCE_MS;
}
/**
* A candidate same-reading row — either already persisted for the user
* or already chosen for insert earlier in the same batch. Carries only
* the fields the match needs.
*/
export interface MergeCandidate {
type: MeasurementType;
source: string;
value: number;
measuredAt: Date;
}
/**
* Does `candidate` represent the same physical reading as an incoming
* row of `(type, incomingSource, value, measuredAt)`, under the
* cross-source MANUAL↔APPLE_HEALTH rule? Requires:
* - the incoming row's source is mergeable,
* - the candidate's source is the OPPOSITE mergeable source,
* - same type, value (±epsilon), measuredAt (±tolerance).
*/
export function isSameReadingAcrossSource(
incoming: {
type: MeasurementType;
source: string;
value: number;
measuredAt: Date;
},
candidate: MergeCandidate,
): boolean {
if (!isMergeableSource(incoming.source)) return false;
if (candidate.source !== oppositeMergeSource(incoming.source)) return false;
if (candidate.type !== incoming.type) return false;
if (!valuesMatch(candidate.value, incoming.value)) return false;
return measuredAtMatch(candidate.measuredAt, incoming.measuredAt);
}
+17
View File
@@ -471,6 +471,20 @@ export const queryKeys = {
*/
measurementDiversity: (type: string) =>
["measurement-diversity", type] as const,
/**
* v1.11.4 item J — bounded 30-day daily-aggregate read backing the
* Insights Trends-row deterministic caption (`trend-descriptor.ts`).
* Keyed by the comma-joined measurement type set so each card's slot
* caches its own small window (≤ 30 rollup rows). Distinct from
* `chartData(...)` so the caption never re-keys when the chart's
* internal value-mode / scale / range state changes; it only needs the
* raw 30-day series to derive direction + magnitude. Shares the
* `["trend-series"]` invalidation prefix below so a measurement write
* refreshes the caption in lockstep with the chart.
*/
insightsTrendSeries: (types: string) =>
["trend-series", types] as const,
};
/**
@@ -493,6 +507,9 @@ export const measurementDependentKeys = [
["chart-data"] as const,
// v1.8.5 — re-run the diversity-nudge clustering when readings change.
["measurement-diversity"] as const,
// v1.11.4 item J — refresh the Trends-row deterministic caption series
// when a reading changes, in lockstep with the chart row above it.
["trend-series"] as const,
];
/**
@@ -137,18 +137,16 @@ describe("GET /api/analytics — sleep-stage aggregation", () => {
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
// v1.4.49.1 — the analytics summaries path no longer aggregates
// stage-tagged sleep rows into a single per-night datapoint; the slim
// rollup-tier reader counts raw rows per type. The dedicated
// `sleepStages` block below still reports the night-level breakdown
// for the dashboard card; only the SLEEP_DURATION summary surfaces the
// raw per-stage count. Tracked as v1.5.1 backlog item: reintroduce
// per-day SLEEP_DURATION sum in `computeFromRollups` /
// `computeFromLiveAggregate` so the trend tile shows "1 night / 880
// min" again rather than "4 rows / 90 min" for stage-tagged users.
// v1.11.4 — the SLEEP_DURATION summary is now computed over per-night
// asleep totals (CORE + DEEP + REM, excluding IN_BED/AWAKE), grouped by
// sleep session, rather than counting raw per-stage rows. One night of
// stage rows therefore surfaces as a single datapoint whose value is the
// night's time asleep (220 + 90 + 90 = 400 min). The dedicated
// `sleepStages` block below still reports the full per-stage breakdown
// (including IN_BED) for the dashboard card.
const summary = envelope.data.summaries.SLEEP_DURATION;
expect(summary.count).toBe(4);
expect(summary.latest).toBe(90);
expect(summary.count).toBe(1);
expect(summary.latest).toBe(400);
// Stage breakdown surfaces the per-stage minutes verbatim.
const sleepStages = envelope.data.sleepStages;
+303 -3
View File
@@ -764,6 +764,11 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
it("treats the same externalId under different sources as two distinct rows", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// The two rows share an externalId but carry DIFFERENT values +
// timestamps so they are genuinely distinct readings — this asserts
// that `source` is part of the composite dedup key, without tripping
// the v1.11.4 cross-source same-reading merge (which only collapses a
// MANUAL + APPLE_HEALTH pair that share value + measuredAt).
const sharedExternalId = "uuid-source-collision-001";
const response = await POST(
makeRequest({
@@ -779,10 +784,10 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
},
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.0,
value: 79.5,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
startDate: "2026-05-09T19:30:00.000Z",
endDate: "2026-05-09T19:30:00.000Z",
externalId: sharedExternalId,
source: "MANUAL",
},
@@ -806,6 +811,301 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
);
});
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL↔APPLE_HEALTH
// mirror duplicate. A standalone manual reading mirrors into HealthKit;
// on pair, adopt-on-pair uploads it as MANUAL and HealthKit background
// sync re-ingests the mirror as APPLE_HEALTH with a different
// externalId. The composite unique index would let both land; the
// cross-source collapse drops the second copy as a `duplicate`.
describe("cross-source same-reading merge (iOS #2)", () => {
const weightEntry = (over: Partial<BatchEntryFixture>): BatchEntryFixture => ({
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.4,
unit: "kg",
startDate: "2026-06-04T07:30:00.000Z",
endDate: "2026-06-04T07:30:00.000Z",
externalId: "uuid-merge-default",
...over,
});
it("collapses MANUAL then APPLE_HEALTH same reading into one row (separate batches)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
// Adopt-on-pair MANUAL upload first.
const first = await POST(
makeRequest({
entries: [
weightEntry({ externalId: "local-manual-001", source: "MANUAL" }),
],
}),
);
expect(first.status).toBe(200);
expect(((await first.json()) as { data: { inserted: number } }).data.inserted).toBe(1);
// HealthKit background sync re-ingests the same physical reading
// as APPLE_HEALTH with a different (HKSample.uuid) externalId.
const second = await POST(
makeRequest({
entries: [
weightEntry({ externalId: "hk-uuid-001", source: "APPLE_HEALTH" }),
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string; reason?: string }>;
};
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.duplicates).toBe(1);
expect(secondJson.data.entries[0]?.status).toBe("duplicate");
expect(secondJson.data.entries[0]?.reason).toBe("cross_source_merge");
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(stored).toHaveLength(1);
expect(stored[0]!.source).toBe("MANUAL");
});
it("collapses regardless of arrival order (APPLE_HEALTH then MANUAL)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const first = await POST(
makeRequest({
entries: [
weightEntry({ externalId: "hk-uuid-002", source: "APPLE_HEALTH" }),
],
}),
);
expect(first.status).toBe(200);
const second = await POST(
makeRequest({
entries: [
weightEntry({ externalId: "local-manual-002", source: "MANUAL" }),
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; duplicates: number };
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.duplicates).toBe(1);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(stored).toHaveLength(1);
expect(stored[0]!.source).toBe("APPLE_HEALTH");
});
it("collapses a MANUAL + APPLE_HEALTH mirror arriving in the SAME batch", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const response = await POST(
makeRequest({
entries: [
weightEntry({ externalId: "local-manual-003", source: "MANUAL" }),
weightEntry({ externalId: "hk-uuid-003", source: "APPLE_HEALTH" }),
],
}),
);
expect(response.status).toBe(200);
const json = (await response.json()) as {
data: { inserted: number; duplicates: number };
};
expect(json.data.inserted).toBe(1);
expect(json.data.duplicates).toBe(1);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(stored).toHaveLength(1);
});
it("matches within the ±2s tolerance window", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
await POST(
makeRequest({
entries: [
weightEntry({
externalId: "local-manual-tol",
source: "MANUAL",
startDate: "2026-06-04T07:30:00.000Z",
endDate: "2026-06-04T07:30:00.000Z",
}),
],
}),
);
// HK mirror ingested with a 1s sub-second drift on the same reading.
const second = await POST(
makeRequest({
entries: [
weightEntry({
externalId: "hk-uuid-tol",
source: "APPLE_HEALTH",
startDate: "2026-06-04T07:30:01.000Z",
endDate: "2026-06-04T07:30:01.000Z",
}),
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; duplicates: number };
};
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.duplicates).toBe(1);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(stored).toHaveLength(1);
});
it("does NOT merge different values (two real readings same minute)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
await POST(
makeRequest({
entries: [
weightEntry({
externalId: "local-manual-distinct",
source: "MANUAL",
value: 81.4,
}),
],
}),
);
const second = await POST(
makeRequest({
entries: [
weightEntry({
externalId: "hk-uuid-distinct",
source: "APPLE_HEALTH",
value: 82.0,
}),
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; duplicates: number };
};
// Different value → genuinely distinct reading, both rows survive.
expect(secondJson.data.inserted).toBe(1);
expect(secondJson.data.duplicates).toBe(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(stored).toHaveLength(2);
});
it("does NOT merge timestamps beyond the tolerance window", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
await POST(
makeRequest({
entries: [
weightEntry({
externalId: "local-manual-far",
source: "MANUAL",
startDate: "2026-06-04T07:30:00.000Z",
endDate: "2026-06-04T07:30:00.000Z",
}),
],
}),
);
// A full minute later — distinct reading, not the mirror pair.
const second = await POST(
makeRequest({
entries: [
weightEntry({
externalId: "hk-uuid-far",
source: "APPLE_HEALTH",
startDate: "2026-06-04T07:31:00.000Z",
endDate: "2026-06-04T07:31:00.000Z",
}),
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; duplicates: number };
};
expect(secondJson.data.inserted).toBe(1);
expect(secondJson.data.duplicates).toBe(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, type: "WEIGHT" },
});
expect(stored).toHaveLength(2);
});
it("leaves the stats:* overwrite contract untouched (no cross-source collapse)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const externalId = "stats:HKQuantityTypeIdentifierStepCount:2026-06-04";
// A MANUAL same-day-total then an APPLE_HEALTH re-post must NOT
// collapse — stats:* rows are per-day aggregates, excluded from the
// merge. They are distinct (type, source) rows and each keeps its
// own overwrite lane.
await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 4000,
unit: "count",
startDate: "2026-06-04T00:00:00.000Z",
endDate: "2026-06-04T12:00:00.000Z",
externalId,
source: "MANUAL",
},
],
}),
);
const second = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 4000,
unit: "count",
startDate: "2026-06-04T00:00:00.000Z",
endDate: "2026-06-04T12:00:00.000Z",
externalId,
source: "APPLE_HEALTH",
},
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: { inserted: number; duplicates: number };
};
// Different source on a stats:* externalId → distinct row, inserted.
expect(secondJson.data.inserted).toBe(1);
expect(secondJson.data.duplicates).toBe(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId },
});
expect(stored).toHaveLength(2);
});
});
it("rejects an out-of-range source value with 422", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");