diff --git a/messages/de.json b/messages/de.json index aa4bcd595..da0e2d9ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index af5e01480..6d7047b16 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 09e524701..79e7d5692 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index a37d14cea..5d723b8ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 6dbf5208e..02336401e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index beb3947b1..eddaff186 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts index c0f149592..9dc332948 100644 --- a/src/app/api/insights/pulse/intraday/route.ts +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -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, }, }); diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 843d3af27..563d3edbd 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -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(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 (
- + + + + {dayLabel} + + +
+ } + />

{caption}

{isLoading ? ( diff --git a/src/lib/analytics/__tests__/intraday-pulse.test.ts b/src/lib/analytics/__tests__/intraday-pulse.test.ts index a03c991fa..bed84682e 100644 --- a/src/lib/analytics/__tests__/intraday-pulse.test.ts +++ b/src/lib/analytics/__tests__/intraday-pulse.test.ts @@ -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"); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts index f28f2f3af..df5089d6b 100644 --- a/src/lib/analytics/intraday-pulse-io.ts +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -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, }; } diff --git a/src/lib/analytics/intraday-pulse.ts b/src/lib/analytics/intraday-pulse.ts index 8eea76598..3a517aa93 100644 --- a/src/lib/analytics/intraday-pulse.ts +++ b/src/lib/analytics/intraday-pulse.ts @@ -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, + dayKey: string, + localOf: LocalResolver, +): IntradayHrBucket[] { + const acc = new Map(); + 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, diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index 5d3b593e4..47e083eac 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -138,11 +138,18 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet = * 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:"; diff --git a/src/lib/tz/__tests__/format.test.ts b/src/lib/tz/__tests__/format.test.ts new file mode 100644 index 000000000..c865685e6 --- /dev/null +++ b/src/lib/tz/__tests__/format.test.ts @@ -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"); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 36ad8cc95..bf1ac4035 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -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 diff --git a/tests/integration/dense-intraday-hourly-rebuild.test.ts b/tests/integration/dense-intraday-hourly-rebuild.test.ts index 450bc9672..ad2435af3 100644 --- a/tests/integration/dense-intraday-hourly-rebuild.test.ts +++ b/tests/integration/dense-intraday-hourly-rebuild.test.ts @@ -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); diff --git a/tests/integration/dense-intraday-retention.test.ts b/tests/integration/dense-intraday-retention.test.ts index 420d1dcad..329d129dd 100644 --- a/tests/integration/dense-intraday-retention.test.ts +++ b/tests/integration/dense-intraday-retention.test.ts @@ -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);