March 1, 2026 · 20 min read

Build Dynamic Reward Loops with Sahha

Turn Sahha health scores into personalized habit streaks, adaptive challenges, and a points system that rewards real health improvement — with anti-burnout mechanics built in.

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:

Your Database → Query 14 days of scores → Compute personal baselines → Detect streaks (with anti-burnout) → Generate challenges → Award points & badges

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:

How Sahha scores work (quick refresher)

Sahha delivers two types of data through webhooks:

  • Scores are 0–1 floats for activity, readiness, sleep, wellbeing, and mental_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;
}
This uses UTC dates for day grouping. In production you would use the user’s timezone to avoid streak miscounts around midnight.

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;
}
We use a 7-day window because Sahha archetypes also use weekly periodicity. The minimum of 3 days prevents baselines from being computed on too little data — new users will need a few days of data before gamification kicks in.

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 × stdDev

When 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;
Convex httpActions must never return HTTP 502 — always use status 500 for errors and let Convex handle retries.
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"}'
Example response 200
json
{
  "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