From f48abc18bfbf6f4001ee7a772c3441befb530e86 Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Fri, 6 Feb 2026 14:09:39 +0100 Subject: [PATCH 1/8] feat: initial draft of fitness app guide --- guides/how-to-build-a-fitness-app.mdx | 138 ++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 guides/how-to-build-a-fitness-app.mdx diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx new file mode 100644 index 0000000..f73beb5 --- /dev/null +++ b/guides/how-to-build-a-fitness-app.mdx @@ -0,0 +1,138 @@ +--- +title: Building a Fitness Tracker +description: Learn how to build a Strava-like fitness app with Next.js, featuring multi-sport tracking, city-based leaderboards, and normalized XP progression. +subtitle: Build a high-retention fitness app with streaks and leaderboards. +--- + +import MetricChangeRequestBlock from "/snippets/metric-change-request-block.mdx"; +import MetricChangeResponseBlock from "/snippets/metric-change-response-block.mdx"; + +In this tutorial, we'll build **TrophyFitness**, a consumer fitness application that tracks running, cycling, and swimming. We'll implement a complete gamification loop including weekly leaderboards, habit-forming streaks, and a leveled progression system. + +If you want to skip straight to the code, check out the [example repository](https://github.com/trophyso/example-fitness-platform). + + + {/* Placeholder for demo video */} +
+ Demo Video Coming Soon +
+ + +## Table of Contents + +- [Tech Stack](#tech-stack) +- [Prerequisites](#prerequisites) +- [Setup & Installation](#setup--installation) +- [Designing the Data Model](#designing-the-data-model) +- [Logging Activities](#logging-activities) +- [Building Leaderboards](#building-leaderboards) +- [Adding Progression (XP & Levels)](#adding-progression-xp--levels) +- [Streaks & Retention](#streaks--retention) +- [The Result](#the-result) + +## Tech Stack + +- **Framework:** [Next.js 15](https://nextjs.org) (App Router) +- **UI:** [Shadcn/UI](https://ui.shadcn.com) + TailwindCSS +- **Icons:** [Lucide React](https://lucide.dev) +- **Gamification:** [Trophy](https://trophy.so) + +## Prerequisites + +- A Trophy account (sign up [here](https://app.trophy.so/sign-up)). +- Node.js 18+ installed. + +## Setup & Installation + +First, clone the starter repository or create a new Next.js app: + +```bash +npx create-next-app@latest trophy-fitness +``` + +Install the required dependencies: + +```bash +npm install @trophyso/node lucide-react clsx tailwind-merge +``` + +Configure your environment variables in `.env.local`: + +```bash +TROPHY_API_KEY=your_api_key_here +``` + +## Designing the Data Model + +For a multi-sport fitness app, we need to normalize efforts. A 10km cycle is not the same as a 10km run. We'll use three distinct metrics to track raw data, and a unified XP system for progression. + +### 1. The Metrics +We will track distance as the primary value. + +* `distance_run` (km) - with `pace` attribute (walk/run). +* `distance_cycled` (km) +* `distance_swum` (m) - with `style` attribute (freestyle/breaststroke). + +### 2. The Attributes +To enable local leaderboards, we'll tag every user with a `city` attribute. + +## Logging Activities + +We'll create a Server Action to handle activity logging. This ensures our API keys stay secure and allows us to revalidate the UI instantly. + +```tsx src/app/actions.ts +import { TrophyApiClient } from "@trophyso/node"; +import { revalidatePath } from "next/cache"; + +const trophy = new TrophyApiClient({ + apiKey: process.env.TROPHY_API_KEY as string, +}); + +export async function logActivity(userId: string, type: "run" | "cycle" | "swim", distance: number) { + let metricKey = ""; + + switch (type) { + case "run": metricKey = "distance_run"; break; + case "cycle": metricKey = "distance_cycled"; break; + case "swim": metricKey = "distance_swum"; break; + } + + await trophy.metrics.event(metricKey, { + user: { id: userId }, + value: distance, + }); + + revalidatePath("/"); +} +``` + +## Building Leaderboards + +We want users to compete locally. Global leaderboards are great, but "Top Runner in London" is far more engaging than "Top Runner in the World." + +We'll use Trophy's **Breakdown Attributes** to automatically segment users by city. + +```tsx +// Fetch leaderboard for a specific city +const leaderboard = await trophy.leaderboards.get("weekly-distance-run-cities", { + userAttributes: "city:London" +}); +``` + +## Adding Progression (XP & Levels) + +To compare athletes across different sports, we use XP. + +* **Run:** 10 XP / km +* **Cycle:** 3 XP / km +* **Swim:** 10 XP / 100m + +This logic is handled entirely within Trophy's **Points System** configuration, so your code just needs to display the user's level. + +## Streaks & Retention + +Consistency is key. We set up a "Daily Active Streak" that triggers whenever *any* of the three metrics is incremented. + +## The Result + +You now have a fully functional fitness gamification loop! Users can log workouts, compete in local leagues, and level up their global athlete profile. From 47384623aa345662a87c3f2789cbcd4f9e3a0fa8 Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Fri, 6 Feb 2026 14:41:29 +0100 Subject: [PATCH 2/8] docs: polish fitness guide content from heartbeat review --- guides/how-to-build-a-fitness-app.mdx | 156 ++++++++++++++++++++++---- 1 file changed, 135 insertions(+), 21 deletions(-) diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index f73beb5..2ba8b6c 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -24,10 +24,9 @@ If you want to skip straight to the code, check out the [example repository](htt - [Prerequisites](#prerequisites) - [Setup & Installation](#setup--installation) - [Designing the Data Model](#designing-the-data-model) -- [Logging Activities](#logging-activities) -- [Building Leaderboards](#building-leaderboards) -- [Adding Progression (XP & Levels)](#adding-progression-xp--levels) -- [Streaks & Retention](#streaks--retention) +- [Server Actions](#server-actions) +- [Building the Dashboard](#building-the-dashboard) +- [Implementing Leaderboards](#implementing-leaderboards) - [The Result](#the-result) ## Tech Stack @@ -76,11 +75,13 @@ We will track distance as the primary value. ### 2. The Attributes To enable local leaderboards, we'll tag every user with a `city` attribute. -## Logging Activities +## Server Actions -We'll create a Server Action to handle activity logging. This ensures our API keys stay secure and allows us to revalidate the UI instantly. +We'll create a `src/app/actions.ts` file to handle all interactions with the Trophy API. This keeps our API keys secure and allows us to leverage Next.js Server Actions. ```tsx src/app/actions.ts +"use server"; + import { TrophyApiClient } from "@trophyso/node"; import { revalidatePath } from "next/cache"; @@ -88,7 +89,25 @@ const trophy = new TrophyApiClient({ apiKey: process.env.TROPHY_API_KEY as string, }); -export async function logActivity(userId: string, type: "run" | "cycle" | "swim", distance: number) { +// Helper to simulate a logged-in user for this demo +const USER_ID = "user_123"; + +export async function getUserStats() { + try { + const [streak, achievements, points] = await Promise.all([ + trophy.users.streak(USER_ID), + trophy.users.achievements(USER_ID), + trophy.users.points(USER_ID, "xp"), + ]); + + return { streak, achievements, points }; + } catch (error) { + console.error(error); + return null; + } +} + +export async function logActivity(type: "run" | "cycle" | "swim", distance: number) { let metricKey = ""; switch (type) { @@ -98,25 +117,120 @@ export async function logActivity(userId: string, type: "run" | "cycle" | "swim" } await trophy.metrics.event(metricKey, { - user: { id: userId }, + user: { id: USER_ID }, value: distance, }); revalidatePath("/"); } + +export async function getLeaderboard(key: string, city?: string) { + const response = await trophy.leaderboards.get(key, { + userAttributes: city ? `city:${city}` : undefined, + }); + return response.rankings; +} ``` -## Building Leaderboards +## Building the Dashboard -We want users to compete locally. Global leaderboards are great, but "Top Runner in London" is far more engaging than "Top Runner in the World." +The dashboard is the player's home base. It shows their current streak, XP level, and recent activity. -We'll use Trophy's **Breakdown Attributes** to automatically segment users by city. +We'll create a main page that fetches stats server-side and renders them. -```tsx -// Fetch leaderboard for a specific city -const leaderboard = await trophy.leaderboards.get("weekly-distance-run-cities", { - userAttributes: "city:London" -}); +```tsx src/app/page.tsx +import { getUserStats } from "./actions"; +import { Flame, Trophy } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; + +export default async function Dashboard() { + const stats = await getUserStats(); + + const streakLength = stats?.streak?.length ?? 0; + const totalPoints = stats?.points?.total ?? 0; + const maxPoints = stats?.points?.maxPoints ?? 1000; + const progressPercent = (totalPoints / maxPoints) * 100; + + return ( +
+ {/* XP & Level Card */} +
+
+

Level {stats?.points?.level?.name ?? "Rookie"}

+ {totalPoints} XP +
+ +
+ + {/* Streak Card */} +
+
+ +
+
+
{streakLength}
+
Day Streak
+
+
+
+ ); +} +``` + +## Implementing Leaderboards + +Competition drives engagement. We'll build a leaderboard page that lets users toggle between "Global" and "Local" (City) views. + +We'll use a client component to handle the toggling state, calling our server action to fetch data. + +```tsx src/app/leaderboards/page.tsx +"use client"; + +import { useState, useEffect } from "react"; +import { getLeaderboard } from "@/app/actions"; +import { Trophy } from "lucide-react"; + +export default function LeaderboardPage() { + const [scope, setScope] = useState<"global" | "city">("global"); + const [data, setData] = useState([]); + + useEffect(() => { + // In a real app, you'd fetch the user's city dynamically + const city = scope === "city" ? "London" : undefined; + + getLeaderboard("weekly-distance-run", city).then(setData); + }, [scope]); + + return ( +
+
+ + +
+ +
+ {data.map((entry: any, index) => ( +
+
{index + 1}
+
User {entry.userId}
+
{entry.value} km
+ {index < 3 && } +
+ ))} +
+
+ ); +} ``` ## Adding Progression (XP & Levels) @@ -127,12 +241,12 @@ To compare athletes across different sports, we use XP. * **Cycle:** 3 XP / km * **Swim:** 10 XP / 100m -This logic is handled entirely within Trophy's **Points System** configuration, so your code just needs to display the user's level. - -## Streaks & Retention - -Consistency is key. We set up a "Daily Active Streak" that triggers whenever *any* of the three metrics is incremented. +This logic is handled entirely within Trophy's **Points System** configuration, so your code just needs to display the user's level as shown in the Dashboard step above. ## The Result You now have a fully functional fitness gamification loop! Users can log workouts, compete in local leagues, and level up their global athlete profile. + + + Fitness App Demo + From 60d60956b13d35efdfcd378473793c35cb117103 Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Fri, 6 Feb 2026 15:09:46 +0100 Subject: [PATCH 3/8] docs: refine leaderboard key logic in fitness guide --- guides/how-to-build-a-fitness-app.mdx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index 2ba8b6c..57f6f5a 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -4,9 +4,6 @@ description: Learn how to build a Strava-like fitness app with Next.js, featurin subtitle: Build a high-retention fitness app with streaks and leaderboards. --- -import MetricChangeRequestBlock from "/snippets/metric-change-request-block.mdx"; -import MetricChangeResponseBlock from "/snippets/metric-change-response-block.mdx"; - In this tutorial, we'll build **TrophyFitness**, a consumer fitness application that tracks running, cycling, and swimming. We'll implement a complete gamification loop including weekly leaderboards, habit-forming streaks, and a leveled progression system. If you want to skip straight to the code, check out the [example repository](https://github.com/trophyso/example-fitness-platform). @@ -79,7 +76,7 @@ To enable local leaderboards, we'll tag every user with a `city` attribute. We'll create a `src/app/actions.ts` file to handle all interactions with the Trophy API. This keeps our API keys secure and allows us to leverage Next.js Server Actions. -```tsx src/app/actions.ts +```tsx src/app/actions.ts [expandable] "use server"; import { TrophyApiClient } from "@trophyso/node"; @@ -124,7 +121,11 @@ export async function logActivity(type: "run" | "cycle" | "swim", distance: numb revalidatePath("/"); } -export async function getLeaderboard(key: string, city?: string) { +export async function getLeaderboard(metricKey: string, city?: string) { + // Use a different leaderboard key if filtering by city + // e.g. "weekly-distance-run" vs "weekly-distance-run-cities" + const key = city ? `${metricKey}-cities` : metricKey; + const response = await trophy.leaderboards.get(key, { userAttributes: city ? `city:${city}` : undefined, }); @@ -138,7 +139,7 @@ The dashboard is the player's home base. It shows their current streak, XP level We'll create a main page that fetches stats server-side and renders them. -```tsx src/app/page.tsx +```tsx src/app/page.tsx [expandable] import { getUserStats } from "./actions"; import { Flame, Trophy } from "lucide-react"; import { Progress } from "@/components/ui/progress"; @@ -183,7 +184,7 @@ Competition drives engagement. We'll build a leaderboard page that lets users to We'll use a client component to handle the toggling state, calling our server action to fetch data. -```tsx src/app/leaderboards/page.tsx +```tsx src/app/leaderboards/page.tsx [expandable] "use client"; import { useState, useEffect } from "react"; From 6069e8cc02f746392ff1205b5455e29b3c37fca4 Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Fri, 6 Feb 2026 17:29:37 +0100 Subject: [PATCH 4/8] docs: align fitness app guide with source code architecture and XP logic --- guides/how-to-build-a-fitness-app.mdx | 110 +++++++++++++++++--------- 1 file changed, 73 insertions(+), 37 deletions(-) diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index 57f6f5a..4052365 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -86,47 +86,66 @@ const trophy = new TrophyApiClient({ apiKey: process.env.TROPHY_API_KEY as string, }); -// Helper to simulate a logged-in user for this demo -const USER_ID = "user_123"; - -export async function getUserStats() { +export async function getUserStats(userId: string) { try { - const [streak, achievements, points] = await Promise.all([ - trophy.users.streak(USER_ID), - trophy.users.achievements(USER_ID), - trophy.users.points(USER_ID, "xp"), + const [streak, achievements, pointsResponse] = await Promise.all([ + trophy.users.streak(userId).catch(() => null), + trophy.users.achievements(userId, { includeIncomplete: "true" }).catch(() => []), + // Points system handles the normalization of XP across sports + trophy.users.points(userId, "xp").catch(() => null), ]); - return { streak, achievements, points }; + return { streak, achievements, points: pointsResponse }; } catch (error) { - console.error(error); + console.error("Failed to fetch user stats:", error); return null; } } -export async function logActivity(type: "run" | "cycle" | "swim", distance: number) { - let metricKey = ""; +export async function logActivity(params: { + type: "run" | "cycle" | "swim"; + distance: number; + userId: string; + city?: string; + pace?: string; + style?: string; +}) { + const { type, distance, userId, city, pace, style } = params; + let metricKey = ""; + const eventAttributes: Record = {}; + switch (type) { - case "run": metricKey = "distance_run"; break; - case "cycle": metricKey = "distance_cycled"; break; - case "swim": metricKey = "distance_swum"; break; + case "run": + metricKey = "distance_run"; + if (pace) eventAttributes.pace = pace; + break; + case "cycle": + metricKey = "distance_cycled"; + break; + case "swim": + metricKey = "distance_swum"; + if (style) eventAttributes.style = style; + break; } + // Update the user's city attribute while logging the event + // to ensure leaderboards are correctly filtered. await trophy.metrics.event(metricKey, { - user: { id: USER_ID }, + user: { + id: userId, + ...(city ? { attributes: { city } } : {}) + }, value: distance, + ...(Object.keys(eventAttributes).length > 0 ? { attributes: eventAttributes } : {}), }); revalidatePath("/"); + revalidatePath("/leaderboards"); } -export async function getLeaderboard(metricKey: string, city?: string) { - // Use a different leaderboard key if filtering by city - // e.g. "weekly-distance-run" vs "weekly-distance-run-cities" - const key = city ? `${metricKey}-cities` : metricKey; - - const response = await trophy.leaderboards.get(key, { +export async function getLeaderboard(leaderboardKey: string, city?: string) { + const response = await trophy.leaderboards.get(leaderboardKey, { userAttributes: city ? `city:${city}` : undefined, }); return response.rankings; @@ -145,22 +164,31 @@ import { Flame, Trophy } from "lucide-react"; import { Progress } from "@/components/ui/progress"; export default async function Dashboard() { - const stats = await getUserStats(); + // In a real app, you would get the current user ID from your auth provider + const userId = "user_123"; + const stats = await getUserStats(userId); const streakLength = stats?.streak?.length ?? 0; const totalPoints = stats?.points?.total ?? 0; - const maxPoints = stats?.points?.maxPoints ?? 1000; - const progressPercent = (totalPoints / maxPoints) * 100; + + // Trophy Points objects return the current level and the points + // required for the next level automatically. + const currentLevel = stats?.points?.level?.name ?? "Rookie"; + const nextLevelPoints = stats?.points?.nextLevelPoints ?? 1000; + const progressPercent = (totalPoints / nextLevelPoints) * 100; return (
{/* XP & Level Card */}
-

Level {stats?.points?.level?.name ?? "Rookie"}

+

Level {currentLevel}

{totalPoints} XP
+

+ {nextLevelPoints - totalPoints} XP until next level +

{/* Streak Card */} @@ -182,7 +210,7 @@ export default async function Dashboard() { Competition drives engagement. We'll build a leaderboard page that lets users toggle between "Global" and "Local" (City) views. -We'll use a client component to handle the toggling state, calling our server action to fetch data. +Ensure you have configured separate leaderboard keys in the Trophy Dashboard for global and filtered views (e.g., `weekly-run` and `weekly-run-cities`). ```tsx src/app/leaderboards/page.tsx [expandable] "use client"; @@ -196,10 +224,11 @@ export default function LeaderboardPage() { const [data, setData] = useState([]); useEffect(() => { - // In a real app, you'd fetch the user's city dynamically + // Determine the correct leaderboard key based on scope + const key = scope === "city" ? "weekly-run-cities" : "weekly-run"; const city = scope === "city" ? "London" : undefined; - getLeaderboard("weekly-distance-run", city).then(setData); + getLeaderboard(key, city).then(setData); }, [scope]); return ( @@ -207,13 +236,13 @@ export default function LeaderboardPage() {
@@ -221,10 +250,12 @@ export default function LeaderboardPage() {
{data.map((entry: any, index) => ( -
-
{index + 1}
-
User {entry.userId}
-
{entry.value} km
+
+
+ {index + 1} +
+
{entry.userName || `User ${entry.userId.slice(0,5)}`}
+
{entry.value.toLocaleString()} km
{index < 3 && }
))} @@ -236,13 +267,18 @@ export default function LeaderboardPage() { ## Adding Progression (XP & Levels) -To compare athletes across different sports, we use XP. +To compare athletes across different sports, we use a normalized XP system. * **Run:** 10 XP / km * **Cycle:** 3 XP / km * **Swim:** 10 XP / 100m -This logic is handled entirely within Trophy's **Points System** configuration, so your code just needs to display the user's level as shown in the Dashboard step above. +In Trophy, you configure a **Points System** with the key `xp`. You then create **Triggers** for each metric: +1. **Metric:** `distance_run` → **Award:** 10 Points per 1 unit. +2. **Metric:** `distance_cycled` → **Award:** 3 Points per 1 unit. +3. **Metric:** `distance_swum` → **Award:** 0.1 Points per 1 unit (since swimming is tracked in meters). + +Trophy automatically aggregates these into a single `total` for the user and maps them to your defined **Levels** (e.g., Level 1: 0 XP, Level 2: 500 XP). Your frontend code simply reads `stats.points.total` and `stats.points.level.name`. ## The Result From 306682cdfb69f79b52673d4760e56bd5f1189098 Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Fri, 6 Feb 2026 17:32:59 +0100 Subject: [PATCH 5/8] docs: correct swimming XP logic to 10XP per 100m --- guides/how-to-build-a-fitness-app.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index 4052365..fc7c132 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -276,7 +276,7 @@ To compare athletes across different sports, we use a normalized XP system. In Trophy, you configure a **Points System** with the key `xp`. You then create **Triggers** for each metric: 1. **Metric:** `distance_run` → **Award:** 10 Points per 1 unit. 2. **Metric:** `distance_cycled` → **Award:** 3 Points per 1 unit. -3. **Metric:** `distance_swum` → **Award:** 0.1 Points per 1 unit (since swimming is tracked in meters). +3. **Metric:** `distance_swum` → **Award:** 10 Points per 100 units (since swimming is tracked in meters). Trophy automatically aggregates these into a single `total` for the user and maps them to your defined **Levels** (e.g., Level 1: 0 XP, Level 2: 500 XP). Your frontend code simply reads `stats.points.total` and `stats.points.level.name`. From fdd898ba3b4675f7107ab8a637c506f4d18df169 Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Sat, 7 Feb 2026 16:02:19 +0100 Subject: [PATCH 6/8] Update fitness app guide to match example-fitness-platform --- guides/how-to-build-a-fitness-app.mdx | 297 ++++++++++++++++++-------- 1 file changed, 203 insertions(+), 94 deletions(-) diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index fc7c132..fcaaccd 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -22,6 +22,7 @@ If you want to skip straight to the code, check out the [example repository](htt - [Setup & Installation](#setup--installation) - [Designing the Data Model](#designing-the-data-model) - [Server Actions](#server-actions) +- [The Leveling System](#the-leveling-system) - [Building the Dashboard](#building-the-dashboard) - [Implementing Leaderboards](#implementing-leaderboards) - [The Result](#the-result) @@ -79,23 +80,40 @@ We'll create a `src/app/actions.ts` file to handle all interactions with the Tro ```tsx src/app/actions.ts [expandable] "use server"; -import { TrophyApiClient } from "@trophyso/node"; +import { TrophyApiClient, TrophyApi } from "@trophyso/node"; import { revalidatePath } from "next/cache"; const trophy = new TrophyApiClient({ apiKey: process.env.TROPHY_API_KEY as string, }); +export async function identifyUser(userId: string, name?: string, tz?: string) { + try { + const user = await trophy.users.identify(userId, { name, tz }); + return { success: true, user }; + } catch (error) { + return { success: false, error: "Failed to identify user" }; + } +} + export async function getUserStats(userId: string) { try { - const [streak, achievements, pointsResponse] = await Promise.all([ + // Fetch all user data in parallel + const [streak, achievements, metrics] = await Promise.all([ trophy.users.streak(userId).catch(() => null), trophy.users.achievements(userId, { includeIncomplete: "true" }).catch(() => []), - // Points system handles the normalization of XP across sports - trophy.users.points(userId, "xp").catch(() => null), + trophy.users.allMetrics(userId).catch(() => []), ]); - return { streak, achievements, points: pointsResponse }; + // Try to get points (XP) + let pointsResponse = null; + try { + pointsResponse = await trophy.users.points(userId, "xp"); + } catch { + // Points system might not be configured yet + } + + return { streak, achievements, points: pointsResponse, metrics }; } catch (error) { console.error("Failed to fetch user stats:", error); return null; @@ -129,8 +147,7 @@ export async function logActivity(params: { break; } - // Update the user's city attribute while logging the event - // to ensure leaderboards are correctly filtered. + // Log the event await trophy.metrics.event(metricKey, { user: { id: userId, @@ -145,62 +162,159 @@ export async function logActivity(params: { } export async function getLeaderboard(leaderboardKey: string, city?: string) { - const response = await trophy.leaderboards.get(leaderboardKey, { - userAttributes: city ? `city:${city}` : undefined, - }); - return response.rankings; + try { + const response = await trophy.leaderboards.get(leaderboardKey, { + userAttributes: city ? `city:${city}` : undefined, + }); + return response.rankings || []; + } catch (error) { + return []; + } +} + +export async function getRecentActivities(userId: string) { + // Fetch daily summaries for the last 30 days + const endDate = new Date().toISOString().split("T")[0]; + const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + const metrics = ["distance_run", "distance_cycled", "distance_swum"]; + + // Implementation details for fetching metric summaries... + // (See full example code for detailed implementation) + + return []; // Placeholder return +} + +// Helper to get User ID from cookies +export async function getUserIdFromCookies() { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + return cookieStore.get("trophy-fitness-user-id")?.value ?? null; } ``` -## Building the Dashboard +## The Leveling System + +To normalize progress across different sports, we'll map XP to Levels locally. Create `src/lib/constants.ts`: + +```tsx src/lib/constants.ts +export const LEVELS = [ + { level: 1, xpThreshold: 0, name: "Rookie" }, + { level: 2, xpThreshold: 100, name: "Active" }, + { level: 3, xpThreshold: 500, name: "Mover" }, + { level: 4, xpThreshold: 2500, name: "Athlete" }, + { level: 5, xpThreshold: 10000, name: "Pro" }, +] as const; + +export function getLevelInfo(xp: number) { + // Find current level based on XP + let currentLevelIndex = 0; + for (let i = LEVELS.length - 1; i >= 0; i--) { + if (xp >= LEVELS[i].xpThreshold) { + currentLevelIndex = i; + break; + } + } + + const currentLevel = LEVELS[currentLevelIndex]; + const nextLevel = currentLevelIndex < LEVELS.length - 1 ? LEVELS[currentLevelIndex + 1] : null; + + // Calculate progress % + const xpInCurrentLevel = xp - currentLevel.xpThreshold; + const xpRequiredForNextLevel = nextLevel ? nextLevel.xpThreshold - currentLevel.xpThreshold : 0; + const progressToNextLevel = nextLevel ? (xpInCurrentLevel / xpRequiredForNextLevel) * 100 : 100; + + return { currentLevel, nextLevel, progressToNextLevel, xpInCurrentLevel, xpRequiredForNextLevel }; +} +``` -The dashboard is the player's home base. It shows their current streak, XP level, and recent activity. +## Building the Dashboard -We'll create a main page that fetches stats server-side and renders them. +The dashboard aggregates all user stats. We fetch data server-side and calculate the level progress before rendering. ```tsx src/app/page.tsx [expandable] -import { getUserStats } from "./actions"; -import { Flame, Trophy } from "lucide-react"; +import { getUserStats, getUserIdFromCookies, getRecentActivities } from "./actions"; +import { getLevelInfo } from "@/lib/constants"; +import { Zap, Flame, Footprints, Bike, Waves, Trophy, TrendingUp } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; export default async function Dashboard() { - // In a real app, you would get the current user ID from your auth provider - const userId = "user_123"; - const stats = await getUserStats(userId); - + const userId = await getUserIdFromCookies(); + const [stats, recentActivities] = await Promise.all([ + getUserStats(userId ?? ""), + getRecentActivities(userId ?? ""), + ]); + const streakLength = stats?.streak?.length ?? 0; - const totalPoints = stats?.points?.total ?? 0; + const totalXP = stats?.points?.total ?? 0; + const levelInfo = getLevelInfo(totalXP); + + // Helper to get total for a metric key + const getMetricTotal = (key: string) => stats?.metrics?.find(m => m.key === key)?.current ?? 0; - // Trophy Points objects return the current level and the points - // required for the next level automatically. - const currentLevel = stats?.points?.level?.name ?? "Rookie"; - const nextLevelPoints = stats?.points?.nextLevelPoints ?? 1000; - const progressPercent = (totalPoints / nextLevelPoints) * 100; + // Find the next badge to earn + const nextAchievement = stats?.achievements?.find(a => !a.achievedAt); return ( -
- {/* XP & Level Card */} -
-
-

Level {currentLevel}

- {totalPoints} XP +
+ {/* Level & Streak Header */} +
+
+
+
+ +
+
+
Level {levelInfo.currentLevel.level}
+
{levelInfo.currentLevel.name}
+
+
+ +
+ {totalXP} XP + {levelInfo.nextLevel && ( + {levelInfo.xpRequiredForNextLevel - levelInfo.xpInCurrentLevel} XP to {levelInfo.nextLevel.name} + )} +
- -

- {nextLevelPoints - totalPoints} XP until next level -

-
- {/* Streak Card */} -
-
- -
-
-
{streakLength}
-
Day Streak
+
+ 0 ? "text-orange-500" : "text-muted-foreground"}`} /> + {streakLength} + day streak
+ + {/* Stats Grid */} +
+ + + +
{getMetricTotal("distance_run").toFixed(1)}
+
km run
+
+
+ {/* Repeat for Cycle and Swim... */} +
+ + {/* Next Badge Teaser */} + {nextAchievement && ( + + +
+ +
+
+
Next Badge
+

{nextAchievement.name}

+

{nextAchievement.description}

+
+ +
+
+ )}
); } @@ -208,81 +322,76 @@ export default async function Dashboard() { ## Implementing Leaderboards -Competition drives engagement. We'll build a leaderboard page that lets users toggle between "Global" and "Local" (City) views. - -Ensure you have configured separate leaderboard keys in the Trophy Dashboard for global and filtered views (e.g., `weekly-run` and `weekly-run-cities`). +We'll build a tabbed interface that allows users to switch between activities (Run/Cycle/Swim) and scopes (Global vs. Local City). ```tsx src/app/leaderboards/page.tsx [expandable] "use client"; import { useState, useEffect } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getLeaderboard } from "@/app/actions"; -import { Trophy } from "lucide-react"; +import { Trophy, Globe, MapPin } from "lucide-react"; -export default function LeaderboardPage() { +export default function LeaderboardsPage() { const [scope, setScope] = useState<"global" | "city">("global"); + const [activeTab, setActiveTab] = useState("run"); const [data, setData] = useState([]); useEffect(() => { - // Determine the correct leaderboard key based on scope - const key = scope === "city" ? "weekly-run-cities" : "weekly-run"; + // 1. Determine the key based on the active tab and scope + // e.g., "weekly-distance-run" vs "weekly-distance-run-cities" + const baseKey = `weekly-distance-${activeTab}`; + const key = scope === "city" ? `${baseKey}-cities` : baseKey; + + // 2. Determine city parameter (mocked for example) const city = scope === "city" ? "London" : undefined; getLeaderboard(key, city).then(setData); - }, [scope]); + }, [scope, activeTab]); return ( -
-
- - +
+
+

+ Leaderboards +

+ + {/* Scope Toggle */} +
+ + +
-
- {data.map((entry: any, index) => ( -
-
- {index + 1} + + + Run + Cycle + Swim + + + + {data.map((entry, index) => ( +
+
{index + 1}
+
{entry.userName || "Anonymous"}
+
{entry.value} {activeTab === "swim" ? "m" : "km"}
-
{entry.userName || `User ${entry.userId.slice(0,5)}`}
-
{entry.value.toLocaleString()} km
- {index < 3 && } -
- ))} -
+ ))} + +
); } ``` -## Adding Progression (XP & Levels) - -To compare athletes across different sports, we use a normalized XP system. - -* **Run:** 10 XP / km -* **Cycle:** 3 XP / km -* **Swim:** 10 XP / 100m - -In Trophy, you configure a **Points System** with the key `xp`. You then create **Triggers** for each metric: -1. **Metric:** `distance_run` → **Award:** 10 Points per 1 unit. -2. **Metric:** `distance_cycled` → **Award:** 3 Points per 1 unit. -3. **Metric:** `distance_swum` → **Award:** 10 Points per 100 units (since swimming is tracked in meters). - -Trophy automatically aggregates these into a single `total` for the user and maps them to your defined **Levels** (e.g., Level 1: 0 XP, Level 2: 500 XP). Your frontend code simply reads `stats.points.total` and `stats.points.level.name`. - ## The Result -You now have a fully functional fitness gamification loop! Users can log workouts, compete in local leagues, and level up their global athlete profile. +You now have a fully functional fitness gamification loop! Users can log workouts across multiple sports, level up their profile, and compete in local leagues. Fitness App Demo From 2f61d774ea1fe7cb50d625909b35d90117cb49da Mon Sep 17 00:00:00 2001 From: Jason Louro Date: Sat, 7 Feb 2026 16:07:59 +0100 Subject: [PATCH 7/8] tweaks --- .gitignore | 3 +- docs.json | 5 +- guides/how-to-build-a-fitness-app.mdx | 217 +++++++++++++++++++------- package-lock.json | 13 ++ 4 files changed, 183 insertions(+), 55 deletions(-) create mode 100644 package-lock.json diff --git a/.gitignore b/.gitignore index b512c09..28f1ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules \ No newline at end of file +node_modules +.DS_Store \ No newline at end of file diff --git a/docs.json b/docs.json index ef13278..88e63aa 100644 --- a/docs.json +++ b/docs.json @@ -101,7 +101,10 @@ }, { "group": "Use Cases", - "pages": ["guides/gamified-study-platform"] + "pages": [ + "guides/gamified-study-platform", + "guides/how-to-build-a-fitness-app" + ] } ] }, diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index fcaaccd..d2eced0 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -64,13 +64,15 @@ TROPHY_API_KEY=your_api_key_here For a multi-sport fitness app, we need to normalize efforts. A 10km cycle is not the same as a 10km run. We'll use three distinct metrics to track raw data, and a unified XP system for progression. ### 1. The Metrics + We will track distance as the primary value. -* `distance_run` (km) - with `pace` attribute (walk/run). -* `distance_cycled` (km) -* `distance_swum` (m) - with `style` attribute (freestyle/breaststroke). +- `distance_run` (km) - with `pace` attribute (walk/run). +- `distance_cycled` (km) +- `distance_swum` (m) - with `style` attribute (freestyle/breaststroke). ### 2. The Attributes + To enable local leaderboards, we'll tag every user with a `city` attribute. ## Server Actions @@ -101,7 +103,9 @@ export async function getUserStats(userId: string) { // Fetch all user data in parallel const [streak, achievements, metrics] = await Promise.all([ trophy.users.streak(userId).catch(() => null), - trophy.users.achievements(userId, { includeIncomplete: "true" }).catch(() => []), + trophy.users + .achievements(userId, { includeIncomplete: "true" }) + .catch(() => []), trophy.users.allMetrics(userId).catch(() => []), ]); @@ -129,32 +133,34 @@ export async function logActivity(params: { style?: string; }) { const { type, distance, userId, city, pace, style } = params; - + let metricKey = ""; const eventAttributes: Record = {}; switch (type) { - case "run": - metricKey = "distance_run"; + case "run": + metricKey = "distance_run"; if (pace) eventAttributes.pace = pace; break; - case "cycle": - metricKey = "distance_cycled"; + case "cycle": + metricKey = "distance_cycled"; break; - case "swim": - metricKey = "distance_swum"; + case "swim": + metricKey = "distance_swum"; if (style) eventAttributes.style = style; break; } // Log the event await trophy.metrics.event(metricKey, { - user: { + user: { id: userId, - ...(city ? { attributes: { city } } : {}) + ...(city ? { attributes: { city } } : {}), }, value: distance, - ...(Object.keys(eventAttributes).length > 0 ? { attributes: eventAttributes } : {}), + ...(Object.keys(eventAttributes).length > 0 + ? { attributes: eventAttributes } + : {}), }); revalidatePath("/"); @@ -173,16 +179,59 @@ export async function getLeaderboard(leaderboardKey: string, city?: string) { } export async function getRecentActivities(userId: string) { - // Fetch daily summaries for the last 30 days - const endDate = new Date().toISOString().split("T")[0]; - const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; - - const metrics = ["distance_run", "distance_cycled", "distance_swum"]; - - // Implementation details for fetching metric summaries... - // (See full example code for detailed implementation) - - return []; // Placeholder return + // Get date range for last 30 days + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 30); + + const formatDate = (d: Date) => d.toISOString().split("T")[0]; + + const metrics = [ + { key: "distance_run", type: "run" as const, unit: "km" }, + { key: "distance_cycled", type: "cycle" as const, unit: "km" }, + { key: "distance_swum", type: "swim" as const, unit: "m" }, + ]; + + try { + // Fetch daily summaries for all metrics in parallel + const summaries = await Promise.all( + metrics.map(async (metric) => { + try { + const data = await trophy.users.metricEventSummary( + userId, + metric.key, + { + aggregation: "daily", + startDate: formatDate(startDate), + endDate: formatDate(endDate), + }, + ); + return data + .filter((item) => item.change > 0) + .map((item) => ({ + id: `${metric.key}-${item.date}`, + type: metric.type, + value: item.change, + unit: metric.unit, + date: item.date, + })); + } catch { + return []; + } + }), + ); + + // Flatten and sort by date (most recent first) + const allActivities = summaries.flat().sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + + // Return the most recent 5 activities + return allActivities.slice(0, 5); + } catch (error) { + console.error("Failed to fetch recent activities:", error); + return []; + } } // Helper to get User ID from cookies @@ -217,14 +266,27 @@ export function getLevelInfo(xp: number) { } const currentLevel = LEVELS[currentLevelIndex]; - const nextLevel = currentLevelIndex < LEVELS.length - 1 ? LEVELS[currentLevelIndex + 1] : null; + const nextLevel = + currentLevelIndex < LEVELS.length - 1 + ? LEVELS[currentLevelIndex + 1] + : null; // Calculate progress % const xpInCurrentLevel = xp - currentLevel.xpThreshold; - const xpRequiredForNextLevel = nextLevel ? nextLevel.xpThreshold - currentLevel.xpThreshold : 0; - const progressToNextLevel = nextLevel ? (xpInCurrentLevel / xpRequiredForNextLevel) * 100 : 100; - - return { currentLevel, nextLevel, progressToNextLevel, xpInCurrentLevel, xpRequiredForNextLevel }; + const xpRequiredForNextLevel = nextLevel + ? nextLevel.xpThreshold - currentLevel.xpThreshold + : 0; + const progressToNextLevel = nextLevel + ? (xpInCurrentLevel / xpRequiredForNextLevel) * 100 + : 100; + + return { + currentLevel, + nextLevel, + progressToNextLevel, + xpInCurrentLevel, + xpRequiredForNextLevel, + }; } ``` @@ -233,9 +295,21 @@ export function getLevelInfo(xp: number) { The dashboard aggregates all user stats. We fetch data server-side and calculate the level progress before rendering. ```tsx src/app/page.tsx [expandable] -import { getUserStats, getUserIdFromCookies, getRecentActivities } from "./actions"; +import { + getUserStats, + getUserIdFromCookies, + getRecentActivities, +} from "./actions"; import { getLevelInfo } from "@/lib/constants"; -import { Zap, Flame, Footprints, Bike, Waves, Trophy, TrendingUp } from "lucide-react"; +import { + Zap, + Flame, + Footprints, + Bike, + Waves, + Trophy, + TrendingUp, +} from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; @@ -250,12 +324,13 @@ export default async function Dashboard() { const streakLength = stats?.streak?.length ?? 0; const totalXP = stats?.points?.total ?? 0; const levelInfo = getLevelInfo(totalXP); - + // Helper to get total for a metric key - const getMetricTotal = (key: string) => stats?.metrics?.find(m => m.key === key)?.current ?? 0; - + const getMetricTotal = (key: string) => + stats?.metrics?.find((m) => m.key === key)?.current ?? 0; + // Find the next badge to earn - const nextAchievement = stats?.achievements?.find(a => !a.achievedAt); + const nextAchievement = stats?.achievements?.find((a) => !a.achievedAt); return (
@@ -267,23 +342,36 @@ export default async function Dashboard() {
-
Level {levelInfo.currentLevel.level}
-
{levelInfo.currentLevel.name}
+
+ Level {levelInfo.currentLevel.level} +
+
+ {levelInfo.currentLevel.name} +
{totalXP} XP {levelInfo.nextLevel && ( - {levelInfo.xpRequiredForNextLevel - levelInfo.xpInCurrentLevel} XP to {levelInfo.nextLevel.name} + + {levelInfo.xpRequiredForNextLevel - levelInfo.xpInCurrentLevel}{" "} + XP to {levelInfo.nextLevel.name} + )}
- 0 ? "text-orange-500" : "text-muted-foreground"}`} /> - {streakLength} - day streak + 0 ? "text-orange-500" : "text-muted-foreground"}`} + /> + + {streakLength} + + + day streak +
@@ -292,7 +380,9 @@ export default async function Dashboard() { -
{getMetricTotal("distance_run").toFixed(1)}
+
+ {getMetricTotal("distance_run").toFixed(1)} +
km run
@@ -307,9 +397,13 @@ export default async function Dashboard() {
-
Next Badge
+
+ Next Badge +

{nextAchievement.name}

-

{nextAchievement.description}

+

+ {nextAchievement.description} +

@@ -342,10 +436,10 @@ export default function LeaderboardsPage() { // e.g., "weekly-distance-run" vs "weekly-distance-run-cities" const baseKey = `weekly-distance-${activeTab}`; const key = scope === "city" ? `${baseKey}-cities` : baseKey; - + // 2. Determine city parameter (mocked for example) const city = scope === "city" ? "London" : undefined; - + getLeaderboard(key, city).then(setData); }, [scope, activeTab]); @@ -355,13 +449,19 @@ export default function LeaderboardsPage() {

Leaderboards

- + {/* Scope Toggle */}
- -
@@ -376,10 +476,17 @@ export default function LeaderboardsPage() { {data.map((entry, index) => ( -
+
{index + 1}
-
{entry.userName || "Anonymous"}
-
{entry.value} {activeTab === "swim" ? "m" : "km"}
+
+ {entry.userName || "Anonymous"} +
+
+ {entry.value} {activeTab === "swim" ? "m" : "km"} +
))} @@ -394,5 +501,9 @@ export default function LeaderboardsPage() { You now have a fully functional fitness gamification loop! Users can log workouts across multiple sports, level up their profile, and compete in local leagues. - Fitness App Demo + Fitness App Demo diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8992e2d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docs", + "version": "1.0.0", + "license": "ISC" + } + } +} From b76f6ec267fd5055009e6a7a99e7c56bf45177ab Mon Sep 17 00:00:00 2001 From: Big Keith <> Date: Sat, 7 Feb 2026 16:12:03 +0100 Subject: [PATCH 8/8] Add Achievements & Profile pages to fitness app guide --- guides/how-to-build-a-fitness-app.mdx | 355 +++++++++++++++----------- 1 file changed, 200 insertions(+), 155 deletions(-) diff --git a/guides/how-to-build-a-fitness-app.mdx b/guides/how-to-build-a-fitness-app.mdx index d2eced0..fef8f71 100644 --- a/guides/how-to-build-a-fitness-app.mdx +++ b/guides/how-to-build-a-fitness-app.mdx @@ -25,6 +25,8 @@ If you want to skip straight to the code, check out the [example repository](htt - [The Leveling System](#the-leveling-system) - [Building the Dashboard](#building-the-dashboard) - [Implementing Leaderboards](#implementing-leaderboards) +- [Building the Achievements Page](#building-the-achievements-page) +- [Building the Profile Page](#building-the-profile-page) - [The Result](#the-result) ## Tech Stack @@ -64,15 +66,13 @@ TROPHY_API_KEY=your_api_key_here For a multi-sport fitness app, we need to normalize efforts. A 10km cycle is not the same as a 10km run. We'll use three distinct metrics to track raw data, and a unified XP system for progression. ### 1. The Metrics - We will track distance as the primary value. -- `distance_run` (km) - with `pace` attribute (walk/run). -- `distance_cycled` (km) -- `distance_swum` (m) - with `style` attribute (freestyle/breaststroke). +* `distance_run` (km) - with `pace` attribute (walk/run). +* `distance_cycled` (km) +* `distance_swum` (m) - with `style` attribute (freestyle/breaststroke). ### 2. The Attributes - To enable local leaderboards, we'll tag every user with a `city` attribute. ## Server Actions @@ -98,14 +98,23 @@ export async function identifyUser(userId: string, name?: string, tz?: string) { } } +export async function updateUserCity(userId: string, city: string) { + try { + await trophy.users.update(userId, { attributes: { city } }); + revalidatePath("/leaderboards"); + revalidatePath("/profile"); + return { success: true }; + } catch (error) { + return { success: false, error: "Failed to update city" }; + } +} + export async function getUserStats(userId: string) { try { // Fetch all user data in parallel const [streak, achievements, metrics] = await Promise.all([ trophy.users.streak(userId).catch(() => null), - trophy.users - .achievements(userId, { includeIncomplete: "true" }) - .catch(() => []), + trophy.users.achievements(userId, { includeIncomplete: "true" }).catch(() => []), trophy.users.allMetrics(userId).catch(() => []), ]); @@ -133,34 +142,32 @@ export async function logActivity(params: { style?: string; }) { const { type, distance, userId, city, pace, style } = params; - + let metricKey = ""; const eventAttributes: Record = {}; switch (type) { - case "run": - metricKey = "distance_run"; + case "run": + metricKey = "distance_run"; if (pace) eventAttributes.pace = pace; break; - case "cycle": - metricKey = "distance_cycled"; + case "cycle": + metricKey = "distance_cycled"; break; - case "swim": - metricKey = "distance_swum"; + case "swim": + metricKey = "distance_swum"; if (style) eventAttributes.style = style; break; } // Log the event await trophy.metrics.event(metricKey, { - user: { + user: { id: userId, - ...(city ? { attributes: { city } } : {}), + ...(city ? { attributes: { city } } : {}) }, value: distance, - ...(Object.keys(eventAttributes).length > 0 - ? { attributes: eventAttributes } - : {}), + ...(Object.keys(eventAttributes).length > 0 ? { attributes: eventAttributes } : {}), }); revalidatePath("/"); @@ -179,57 +186,38 @@ export async function getLeaderboard(leaderboardKey: string, city?: string) { } export async function getRecentActivities(userId: string) { - // Get date range for last 30 days - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - 30); - - const formatDate = (d: Date) => d.toISOString().split("T")[0]; + // Fetch daily summaries for the last 30 days + const endDate = new Date().toISOString().split("T")[0]; + const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const metrics = [ - { key: "distance_run", type: "run" as const, unit: "km" }, - { key: "distance_cycled", type: "cycle" as const, unit: "km" }, - { key: "distance_swum", type: "swim" as const, unit: "m" }, + { key: "distance_run", type: "run", unit: "km" }, + { key: "distance_cycled", type: "cycle", unit: "km" }, + { key: "distance_swum", type: "swim", unit: "m" }, ]; - + try { - // Fetch daily summaries for all metrics in parallel const summaries = await Promise.all( metrics.map(async (metric) => { try { - const data = await trophy.users.metricEventSummary( - userId, - metric.key, - { - aggregation: "daily", - startDate: formatDate(startDate), - endDate: formatDate(endDate), - }, - ); - return data - .filter((item) => item.change > 0) - .map((item) => ({ - id: `${metric.key}-${item.date}`, - type: metric.type, - value: item.change, - unit: metric.unit, - date: item.date, - })); - } catch { - return []; - } - }), + const data = await trophy.users.metricEventSummary(userId, metric.key, { + aggregation: "daily", + startDate, + endDate, + }); + return data.filter(item => item.change > 0).map(item => ({ + id: `${metric.key}-${item.date}`, + type: metric.type, + value: item.change, + unit: metric.unit, + date: item.date, + })); + } catch { return []; } + }) ); - - // Flatten and sort by date (most recent first) - const allActivities = summaries.flat().sort((a, b) => { - return new Date(b.date).getTime() - new Date(a.date).getTime(); - }); - - // Return the most recent 5 activities - return allActivities.slice(0, 5); - } catch (error) { - console.error("Failed to fetch recent activities:", error); + + return summaries.flat().sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); + } catch { return []; } } @@ -266,27 +254,14 @@ export function getLevelInfo(xp: number) { } const currentLevel = LEVELS[currentLevelIndex]; - const nextLevel = - currentLevelIndex < LEVELS.length - 1 - ? LEVELS[currentLevelIndex + 1] - : null; + const nextLevel = currentLevelIndex < LEVELS.length - 1 ? LEVELS[currentLevelIndex + 1] : null; // Calculate progress % const xpInCurrentLevel = xp - currentLevel.xpThreshold; - const xpRequiredForNextLevel = nextLevel - ? nextLevel.xpThreshold - currentLevel.xpThreshold - : 0; - const progressToNextLevel = nextLevel - ? (xpInCurrentLevel / xpRequiredForNextLevel) * 100 - : 100; - - return { - currentLevel, - nextLevel, - progressToNextLevel, - xpInCurrentLevel, - xpRequiredForNextLevel, - }; + const xpRequiredForNextLevel = nextLevel ? nextLevel.xpThreshold - currentLevel.xpThreshold : 0; + const progressToNextLevel = nextLevel ? (xpInCurrentLevel / xpRequiredForNextLevel) * 100 : 100; + + return { currentLevel, nextLevel, progressToNextLevel, xpInCurrentLevel, xpRequiredForNextLevel }; } ``` @@ -295,21 +270,9 @@ export function getLevelInfo(xp: number) { The dashboard aggregates all user stats. We fetch data server-side and calculate the level progress before rendering. ```tsx src/app/page.tsx [expandable] -import { - getUserStats, - getUserIdFromCookies, - getRecentActivities, -} from "./actions"; +import { getUserStats, getUserIdFromCookies, getRecentActivities } from "./actions"; import { getLevelInfo } from "@/lib/constants"; -import { - Zap, - Flame, - Footprints, - Bike, - Waves, - Trophy, - TrendingUp, -} from "lucide-react"; +import { Zap, Flame, Footprints, Bike, Waves, Trophy, TrendingUp } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; @@ -324,13 +287,12 @@ export default async function Dashboard() { const streakLength = stats?.streak?.length ?? 0; const totalXP = stats?.points?.total ?? 0; const levelInfo = getLevelInfo(totalXP); - + // Helper to get total for a metric key - const getMetricTotal = (key: string) => - stats?.metrics?.find((m) => m.key === key)?.current ?? 0; - + const getMetricTotal = (key: string) => stats?.metrics?.find(m => m.key === key)?.current ?? 0; + // Find the next badge to earn - const nextAchievement = stats?.achievements?.find((a) => !a.achievedAt); + const nextAchievement = stats?.achievements?.find(a => !a.achievedAt); return (
@@ -342,36 +304,23 @@ export default async function Dashboard() {
-
- Level {levelInfo.currentLevel.level} -
-
- {levelInfo.currentLevel.name} -
+
Level {levelInfo.currentLevel.level}
+
{levelInfo.currentLevel.name}
{totalXP} XP {levelInfo.nextLevel && ( - - {levelInfo.xpRequiredForNextLevel - levelInfo.xpInCurrentLevel}{" "} - XP to {levelInfo.nextLevel.name} - + {levelInfo.xpRequiredForNextLevel - levelInfo.xpInCurrentLevel} XP to {levelInfo.nextLevel.name} )}
- 0 ? "text-orange-500" : "text-muted-foreground"}`} - /> - - {streakLength} - - - day streak - + 0 ? "text-orange-500" : "text-muted-foreground"}`} /> + {streakLength} + day streak
@@ -380,9 +329,7 @@ export default async function Dashboard() { -
- {getMetricTotal("distance_run").toFixed(1)} -
+
{getMetricTotal("distance_run").toFixed(1)}
km run
@@ -397,13 +344,9 @@ export default async function Dashboard() {
-
- Next Badge -
+
Next Badge

{nextAchievement.name}

-

- {nextAchievement.description} -

+

{nextAchievement.description}

@@ -436,10 +379,10 @@ export default function LeaderboardsPage() { // e.g., "weekly-distance-run" vs "weekly-distance-run-cities" const baseKey = `weekly-distance-${activeTab}`; const key = scope === "city" ? `${baseKey}-cities` : baseKey; - + // 2. Determine city parameter (mocked for example) const city = scope === "city" ? "London" : undefined; - + getLeaderboard(key, city).then(setData); }, [scope, activeTab]); @@ -449,19 +392,13 @@ export default function LeaderboardsPage() {

Leaderboards

- + {/* Scope Toggle */}
- -
@@ -476,17 +413,10 @@ export default function LeaderboardsPage() { {data.map((entry, index) => ( -
+
{index + 1}
-
- {entry.userName || "Anonymous"} -
-
- {entry.value} {activeTab === "swim" ? "m" : "km"} -
+
{entry.userName || "Anonymous"}
+
{entry.value} {activeTab === "swim" ? "m" : "km"}
))} @@ -496,14 +426,129 @@ export default function LeaderboardsPage() { } ``` +## Building the Achievements Page + +A dedicated space to show off badges is essential for long-term retention. We'll use the `stats.achievements` data to render a grid of badges, visually distinguishing between earned (colorful) and locked (grayscale) states. + +```tsx src/app/achievements/page.tsx [expandable] +import { getUserStats, getUserIdFromCookies } from "@/app/actions"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent } from "@/components/ui/card"; +import { Award, Lock } from "lucide-react"; + +export default async function AchievementsPage() { + const userId = await getUserIdFromCookies(); + const stats = await getUserStats(userId ?? ""); + + // Helper to split achievements + const earned = stats?.achievements?.filter(a => a.achievedAt) ?? []; + const locked = stats?.achievements?.filter(a => !a.achievedAt) ?? []; + + const AchievementCard = ({ achievement, isEarned }) => ( + + +
+ {isEarned ? : } +
+
{achievement.name}
+
{achievement.description}
+
+
+ ); + + return ( +
+

+ Achievements +

+ + + + All + Earned + Locked + + + + {[...earned, ...locked].map(a => ( + + ))} + + {/* Repeat grid for "earned" and "locked" tabs... */} + +
+ ); +} +``` + +## Building the Profile Page + +Finally, the profile page brings it all together. It shows the user's "Lifetime Stats," their current XP progression, and allows them to update their settings (like their city). + +```tsx src/app/profile/page.tsx [expandable] +import { getUserStats, getUserIdFromCookies } from "@/app/actions"; +import { getLevelInfo } from "@/lib/constants"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Card, CardContent } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { CitySetting } from "@/components/city-setting"; + +export default async function ProfilePage() { + const userId = await getUserIdFromCookies(); + const stats = await getUserStats(userId ?? ""); + const levelInfo = getLevelInfo(stats?.points?.total ?? 0); + + // Helper to safely get metric totals + const getTotal = (key: string) => stats?.metrics?.find(m => m.key === key)?.current ?? 0; + + return ( +
+ {/* Header */} +
+ + ME + +

Athlete Profile

+
{levelInfo.currentLevel.name}
+
+ + {/* Progress Card */} + + +
+ Experience Points + {stats?.points?.total ?? 0} XP +
+ +
+
+ + {/* Lifetime Stats */} +
+ + +
{getTotal("distance_run").toFixed(1)}
+
km run
+
+
+ {/* Repeat for other sports... */} +
+ + {/* Settings Component (Client Component) */} + + + + + +
+ ); +} +``` + ## The Result -You now have a fully functional fitness gamification loop! Users can log workouts across multiple sports, level up their profile, and compete in local leagues. +You now have a fully functional fitness gamification loop! Users can log workouts across multiple sports, level up their profile, earn badges, and compete in local leagues. - Fitness App Demo + Fitness App Demo