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 }; +}