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:
Marc-André Bombeck committed 2026-07-17 14:29:07 +02:00
1 parent 3aa11b0531
commit 4f198ec25f
16 files changed
+354 -32

No files matched your search

+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.",
+6 -1
View File
@@ -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");
+69 -12
View File
@@ -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,
};
}
+70
View File
@@ -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:";
+32
View File
@@ -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");
});
});
+15
View File
@@ -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);