Introduction
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. 🙌
Core Data Structure
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.
Timezone-Aware Date Handling
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 timezonetodayStart
: The start of the current day (midnight) in user's timezonetimezone
: The user's timezone string for future calculations
Core Streak Logic
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
Browser Requirements & User Interaction
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>
);
}
Updating the Streak
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);
}
Edge Cases & Best Practices
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 calculationsCompare 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.
Conclusion
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.
References
I usually don’t do a references section but if you want to read up on things: