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
new file mode 100644
index 0000000..fef8f71
--- /dev/null
+++ b/guides/how-to-build-a-fitness-app.mdx
@@ -0,0 +1,554 @@
+---
+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.
+---
+
+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)
+- [Server Actions](#server-actions)
+- [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
+
+- **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.
+
+## Server Actions
+
+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 [expandable]
+"use server";
+
+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 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.allMetrics(userId).catch(() => []),
+ ]);
+
+ // 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;
+ }
+}
+
+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";
+ if (pace) eventAttributes.pace = pace;
+ break;
+ case "cycle":
+ metricKey = "distance_cycled";
+ break;
+ case "swim":
+ metricKey = "distance_swum";
+ if (style) eventAttributes.style = style;
+ break;
+ }
+
+ // Log the event
+ await trophy.metrics.event(metricKey, {
+ user: {
+ id: userId,
+ ...(city ? { attributes: { city } } : {})
+ },
+ value: distance,
+ ...(Object.keys(eventAttributes).length > 0 ? { attributes: eventAttributes } : {}),
+ });
+
+ revalidatePath("/");
+ revalidatePath("/leaderboards");
+}
+
+export async function getLeaderboard(leaderboardKey: string, city?: string) {
+ 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 = [
+ { key: "distance_run", type: "run", unit: "km" },
+ { key: "distance_cycled", type: "cycle", unit: "km" },
+ { key: "distance_swum", type: "swim", unit: "m" },
+ ];
+
+ try {
+ const summaries = await Promise.all(
+ metrics.map(async (metric) => {
+ try {
+ 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 []; }
+ })
+ );
+
+ return summaries.flat().sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
+ } catch {
+ 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;
+}
+```
+
+## 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 };
+}
+```
+
+## Building the Dashboard
+
+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 { 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() {
+ const userId = await getUserIdFromCookies();
+ const [stats, recentActivities] = await Promise.all([
+ getUserStats(userId ?? ""),
+ getRecentActivities(userId ?? ""),
+ ]);
+
+ 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;
+
+ // Find the next badge to earn
+ const nextAchievement = stats?.achievements?.find(a => !a.achievedAt);
+
+ return (
+
+ {/* Level & Streak Header */}
+
+
+
+
+
+
+
+
Level {levelInfo.currentLevel.level}
+
{levelInfo.currentLevel.name}
+
+
+
+
+ {totalXP} XP
+ {levelInfo.nextLevel && (
+ {levelInfo.xpRequiredForNextLevel - levelInfo.xpInCurrentLevel} XP to {levelInfo.nextLevel.name}
+ )}
+