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.
+
+
+
+
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() {
);
}
```
-## 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.
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}
+
)}
))}
@@ -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.
-
+
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}
)}
+ );
+}
+```
+
## 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.
-
+