mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user