"Days since I last smoked." "Days since the last production incident." "Days without sugar." A streak counter looks like the easiest widget in the world: store a start date and subtract. Then it shows 0 for two days after a clock change, crashes on the one user whose localStorage holds garbage, and stays on yesterday's number when the tab is left open overnight.
Here's the version I ended up with. It's plain JavaScript plus one React hook, with no libraries. All the logic below was run under Node 20 with TZ=Europe/Warsaw and TZ=America/New_York.
Rule 1: store calendar days, not timestamps
A streak is about days the user lived through, not milliseconds. If you store Date.now() and divide by 86,400,000, a 23-hour DST day or a trip across time zones will shift your count. So store a local day key, a plain YYYY-MM-DD string:
export function toDayKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
Note the use of getFullYear/getMonth/getDate, which are local, not toISOString(). toISOString() is UTC, so in Warsaw shortly after midnight it would still say yesterday.
Rule 2: subtract day keys in UTC
To count the days between two keys, turn each into a UTC midnight. UTC has no DST, so every day there really is 86,400,000 ms long and Math.round handles the rest:
export function daysBetweenKeys(a, b) {
const [y1, m1, d1] = a.split("-").map(Number);
const [y2, m2, d2] = b.split("-").map(Number);
return Math.round(
(Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86400000
);
}
export function daysSince(startKey, todayKey = toDayKey()) {
return Math.max(0, daysBetweenKeys(startKey, todayKey));
}
Quick checks that used to bite me:
daysBetweenKeys("2026-03-28", "2026-03-30"); // 2 (EU DST weekend)
daysBetweenKeys("2026-10-24", "2026-10-26"); // 2 (EU DST ends)
daysBetweenKeys("2024-02-28", "2024-03-01"); // 2 (leap year)
daysSince("2026-11-01", "2026-10-26"); // 0 (future start never goes negative)
Math.max(0, …) matters more than it looks. Users change their device clock, and a negative streak is a bug report waiting to happen.
Rule 3: resetting should keep the record
People like seeing their best streak, and it makes a reset feel less painful. A reset is a pure function that takes the old state and returns the new one:
export function reset(state, todayKey = toDayKey()) {
const current = daysSince(state.startKey, todayKey);
return {
startKey: todayKey,
best: Math.max(state.best ?? 0, current),
resets: (state.resets ?? 0) + 1,
};
}
reset({ startKey: "2026-10-01", best: 10, resets: 0 }, "2026-10-26");
// { startKey: "2026-10-26", best: 25, resets: 1 }
Because todayKey is a parameter, it's trivial to test without mocking Date.
Rule 4: treat localStorage as hostile
localStorage can be missing (some private modes), full, or hold an old format from last year's version of your app. Wrap it so that the worst case is "start fresh", never a white screen:
export function load(storage, key, todayKey = toDayKey()) {
try {
const raw = storage.getItem(key);
if (!raw) return { startKey: todayKey, best: 0, resets: 0 };
const s = JSON.parse(raw);
if (!/^\d{4}-\d{2}-\d{2}$/.test(s.startKey)) throw new Error("bad data");
return { best: 0, resets: 0, ...s };
} catch {
return { startKey: todayKey, best: 0, resets: 0 };
}
}
export function save(storage, key, state) {
try {
storage.setItem(key, JSON.stringify(state));
} catch {
// quota exceeded or storage disabled: keep working in memory
}
}
Passing storage in, instead of touching window.localStorage directly, lets you test it in Node with a five-line fake:
const mem = new Map();
const storage = {
getItem: (k) => mem.get(k) ?? null,
setItem: (k, v) => mem.set(k, v),
};
mem.set("streak", "{oops");
load(storage, "streak", "2026-10-27").startKey; // "2026-10-27", no crash
If you ever change the shape, add a version field now. Future you will thank you.
Rule 5: tick over at local midnight
The classic bug: the tab stays open overnight and still shows yesterday's number. You don't need a one-second interval. Schedule a single timeout for the next local midnight, and also refresh when the tab becomes visible again, because timers get throttled in background tabs and laptops go to sleep.
import { useEffect, useState, useCallback } from "react";
import { toDayKey, daysSince, reset, load, save } from "./streak";
function msUntilNextMidnight(now = new Date()) {
const next = new Date(now);
next.setHours(24, 0, 0, 0); // local midnight, DST-aware
return next - now;
}
function useToday() {
const [today, setToday] = useState(toDayKey);
useEffect(() => {
let timer;
const refresh = () => setToday(toDayKey());
const schedule = () => {
timer = setTimeout(() => { refresh(); schedule(); }, msUntilNextMidnight() + 1000);
};
schedule();
document.addEventListener("visibilitychange", refresh);
return () => {
clearTimeout(timer);
document.removeEventListener("visibilitychange", refresh);
};
}, []);
return today;
}
export function useStreak(key = "streak") {
const today = useToday();
const [state, setState] = useState(() =>
typeof window === "undefined"
? { startKey: today, best: 0, resets: 0 }
: load(window.localStorage, key, today)
);
useEffect(() => {
save(window.localStorage, key, state);
}, [key, state]);
// keep several tabs in sync
useEffect(() => {
const onStorage = (e) => {
if (e.key === key) setState(load(window.localStorage, key, toDayKey()));
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key]);
const doReset = useCallback(() => setState((s) => reset(s, toDayKey())), []);
return {
days: daysSince(state.startKey, today),
best: Math.max(state.best, daysSince(state.startKey, today)),
resets: state.resets,
reset: doReset,
};
}
setHours(24, 0, 0, 0) asks the engine for "the next local midnight", so DST is handled for you. The extra second avoids landing at 23:59:59.999 because of timer jitter.
The component
export function StreakCard({ label }) {
const { days, best, reset } = useStreak(`streak:${label}`);
return (
<section aria-live="polite">
<h2>{label}</h2>
<p style={{ fontSize: "3rem", margin: 0 }}>{days}</p>
<p>{days === 1 ? "day" : "days"} · best: {best}</p>
<button onClick={() => confirm("Reset this streak?") && reset()}>
Reset
</button>
</section>
);
}
Two small UX details: aria-live so screen readers announce the new count at midnight, and a confirm step, because an accidental tap that wipes a 200-day streak is the fastest way to lose a user.
Server rendering note
If you render on the server (Next.js and friends), the server doesn't know the user's time zone or their localStorage. Render a neutral placeholder and fill in the number after hydration, or you'll get hydration mismatch warnings around midnight. The typeof window guard above covers the initial state.
Recap
- Store local day keys (
YYYY-MM-DD), not timestamps. - Subtract them in UTC, and clamp at zero.
- Make reset a pure function that keeps the best streak.
- Treat localStorage as unreliable: validate, catch and fall back.
- Re-render at local midnight and on
visibilitychange.
That's about 100 lines for a counter that behaves correctly on DST weekends, on leap days, and in tabs left open for a week.
If you'd rather not build it yourself, I made this into a free app with countdowns and "days since" trackers, no ads and no sign-up: CountMyDays.
Top comments (0)