Skip to content

feat: Nutrition Challenges — time-bound goals, rest days, badges, AI personalized (Strava Challenges pattern) #222

Description

@Eliaaazzz

Summary

Build Nutrition Challenges — time-bound, opt-in challenges (7 / 14 / 30 days) with daily progress bars, completion badges, and an optional Squad leaderboard. Direct adaptation of Strava Challenges, the single feature with the strongest published retention number in consumer fitness.

Strava's "Challenges" feature lifted 90-day retention from 18% to 32% (+14pp), drove a 28% DAU lift, and a 15% premium subscription lift. Source: StriveCloud / Trophy Strava case study.

This is week 1, feature 3 of 3 (alongside Squads + Behavior Insights). Challenges are the connective tissue: they consume Behavior Insights as suggested challenges and use Squads as the social leaderboard layer.


Why this fits AuraFitness

  • Stacks with the other two features: Insights surface "you do X often" → suggest challenge "X for 14 days" → Squads compete on it.
  • Monetization lever: Premium-only challenges (e.g., "30-day AI Meal Plan Challenge") drive Premium tier conversion — directly mirrors Strava's 15% premium lift.
  • AI fit: Gemini LLM generates personalized challenge titles + completion copy.

User Stories

US-3.1 — As a user, I want to browse a curated list of challenges (e.g., "21-day Protein Streak", "14-day Breakfast Before 10am") so I have a clear short-term goal.

US-3.2 — As a user, I want to join a challenge with one tap and see daily progress on my home dashboard so I'm reminded of my commitment without opening a separate screen.

US-3.3 — As a user in a Squad, I want my Squad mates to join the same challenge so we compete on a shared leaderboard.

US-3.4 — As a user, I want a completion badge added to my profile when I finish so the achievement is durable, not just a transient streak.

US-3.5 — As a Premium user, I want access to AI-generated personalized challenges based on my Behavior Insights so the challenge actually targets my weak spot.

US-3.6 — As a user mid-challenge, I want a "rest day" allowance (1 per 14 days) so a single bad day doesn't kill my progress (Duolingo Streak Freeze pattern).


Acceptance Criteria

AC-1 Catalog & lifecycle

  • Catalog has ≥10 seeded challenges across categories: Protein, Fiber, Hydration, Timing, Whole Foods, Squad-only.
  • Each challenge has: id, slug, title, description, duration_days, success_predicate_key, category, tier (free|pro|premium), badge_image_url, max_rest_days.
  • User joins with POST /challenges/{id}/join — creates a challenge_participation row with start_date = today, end_date = today + duration_days.
  • User can be in at most 3 active challenges; HTTP 409 on 4th.

AC-2 Daily evaluation

  • Nightly job at 03:30 user-local evaluates each active participation:
    • For each day in window, compute success_predicate(user, day) -> boolean using same predicate registry as Behavior Insights (DRY).
    • Update days_succeeded, days_failed, rest_days_used.
    • Status transitions: active → completed (all required days hit) or active → failed (failed days > duration - max_rest_days).

AC-3 Rest day

  • If user fails a day but rest_days_used < max_rest_days, the failure is converted to a rest day (not counted against success).
  • User notified in-app: "Rest day used. X of Y remaining."

AC-4 Squad leaderboard

  • If user is in a Squad and the challenge has squad_leaderboard_enabled=true, all Squad members who joined appear on a per-challenge leaderboard ranked by days_succeeded desc, joined_at asc.
  • Cold-start guard: members with 0 success days hidden from leaderboard (avoids public "0%").

AC-5 Completion

  • On status → completed, server creates user_badge row with timestamp + challenge metadata.
  • Push notification fires (existing notification service): "You completed {title} 🎉".
  • Badge appears in ProfileBadgesGrid.

AC-6 AI-generated personalized challenges (Premium)

  • Premium endpoint POST /challenges/personalized calls Gemini LLM with prompt template:
    Given user's behavior insights {topNegativeInsights}, propose 3 nutrition
    challenges. Each: title (≤40 chars), description (≤120 chars), duration
    (7|14|30), predicate from {predicateRegistry}. Output JSON.
    
  • Response validated against schema; invalid items dropped.
  • AI disclaimer attached.

AC-7 Tier gating

  • Free: 3 catalog challenges/month, no AI personalized, no Squad leaderboard.
  • Pro: unlimited catalog, Squad leaderboard, no AI personalized.
  • Premium: all of above + AI personalized challenges.

UI / UX Spec

Inspired by: Strava Challenges (catalog grid + progress card), Apple Fitness+ Awards (durable badges), Duolingo Streak Freeze (rest day mechanic), Linear Cycles (time-bound progress bar).

