From 148d6c035ff9b8092c71f1a22e2cb48d31a84963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:37:25 +0200 Subject: [PATCH] fix(dashboard): count the logging streak on local day keys The streak walked back from UTC midnight of today's key and read each instant in the user's zone. West of UTC that instant is still yesterday, so the walk started a day early and the streak came out one day short. It now steps through the day keys themselves. --- src/app/api/dashboard/summary/route.ts | 48 +------------- src/lib/dashboard/__tests__/streak.test.ts | 77 ++++++++++++++++++++++ src/lib/dashboard/streak.ts | 47 +++++++++++++ 3 files changed, 125 insertions(+), 47 deletions(-) create mode 100644 src/lib/dashboard/__tests__/streak.test.ts create mode 100644 src/lib/dashboard/streak.ts diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 95d65fdbf..084b12087 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -67,6 +67,7 @@ import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { validTimezoneOr } from "@/lib/tz/format"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { computeStreak } from "@/lib/dashboard/streak"; import { readLatestEver, readSparkBuckets, @@ -256,53 +257,6 @@ function trendOf(values: number[]): MetricCard["trend"] { return delta > 0 ? "up" : "down"; } -interface StreakInfo { - currentDays: number; - longest: number; -} - -/** Compute the current logging-day streak (days where any measurement or - * intake event was recorded, in the user's display timezone) plus the - * longest streak in the last `STREAK_WINDOW_DAYS` days. - * - * v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/ - * Auckland user gets their Auckland-day streak rather than the Berlin - * one. The activity-day Set entries are already produced in the same - * zone by the caller (via `userDayKey`), so the cursor walk only needs - * the same zone here to align. */ -function computeStreak(activityDays: Set, userTz: string): StreakInfo { - if (activityDays.size === 0) return { currentDays: 0, longest: 0 }; - - const sorted = [...activityDays].sort(); - let longest = 1; - let run = 1; - for (let i = 1; i < sorted.length; i++) { - const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime(); - const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime(); - if (cur - prev === 86_400_000) { - run += 1; - longest = Math.max(longest, run); - } else { - run = 1; - } - } - - // Current streak: walk back from today (user's tz). - const todayKey = userDayKey(new Date(), userTz); - let currentDays = 0; - let cursor = new Date(`${todayKey}T00:00:00.000Z`); - // Allow yesterday's last day to count if today not yet logged. - if (!activityDays.has(userDayKey(cursor, userTz))) { - cursor = new Date(cursor.getTime() - 86_400_000); - } - while (activityDays.has(userDayKey(cursor, userTz))) { - currentDays += 1; - cursor = new Date(cursor.getTime() - 86_400_000); - } - - return { currentDays, longest }; -} - /** v1.4.38 W-F — distinct activity day-keys from the streak window. * The route only needs YYYY-MM-DD strings, so the aggregate runs * `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns diff --git a/src/lib/dashboard/__tests__/streak.test.ts b/src/lib/dashboard/__tests__/streak.test.ts new file mode 100644 index 000000000..8f810c1dd --- /dev/null +++ b/src/lib/dashboard/__tests__/streak.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { computeStreak } from "../streak"; +import { userDayKey } from "@/lib/tz/format"; + +const ZONES = [ + "America/New_York", + "America/Santiago", + "Pacific/Auckland", + "Pacific/Tongatapu", + "Europe/Berlin", + "UTC", +]; + +/** The last `n` local days ending with today's key in `tz`. */ +function lastDays(now: Date, tz: string, n: number, skipToday = false) { + const keys = new Set(); + const today = userDayKey(now, tz); + const [y, m, d] = today.split("-").map(Number); + for (let i = skipToday ? 1 : 0; i < n + (skipToday ? 1 : 0); i++) { + keys.add(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10)); + } + return keys; +} + +describe("computeStreak", () => { + // Evening in New York is already the next UTC day, and the early hours in + // Auckland or Tonga are still the previous UTC day: both ends of the walk. + const INSTANTS = [ + "2026-03-08T01:30:00.000Z", + "2026-03-08T23:30:00.000Z", + "2026-04-05T13:00:00.000Z", + "2026-09-06T03:30:00.000Z", + "2026-10-25T00:30:00.000Z", + "2026-11-01T06:00:00.000Z", + ]; + + for (const tz of ZONES) { + for (const iso of INSTANTS) { + const now = new Date(iso); + it(`counts three logged days ending today in ${tz} at ${iso}`, () => { + const streak = computeStreak(lastDays(now, tz, 3), tz, now); + expect(streak.currentDays).toBe(3); + expect(streak.longest).toBe(3); + }); + it(`counts from yesterday when today is not logged yet in ${tz} at ${iso}`, () => { + const streak = computeStreak(lastDays(now, tz, 4, true), tz, now); + expect(streak.currentDays).toBe(4); + }); + } + } + + it("breaks the current streak on a gap", () => { + const now = new Date("2026-06-10T20:00:00.000Z"); + const tz = "America/New_York"; + const streak = computeStreak( + new Set([ + "2026-06-10", + "2026-06-09", + "2026-06-07", + "2026-06-06", + "2026-06-05", + ]), + tz, + now, + ); + expect(streak.currentDays).toBe(2); + expect(streak.longest).toBe(3); + }); + + it("returns zeros for no activity", () => { + expect(computeStreak(new Set(), "UTC")).toEqual({ + currentDays: 0, + longest: 0, + }); + }); +}); diff --git a/src/lib/dashboard/streak.ts b/src/lib/dashboard/streak.ts new file mode 100644 index 000000000..13ebd4cfc --- /dev/null +++ b/src/lib/dashboard/streak.ts @@ -0,0 +1,47 @@ +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; + +export interface StreakInfo { + currentDays: number; + longest: number; +} + +/** + * The current logging-day streak (days where any measurement or intake + * event was recorded, in the user's zone) plus the longest streak among + * `activityDays`. + * + * `activityDays` holds `YYYY-MM-DD` keys already cut in `userTz`. The walk + * steps through day KEYS (`shiftDateKey`), never instants: stepping back from + * UTC midnight of today's key read that instant in the user's zone, which + * west of UTC is still yesterday, so the streak started one day early and + * came out one day short. Yesterday counts as the streak's head when today + * has not been logged yet. + */ +export function computeStreak( + activityDays: ReadonlySet, + userTz: string, + now: Date = new Date(), +): StreakInfo { + if (activityDays.size === 0) return { currentDays: 0, longest: 0 }; + + const sorted = [...activityDays].sort(); + let longest = 1; + let run = 1; + for (let i = 1; i < sorted.length; i++) { + if (shiftDateKey(sorted[i - 1], 1) === sorted[i]) { + run += 1; + longest = Math.max(longest, run); + } else { + run = 1; + } + } + + let cursor = userDayKey(now, userTz); + if (!activityDays.has(cursor)) cursor = shiftDateKey(cursor, -1); + let currentDays = 0; + while (activityDays.has(cursor)) { + currentDays += 1; + cursor = shiftDateKey(cursor, -1); + } + return { currentDays, longest }; +}