mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.
Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
This commit is contained in:
1 parent
3aa11b0531
commit
4f198ec25f
16 files changed
+354
-32
No files matched your search
+5
-1
@@ -3869,8 +3869,12 @@
|
||||
"intradayPulse": {
|
||||
"title": "Der Verlauf deines Tages",
|
||||
"caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.",
|
||||
"empty": "Heute noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
|
||||
"hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.",
|
||||
"empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
|
||||
"baseline": "Ruhe",
|
||||
"today": "Heute",
|
||||
"previousDay": "Vorheriger Tag",
|
||||
"nextDay": "Nächster Tag",
|
||||
"tension": {
|
||||
"morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag — möglicherweise etwas Anspannung.",
|
||||
"afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag — möglicherweise etwas Anspannung.",
|
||||
|
||||
+5
-1
@@ -3869,8 +3869,12 @@
|
||||
"intradayPulse": {
|
||||
"title": "Your day's shape",
|
||||
"caption": "Your heart rate through the day, averaged in 10-minute steps.",
|
||||
"empty": "Not enough heart-rate readings today to show the day's shape yet.",
|
||||
"hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.",
|
||||
"empty": "Not enough heart-rate readings for this day to show its shape yet.",
|
||||
"baseline": "Resting",
|
||||
"today": "Today",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"tension": {
|
||||
"morning": "A sustained elevated-at-rest stretch this morning — possibly some tension.",
|
||||
"afternoon": "A sustained elevated-at-rest stretch this afternoon — possibly some tension.",
|
||||
|
||||
+5
-1
@@ -3869,8 +3869,12 @@
|
||||
"intradayPulse": {
|
||||
"title": "La forma de tu día",
|
||||
"caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.",
|
||||
"empty": "Aún no hay suficientes lecturas de hoy para mostrar la forma del día.",
|
||||
"hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.",
|
||||
"empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.",
|
||||
"baseline": "Reposo",
|
||||
"today": "Hoy",
|
||||
"previousDay": "Día anterior",
|
||||
"nextDay": "Día siguiente",
|
||||
"tension": {
|
||||
"morning": "Un tramo elevado en reposo esta mañana; posiblemente algo de tensión.",
|
||||
"afternoon": "Un tramo elevado en reposo esta tarde; posiblemente algo de tensión.",
|
||||
|
||||
+5
-1
@@ -3869,8 +3869,12 @@
|
||||
"intradayPulse": {
|
||||
"title": "Le rythme de votre journée",
|
||||
"caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures aujourd'hui pour afficher le rythme de la journée.",
|
||||
"hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
|
||||
"baseline": "Repos",
|
||||
"today": "Aujourd'hui",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"tension": {
|
||||
"morning": "Une période élevée au repos ce matin — peut-être un peu de tension.",
|
||||
"afternoon": "Une période élevée au repos cet après-midi — peut-être un peu de tension.",
|
||||
|
||||
+5
-1
@@ -3869,8 +3869,12 @@
|
||||
"intradayPulse": {
|
||||
"title": "L'andamento della tua giornata",
|
||||
"caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.",
|
||||
"empty": "Non ci sono ancora abbastanza misurazioni di oggi per mostrare l'andamento della giornata.",
|
||||
"hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.",
|
||||
"empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.",
|
||||
"baseline": "Riposo",
|
||||
"today": "Oggi",
|
||||
"previousDay": "Giorno precedente",
|
||||
"nextDay": "Giorno successivo",
|
||||
"tension": {
|
||||
"morning": "Un tratto elevato a riposo stamattina — forse un po' di tensione.",
|
||||
"afternoon": "Un tratto elevato a riposo questo pomeriggio — forse un po' di tensione.",
|
||||
|
||||
+5
-1
@@ -3869,8 +3869,12 @@
|
||||
"intradayPulse": {
|
||||
"title": "Przebieg Twojego dnia",
|
||||
"caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.",
|
||||
"empty": "Za mało dzisiejszych pomiarów, aby pokazać przebieg dnia.",
|
||||
"hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.",
|
||||
"empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.",
|
||||
"baseline": "Spoczynek",
|
||||
"today": "Dziś",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
"nextDay": "Następny dzień",
|
||||
"tension": {
|
||||
"morning": "Utrzymujący się podwyższony spoczynek rano — możliwe napięcie.",
|
||||
"afternoon": "Utrzymujący się podwyższony spoczynek po południu — możliwe napięcie.",
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
|
||||
* resolved session — never a body field. Gated on the `insights` module. The
|
||||
* `date` defaults to the user's local today; anything but a `YYYY-MM-DD`
|
||||
* literal is a 422 via `returnAllZodIssues`.
|
||||
* literal is a 422 via `returnAllZodIssues`. The frontend day navigator
|
||||
* pages this same route backward through prior days — a day outside
|
||||
* `DENSE_INTRADAY_RETENTION_DAYS` (`dense-intraday-retention.ts`) reads back
|
||||
* at the coarser hourly grain instead of an empty series; the response's
|
||||
* `resolution` field tells the caller which.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -54,6 +58,7 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
buckets: result.series.length,
|
||||
baseline_source: result.baselineSource,
|
||||
tension: result.tension !== null,
|
||||
resolution: result.resolution,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
IntradayHrBucket,
|
||||
IntradayResolution,
|
||||
TensionWindow,
|
||||
} from "@/lib/analytics/intraday-pulse";
|
||||
|
||||
@@ -34,6 +36,7 @@ interface IntradayPulseDto {
|
||||
baseline: number | null;
|
||||
baselineSource: "resting" | "proxy" | "none";
|
||||
tension: TensionWindow | null;
|
||||
resolution: IntradayResolution;
|
||||
}
|
||||
|
||||
/** Minutes-since-midnight → "HH:mm" wall-clock label. */
|
||||
@@ -53,6 +56,14 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
|
||||
* detected elevated-at-rest ("tension") window softly shaded. Copy is cautious
|
||||
* and non-diagnostic — "possible tension", never a stress score. Empty / no-
|
||||
* baseline days render an honest one-liner rather than an empty grid.
|
||||
*
|
||||
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS
|
||||
* §11) pages the same route backward through prior days; "next" is disabled
|
||||
* at today, so the view can never step into the future. A day outside the
|
||||
* dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the
|
||||
* coarser hourly grain instead of an empty chart — `resolution` on the DTO
|
||||
* tells the caption which, and the tension read is always absent on an
|
||||
* hourly day (it needs per-sample resolution to stay honest).
|
||||
*/
|
||||
export function IntradayPulseChart({
|
||||
userTimezone,
|
||||
@@ -61,8 +72,24 @@ export function IntradayPulseChart({
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const tz = userTimezone ?? "Europe/Berlin";
|
||||
const dateKey = userDayKey(new Date(), tz);
|
||||
const todayKey = userDayKey(new Date(), tz);
|
||||
// `null` tracks "today" live (so a session left open across midnight keeps
|
||||
// following the current day); a non-null value pins the viewed day once
|
||||
// the user has navigated away from today.
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||
const dateKey = selectedDateKey ?? todayKey;
|
||||
const isToday = dateKey === todayKey;
|
||||
|
||||
const goToPreviousDay = () => setSelectedDateKey(shiftDateKey(dateKey, -1));
|
||||
const goToNextDay = () => {
|
||||
const next = shiftDateKey(dateKey, 1);
|
||||
// Defence in depth alongside the button's `disabled` — never step past
|
||||
// today regardless of how the handler is reached.
|
||||
if (next > todayKey) return;
|
||||
setSelectedDateKey(next === todayKey ? null : next);
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsPulseIntraday(dateKey),
|
||||
@@ -85,7 +112,13 @@ export function IntradayPulseChart({
|
||||
|
||||
const caption = data?.tension
|
||||
? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`)
|
||||
: t("insights.intradayPulse.caption");
|
||||
: data?.resolution === "hourly"
|
||||
? t("insights.intradayPulse.hourlyNote")
|
||||
: t("insights.intradayPulse.caption");
|
||||
|
||||
const dayLabel = isToday
|
||||
? t("insights.intradayPulse.today")
|
||||
: fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -98,7 +131,41 @@ export function IntradayPulseChart({
|
||||
className="bg-card metric-accent space-y-1.5 rounded-xl border p-4"
|
||||
style={{ "--tile-hue": "var(--tile-stress)" } as React.CSSProperties}
|
||||
>
|
||||
<TileHeader icon={Activity} title={t("insights.intradayPulse.title")} />
|
||||
<TileHeader
|
||||
icon={Activity}
|
||||
title={t("insights.intradayPulse.title")}
|
||||
right={
|
||||
<div
|
||||
data-slot="intraday-pulse-day-nav"
|
||||
className="flex items-center gap-0.5"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:size-8"
|
||||
onClick={goToPreviousDay}
|
||||
aria-label={t("insights.intradayPulse.previousDay")}
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground min-w-14 text-center text-xs tabular-nums">
|
||||
{dayLabel}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:size-8"
|
||||
onClick={goToNextDay}
|
||||
disabled={isToday}
|
||||
aria-label={t("insights.intradayPulse.nextDay")}
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BUCKET_MINUTES,
|
||||
ELEVATION_MARGIN_BPM,
|
||||
MIN_STEP_COVERAGE_BUCKETS,
|
||||
computeHourlyMeanSeries,
|
||||
computeTenMinuteMeanSeries,
|
||||
detectTensionWindow,
|
||||
makeLocalResolver,
|
||||
@@ -114,6 +115,50 @@ describe("computeTenMinuteMeanSeries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeHourlyMeanSeries", () => {
|
||||
it("means folded stats: rows landing in the same local hour", () => {
|
||||
const series = computeHourlyMeanSeries(
|
||||
[
|
||||
sample("2026-05-01T10:30:00Z", 70), // hour 10
|
||||
sample("2026-05-01T10:45:00Z", 80), // same hour 10 (defensive dup)
|
||||
sample("2026-05-01T14:30:00Z", 90), // hour 14
|
||||
],
|
||||
"2026-05-01",
|
||||
utcOf,
|
||||
);
|
||||
expect(series).toEqual([
|
||||
{ startMinute: 600, mean: 75, count: 2 },
|
||||
{ startMinute: 840, mean: 90, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes samples from other local days (no next-day bleed)", () => {
|
||||
const series = computeHourlyMeanSeries(
|
||||
[
|
||||
sample("2026-05-01T23:30:00Z", 70), // stays on 05-01, hour 23
|
||||
sample("2026-05-02T00:30:00Z", 90), // 05-02, hour 0
|
||||
],
|
||||
"2026-05-01",
|
||||
utcOf,
|
||||
);
|
||||
expect(series).toEqual([{ startMinute: 1380, mean: 70, count: 1 }]);
|
||||
});
|
||||
|
||||
it("buckets on the USER's local day, not UTC (tz-correct windows)", () => {
|
||||
const nyOf = makeLocalResolver("America/New_York");
|
||||
// 02:30Z on 05-01 is 22:30 EDT on 04-30 → hour 22, prior day.
|
||||
const s = [sample("2026-05-01T02:30:00Z", 72)];
|
||||
expect(computeHourlyMeanSeries(s, "2026-05-01", nyOf)).toEqual([]);
|
||||
expect(computeHourlyMeanSeries(s, "2026-04-30", nyOf)).toEqual([
|
||||
{ startMinute: 1320, mean: 72, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty series for a day with no folded rows", () => {
|
||||
expect(computeHourlyMeanSeries([], "2026-05-01", utcOf)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("partOfDayForMinute", () => {
|
||||
it("labels the coarse part of day", () => {
|
||||
expect(partOfDayForMinute(5 * 60)).toBe("night");
|
||||
|
||||
@@ -20,11 +20,14 @@ import { percentile } from "@/lib/insights/strain-score";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import {
|
||||
BUCKET_MINUTES,
|
||||
HOURLY_BUCKET_MINUTES,
|
||||
MIN_BASELINE_DAYS,
|
||||
computeHourlyMeanSeries,
|
||||
computeTenMinuteMeanSeries,
|
||||
detectTensionWindow,
|
||||
makeLocalResolver,
|
||||
type IntradayHrBucket,
|
||||
type IntradayResolution,
|
||||
type LocalResolver,
|
||||
type TensionWindow,
|
||||
type WorkoutInterval,
|
||||
@@ -47,6 +50,15 @@ export interface IntradayPulseResult {
|
||||
baseline: number | null;
|
||||
baselineSource: "resting" | "proxy" | "none";
|
||||
tension: TensionWindow | null;
|
||||
/**
|
||||
* S11 day navigator — `"tenMin"` when `series` was folded from live raw
|
||||
* per-sample rows, `"hourly"` when the day fell outside
|
||||
* `DENSE_INTRADAY_RETENTION_DAYS` and `series` is the coarser fallback
|
||||
* read off the folded `stats:` hourly-mean tier instead. `tension` is
|
||||
* always `null` on an `"hourly"` day — see `detectTensionWindow`'s
|
||||
* docblock for why a tension read needs per-sample resolution.
|
||||
*/
|
||||
resolution: IntradayResolution;
|
||||
}
|
||||
|
||||
/** Median (p50), or null on an empty series. */
|
||||
@@ -147,7 +159,7 @@ export async function loadIntradayPulse(
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
take: MAX_DAY_PULSE_ROWS,
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, externalId: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
@@ -171,12 +183,49 @@ export async function loadIntradayPulse(
|
||||
resolveBaseline(userId),
|
||||
]);
|
||||
|
||||
const series = computeTenMinuteMeanSeries(
|
||||
pulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
// Split live PULSE rows into raw per-sample rows and already-folded
|
||||
// `stats:` hourly-mean rows (the dense-intraday-retention fold's output —
|
||||
// see `dense-intraday-retention.ts`). A day's raw rows are folded and
|
||||
// tombstoned atomically as a whole, so in practice a day is either wholly
|
||||
// raw or wholly folded; the split still guards against ever mixing the two
|
||||
// grains into one mislabeled series.
|
||||
const rawPulseRows = pulseRows.filter(
|
||||
(r) => !r.externalId?.startsWith("stats:"),
|
||||
);
|
||||
const foldedPulseRows = pulseRows.filter((r) =>
|
||||
r.externalId?.startsWith("stats:"),
|
||||
);
|
||||
|
||||
const tenMinSeries = computeTenMinuteMeanSeries(
|
||||
rawPulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dateKey,
|
||||
localOf,
|
||||
);
|
||||
|
||||
// Day-navigator fallback (S11 / v1.29.x): a day outside
|
||||
// `DENSE_INTRADAY_RETENTION_DAYS` has its raw rows tombstoned by the
|
||||
// retention fold, so `tenMinSeries` comes back empty even though the day's
|
||||
// shape survives at hour resolution in the folded `stats:` tier. Fall back
|
||||
// to that coarser read rather than rendering an empty chart. A day with
|
||||
// genuinely no data at all (never synced) stays on the empty "tenMin"
|
||||
// series — `foldedPulseRows` is empty there too, so `hourlySeries` is
|
||||
// empty and the branch below is skipped.
|
||||
const useHourlyFallback = tenMinSeries.length === 0;
|
||||
const hourlySeries = useHourlyFallback
|
||||
? computeHourlyMeanSeries(
|
||||
foldedPulseRows.map((r) => ({
|
||||
measuredAt: r.measuredAt,
|
||||
value: r.value,
|
||||
})),
|
||||
dateKey,
|
||||
localOf,
|
||||
)
|
||||
: [];
|
||||
|
||||
const resolution: IntradayResolution =
|
||||
useHourlyFallback && hourlySeries.length > 0 ? "hourly" : "tenMin";
|
||||
const series = resolution === "hourly" ? hourlySeries : tenMinSeries;
|
||||
|
||||
// Intraday step buckets — EXCLUDE the consolidated `stats:` daily totals, or a
|
||||
// single day-sum row would dump the whole day's steps into one bucket and
|
||||
// wrongly mark an at-rest stretch as moving.
|
||||
@@ -197,22 +246,30 @@ export async function loadIntradayPulse(
|
||||
.map((w) => workoutToInterval(w.startedAt, w.endedAt, dateKey, localOf))
|
||||
.filter((w): w is WorkoutInterval => w !== null);
|
||||
|
||||
const tension = detectTensionWindow({
|
||||
buckets: series,
|
||||
baseline: baseline.baseline,
|
||||
baselineMature: baseline.mature,
|
||||
stepBuckets,
|
||||
workouts,
|
||||
hrvConfirmMinutes: [],
|
||||
});
|
||||
// Tension needs per-sample resolution (see `detectTensionWindow`'s
|
||||
// docblock) — never compute it against the hourly fallback, regardless of
|
||||
// what the (independently folded) step tier happens to report.
|
||||
const tension =
|
||||
resolution === "tenMin"
|
||||
? detectTensionWindow({
|
||||
buckets: series,
|
||||
baseline: baseline.baseline,
|
||||
baselineMature: baseline.mature,
|
||||
stepBuckets,
|
||||
workouts,
|
||||
hrvConfirmMinutes: [],
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
timezone,
|
||||
bucketMinutes: BUCKET_MINUTES,
|
||||
bucketMinutes:
|
||||
resolution === "hourly" ? HOURLY_BUCKET_MINUTES : BUCKET_MINUTES,
|
||||
series,
|
||||
baseline: baseline.baseline,
|
||||
baselineSource: baseline.source,
|
||||
tension,
|
||||
resolution,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,26 @@ import { formatInUserTz } from "@/lib/tz/format";
|
||||
/** Intraday bucket width, in minutes. Ten minutes reveals the day's shape. */
|
||||
export const BUCKET_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* S11 day-navigator fallback bucket width, in minutes. Days older than
|
||||
* `DENSE_INTRADAY_RETENTION_DAYS` (`dense-intraday-retention.ts`) have their
|
||||
* raw per-sample rows folded to one `stats:` hourly-mean row per local hour;
|
||||
* `computeHourlyMeanSeries` re-buckets those at this coarser grain instead of
|
||||
* pretending they carry 10-minute resolution.
|
||||
*/
|
||||
export const HOURLY_BUCKET_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* The resolution the intraday series was actually computed at — surfaced on
|
||||
* the DTO so the chart can label a folded, older day honestly instead of
|
||||
* implying every day carries the same 10-minute detail. `"tenMin"` reads
|
||||
* raw per-sample rows; `"hourly"` reads the folded `stats:` tier
|
||||
* (`loadIntradayPulse`'s fallback for a day outside the dense retention
|
||||
* window). The tension-window detector requires `"tenMin"` resolution — see
|
||||
* `detectTensionWindow`'s docblock.
|
||||
*/
|
||||
export type IntradayResolution = "tenMin" | "hourly";
|
||||
|
||||
/**
|
||||
* A 10-minute bucket needs at least this many raw samples before it is
|
||||
* TRUSTED as a mean. A lone reading is a point, not a bucket — trusting it
|
||||
@@ -185,6 +205,48 @@ export function computeTenMinuteMeanSeries(
|
||||
.sort((a, b) => a.startMinute - b.startMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a day's already-folded `stats:` hourly-mean rows into ascending
|
||||
* per-local-hour buckets — the day-navigator's fallback shape for a day
|
||||
* outside the dense retention window, where the raw per-sample rows are
|
||||
* long tombstoned and only the hourly means survive live. Only samples
|
||||
* whose LOCAL day matches `dayKey` participate, same as
|
||||
* `computeTenMinuteMeanSeries`. Bucket `count` reflects how many `stats:`
|
||||
* rows landed in that local hour — normally exactly one (the dense-tier
|
||||
* fold mints at most one hourly row per hour), so the density gate in
|
||||
* `detectTensionWindow` would reject every bucket outright; callers must
|
||||
* not feed this series into tension detection regardless (see
|
||||
* `IntradayResolution`).
|
||||
*/
|
||||
export function computeHourlyMeanSeries(
|
||||
samples: ReadonlyArray<IntradaySample>,
|
||||
dayKey: string,
|
||||
localOf: LocalResolver,
|
||||
): IntradayHrBucket[] {
|
||||
const acc = new Map<number, { sum: number; count: number }>();
|
||||
for (const s of samples) {
|
||||
const local = localOf(s.measuredAt);
|
||||
if (local.dayKey !== dayKey) continue;
|
||||
const startMinute =
|
||||
Math.floor(local.minuteOfDay / HOURLY_BUCKET_MINUTES) *
|
||||
HOURLY_BUCKET_MINUTES;
|
||||
const bucket = acc.get(startMinute);
|
||||
if (bucket) {
|
||||
bucket.sum += s.value;
|
||||
bucket.count += 1;
|
||||
} else {
|
||||
acc.set(startMinute, { sum: s.value, count: 1 });
|
||||
}
|
||||
}
|
||||
return [...acc.entries()]
|
||||
.map(([startMinute, { sum, count }]) => ({
|
||||
startMinute,
|
||||
mean: round1(sum / count),
|
||||
count,
|
||||
}))
|
||||
.sort((a, b) => a.startMinute - b.startMinute);
|
||||
}
|
||||
|
||||
/** Coarse part-of-day for a minute-of-day (drives the cautious copy). */
|
||||
export function partOfDayForMinute(minute: number): PartOfDay {
|
||||
const hour = Math.floor(minute / 60);
|
||||
@@ -226,6 +288,14 @@ export interface DetectTensionInput {
|
||||
* reading. The longest run of ≥ `MIN_SUSTAINED_BUCKETS` consecutive qualifying
|
||||
* buckets becomes the window (earliest wins a tie). Every gate must hold; when
|
||||
* any fails, the honest answer is no window.
|
||||
*
|
||||
* Callers must only invoke this against `"tenMin"`-resolution buckets
|
||||
* (`computeTenMinuteMeanSeries`) — never the `"hourly"` day-navigator
|
||||
* fallback (`computeHourlyMeanSeries`). A tension read needs per-sample
|
||||
* resolution to sustain the run/density gates honestly; an hourly mean would
|
||||
* either starve every bucket via the density gate (misleadingly silent) or,
|
||||
* worse, pass it on a coincidence of the hourly average. `loadIntradayPulse`
|
||||
* enforces this by only calling `detectTensionWindow` for `"tenMin"` days.
|
||||
*/
|
||||
export function detectTensionWindow(
|
||||
input: DetectTensionInput,
|
||||
|
||||
@@ -138,11 +138,18 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet<MeasurementType> =
|
||||
* fold to hourly-mean rows and the raw rows tombstone; rows inside the
|
||||
* window stay raw so the Stress engine has its intra-day inputs.
|
||||
*
|
||||
* 14 days covers the Stress engine's 7-day reference window plus a margin
|
||||
* for late watch syncs and a backfilled gap, while keeping the raw
|
||||
* per-sample volume bounded to roughly two weeks of samples per dense type.
|
||||
* 90 days comfortably covers the Stress engine's 7-day reference window and
|
||||
* lets the intraday pulse day-navigator (S11) page back through a full
|
||||
* quarter of true 10-minute-resolution days before it hits the coarser
|
||||
* hourly fold. Raised from the original 14-day bound deliberately: a
|
||||
* self-hoster's own Postgres volume is the trade-off, not a shared resource,
|
||||
* so trading roughly six extra weeks of raw per-sample rows per dense type
|
||||
* for three months of full-resolution history is the right default. Days
|
||||
* outside the window still fold to hourly means rather than disappearing —
|
||||
* `loadIntradayPulse` falls back to that hourly tier so the navigator never
|
||||
* renders an empty chart for an older day.
|
||||
*/
|
||||
export const DENSE_INTRADAY_RETENTION_DAYS = 14;
|
||||
export const DENSE_INTRADAY_RETENTION_DAYS = 90;
|
||||
|
||||
/** The stats externalId prefix marks an already-collapsed row. */
|
||||
const DAILY_STATS_PREFIX = "stats:";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
describe("shiftDateKey", () => {
|
||||
it("steps a day key backward and forward", () => {
|
||||
expect(shiftDateKey("2026-06-15", -1)).toBe("2026-06-14");
|
||||
expect(shiftDateKey("2026-06-15", 1)).toBe("2026-06-16");
|
||||
});
|
||||
|
||||
it("rolls over a month boundary", () => {
|
||||
expect(shiftDateKey("2026-06-01", -1)).toBe("2026-05-31");
|
||||
expect(shiftDateKey("2026-05-31", 1)).toBe("2026-06-01");
|
||||
});
|
||||
|
||||
it("rolls over a year boundary", () => {
|
||||
expect(shiftDateKey("2026-01-01", -1)).toBe("2025-12-31");
|
||||
expect(shiftDateKey("2025-12-31", 1)).toBe("2026-01-01");
|
||||
});
|
||||
|
||||
it("is unaffected by DST transitions (pure UTC-anchored calendar math)", () => {
|
||||
// Europe/Berlin spring-forward day 2026-03-29 — the day key itself is
|
||||
// just a calendar date, so stepping across it is a plain +/-1 day.
|
||||
expect(shiftDateKey("2026-03-29", 1)).toBe("2026-03-30");
|
||||
expect(shiftDateKey("2026-03-29", -1)).toBe("2026-03-28");
|
||||
});
|
||||
|
||||
it("supports multi-day deltas", () => {
|
||||
expect(shiftDateKey("2026-06-15", 7)).toBe("2026-06-22");
|
||||
expect(shiftDateKey("2026-06-15", -30)).toBe("2026-05-16");
|
||||
});
|
||||
});
|
||||
@@ -191,6 +191,21 @@ export function userDayKey(date: Date, tz: string): string {
|
||||
return formatInUserTz(date, tz, "date");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift a `YYYY-MM-DD` day key by `deltaDays` (negative steps back). Pure
|
||||
* calendar-day arithmetic, UTC-anchored so it never touches a timezone
|
||||
* offset or DST edge — the key is already a resolved local calendar date,
|
||||
* not an instant, so incrementing it needs no `tz` argument. Used by the
|
||||
* intraday-pulse day navigator (S11) to page `?date=` a day at a time
|
||||
* without re-deriving the viewer's timezone for a pure string increment.
|
||||
*/
|
||||
export function shiftDateKey(dateKey: string, deltaDays: number): string {
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + deltaDays);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock hour (0–23) an observer in `tz` reads off the clock at
|
||||
* `date`. Used for the time-of-day greeting so a traveller whose device
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("runDenseIntradayHourlyRebuild (real Postgres)", () => {
|
||||
|
||||
it("never touches days inside the retention window", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// A daily row anchored 2 days ago — inside the default 14-day window.
|
||||
// A daily row anchored 2 days ago — inside the default 90-day window.
|
||||
// (Synthetic: the real fold never produces one this recent, but the
|
||||
// window bound must hold regardless of how the row got there.)
|
||||
const recentNoon = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("runDenseIntradayRetention (real Postgres)", () => {
|
||||
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
// Default 14-day window — both rows are inside it.
|
||||
// Default 90-day window — both rows are inside it.
|
||||
log: () => {},
|
||||
});
|
||||
expect(summary.totals.daysConsolidated).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user