Screens

  1. ChallengesScreen — top tabs Active | Browse | Completed. Browse = grid of ChallengeCard. Active = stacked ActiveChallengeCard with progress bar.
  2. ChallengeDetailScreen — hero badge image, description, predicate explainer, "Join" CTA, optional Squad section showing teammates' progress.
  3. ChallengeProgressCard — a Bento card surfaced on home dashboard for each active challenge. Shows day X of Y + progress bar + today's status (✅ done / 🟡 pending / ❄️ rest day used).
  4. BadgeUnlockModal — celebratory full-screen on completion: badge image, AI-generated congratulation copy, "Share to Squad" CTA.

Components

  • ChallengeCard — glass morphism, badge thumbnail (top-right), title, duration chip, category accent stripe.
  • ChallengeProgressBar — segmented bar (one segment per day), filled = success, hatched = rest day, empty = pending/failed.
  • RestDayPill❄️ Rest Day Used chip on day cell, animated entrance.
  • SquadChallengeLeaderboard — reuses SquadLeaderboardRow from feature 1.

Theming

  • Category accents (theme tokens):
    • Protein → orange primary
    • Fiber → existing success token
    • Hydration → existing info token
    • Timing → violet primaryDark
  • Badge unlock confetti uses Reanimated, no third-party confetti lib (keeps bundle lean).

Animations

  • Day cell flip on success: rotateY 0 → 180deg, 400ms spring.
  • Badge unlock: badge scales 0 → 1.2 → 1.0 with 60ms haptic medium then heavy.
  • Progress bar fill on join: 0% → first day cell pulse.

Database Schema (PostgreSQL + Flyway)

