mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Cap chronotype window in the shared assembler
Make computeSleepRhythmFromNights own the chronotype look-back so the dashboard summary (a year of nights) and GET /api/sleep/rhythm (six weeks) return the same band, mid-sleep, and social-jetlag values rather than averaging different samples. Derive the free/work day-type from the local wake-day digits so the classification holds in far-east timezones, and read the shared sleep-rhythm DTO type into the client hook instead of a hand-mirrored copy.
This commit is contained in:
1 parent
d1ba01548f
commit
1ce1c786b6
4 files changed
+99
-59
No files matched your search
@@ -661,7 +661,6 @@ async function buildDashboardSummary(
|
||||
sleepPriorityJson,
|
||||
),
|
||||
sleepNeedMinutes(sleepProfile.ageYears),
|
||||
userTz,
|
||||
);
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
@@ -5,42 +5,16 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.17.0 — sleep-debt DTO (mirrors the server `SleepDebtDto`). `partial` is
|
||||
* the calm "still learning" state below the night threshold; `ready` asserts
|
||||
* the cumulative deficit.
|
||||
*/
|
||||
export interface SleepDebtDto {
|
||||
state: "partial" | "ready";
|
||||
debtMinutes: number;
|
||||
needMinutes: number;
|
||||
nightsCounted: number;
|
||||
windowNights: number;
|
||||
nightsUntilReady: number;
|
||||
}
|
||||
// v1.17.0 — the wire shape IS the server DTO. Import the type directly (erased
|
||||
// at build, so no server code reaches the client bundle) rather than keeping a
|
||||
// second hand-mirrored copy that silently drifts when the server shape changes.
|
||||
import type {
|
||||
SleepDebtDto,
|
||||
ChronotypeDto,
|
||||
SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
/** v1.17.0 — chronotype DTO (mirrors the server `ChronotypeDto`). */
|
||||
export interface ChronotypeDto {
|
||||
state: "learning" | "ready";
|
||||
msfMinutes: number | null;
|
||||
msfScMinutes: number | null;
|
||||
band:
|
||||
| "extreme_early"
|
||||
| "early"
|
||||
| "intermediate"
|
||||
| "late"
|
||||
| "extreme_late"
|
||||
| null;
|
||||
socialJetlagMinutes: number | null;
|
||||
freeNightsCounted: number;
|
||||
workNightsCounted: number;
|
||||
freeNightsUntilReady: number;
|
||||
}
|
||||
|
||||
export interface SleepRhythmDto {
|
||||
sleepDebt: SleepDebtDto;
|
||||
chronotype: ChronotypeDto;
|
||||
}
|
||||
export type { SleepDebtDto, ChronotypeDto, SleepRhythmDto };
|
||||
|
||||
/**
|
||||
* Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a
|
||||
|
||||
@@ -21,7 +21,12 @@ vi.mock("../baseline", () => ({
|
||||
import * as sleepDebtMod from "../sleep-debt";
|
||||
import * as chronotypeMod from "../chronotype";
|
||||
|
||||
import { buildSleepRhythm, defaultDayType } from "../sleep-rhythm";
|
||||
import {
|
||||
buildSleepRhythm,
|
||||
computeSleepRhythmFromNights,
|
||||
defaultDayType,
|
||||
type RhythmNight,
|
||||
} from "../sleep-rhythm";
|
||||
|
||||
/**
|
||||
* Build per-stage rows for one night: a single bare-ASLEEP block whose END is
|
||||
@@ -41,11 +46,18 @@ function nightRows(wakeIso: string, asleepMinutes: number) {
|
||||
}
|
||||
|
||||
describe("defaultDayType", () => {
|
||||
it("tags weekend wake days free and weekdays work (UTC)", () => {
|
||||
it("tags weekend wake days free and weekdays work from the date key alone", () => {
|
||||
// 2026-06-13 is a Saturday, 2026-06-14 a Sunday, 2026-06-15 a Monday.
|
||||
expect(defaultDayType("2026-06-13", "UTC")).toBe("free");
|
||||
expect(defaultDayType("2026-06-14", "UTC")).toBe("free");
|
||||
expect(defaultDayType("2026-06-15", "UTC")).toBe("work");
|
||||
expect(defaultDayType("2026-06-13")).toBe("free");
|
||||
expect(defaultDayType("2026-06-14")).toBe("free");
|
||||
expect(defaultDayType("2026-06-15")).toBe("work");
|
||||
});
|
||||
|
||||
it("reads the weekday off the local date digits — correct for far-east zones", () => {
|
||||
// The wake-day key is already local; the weekday must not shift for a
|
||||
// UTC+14 user (the old noon-UTC anchor mislabelled a Friday as Saturday).
|
||||
expect(defaultDayType("2026-06-12")).toBe("work"); // Friday
|
||||
expect(defaultDayType("2026-06-13")).toBe("free"); // Saturday
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,3 +120,28 @@ describe("buildSleepRhythm", () => {
|
||||
expect(dto.chronotype.msfMinutes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSleepRhythmFromNights window cap", () => {
|
||||
/** Build N trailing nights ending 2026-06-30, midpoint 04:00, asleep 420. */
|
||||
function trailingNights(count: number): RhythmNight[] {
|
||||
const out: RhythmNight[] = [];
|
||||
const end = Date.UTC(2026, 5, 30); // 2026-06-30
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const d = new Date(end - i * 86_400_000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
out.push({ night: key, asleepMinutes: 420, midpoint: 4 * 60 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("yields the same chronotype DTO whether fed 42 or 365 nights (source-window-independent)", () => {
|
||||
const need = 420;
|
||||
const sixWeeks = computeSleepRhythmFromNights(trailingNights(42), need);
|
||||
const oneYear = computeSleepRhythmFromNights(trailingNights(365), need);
|
||||
// The most-recent 42 nights are identical in both inputs, so the chronotype
|
||||
// (which the helper caps to the trailing window) must match exactly. This
|
||||
// is the invariant the dashboard summary (365-day read) + the /api/sleep/
|
||||
// rhythm route (42-day read) both depend on to render identical values.
|
||||
expect(oneYear.chronotype).toEqual(sixWeeks.chronotype);
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,6 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { loadBaselineProfile } from "./baseline";
|
||||
@@ -100,15 +99,22 @@ export interface SleepRhythmDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Day type of a night from its WAKE day's weekday in the user's timezone:
|
||||
* Saturday / Sunday → "free", else "work". The documented calendar default
|
||||
* (no work calendar exists). `night` is the YYYY-MM-DD wake-day key.
|
||||
* Day type of a night from its WAKE day's weekday: Saturday / Sunday → "free",
|
||||
* else "work". The documented calendar default (no work calendar exists).
|
||||
*
|
||||
* `night` is ALREADY the YYYY-MM-DD wake-day key in the user's timezone (the
|
||||
* canonical engine keyed it with `userDayKey`), so its weekday is a pure
|
||||
* property of those calendar digits — there is no second tz conversion to do.
|
||||
* Parsing the digits and taking `getUTCDay()` is timezone-independent: a UTC
|
||||
* instant at midnight of the same Y-M-D reads back the identical weekday in
|
||||
* every zone, so this is correct for far-east (UTC+13/+14) users too — the
|
||||
* earlier "noon-UTC instant + wallClockInTz" form shifted the weekday by a day
|
||||
* there.
|
||||
*/
|
||||
export function defaultDayType(night: string, tz: string): "work" | "free" {
|
||||
// Anchor at local noon of the wake day so the weekday read is immune to the
|
||||
// tz offset and DST — noon never crosses a day boundary in any zone.
|
||||
const noon = new Date(`${night}T12:00:00Z`);
|
||||
const weekday = wallClockInTz(noon, tz).weekday; // 0 = Sun … 6 = Sat
|
||||
export function defaultDayType(night: string): "work" | "free" {
|
||||
const [y, m, d] = night.split("-").map(Number);
|
||||
if (!y || !m || !d) return "work";
|
||||
const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sun … 6 = Sat
|
||||
return weekday === 0 || weekday === 6 ? "free" : "work";
|
||||
}
|
||||
|
||||
@@ -151,11 +157,28 @@ export interface RhythmNight {
|
||||
midpoint: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chronotype look-back in nights. The helper caps its OWN chronotype input to
|
||||
* the trailing `CHRONOTYPE_WINDOW_NIGHTS` so the result is a pure function of
|
||||
* the most-recent N nights — NOT of however many nights a given caller happened
|
||||
* to pass. Without this cap a caller that hands in a year of nights (the
|
||||
* dashboard summary's 365-day read) would average MSF/MSFsc/social-jetlag over
|
||||
* a different sample than a caller passing six weeks (the route), and the two
|
||||
* surfaces would disagree. `computeSleepDebt` already self-caps to 14 nights,
|
||||
* so the debt headline is source-window-independent for free; this brings the
|
||||
* chronotype to the same guarantee. 42 nights (six weeks) gives ~12 weekend
|
||||
* nights for a stable free-day MSF.
|
||||
*/
|
||||
const CHRONOTYPE_WINDOW_NIGHTS = 42;
|
||||
|
||||
/**
|
||||
* Pure DTO assembler from already-reconstructed nights. The route reads + runs
|
||||
* the canonical reconstruction; the dashboard summary reuses its own loaded
|
||||
* rows. Both feed the SAME foundation modules here so the values are identical
|
||||
* — this is the one place the day-type default + the two `compute*` calls live.
|
||||
* REGARDLESS of how many nights each caller passes — the helper owns the
|
||||
* chronotype window cap, so a 42-night and a 365-night caller get the same DTO.
|
||||
* This is the one place the window cap + day-type default + the two `compute*`
|
||||
* calls live.
|
||||
*
|
||||
* `nights` must already be the canonical engine's output (asleep + midpoint);
|
||||
* this function does NOT reconstruct. `needMinutes` is the age-resolved need.
|
||||
@@ -163,23 +186,31 @@ export interface RhythmNight {
|
||||
export function computeSleepRhythmFromNights(
|
||||
nights: readonly RhythmNight[],
|
||||
needMinutes: number,
|
||||
tz: string,
|
||||
): SleepRhythmDto {
|
||||
const scorable = nights.filter((n) => n.asleepMinutes > 0);
|
||||
// Sort ascending by wake-day key so "trailing N" is well-defined regardless
|
||||
// of the caller's input order, then keep only scorable nights.
|
||||
const scorable = [...nights]
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.sort((a, b) => (a.night < b.night ? -1 : a.night > b.night ? 1 : 0));
|
||||
|
||||
// Debt self-caps to its own 14-night window internally; pass every scorable
|
||||
// night and let the module slice.
|
||||
const debtNights: SleepDebtNight[] = scorable.map((n) => ({
|
||||
night: n.night,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
}));
|
||||
const sleepDebt = computeSleepDebt(debtNights, needMinutes);
|
||||
|
||||
// Chronotype has no internal window — cap HERE to the trailing N nights so
|
||||
// the value is a function of the recent rhythm, not the caller's read span.
|
||||
const chronoNights: ChronotypeNight[] = scorable
|
||||
.filter((n) => n.midpoint != null)
|
||||
.slice(-CHRONOTYPE_WINDOW_NIGHTS)
|
||||
.map((n) => ({
|
||||
night: n.night,
|
||||
midpointMinutes: n.midpoint as number,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
dayType: defaultDayType(n.night, tz),
|
||||
dayType: defaultDayType(n.night),
|
||||
}));
|
||||
const chronotype = computeChronotype(chronoNights);
|
||||
|
||||
@@ -229,11 +260,10 @@ export async function buildSleepRhythm(
|
||||
|
||||
// Canonical reconstruction (session-clustering, wake-day keying, writer
|
||||
// dedup) → per-night asleep total + wall-clock midpoint. Same engine as the
|
||||
// Sleep Score; we never re-derive a midpoint here.
|
||||
const nights = reconstructNights(rows, tz, priorityJson).filter(
|
||||
(n) => n.asleepMinutes > 0,
|
||||
);
|
||||
// Sleep Score; we never re-derive a midpoint here. The helper owns the
|
||||
// scorable-night filter + the window cap, so we hand it the raw output.
|
||||
const nights = reconstructNights(rows, tz, priorityJson);
|
||||
|
||||
const needMinutes = sleepNeedMinutes(profile.ageYears);
|
||||
return computeSleepRhythmFromNights(nights, needMinutes, tz);
|
||||
return computeSleepRhythmFromNights(nights, needMinutes);
|
||||
}
|
||||
Reference in new issue
Block a user