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} + )} +
+
+ +
+ 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}

+
+ +
+
+ )} +
+ ); +} +``` + +## Implementing Leaderboards + +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, Globe, MapPin } from "lucide-react"; + +export default function LeaderboardsPage() { + const [scope, setScope] = useState<"global" | "city">("global"); + const [activeTab, setActiveTab] = useState("run"); + const [data, setData] = useState([]); + + useEffect(() => { + // 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, activeTab]); + + return ( +
+
+

+ Leaderboards +

+ + {/* Scope Toggle */} +
+ + +
+
+ + + + Run + Cycle + Swim + + + + {data.map((entry, index) => ( +
+
{index + 1}
+
{entry.userName || "Anonymous"}
+
{entry.value} {activeTab === "swim" ? "m" : "km"}
+
+ ))} +
+
+
+ ); +} +``` + +## 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, earn badges, and compete in local leagues. + + + 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" + } + } +}