mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(sleep): emit the per-night total, not a single stage, on the sleep tile and series
SLEEP_DURATION is stored in minutes, one row per sleep stage per night (IN_BED / AWAKE / ASLEEP / CORE / DEEP / REM) since the HealthKit category-sample ingest landed. The value is not cumulative, so reading the single most-recent row surfaced one stage's minutes — never the night. The dashboard sleep tile and the sleep series therefore showed a fragment of the night rather than the night. Add a shared `summarizeSleepNights` helper that groups the per-stage rows by the wake-day calendar date in the user's timezone and sums the asleep stages (CORE + DEEP + REM, plus a legacy bare-duration row), excluding IN_BED and AWAKE per the AASM convention already used by the sleep score. Wire it into: - the dashboard summary tile, which now emits last night's time asleep in hours with an explicit `unit: "h"` and a per-stage breakdown for a future detail view; - the sleep series, which now returns one point per night (time asleep in hours) plus a top-level `unit` for every kind so clients never infer the unit; - the slim summaries slice, so the web dashboard tile and the insights overview read per-night totals (kept in canonical minutes there; the web tile converts to hours) — strictly more correct than averaging across stage rows, and parity with the iOS value.
This commit is contained in:
1 parent
6e241c16a4
commit
ebeb16daa3
9 files changed
+691
-11
No files matched your search
@@ -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
|
||||
@@ -82,6 +84,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 +449,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
|
||||
|
||||
@@ -57,6 +57,12 @@ 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 type { SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -96,6 +102,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 +182,20 @@ 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): number[] {
|
||||
return reconstructSleepNights(rows, tz)
|
||||
.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 +458,7 @@ async function buildDashboardSummary(
|
||||
todaysIntakes,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
] = await Promise.all([
|
||||
time("latestEver", () =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
@@ -508,6 +547,27 @@ 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 },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
@@ -594,6 +654,16 @@ 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,
|
||||
);
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// v1.4.33 maintainer-item-1 — every emitted card now carries
|
||||
@@ -614,6 +684,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 +724,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 +753,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 +780,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 +808,47 @@ 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,
|
||||
);
|
||||
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 +857,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,
|
||||
|
||||
@@ -10,6 +10,13 @@ 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() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -117,6 +124,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
|
||||
|
||||
@@ -21,7 +21,9 @@ 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 { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const kindEnum = z.enum([
|
||||
"weight",
|
||||
@@ -71,11 +73,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 +168,50 @@ 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.
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: since },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true, sleepStage: true },
|
||||
});
|
||||
unit = "h";
|
||||
points = reconstructSleepNights(rows, tz)
|
||||
.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 +287,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
return apiSuccess({
|
||||
kind,
|
||||
unit,
|
||||
points,
|
||||
stats: summary
|
||||
? {
|
||||
|
||||
+48
-6
@@ -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
|
||||
@@ -1124,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")}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { 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 };
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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 keyed by the wake-day calendar date in the USER's
|
||||
* timezone (`userDayKey`). Apple Health / WHOOP write each stage with
|
||||
* `measuredAt` at the stage's start instant; all the stages of one
|
||||
* sleep session fall on the same wall-clock calendar day, so the tz day
|
||||
* key collapses them into one bucket. (This matches the existing
|
||||
* `computeSleepStageBreakdown` convention in the analytics route.)
|
||||
*
|
||||
* 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 { SleepStage } from "@/generated/prisma/client";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */
|
||||
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
|
||||
"ASLEEP",
|
||||
"REM",
|
||||
"CORE",
|
||||
"DEEP",
|
||||
]);
|
||||
|
||||
export interface SleepStageRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: SleepStage | 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>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw per-stage rows into per-night totals (tz-aware wake-day key).
|
||||
* Pure — the caller does the bounded DB read. Nights are returned sorted
|
||||
* ascending by key so the last element is the most recent night.
|
||||
*/
|
||||
export function reconstructSleepNights(
|
||||
rows: SleepStageRow[],
|
||||
tz: string,
|
||||
): SleepNight[] {
|
||||
const byNight = new Map<string, SleepStageRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = userDayKey(row.measuredAt, tz);
|
||||
const list = byNight.get(key) ?? [];
|
||||
list.push(row);
|
||||
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,
|
||||
): SleepNightSummary {
|
||||
const nights = reconstructSleepNights(rows, tz).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,
|
||||
};
|
||||
}
|
||||
@@ -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,57 @@ 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 },
|
||||
})) as SleepStageRow[];
|
||||
if (rows.length === 0) return slice;
|
||||
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const { summary } = summarizeSleepNights(rows, tz);
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user