-- V20260509_001__create_challenges.sql
CREATE TABLE challenges (
  id                       BIGSERIAL  PRIMARY KEY,
  slug                     VARCHAR(64) NOT NULL UNIQUE,
  title                    VARCHAR(80) NOT NULL,
  description              TEXT        NOT NULL,
  category                 VARCHAR(32) NOT NULL,
  duration_days            SMALLINT    NOT NULL,
  success_predicate_key    VARCHAR(64) NOT NULL,  -- shared registry with behavior insights
  tier                     VARCHAR(16) NOT NULL,  -- 'free'|'pro'|'premium'
  squad_leaderboard_enabled BOOLEAN    NOT NULL DEFAULT TRUE,
  badge_image_url          TEXT        NOT NULL,
  max_rest_days            SMALLINT    NOT NULL DEFAULT 1,
  is_ai_generated          BOOLEAN     NOT NULL DEFAULT FALSE,
  created_at               TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE challenge_participations (
  id                BIGSERIAL   PRIMARY KEY,
  user_id           BIGINT      NOT NULL REFERENCES users(id)      ON DELETE CASCADE,
  challenge_id      BIGINT      NOT NULL REFERENCES challenges(id) ON DELETE CASCADE,
  start_date        DATE        NOT NULL,
  end_date          DATE        NOT NULL,
  status            VARCHAR(16) NOT NULL DEFAULT 'active', -- active|completed|failed|abandoned
  days_succeeded    SMALLINT    NOT NULL DEFAULT 0,
  days_failed       SMALLINT    NOT NULL DEFAULT 0,
  rest_days_used    SMALLINT    NOT NULL DEFAULT 0,
  joined_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at      TIMESTAMPTZ,
  UNIQUE (user_id, challenge_id, start_date)
);
CREATE INDEX idx_part_user_status ON challenge_participations(user_id, status);
CREATE INDEX idx_part_challenge_status ON challenge_participations(challenge_id, status);

CREATE TABLE challenge_day_results (
  participation_id BIGINT NOT NULL REFERENCES challenge_participations(id) ON DELETE CASCADE,
  day              DATE   NOT NULL,
  outcome          VARCHAR(16) NOT NULL, -- 'success'|'fail'|'rest'|'pending'
  evaluated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (participation_id, day)
);

CREATE TABLE user_badges (
  id            BIGSERIAL  PRIMARY KEY,
  user_id       BIGINT     NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  challenge_id  BIGINT     NOT NULL REFERENCES challenges(id),
  awarded_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (user_id, challenge_id, awarded_at)
);

Predicate registry is shared with feature 2 (Behavior Insights) — both use the same BehaviorPredicate interface, avoiding duplicate logic.


API Contract

Method Path Body / Params Returns
GET /api/v1/challenges ?category=&tier= Challenge[] (catalog)
GET /api/v1/challenges/{id} ChallengeDetail
POST /api/v1/challenges/{id}/join Participation
POST /api/v1/participations/{id}/abandon 204
GET /api/v1/participations?status=active Participation[] with dayResults
GET /api/v1/challenges/{id}/squad-leaderboard LeaderboardEntry[]
POST /api/v1/challenges/personalized (Premium) Challenge[] (3 AI-generated, ephemeral until joined)
GET /api/v1/users/me/badges Badge[]

Caching: catalog cached in Redis challenges:catalog:{tier} 1h TTL; participations not cached (read after write).


Tests

Unit (Spring Boot)

  • ChallengeServiceTest
    • join_rejectsWhenUserHas3Active.
    • join_rejectsWhenTierInsufficient.
    • abandon_setsStatusButPreservesHistory.
  • ChallengeEvaluationJobTest
    • evaluatesAllActiveDaysSinceLastRun.
    • restDay_convertsFailToRest_whenWithinAllowance.
    • restDay_doesNotConvert_whenAllowanceExhausted.
    • transitionsToCompleted_whenAllRequiredDaysSucceeded.
    • transitionsToFailed_whenFailuresExceedAllowance.
  • BehaviorPredicateRegistryTest — shared registry returns same value as BehaviorDeriver (feature 2) for identical input — guards against drift.
  • PersonalizedChallengeServiceTest
    • gemini_responseValidatedAgainstSchema_invalidDropped.
    • freeTier_endpointReturns403.

Unit (Frontend)

  • ChallengeProgressBar.test.tsx — segments render correct outcome state; rest day hatched.
  • ActiveChallengeCard.test.tsx — shows correct day X of Y; today's status pill correct.
  • BadgeUnlockModal.test.tsx — fires share_to_squad callback on CTA tap.
  • useChallenges.test.ts — join optimistically adds to active list; rolls back on API error.

E2E

  • e2e/challenges.join-and-complete.e2e.ts
    • User joins 7-day challenge → mock clock + log meals matching predicate → day 7 evaluation → status=completed → badge visible in profile.
  • e2e/challenges.rest-day.e2e.ts
    • User fails day 3 → rest day used → status remains active → second fail (no rest day left) → status=failed.
  • e2e/challenges.squad-leaderboard.e2e.ts
    • 3 Squad members join same challenge → varying success days → leaderboard ordering correct.
  • e2e/challenges.tier-gating.e2e.ts
    • Free user blocked from /challenges/personalized (403); upgrade to Premium → 3 AI challenges returned with valid schema.
  • e2e/challenges.ai-disclaimer.e2e.ts
    • AI-generated challenge cards show the AI disclaimer text.

Day-by-day plan (5 working days)

Day Backend Frontend Tests
Mon Flyway migration; seed 10 catalog challenges; BehaviorPredicateRegistry extracted (shared with feature 2) ChallengeCard, ChallengeProgressBar components Predicate registry tests
Tue ChallengeService join/abandon + tier gating; controller + DTOs ChallengesScreen (Browse + Active tabs), ChallengeDetailScreen Service unit tests
Wed Nightly ChallengeEvaluationJob (@Scheduled 03:30); rest day logic ChallengeProgressCard on home dashboard Evaluation job tests
Thu Squad leaderboard endpoint (joins with feature 1); badge awarding + push notification BadgeUnlockModal + confetti animation; ProfileBadgesGrid Leaderboard + completion e2e
Fri Gemini personalized challenges endpoint + schema validation; AI disclaimer enforcement Tier gating UI + paywall hooks; AI challenges section All e2e flows + tier gating + AI disclaimer e2e

Dependencies / sequencing notes

  • Depends on Feature 1 (Squads) for the Squad leaderboard endpoint to be meaningful (graceful fallback if user has no Squad).
  • Depends on Feature 2 (Behavior Insights) for the BehaviorPredicateRegistry — extract this on Day 1 to avoid duplicate logic.
  • Order recommended: ship Feature 2 deriver early Mon, then this feature reuses it the same day.

Definition of Done

  • All ACs check.
  • Predicate registry shared between Behavior Insights and Challenges (no duplicate predicate code).
  • AI disclaimer on every AI-generated challenge surface.
  • Push notification on completion fires reliably (test on iOS device).
  • Badge appears in profile within 1 minute of completion.
  • Free/Pro/Premium tier gating verified end-to-end.
  • withErrorBoundary on every new screen.
  • Feature flag: feature.challenges.enabled.
  • Analytics: challenge_browsed, challenge_joined, challenge_completed, challenge_failed, rest_day_used, badge_awarded, personalized_challenge_generated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions