Implementing a Daily Streak System: A Practical Guide

Implementing a Daily Streak System: A Practical Guide

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 timezone

  • todayStart: The start of the current day (midnight) in user's timezone

  • timezone: 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

  1. Day Boundaries: Always use startOfDay when comparing dates to ensure consistent behavior around midnight.

  2. 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

  3. Multiple Updates: The canUpdateStreakToday function stops multiple updates from happening on the same day.

  4. New Users: We handle new users (lastStreakDate is null) explicitly in our logic.

  5. 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: