mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(sleep): dedup the bare ASLEEP aggregate across the stage breakdown and hypnogram
Apple Health writes an unspecified ASLEEP aggregate alongside the granular CORE/DEEP/REM partition for the same period. The asleep total already preferred the granular partition, but the per-night/per-session `stages` map and the hypnogram `segments` list recorded every stage row unconditionally, so a night ended up carrying the granular set PLUS its ~equal bare twin — a double-height green ASLEEP block on the stacked bar and the hypnogram, and a doubled total in the analytics stage breakdown. Route the `stages` map and the `segments` list through the same granular-over-bare gate as the asleep total: when a session carries a granular stage, drop the redundant bare ASLEEP (and stage-less) rows everywhere. WHOOP never emits a bare ASLEEP, so WHOOP nights are unaffected. Apply the gate per session, not over the merged night pool, so a granular overnight no longer strips a same-day bare-only nap's minutes from the night total. Render the hypnogram per-segment in the stage palette (one ReferenceArea per segment) instead of a single flat cyan line, and show the breakdown plus totals footer whenever any per-stage minutes exist — reserving the unavailable state for a truly empty session — so a legacy stage-summary night still surfaces its breakdown. Thread the user's source priority into the CSV export's sleep dedup, and honour offset with a true pre-slice night count on the sleep list.
This commit is contained in:
1 parent
c0065437a7
commit
766b395900
7 files changed
+343
-156
No files matched your search
@@ -24,6 +24,7 @@ import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -44,18 +45,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? "raw"
|
||||
: "night";
|
||||
|
||||
const [measurements, userTz] = await Promise.all([
|
||||
const [measurements, userTz, sourcePriorityJson] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
|
||||
const csv = toCSV(
|
||||
formatMeasurementsForExport(measurements, userTz, {
|
||||
granularity,
|
||||
sleepTz: userTz,
|
||||
sourcePriorityJson,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatMoodEntriesForExport,
|
||||
} from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const userTz = await resolveUserTimezone(userId);
|
||||
const [userTz, sourcePriorityJson] = await Promise.all([
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
@@ -63,10 +67,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
// v1.11.5 — sleep collapses to one row per night by default (the
|
||||
// formatter's `night` granularity), matching the per-type CSV route.
|
||||
// Thread the user's source-priority so a multi-source night dedups to
|
||||
// the same canonical source the UI shows.
|
||||
const measurementRecords = formatMeasurementsForExport(
|
||||
measurements,
|
||||
userTz,
|
||||
{ sleepTz: userTz },
|
||||
{ sleepTz: userTz, sourcePriorityJson },
|
||||
);
|
||||
data.measurements =
|
||||
format === "csv" ? toCSV(measurementRecords) : measurementRecords;
|
||||
|
||||
@@ -135,6 +135,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
from,
|
||||
to,
|
||||
limit,
|
||||
offset,
|
||||
sortDir,
|
||||
dayKey,
|
||||
});
|
||||
@@ -677,11 +678,12 @@ async function sleepListResponse(
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
limit: number;
|
||||
offset: number;
|
||||
sortDir: "asc" | "desc";
|
||||
dayKey?: string;
|
||||
},
|
||||
) {
|
||||
const { from, to, limit, sortDir, dayKey } = opts;
|
||||
const { from, to, limit, offset, sortDir, dayKey } = opts;
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
@@ -809,17 +811,21 @@ async function sleepListResponse(
|
||||
});
|
||||
}
|
||||
|
||||
// Honour offset + limit against the full night set, and report the true
|
||||
// pre-slice night count as `meta.total` — matching the non-sleep list
|
||||
// branch's pagination contract so a client can page through nights.
|
||||
const totalNights = nightRows.length;
|
||||
const measurements = nightRows
|
||||
.sort((a, b) => {
|
||||
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
})
|
||||
.slice(0, limit);
|
||||
.slice(offset, offset + limit);
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
total: totalNights,
|
||||
type: "SLEEP_DURATION",
|
||||
mode: "sleep-per-night",
|
||||
rowsScanned: rows.length,
|
||||
@@ -827,7 +833,7 @@ async function sleepListResponse(
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: { total: nightRows.length, limit, offset: 0, groupBy: "night" },
|
||||
meta: { total: totalNights, limit, offset, groupBy: "night" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user