Skip to main content

Command Palette

Search for a command to run...

How To Implement A Daily Streak System With Convex

Published
5 min read
How To Implement A Daily Streak System With Convex

The goal

A daily streak system sounds simple. It is not. The tricky part is not counting. The tricky part is deciding what a day means. If someone played at 10pm, then again at 2am, that is only four hours later. But it is still a new local day. That should count as a streak extension. So the system needs to be local day based, not elapsed hours based.

What to store

Keep the streak state on the profile. That is enough for a solid version one.

profiles: defineTable({
  streakCount: v.number(),
  lastStreakDate: v.optional(v.number()),
  longestStreak: v.number(),
})

These three fields are enough to drive the whole feature. streakCount, the current streak. lastStreakDate, when you last counted a streak day. longestStreak, the best streak this profile has ever had.

The rule set

You need clear rules. These are the rules we use. Same local day, no update. Next local day, extend by one. Miss more than one local day, mark the streak as broken, then start again at one. The return to one is important. The person came back today, so today still counts.

Why local time zone matters

Do not compare raw UTC dates. Do not compare elapsed hours. Do not use the server time zone. Pass the user time zone from the client and calculate the day boundary on the backend. We used date-fns and @date-fns/tz for that.

import { TZDate } from '@date-fns/tz'
import { differenceInDays, startOfDay } from 'date-fns'

function getLocalDayDifference({
  lastStreakDate,
  nowMs,
  timeZone,
}: {
  lastStreakDate: number
  nowMs: number
  timeZone: string
}) {
  const lastUpdateStart = startOfDay(new TZDate(lastStreakDate, timeZone))
  const todayStart = startOfDay(new TZDate(nowMs, timeZone))
  return differenceInDays(todayStart, lastUpdateStart)
}

That one helper is the core of the feature.

Keep the streak math pure

Do not put the streak decision logic straight inside the mutation. Make it a pure helper first. That makes it easy to test and easy to trust. This is the pattern.

type DailyStreakUpdateStatus = 'unchanged' | 'extended' | 'broken'

function getDailyStreakUpdate({
  currentStreakCount,
  lastStreakDate,
  longestStreak,
  nowMs,
  timeZone,
}: {
  currentStreakCount: number
  lastStreakDate: number | null | undefined
  longestStreak: number
  nowMs: number
  timeZone: string
}) {
  if (!lastStreakDate) {
    return {
      status: 'extended' as DailyStreakUpdateStatus,
      shouldUpdate: true,
      wasFirstUpdate: true,
      previousCount: currentStreakCount,
      newCount: 1,
      newLongestStreak: Math.max(longestStreak, 1),
    }
  }

  const dayDifference = getLocalDayDifference({
    lastStreakDate,
    nowMs,
    timeZone,
  })

  if (dayDifference <= 0) {
    return {
      status: 'unchanged' as DailyStreakUpdateStatus,
      shouldUpdate: false,
      wasFirstUpdate: false,
      previousCount: currentStreakCount,
      newCount: currentStreakCount,
      newLongestStreak: longestStreak,
    }
  }

  const status = dayDifference === 1 ? 'extended' : 'broken'
  const newCount = status === 'extended' ? currentStreakCount + 1 : 1

  return {
    status,
    shouldUpdate: true,
    wasFirstUpdate: false,
    previousCount: currentStreakCount,
    newCount,
    newLongestStreak: Math.max(longestStreak, newCount),
  }
}

The mutation

Once the pure helper is right, the Convex mutation gets simple.

export const syncDailyStreak = mutation({
  args: {
    timeZone: v.string(),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx)
    if (!userId) {
      throw appError({
        code: 'NOT_AUTHENTICATED',
        message: 'You must be signed in to update the daily streak',
      })
    }

    const user = await ctx.db.get(userId)
    const profile = user?.activeProfileId
      ? await ctx.db.get(user.activeProfileId)
      : null

    if (!profile || profile.deletedAt) {
      throw appError({
        code: 'NOT_FOUND',
        message: 'Active profile not found',
      })
    }

    const nowMs = Date.now()
    const update = getDailyStreakUpdate({
      currentStreakCount: profile.streakCount,
      lastStreakDate: profile.lastStreakDate ?? null,
      longestStreak: profile.longestStreak,
      nowMs,
      timeZone: args.timeZone,
    })

    if (!update.shouldUpdate) {
      return {
        didUpdate: false,
        status: update.status,
        previousStreakCount: update.previousCount,
        newStreakCount: update.newCount,
      }
    }

    await ctx.db.patch(profile._id, {
      streakCount: update.newCount,
      lastStreakDate: nowMs,
      longestStreak: update.newLongestStreak,
    })

    return {
      didUpdate: true,
      status: update.status,
      previousStreakCount: update.previousCount,
      newStreakCount: update.newCount,
    }
  },
})

The frontend trigger

You do not want to run this on every render. A good pattern is this. Wait for the first user interaction. Then sync once. Then sync again when the tab becomes visible later. That keeps the system responsive without spamming writes.

useEffect(() => {
  if (hasInteracted) return

  function handleFirstInteraction() {
    setHasInteracted(true)
    void syncDailyStreak({ timeZone })
  }

  window.addEventListener('pointerdown', handleFirstInteraction, {
    once: true,
  })
  window.addEventListener('keydown', handleFirstInteraction, {
    once: true,
  })

  return () => {
    window.removeEventListener('pointerdown', handleFirstInteraction)
    window.removeEventListener('keydown', handleFirstInteraction)
  }
}, [hasInteracted, syncDailyStreak, timeZone])

When to show the streak dialog

Only show the dialog if the mutation says didUpdate. That means. No dialog on the same local day. Dialog on the first ever day. Dialog on a proper next day extension. Dialog on a broken streak that restarts at one. That part keeps the UX honest.

One bug that is easy to create

Do not wipe streak fields when you only meant to reset gameplay progress. This is easy to do in dev tools and admin reset helpers. If you clear streakCount and lastStreakDate, the next interaction looks like day one again. Then the dialog opens again, even on the same day. That is not a streak math bug. That is a reset helper bug.

What to test

The streak helper is pure, so test it first. These cases matter. First ever update returns one. Same local day returns unchanged. Next local day extends by one. Missing more than one local day returns broken and restarts at one. The 10pm to 2am case across local midnight extends correctly. That gives you confidence in the hard part before you wire UI on top.

The main lesson

A good daily streak system is really a local calendar system. Once you treat it that way, the implementation gets much cleaner.