DEV Community

CountMyDays
CountMyDays

Posted on

Build a 'days since' streak counter in React with localStorage (that survives midnight, DST and bad data)

"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}`;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)