mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
35f7741006
commit
148d6c035f
3 files changed
+125
-47
No files matched your search
@@ -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<string>, 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
|
||||
|
||||
@@ -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<string>();
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user