Most fitness apps have streaks. RepRewards has streaks with personality — animated flame SVGs that evolve based on streak length, Spotify-style monthly wrapped recaps, and a buddy system with shared accountability. But before getting to the fun parts, let me walk through some of the more interesting engineering decisions underneath.
The XP Economy
Every game needs a currency system that feels fair. RepRewards uses this formula:
XP = min(duration, 120) * intensityMultiplier
// light: 1.0x | moderate: 1.5x | intense: 2.0x
Points = XP / 10
Level = Math.floor(Math.sqrt(totalXP / 100))The min(duration, 120) cap matters. Without it, a user could log a "6-hour workout" and blow past everyone. The square root level formula flattens progression at higher levels — you can't just brute-force your way to max level with one long session. Early levels feel achievable; later ones require sustained effort.
All of this runs as a single inline calculation inside the workouts.log Convex mutation. No separate mutation for XP, no separate mutation for leveling. The streak update also happens in the same mutation. One round trip, one atomic write.
Grace Day: Habit Formation as a Feature
The most recently shipped mechanic — and the one I'm most proud of — is the grace day system. The idea came directly from Phillippa Lally's 2010 habit formation research (the paper behind the commonly cited "21 days" myth). Her actual finding: one missed day doesn't break a habit in formation. Consistent behavior over weeks does. Missing occasionally is irrelevant.
So RepRewards implements that as a feature: if you miss exactly one day but have a streak of at least 7, you keep the streak. The grace day is marked on your calendar, but it doesn't reset your count.
This sounds simple. The implementation is not.
The cron job that resets broken streaks runs daily at midnight UTC. It has to distinguish between:
- A user who last worked out today (streak intact)
- A user who last worked out yesterday (still OK, within the window)
- A user who last worked out 2 days ago with a streak ≥ 7 (grace day, keep it)
- A user who last worked out 2 days ago with a streak < 7 (reset)
- A user who last worked out 3+ days ago (reset regardless)
Each of those cases requires knowing the user's local date, not UTC. Timezone-aware date handling in a Convex cron job — which runs server-side, without client context — meant storing the user's timezone at workout-log time and passing it through to the streak calculation.
The N+1 That Bit Us
The monthly wrapped feature renders a Spotify-style carousel of your fitness year: longest streak, most active month, favorite activity type, XP earned per month, and a few more stats. When I first implemented it, the data fetch looked something like this (paraphrasing the bad version):
// BAD: Fetches each workout date individually
for (const month of last12Months) {
const workouts = await ctx.db
.query("workouts")
.filter(q => q.eq(q.field("userId"), userId))
.filter(q => q.eq(q.field("month"), month))
.collect();
results.push(aggregate(workouts));
}That's 12 queries for 12 months. But the original version of the stats calculator was doing this per-day, not per-month. 365 sequential queries to build one user's annual recap. On a Convex backend, each query is an async round trip. The stats page took several seconds to load for any user with a full year of data.
The fix is obvious in retrospect: fetch all workouts for the user in one query, then aggregate in memory.
// GOOD: One query, aggregate in memory
const allWorkouts = await ctx.db
.query("workouts")
.withIndex("by_user_date", q => q.eq("userId", userId))
.filter(q => q.neq(q.field("isDeleted"), true))
.collect();
const byMonth = groupBy(allWorkouts, w => w.date.slice(0, 7));
const stats = Object.entries(byMonth).map(([month, workouts]) => ({
month,
xp: sum(workouts.map(w => w.xpEarned)),
count: workouts.length,
}));One round trip. The index on (userId, date) makes the collection fast even with hundreds of workout records. This is the core Convex rule that I keep having to re-internalize: collect once, filter in memory. Reactive queries make N+1 patterns especially expensive because each query is a live subscription, not just a one-time fetch.
Buddy System Design
The buddy system uses a sorted-pair index. Two users in a buddy relationship are stored as a single document where userAId < userBId (lexicographically). This means:
- No duplicate edges (A→B and B→A are the same record)
- Lookups by either user are possible via a compound index
- Deleting a relationship requires no direction knowledge
// In schema
buddies: defineTable({
userAId: v.id("users"),
userBId: v.id("users"),
createdAt: v.number(),
}).index("by_pair", ["userAId", "userBId"])
.index("by_userA", ["userAId"])
.index("by_userB", ["userBId"]),The buddy profile view loads stats on-demand via a getBuddyProfile query rather than including stats in the listBuddies response. This keeps the friends list feed lightweight — most users glance at the feed, they don't drill into every buddy profile every load.
Shared streak tracking turned out to be interesting. A shared streak increments only when both users log a workout on the same day. The comparison uses UTC date strings to avoid timezone ambiguity. Getting this wrong would mean two users in different timezones never getting credit for working out "together."
ClaudeSearch Audit: What Got Found
After shipping the main feature set, I ran a ClaudeSearch audit — a parallel multi-agent scan that looks for patterns, anti-patterns, and bugs across the codebase. It found 24 issues across the fitness app, ranging from minor to serious.
The most impactful ones beyond the N+1:
-
Unclamped deltas: When editing or deleting a workout, XP and points were being recalculated but the delta (new - old) wasn't being
Math.max(0, ...)-clamped. A user who edited a workout to a shorter duration would get a negative XP adjustment that could push their total below zero, causing NaN to propagate through the level calculation. -
Missing
isDeletedfilter: Soft-deleted workouts were being included in goal progress and variety calculations. A user could delete a workout but it would still count toward their weekly goal. -
Double-send race: Mutation buttons were using
useStateas the primary guard against rapid taps. But state updates are async in React — a fast double-tap can read the "not loading" state from both taps before either state update commits. Fixed with auseRefprimary guard:if (submitting.current) return; submitting.current = true; -
nullvsundefinedin Convex:useQueryreturnsundefinedwhile loading andnullwhen the query explicitly returns null (e.g., for a deleted record). Several loading spinners were checking!dataand showing an infinite spinner for deleted records instead of a "not found" state.
What's Next
Phase 5 is community and buddy discovery — helping people find accountability partners who share activity types and workout schedules. The design challenge is being useful for discovery without being a social network. No leaderboards, no public feeds by default, no location sharing. The model I keep coming back to is "people who also do yoga 3x/week" rather than "rank everyone by XP."
The prerequisite is a larger active user base. For now, the focus is on making the existing solo and buddy experience excellent. The monthly wrapped feature and the grace day mechanic are both there to increase long-term retention — the goal is fitness habits that stick, not fitness apps that get opened for a week and forgotten.