Habit and streak tracking looks simple until you actually build it: timezones, missed days, "does logging at 1am count for yesterday," and the eternal question of whether a streak breaks at midnight or at the user's personal reset time. Most of that complexity belongs in a hook, not scattered across your components.
Extracting the logic
function useStreak(entries, { resetHour = 0 } = {}) {
const adjustedDate = (timestamp) => {
const d = new Date(timestamp);
d.setHours(d.getHours() - resetHour);
return d.toDateString();
};
const streak = useMemo(() => {
const days = [...new Set(entries.map((e) => adjustedDate(e.loggedAt)))].sort();
let current = 0;
for (let i = days.length - 1; i >= 0; i--) {
const expected = new Date();
expected.setDate(expected.getDate() - (days.length - 1 - i));
if (days[i] === adjustedDate(expected.getTime())) current++;
else break;
}
return current;
}, [entries, resetHour]);
return { streak };
}
The resetHour parameter matters more than it looks. A user who works night shifts and logs a midnight workout shouldn't lose their streak because your app assumes everyone's day ends at 12am.
Why this belongs in a hook, not a utility function
You could write this as a plain function and call it from useMemo in every component that needs it. The reason to wrap it as a hook is composability — you can layer useStreak with other hooks (useNotificationSchedule, useMilestone) without threading the same recalculation logic through five components. It also makes this logic trivially unit-testable in isolation from any UI.
The honest limitation
Streaks are a motivational device, not a health metric, and they can push people toward all-or-nothing thinking — one missed day feels like failure instead of a data point. If you're building this for a health-adjacent product, consider surfacing "longest streak" alongside "current streak," so a broken streak doesn't erase the user's sense of progress. Small framing decision, real behavioral impact.
Top comments (0)