In this guide you will build a dynamic reward loop — a gamification layer that turns raw Sahha health data into personal baselines, habit streaks, adaptive challenges, and a points system. The flow looks like this:
The key insight: generic gamification (“walk 10,000 steps!”) kills motivation because the same target does not work for everyone. Instead, every threshold in this system is derived from the user’s own data — their baselines shift as they improve, streaks get easier during stress weeks, and challenges target the individual’s weakest area.
By the end you will have an API endpoint that accepts a Sahha profile ID and returns the full gamification state.
Prerequisites
Before you begin, make sure you have Sahha webhook data streaming into your database — follow the guide for your platform:
Stream health events to Supabase with Edge Functions
Stream health events to Convex with HTTP Actions
Stream health events to Firebase with Cloud Functions
How Sahha scores work (quick refresher)
Sahha delivers two types of data through webhooks:
- Scores are 0–1 floats for
activity,readiness,sleep,wellbeing, andmental_wellbeing. Each score includes factor breakdowns (name, value, state) - Archetypes capture weekly behavioral patterns like
activity_level— they are less frequent than scores and useful for trend detection
Scores are relative to population norms, so a 0.6 for an active person means something different than 0.6 for a sedentary person. This is exactly why personal baselines matter — we need to know what “normal” looks like for each individual.
Query historical scores
The reward loop needs more history than a single day — fetch the last 14 days of score events so we have enough data for baseline computation and streak detection.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
async function getScoreHistory(externalId: string) {
const fourteenDaysAgo = new Date(
Date.now() - 14 * 24 * 60 * 60 * 1000
).toISOString();
const { data, error } = await supabase
.from("sahha_events")
.select("payload, created_at")
.eq("external_id", externalId)
.eq("event_type", "ScoreCreatedIntegrationEvent")
.gte("created_at", fourteenDaysAgo)
.order("created_at", { ascending: false })
.limit(200);
if (error) throw error;
return data;
}import { internalQuery } from "./_generated/server";
import { v } from "convex/values";
export const getScoreHistory = internalQuery({
args: {
externalId: v.string(),
sinceTimestamp: v.number(),
},
handler: async (ctx, args) => {
const events = await ctx.db
.query("healthEvents")
.withIndex("by_user_type", (q) =>
q
.eq("externalId", args.externalId)
.eq("eventType", "ScoreCreatedIntegrationEvent")
)
.order("desc")
.take(200);
return events
.filter((e) => e.receivedAt >= args.sinceTimestamp)
.map((e) => ({
payload: e.payload,
receivedAt: e.receivedAt,
}));
},
});import { getFirestore } from "firebase-admin/firestore";
const db = getFirestore();
async function getScoreHistory(externalId: string) {
const fourteenDaysAgo = new Date(
Date.now() - 14 * 24 * 60 * 60 * 1000
);
const snapshot = await db
.collection("sahha_events")
.where("externalId", "==", externalId)
.where("eventType", "==", "ScoreCreatedIntegrationEvent")
.where("receivedAt", ">=", fourteenDaysAgo)
.orderBy("receivedAt", "desc")
.limit(200)
.get();
return snapshot.docs.map((doc) => doc.data());
}Before computing baselines, convert the raw events into a flat array of daily scores:
interface DailyScore {
date: string // "2026-02-28" (UTC calendar day)
scoreType: string // "sleep", "activity", "readiness", etc.
score: number // 0–1
}
function extractDailyScores(
events: { payload: Record<string, unknown>; receivedAt: number }[]
): DailyScore[] {
const scores: DailyScore[] = [];
for (const e of events) {
const scoreType = e.payload.type as string;
const score = e.payload.score as number;
if (!scoreType || typeof score !== "number") continue;
const date = new Date(e.receivedAt).toISOString().slice(0, 10);
scores.push({ date, scoreType, score });
}
return scores;
}Compute personal baselines
A baseline captures what “normal” looks like for a specific user and score type. We compute a 7-day rolling average and standard deviation — the average tells us their typical performance, and the standard deviation defines how much natural variation they have.
interface BaselineEntry {
scoreType: string
mean: number
stdDev: number
trend: "improving" | "stable" | "declining"
sampleCount: number
}
function computeBaselines(
dailyScores: DailyScore[],
windowDays: number = 7
): BaselineEntry[] {
// Group by scoreType
const byType: Record<string, { date: string; score: number }[]> = {};
for (const ds of dailyScores) {
if (!byType[ds.scoreType]) byType[ds.scoreType] = [];
byType[ds.scoreType].push({ date: ds.date, score: ds.score });
}
const baselines: BaselineEntry[] = [];
for (const [scoreType, entries] of Object.entries(byType)) {
// Deduplicate: keep latest per day
const byDate = new Map<string, number>();
for (const e of entries) {
if (!byDate.has(e.date)) byDate.set(e.date, e.score);
}
const sorted = [...byDate.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.slice(-windowDays);
if (sorted.length < 3) continue; // not enough data
const scores = sorted.map(([, s]) => s);
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
const variance =
scores.reduce((sum, s) => sum + (s - mean) ** 2, 0) / scores.length;
const stdDev = Math.sqrt(variance);
// Trend: compare first half vs second half
const mid = Math.floor(scores.length / 2);
const firstMean =
scores.slice(0, mid).reduce((a, b) => a + b, 0) / mid;
const secondMean =
scores.slice(mid).reduce((a, b) => a + b, 0) / (scores.length - mid);
let trend: "improving" | "stable" | "declining" = "stable";
const threshold = Math.max(stdDev * 0.5, 0.02);
if (secondMean - firstMean > threshold) trend = "improving";
else if (firstMean - secondMean > threshold) trend = "declining";
baselines.push({
scoreType,
mean: Math.round(mean * 1000) / 1000,
stdDev: Math.round(stdDev * 1000) / 1000,
trend,
sampleCount: sorted.length,
});
}
return baselines;
}Detect streaks
A streak is a run of consecutive days where a score type stays above the user’s personal baseline. The anti-burnout mechanic is the key differentiator: when readiness is low, the threshold drops so streaks are easier to maintain.
interface Streak {
scoreType: string
currentLength: number
longestLength: number
isActive: boolean
threshold: number
}
function detectStreaks(
dailyScores: DailyScore[],
baselines: BaselineEntry[],
currentReadiness: number | null,
previousStreaks: Streak[] = []
): Streak[] {
const baselineMap = new Map(baselines.map((b) => [b.scoreType, b]));
const prevMap = new Map(previousStreaks.map((s) => [s.scoreType, s]));
const streaks: Streak[] = [];
for (const [scoreType, baseline] of baselineMap) {
const typeScores = dailyScores
.filter((ds) => ds.scoreType === scoreType)
.sort((a, b) => a.date.localeCompare(b.date));
// Deduplicate by date (keep latest)
const byDate = new Map<string, number>();
for (const ds of typeScores) byDate.set(ds.date, ds.score);
// Anti-burnout: soften threshold when readiness is low
const readiness = currentReadiness ?? 1;
const softening = Math.max(0, (0.5 - readiness) * 2);
const threshold =
baseline.mean - softening * 0.5 * baseline.stdDev;
// Walk dates to find consecutive days above threshold
const dates = [...byDate.keys()].sort();
let currentLength = 0;
for (let i = 0; i < dates.length; i++) {
// Gap detection: missing day breaks the streak
if (i > 0) {
const prev = new Date(dates[i - 1]);
const curr = new Date(dates[i]);
const diffDays =
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays > 1) currentLength = 0;
}
if (byDate.get(dates[i])! >= threshold) {
currentLength++;
} else {
currentLength = 0;
}
}
const prev = prevMap.get(scoreType);
streaks.push({
scoreType,
currentLength,
longestLength: Math.max(currentLength, prev?.longestLength ?? 0),
isActive: currentLength > 0,
threshold: Math.round(threshold * 1000) / 1000,
});
}
return streaks;
}The anti-burnout formula is continuous, not binary:
softening = max(0, (0.5 - readiness) × 2)
threshold = mean - softening × 0.5 × stdDevWhen readiness is 0.5 or above, the threshold equals the user’s mean (normal mode). As readiness drops below 0.5, the threshold softens proportionally — at readiness 0.3, streaks become noticeably easier to maintain; at readiness 0, the threshold drops by a full half-standard-deviation. This prevents stress weeks from wiping out long streaks.
Generate dynamic challenges
Challenges target the user’s weakest score type — the one furthest below their personal baseline. Difficulty and duration adapt to the user’s current health state:
type Difficulty = "easy" | "medium" | "hard";
interface Challenge {
challengeId: string
scoreType: string
title: string
description: string
targetScore: number
durationDays: number
difficulty: Difficulty
startedAt: number
status: "active" | "completed" | "expired"
progress: number
}
function generateChallenges(
baselines: BaselineEntry[],
currentScores: Record<string, number>,
currentReadiness: number | null,
activeChallengeCount: number
): Challenge[] {
// Never more than 2 active challenges at once
if (activeChallengeCount >= 2 || baselines.length === 0) return [];
// Find weakest score (lowest z-score vs baseline)
let weakest: { scoreType: string; zScore: number } | null = null;
for (const b of baselines) {
const current = currentScores[b.scoreType];
if (current === undefined) continue;
const z = b.stdDev > 0 ? (current - b.mean) / b.stdDev : 0;
if (!weakest || z < weakest.zScore) {
weakest = { scoreType: b.scoreType, zScore: z };
}
}
if (!weakest) return [];
const baseline = baselines.find(
(b) => b.scoreType === weakest!.scoreType
)!;
// Difficulty from trend
const difficulty: Difficulty =
baseline.trend === "declining"
? "easy"
: baseline.trend === "stable"
? "medium"
: "hard";
// Target: baseline mean + difficulty multiplier
const multiplier =
difficulty === "easy" ? 0 : difficulty === "medium" ? 0.3 : 0.6;
const targetScore = Math.min(
1,
baseline.mean + multiplier * baseline.stdDev
);
// Duration from readiness
const readiness = currentReadiness ?? 0.6;
const durationDays =
readiness < 0.5 ? 3 : readiness < 0.75 ? 5 : 7;
return [{
challengeId: `ch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
scoreType: weakest.scoreType,
title: "Dynamic Challenge",
description: `Raise your ${weakest.scoreType} score above ${(targetScore * 100).toFixed(0)}%`,
targetScore: Math.round(targetScore * 1000) / 1000,
durationDays,
difficulty,
startedAt: Date.now(),
status: "active",
progress: 0,
}];
}Key design decisions:
- Max 2 active challenges prevents overwhelm
- Easy challenges during decline create achievable “quick wins” that maintain engagement
- Shorter durations during low readiness — a 3-day challenge feels manageable when energy is low; 7 days would feel impossible
- The target is always relative to the user’s own baseline, not a fixed number
Calculate points
Points reward consistent effort rather than raw performance. The daily cap prevents gaming:
const DAILY_POINT_CAP = 500;
const POINTS_PER_STREAK_DAY = 10;
const STREAK_MILESTONES: Record<number, number> = {
3: 50, 7: 150, 14: 500,
};
const CHALLENGE_POINTS: Record<Difficulty, number> = {
easy: 100, medium: 200, hard: 400,
};
function calculatePoints(
streaks: Streak[],
newlyCompletedChallenges: Challenge[],
currentScores: Record<string, number>,
baselines: BaselineEntry[],
currentDailyPoints: number
): { delta: number; breakdown: { points: number; reason: string }[] } {
const breakdown: { points: number; reason: string }[] = [];
let delta = 0;
const remaining = DAILY_POINT_CAP - currentDailyPoints;
function award(points: number, reason: string) {
const capped = Math.min(points, Math.max(0, remaining - delta));
if (capped > 0) {
delta += capped;
breakdown.push({ points: capped, reason });
}
}
// +10 per active streak per day
for (const streak of streaks) {
if (streak.isActive && streak.currentLength > 0) {
award(10, `${streak.scoreType} streak day ${streak.currentLength}`);
// Milestone bonuses
const milestone = STREAK_MILESTONES[streak.currentLength];
if (milestone) {
award(milestone, `${streak.scoreType} ${streak.currentLength}-day milestone`);
}
}
}
// Challenge completion rewards
for (const ch of newlyCompletedChallenges) {
award(CHALLENGE_POINTS[ch.difficulty], `Completed "${ch.title}"`);
}
// Improvement bonus: score exceeds personal best
for (const b of baselines) {
const current = currentScores[b.scoreType];
if (current !== undefined && current > b.mean + b.stdDev) {
award(25, `${b.scoreType} personal best`);
}
}
return { delta, breakdown };
}Store gamification state
You need new tables (or columns) to persist baselines, streaks, challenges, and points between calls:
-- Personal baselines
CREATE TABLE user_baselines (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
external_id TEXT NOT NULL,
score_type TEXT NOT NULL,
mean FLOAT NOT NULL,
std_dev FLOAT NOT NULL,
trend TEXT NOT NULL,
sample_count INT NOT NULL,
computed_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(external_id, score_type)
);
-- Streak tracking
CREATE TABLE user_streaks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
external_id TEXT NOT NULL,
score_type TEXT NOT NULL,
current_length INT NOT NULL DEFAULT 0,
longest_length INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT false,
threshold FLOAT NOT NULL,
last_updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(external_id, score_type)
);
-- Challenges
CREATE TABLE user_challenges (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
external_id TEXT NOT NULL,
challenge_id TEXT NOT NULL UNIQUE,
score_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
target_score FLOAT NOT NULL,
duration_days INT NOT NULL,
difficulty TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active',
progress FLOAT NOT NULL DEFAULT 0
);
-- Points and badges
CREATE TABLE user_points (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
external_id TEXT NOT NULL UNIQUE,
total_points INT NOT NULL DEFAULT 0,
level INT NOT NULL DEFAULT 1,
daily_points_today INT NOT NULL DEFAULT 0,
last_day_reset DATE DEFAULT CURRENT_DATE,
last_awarded_at TIMESTAMPTZ DEFAULT now(),
history JSONB DEFAULT '[]',
badges JSONB DEFAULT '[]'
);import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
healthEvents: defineTable({
externalId: v.string(),
eventType: v.string(),
payload: v.any(),
eventHash: v.string(),
receivedAt: v.number(),
})
.index("by_user_type", ["externalId", "eventType"])
.index("by_event_hash", ["eventHash"])
.index("by_external_id", ["externalId"]),
userBaselines: defineTable({
externalId: v.string(),
scoreType: v.string(),
mean: v.number(),
stdDev: v.number(),
trend: v.string(),
sampleCount: v.number(),
computedAt: v.number(),
})
.index("by_user", ["externalId"])
.index("by_user_type", ["externalId", "scoreType"]),
userStreaks: defineTable({
externalId: v.string(),
scoreType: v.string(),
currentLength: v.number(),
longestLength: v.number(),
isActive: v.boolean(),
threshold: v.number(),
lastUpdatedAt: v.number(),
})
.index("by_user", ["externalId"])
.index("by_user_type", ["externalId", "scoreType"]),
userChallenges: defineTable({
externalId: v.string(),
challengeId: v.string(),
scoreType: v.string(),
title: v.string(),
description: v.string(),
targetScore: v.number(),
durationDays: v.number(),
difficulty: v.string(),
startedAt: v.number(),
completedAt: v.optional(v.number()),
status: v.string(),
progress: v.number(),
})
.index("by_user", ["externalId"])
.index("by_user_status", ["externalId", "status"]),
userPoints: defineTable({
externalId: v.string(),
totalPoints: v.number(),
level: v.number(),
dailyPointsToday: v.number(),
lastDayReset: v.string(),
lastAwardedAt: v.number(),
history: v.array(v.object({
points: v.number(),
reason: v.string(),
awardedAt: v.number(),
})),
badges: v.array(v.object({
id: v.string(),
name: v.string(),
description: v.string(),
earnedAt: v.number(),
})),
}).index("by_user", ["externalId"]),
});// Firestore collections (auto-created on first write):
//
// users/{externalId}/baselines/{scoreType}
// - mean, stdDev, trend, sampleCount, computedAt
//
// users/{externalId}/streaks/{scoreType}
// - currentLength, longestLength, isActive, threshold, lastUpdatedAt
//
// users/{externalId}/challenges/{challengeId}
// - scoreType, title, description, targetScore, durationDays,
// difficulty, startedAt, completedAt, status, progress
//
// users/{externalId}/points (single doc)
// - totalPoints, level, dailyPointsToday, lastDayReset,
// lastAwardedAt, history[], badges[]Build the API endpoint
Wire everything together into a single POST endpoint that computes the full gamification state:
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
serve(async (req) => {
const { externalId } = await req.json();
// 1. Fetch 14 days of score history
const events = await getScoreHistory(externalId);
const dailyScores = extractDailyScores(events);
const currentScores = getLatestScores(dailyScores);
const currentReadiness = currentScores["readiness"] ?? null;
// 2. Compute baselines
const baselines = computeBaselines(dailyScores);
// 3. Detect streaks
const previousStreaks = await getStoredStreaks(externalId);
const streaks = detectStreaks(
dailyScores, baselines, currentReadiness, previousStreaks
);
// 4. Evaluate & generate challenges
const activeChallenges = await getActiveChallenges(externalId);
const evaluated = evaluateChallenges(activeChallenges, currentScores);
const stillActive = evaluated.filter((c) => c.status === "active");
const newChallenges = generateChallenges(
baselines, currentScores, currentReadiness, stillActive.length
);
// 5. Calculate points
const pointsRecord = await getPoints(externalId);
const newlyCompleted = evaluated.filter((c) => c.status === "completed");
const pointsUpdate = calculatePoints(
streaks, newlyCompleted, currentScores, baselines,
pointsRecord?.daily_points_today ?? 0
);
// 6. Persist & return
await persistState(externalId, baselines, streaks, evaluated, newChallenges, pointsUpdate);
return new Response(JSON.stringify({
baselines, streaks,
challenges: [...evaluated, ...newChallenges],
points: {
total: (pointsRecord?.total_points ?? 0) + pointsUpdate.delta,
todayEarned: pointsUpdate.delta,
recentActivity: pointsUpdate.breakdown,
},
}), {
headers: { "Content-Type": "application/json" },
});
});import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import {
extractDailyScores, getLatestScores, computeBaselines,
detectStreaks, generateChallenges, evaluateChallenges,
calculatePoints, todayUTC,
} from "./rewardEngine";
export const processRewardLoop = httpAction(async (ctx, request) => {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
const { externalId } = await request.json();
const fourteenDaysAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
// 1. Fetch all data in parallel
const [scoreEvents, existingStreaks, activeChallenges, pointsRecord] =
await Promise.all([
ctx.runQuery(internal.rewardQueries.getScoreHistory, {
externalId, sinceTimestamp: fourteenDaysAgo,
}),
ctx.runQuery(internal.rewardQueries.getStreaks, { externalId }),
ctx.runQuery(internal.rewardQueries.getActiveChallenges, { externalId }),
ctx.runQuery(internal.rewardQueries.getPoints, { externalId }),
]);
// 2. Compute
const dailyScores = extractDailyScores(scoreEvents);
const currentScores = getLatestScores(dailyScores);
const baselines = computeBaselines(dailyScores);
const streaks = detectStreaks(
dailyScores, baselines,
currentScores["readiness"] ?? null,
existingStreaks
);
const evaluated = evaluateChallenges(activeChallenges, currentScores);
const newChallenges = generateChallenges(
baselines, currentScores,
currentScores["readiness"] ?? null,
evaluated.filter((c) => c.status === "active").length
);
// 3. Points
const today = todayUTC();
const dailyPointsSoFar = pointsRecord?.lastDayReset === today
? pointsRecord.dailyPointsToday : 0;
const newlyCompleted = evaluated.filter((c) => c.status === "completed");
const pointsUpdate = calculatePoints(
streaks, newlyCompleted, currentScores, baselines, dailyPointsSoFar
);
// 4. Persist all state (mutations)
// ... upsert baselines, streaks, challenges, points ...
// 5. Return
return new Response(JSON.stringify({
baselines, streaks,
challenges: [...evaluated, ...newChallenges],
points: {
total: (pointsRecord?.totalPoints ?? 0) + pointsUpdate.delta,
todayEarned: pointsUpdate.delta,
recentActivity: pointsUpdate.breakdown,
},
}), {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
});Register the route in convex/http.ts:
import { httpRouter } from "convex/server";
import { processRewardLoop } from "./rewardLoop";
const http = httpRouter();
http.route({ path: "/reward-loop", method: "POST", handler: processRewardLoop });
http.route({ path: "/reward-loop", method: "OPTIONS", handler: processRewardLoop });
export default http;import { onRequest } from "firebase-functions/v2/https";
export const rewardLoop = onRequest(async (req, res) => {
const { externalId } = req.body;
// 1. Fetch 14 days of score history
const events = await getScoreHistory(externalId);
const dailyScores = extractDailyScores(events);
const currentScores = getLatestScores(dailyScores);
const currentReadiness = currentScores["readiness"] ?? null;
// 2. Compute baselines
const baselines = computeBaselines(dailyScores);
// 3. Detect streaks
const previousStreaks = await getStoredStreaks(externalId);
const streaks = detectStreaks(
dailyScores, baselines, currentReadiness, previousStreaks
);
// 4. Evaluate & generate challenges
const activeChallenges = await getActiveChallenges(externalId);
const evaluated = evaluateChallenges(activeChallenges, currentScores);
const stillActive = evaluated.filter((c) => c.status === "active");
const newChallenges = generateChallenges(
baselines, currentScores, currentReadiness, stillActive.length
);
// 5. Calculate points & persist
const pointsRecord = await getPoints(externalId);
const newlyCompleted = evaluated.filter((c) => c.status === "completed");
const pointsUpdate = calculatePoints(
streaks, newlyCompleted, currentScores, baselines,
pointsRecord?.dailyPointsToday ?? 0
);
await persistState(externalId, baselines, streaks, evaluated, newChallenges, pointsUpdate);
res.json({
baselines, streaks,
challenges: [...evaluated, ...newChallenges],
points: {
total: (pointsRecord?.totalPoints ?? 0) + pointsUpdate.delta,
todayEarned: pointsUpdate.delta,
recentActivity: pointsUpdate.breakdown,
},
});
});Test it with curl:
curl -X POST https://your-convex-url.convex.site/reward-loop \
-H "Content-Type: application/json" \
-d '{"externalId": "your-sahha-profile-id"}'{
"baselines": [
{ "scoreType": "sleep", "mean": 0.72, "stdDev": 0.08, "trend": "improving", "sampleCount": 7 },
{ "scoreType": "activity", "mean": 0.65, "stdDev": 0.12, "trend": "stable", "sampleCount": 7 }
],
"streaks": [
{ "scoreType": "sleep", "currentLength": 5, "longestLength": 8, "isActive": true, "threshold": 0.72 },
{ "scoreType": "activity", "currentLength": 3, "longestLength": 5, "isActive": true, "threshold": 0.65 }
],
"challenges": [
{
"challengeId": "ch-1709251200-abc123",
"scoreType": "readiness",
"title": "Recovery Focus",
"targetScore": 0.58,
"durationDays": 3,
"difficulty": "easy",
"status": "active",
"progress": 0.72
}
],
"points": {
"total": 1250,
"todayEarned": 85,
"recentActivity": [
{ "points": 10, "reason": "sleep streak day 5" },
{ "points": 150, "reason": "wellbeing 7-day milestone" },
{ "points": 25, "reason": "sleep personal best" }
]
}
}Bonus: Reward mapping and badges
Once you have a points system, map thresholds to tangible rewards. Here is a starter catalog you can adapt:
const REWARD_CATALOG = [
{ threshold: 100, reward: "Unlock daily insights dashboard" },
{ threshold: 500, reward: "Custom challenge creation" },
{ threshold: 1000, reward: "Partner discount: 10% off wellness products" },
{ threshold: 2500, reward: "Premium content unlock" },
{ threshold: 5000, reward: "VIP early access to new features" },
];
const BADGE_DEFINITIONS = [
{ id: "streak-3", name: "Getting Started", description: "Maintain any streak for 3 days" },
{ id: "streak-7", name: "Week Warrior", description: "Maintain any streak for 7 days" },
{ id: "streak-14", name: "Consistency King", description: "Maintain any streak for 14 days" },
{ id: "multi-streak", name: "Multi-Tracker", description: "Hold 3 simultaneous active streaks" },
{ id: "challenge-easy", name: "First Win", description: "Complete an easy challenge" },
{ id: "challenge-hard", name: "Goal Crusher", description: "Complete a hard challenge" },
{ id: "recovery-streak", name: "Recovery Champion", description: "Maintain a streak during low readiness" },
];The “Recovery Champion” badge is the most interesting — it rewards users who keep going during a difficult period, when the anti-burnout mechanic is actively softening their thresholds. This turns a potential frustration point into a positive reinforcement moment.
Troubleshooting
No baseline computed — not enough historical data
Baselines require at least 3 days of score data for a given type. New users will see empty baselines until enough webhook events have been received. Consider showing an onboarding state in your UI that explains data is being collected, and fall back to population-level defaults if you want gamification from day one.
Streaks resetting unexpectedly
The most common cause is timezone mismatch. The extractDailyScores function groups by UTC date — if a user is in UTC+12, their evening activity might land on the “next” day in UTC. For production, convert receivedAt to the user’s local timezone before extracting the date string.
Challenges too easy or too hard
Adjust the multipliers in generateChallenges. The defaults are: easy = mean + 0, medium = mean + 0.3 × stdDev, hard = mean + 0.6 × stdDev. If challenges feel too easy, increase the multipliers. If too hard, decrease them or widen the readiness threshold for “easy” challenges.
Points not accumulating
Check that the endpoint is being called after new webhook events arrive. The reward loop computes everything on-demand — it does not run on a schedule. If you want real-time updates, trigger the endpoint from a webhook post-processing step or run it on a cron schedule.
Convex endpoint returns a generic error
Convex httpActions must return 500 (not 502) for server errors. Check the Convex dashboard logs for the actual error message. Common causes: missing environment variables, schema mismatch after updating types, or query timeout on large datasets.
Next steps
- Scheduled recomputation — Run the reward loop on a daily cron to keep baselines and streaks fresh even if the user does not open the app
- Push notifications — Alert users when a streak is about to break (“Your sleep streak is at 6 days — one more night to hit the 7-day milestone!”)
- Leaderboards — Compare anonymized streak lengths or point totals across users (opt-in only)
- Reward redemption API — Build a separate endpoint that checks point balances and issues discount codes or content unlocks