Implementing a Daily Streak System: A Practical Guide

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

Daily streaks are a strong way to keep users engaged, made popular by apps like Duolingo. The idea is simple: "do something every day." But setting it up right needs careful thought about timezones, unusual situations, and how users interact with it.
In this post, we’ll go over how to do it with the date-fns-tz package.
A lot of the stuff here are taken from a side project of mine by the way. I use Convex but tried to make this post more generic to avoid confusion. 🙌
First, let's define what data we need to track for each user:
type User = {
// Core streak-related fields
streakCount: number; // Current streak count
lastStreakUpgradeDate: number | null; // Unix timestamp of last streak update
longestStreak: number; // Historical best streak
streakStatus: "uninitiated" | "started"; // Track if user has ever started
};
Each field has a specific role:
streakCount keeps track of the current streak.
lastStreakUpgradeDate checks if a user can update their streak today.
longestStreak records the user's best streak ever.
streakStatus distinguishes new users from those who have already started their streak journey.
One of the most important parts of a streak system is managing dates correctly across different timezones. Users expect their streaks to reset at their local midnight, not based on server time.
import { TZDate } from "@date-fns/tz";
import { startOfDay, differenceInDays } from "date-fns";
function getStreakDates() {
// Get user's local timezone using the Intl API
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Create a timezone-aware date object for 'now'
const now = new TZDate(new Date(), timezone);
// Get start of day in user's timezone
const todayStart = startOfDay(now);
return { now, todayStart, timezone };
}
The getStreakDates function gives us three fundamental pieces:
now: The current time in the user's timezone
todayStart: The start of the current day (midnight) in user's timezone
timezone: The user's timezone string for future calculations
Now let's create the two main functions that run our streak system:
function canUpdateStreakToday({
lastStreakDate,
}: {
lastStreakDate: number | null;
}) {
// New users can always update their streak
if (!lastStreakDate) return true;
const { todayStart, timezone } = getStreakDates();
const lastUpdate = new TZDate(lastStreakDate, timezone);
const lastUpdateStart = startOfDay(lastUpdate);
// Allow update if last update was on a different calendar day
return differenceInDays(todayStart, lastUpdateStart) > 0;
}
function isStreakBroken({ lastStreakDate }: { lastStreakDate: number | null }) {
// New users haven't broken their streak
if (!lastStreakDate) return false;
const { todayStart, timezone } = getStreakDates();
const lastUpdate = new TZDate(lastStreakDate, timezone);
const lastUpdateStart = startOfDay(lastUpdate);
// Streak is broken if more than one day has passed
return differenceInDays(todayStart, lastUpdateStart) > 1;
}
These functions take care of several important things:
New users (lastStreakDate is null)
Same-day updates (prevented by canUpdateStreakToday)
Broken streaks (more than one day between updates)
Day boundaries that consider time zones
Modern browsers, like Chrome, have strict rules about when websites can play audio. Since many streak systems use sound effects or voice feedback, we need to manage user interaction correctly:
function useStreak() {
const [hasInteracted, setHasInteracted] = useState(false);
// Call this on user interaction
const handleFirstInteraction = useCallback(() => {
if (!hasInteracted) {
setHasInteracted(true);
// Initialize audio systems here
}
}, [hasInteracted]);
// Attach to your root component
return (
<div
onKeyDown={handleFirstInteraction}
onMouseDown={handleFirstInteraction}
>
{/* Your app content */}
</div>
);
}
When it's time to update a streak, we need to handle all possible cases:
async function updateStreak(user: User) {
// Early returns for invalid states
// hasAccess means they paid for the app
if (!user.hasAccess || !hasInteracted) {
return;
}
const canUpdateStreakToday = canUpdateStreakToday({
lastStreakDate: user.lastStreakUpgradeDate,
});
const hasStreakStarted = user.streakStatus === "started";
if (hasStreakStarted && !canUpdateStreakToday) {
return;
}
// They missed a day?
const isBroken = isStreakBroken({
lastStreakDate: user.lastStreakUpgradeDate,
});
const { now } = getStreakDates();
const currentStreak = user.streakCount;
// Calculate new streak count
// If broken, start over at 1
const newCount = isBroken ? 1 : currentStreak + 1;
// Update longest streak if needed
const newLongest = Math.max(newCount, user.longestStreak);
// Update user data
const updatedUser = {
streakCount: newCount,
lastStreakUpgradeDate: now.getTime(),
longestStreak: newLongest,
streakStatus: "started" as const,
};
// Save to your database
await saveUser(updatedUser);
}
Day Boundaries: Always use startOfDay when comparing dates to ensure consistent behavior around midnight.
Timezone Changes: Our implementation manages timezone changes well because we:
Always get the current timezone on each check
Use TZDate for consistent calculations
Compare dates using startOfDay
Multiple Updates: The canUpdateStreakToday function stops multiple updates from happening on the same day.
New Users: We handle new users (lastStreakDate is null) explicitly in our logic.
Data Consistency: Store timestamps in UTC (Unix time), but always do calculations in the user's timezone.
A strong streak system needs careful handling of timezones, user experience, and special cases. By using date-fns/tz and managing timezones correctly, we can build a system that works well in different timezones and situations for users.
I usually don’t do a references section but if you want to read up on things: