diff --git a/architecture_review.md b/architecture_review.md index 8e38860..7a84a74 100644 --- a/architecture_review.md +++ b/architecture_review.md @@ -330,6 +330,13 @@ There is no complex Role-Based Access Control (RBAC) yet. Authorization is handl Based on our architectural review and brainstorming session, the following frontend-heavy gamification features are approved for the next development cycle before the Vercel deployment: +### 0. UI/UX Pro Max Overhaul (Modern Athletic) [DONE ✅ ] + +A full transition from a generic SaaS look to a high-contrast, gamified athletic aesthetic. +- **Design Tokens:** Strict semantic CSS variables (`bg-background`, `bg-card`) ensuring perfect light/dark mode parity and OLED blacks. +- **Typography:** `next/font/google` optimized loading of **Barlow Condensed** (HUD headers/numbers) and **Barlow** (body). +- **Physical Interactions:** Replaced ad-hoc hover states with a globally consistent `.active-press` utility providing tactile, spring-physics scaling (200ms) on all interactable elements. + ### 1. Interactive Human Anatomy UI [INITIALLY DONE ✅ ] A visual representation of the human muscular system mapping to the `TargetMuscle` enum (`Chest`, `Back`, `Legs`, etc.). It will be implemented across 3 contexts: diff --git a/docs/superpowers/specs/2026-07-31-ui-overhaul-design.md b/docs/superpowers/specs/2026-07-31-ui-overhaul-design.md new file mode 100644 index 0000000..d56c053 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-ui-overhaul-design.md @@ -0,0 +1,41 @@ +## Problem Statement + +The current front-end UI for Fit Level Up relies heavily on generic "vibe-coded" Tailwind utilities (like slow, floaty `hover-lift` animations and generic glass panels). It lacks a unique, cohesive identity that balances the "gaming/RPG" feel with a premium fitness tracking experience. The typography and component physics feel disjointed from the core product vision. + +## Solution + +We will implement a "Modern Athletic" design overhaul. This replaces generic SaaS styles with a high-contrast, tactile, and gamified aesthetic. We will switch to OLED blacks, tight layout density, structured typography (Barlow Condensed + Barlow), and native-feeling spring-physics for interactions (active press states instead of floaty hovers). + +## User Stories + +1. As a fitness user, I want the app to feel snappy and responsive to my touch, so that navigating my workout stats feels like using a premium piece of hardware or high-end game. +2. As a user tracking my fitness streaks, I want the data to look intense and athletic, so that I feel motivated to push my limits. +3. As a developer, I want a cohesive set of Tailwind variables and utilities, so that I stop writing "vibe-code" and can reliably build new pages that look on-brand. + +## Implementation Decisions + +- **Art Direction:** "Modern Athletic" (Apple Fitness + Gaming). +- **Typography:** Barlow Condensed (for display/stats) and Barlow (for body text). +- **Colors:** OLED Black background (`#000000`). Flat dark surfaces for cards (`#111111`) with sharp 1px borders (`border-white/10`). Lime Green (`#d1ff1a`) remains the primary accent. +- **Motion:** Replace `hover-lift` (translate Y and shadow over 300ms) with an `active-press` utility (`scale-95` and border highlight over 100ms) to mimic physical buttons. +- **Density:** Tighten spacing arrays globally to give the app a more data-rich, HUD-like feel. +- **Modules modified:** + - `src/app/globals.css` (Base tokens and utilities) + - `src/components/ui/*` (Core primitives like Button and Card) + - `src/app/(landing)/page.tsx` (Homepage facelift) + +## Testing Decisions + +- A visual QA pass is required across all modified components. +- The UI must remain fully accessible (contrast ratios of text against the new flat dark backgrounds). +- Standard unit tests (`vitest`) should not be affected since this is a pure CSS/component styling refactor. + +## Out of Scope + +- Refactoring the entire routing or data-fetching logic. +- Adding new pages or features (only restyling existing ones). +- Changing the primary brand logo. + +## Further Notes + +This spec establishes the new visual baseline. Future features will inherit these tight, high-contrast primitives. diff --git a/project_briefing.md b/project_briefing.md index eb819c7..c8aa1a6 100644 --- a/project_briefing.md +++ b/project_briefing.md @@ -187,6 +187,10 @@ POST /api/workouts - Custom exercises (create, reuse across workouts) - Full CRUD for workouts and runs (create, list with pagination, edit, delete) - Profile page with personal records +- **UI & UX Overhaul (Modern Athletic Style):** + - Transitioned from generic SaaS design to a high-contrast, gamified athletic HUD aesthetic. + - Implemented strict CSS token hierarchy (OLED blacks, vibrant accents) and globally optimized fonts (Barlow Condensed / Barlow). + - Adopted tactile `.active-press` spring-physics animations for all interactive surfaces. - **Interactive Human Anatomy UI:** - 7-Day Heatmap (Dashboard widget) to visualize trained vs neglected muscles - Live Session "Pump" Tracker (Workout Form) with dynamic SVG highlighting diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 175b23f..3397c2b 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -32,11 +32,8 @@ export default async function DashboardPage() { const dates = allWorkouts.map(w => w.date); const activeDays = calcActiveDays(dates); - // We already have the formatted quests from your service! - const quests = allQuests.slice(0, 5); - - // 3. Filter and prepare data for the specific components - const dailyQuests = quests.filter(q => q.category === "daily"); + // Filter for daily quests first, then limit to top 5 + const dailyQuests = allQuests.filter(q => q.category === "daily").slice(0, 5); const workoutDiff = stats.weeklyWorkouts - stats.lastWeekWorkouts; let workoutTrendPercent = 0; diff --git a/src/app/(app)/profile/page.tsx b/src/app/(app)/profile/page.tsx index 0eee8cf..771383c 100644 --- a/src/app/(app)/profile/page.tsx +++ b/src/app/(app)/profile/page.tsx @@ -29,11 +29,11 @@ export default async function ProfilePage() {
-
+
diff --git a/src/app/(app)/workouts/page.tsx b/src/app/(app)/workouts/page.tsx index d258d39..1b5db45 100644 --- a/src/app/(app)/workouts/page.tsx +++ b/src/app/(app)/workouts/page.tsx @@ -55,8 +55,6 @@ export default function WorkoutsPage() { />
-

Workout History

- {isLoading &&

Loading...

} {isError &&

Could not load workouts

} diff --git a/src/app/(landing)/page.tsx b/src/app/(landing)/page.tsx index b8e6671..fce7d9f 100644 --- a/src/app/(landing)/page.tsx +++ b/src/app/(landing)/page.tsx @@ -1,65 +1,65 @@ import Link from "next/link"; import { Button } from "@/components/ui/Button"; -import { Zap, Flame, Trophy, LineChart } from "lucide-react"; +import { Flame, Trophy, LineChart } from "lucide-react"; export default function LandingPage() { return ( -
- {/* Subtle glow instead of loud radial gradient for OLED contrast */} -
+
+ {/* Extremely subtle glow for OLED contrast */} +
-
+
-
-
+
YOUR FITNESS JOURNEY, REIMAGINED
-

+

Level Up
Your Fitness

-

- Gamify your workouts, earn XP, complete daily quests, and unlock epic achievements. Your fitness journey is now an RPG. +

+ Gamify your workouts. Earn XP. Complete daily quests. +
Your fitness journey is now an RPG.

-
+
- -
-
-
-
- +
+
+
+
-

Build Streaks

-

Stay consistent and watch your streak multiplier grow effortlessly.

+

Build Streaks

+

Stay consistent and watch your streak multiplier grow effortlessly.

-
-
- +
+
+
-

Unlock Badges

-

Earn rare achievements for pushing your limits and reaching milestones.

+

Unlock Badges

+

Earn rare achievements for pushing your limits and reaching milestones.

-
-
- +
+
+
-

Track Progress

-

Watch your stats improve over time with highly detailed analytics.

+

Track Progress

+

Watch your stats improve over time with highly detailed analytics.

diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..69f4f02 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1 @@ +// implement api calls for health checks diff --git a/src/app/api/runs/route.ts b/src/app/api/runs/route.ts index 7b28a74..3c45c37 100644 --- a/src/app/api/runs/route.ts +++ b/src/app/api/runs/route.ts @@ -8,43 +8,52 @@ import { RateLimit } from "@/lib/auth/rate-limit"; import { handleApiError } from "@/lib/api/handle-api-error"; export async function GET(request: Request) { - try { - const userId = await getAuthUserId(); - const { searchParams } = new URL(request.url); - let limit = searchParams.has("limit") ? parseInt(searchParams.get("limit")!) : 5; - if (isNaN(limit) || limit < 1) limit = 5; - if (limit > 50) limit = 50; - const page = searchParams.has("page") ? parseInt(searchParams.get("page")!) : 1; - const skip = (page - 1) * limit; + try { + const userId = await getAuthUserId(); + const { searchParams } = new URL(request.url); + let limit = searchParams.has("limit") + ? parseInt(searchParams.get("limit")!) + : 5; + if (isNaN(limit) || limit < 1) limit = 5; + //TODO: turn into constant . avoid magic numbers + if (limit > 50) limit = 50; + const page = searchParams.has("page") + ? parseInt(searchParams.get("page")!) + : 1; + const skip = (page - 1) * limit; - const { data, totalCount } = await getPaginatedRunsFromDb(userId, limit, skip); - const totalPages = Math.ceil(totalCount / limit); - - return NextResponse.json({ - data, - totalCount, - totalPages, - currentPage: page - }); - } catch (err) { - return handleApiError(err); - } + const { data, totalCount } = await getPaginatedRunsFromDb( + userId, + limit, + skip, + ); + const totalPages = Math.ceil(totalCount / limit); + + return NextResponse.json({ + data, + totalCount, + totalPages, + currentPage: page, + }); + } catch (err) { + return handleApiError(err); + } } export async function POST(request: Request) { - try { - const userId = await getAuthUserId(); + try { + const userId = await getAuthUserId(); - const { success } = await RateLimit.limit(userId); - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const body = await request.json(); - const parsed = CreateRunSchema.parse(body); - const run = await logRun(parsed, userId); - return NextResponse.json(run, { status: 201 }); - } catch (err) { - return handleApiError(err); + const { success } = await RateLimit.limit(userId); + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); } + + const body = await request.json(); + const parsed = CreateRunSchema.parse(body); + const run = await logRun(parsed, userId); + return NextResponse.json(run, { status: 201 }); + } catch (err) { + return handleApiError(err); + } } diff --git a/src/app/globals.css b/src/app/globals.css index 97838f7..53c22ca 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,25 +1,20 @@ @import "tailwindcss"; -/* These CSS variables define our entire color system. - Updated to OLED-friendly Apple-vibe. */ :root { --background: #000000; --foreground: #fcfcfc; - /* Slightly lighter, elevated slate/zinc for cards to contrast against black */ - --card: #18181b; - --card-hover: #27272a; - --border: #3f3f46; + --card: #111111; + --card-hover: #18181b; + --border: #27272a; --muted: #a1a1aa; - /* Retaining app accent colors but tweaking to Apple-like vibrance */ - --accent-green: #d1ff1a; /* Bright Lime Green */ - --accent-orange: #ff9500; /* iOS Orange */ - --accent-purple: #af52de; /* iOS Purple */ - --accent-blue: #007aff; /* iOS Blue */ - --accent-red: #ff3b30; /* iOS Red */ + --accent-green: #d1ff1a; + --accent-orange: #ff9500; + --accent-purple: #af52de; + --accent-blue: #007aff; + --accent-red: #ff3b30; } -/* @theme inline tells Tailwind to generate utility classes */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -32,9 +27,10 @@ --color-accent-purple: var(--accent-purple); --color-accent-blue: var(--accent-blue); --color-accent-red: var(--accent-red); - --font-sans: var(--font-inter); - /* Systematically scale up typography for better readability globally */ + --font-sans: var(--font-barlow), system-ui, sans-serif; + --font-display: var(--font-barlow-condensed), system-ui, sans-serif; + --text-xs: 0.875rem; --text-xs--line-height: 1.25rem; --text-sm: 1rem; @@ -50,46 +46,37 @@ body { background: var(--background); color: var(--foreground); - font-family: var(--font-sans), system-ui, sans-serif; + font-family: var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @utility glass-panel { - background: linear-gradient(145deg, rgba(39, 39, 42, 0.7), rgba(24, 24, 27, 0.8)); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); + background: var(--card); + border: 1px solid rgba(255, 255, 255, 0.05); } -@utility hover-lift { - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1); - &:hover { - transform: translateY(-4px) scale(1.02); - box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.5); +@utility active-press { + transition: transform 0.1s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.2s ease-out; + &:active { + transform: scale(0.96); } } -/* Smooth transitions for interactive elements */ .transition-default { transition: all 0.2s ease-in-out; } -/* Custom scrollbar for dark theme */ ::-webkit-scrollbar { width: 6px; } - ::-webkit-scrollbar-track { background: var(--background); } - ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } - ::-webkit-scrollbar-thumb:hover { background: var(--muted); } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c9f9844..a24ec2d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,18 @@ import type { Metadata } from "next"; -import { Inter } from "next/font/google"; +import { Barlow, Barlow_Condensed } from "next/font/google"; import "./globals.css"; import { QueryProvider } from "@/components/providers/QueryProvider"; -const inter = Inter({ - variable: "--font-inter", +const barlow = Barlow({ + weight: ["300", "400", "500", "600", "700"], subsets: ["latin"], + variable: "--font-barlow", +}); + +const barlowCondensed = Barlow_Condensed({ + weight: ["400", "500", "600", "700"], + subsets: ["latin"], + variable: "--font-barlow-condensed", }); export const metadata: Metadata = { @@ -20,8 +27,8 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - + + {children} diff --git a/src/components/profile/AchievementGrid.tsx b/src/components/profile/AchievementGrid.tsx index 6947bf3..11f7eb4 100644 --- a/src/components/profile/AchievementGrid.tsx +++ b/src/components/profile/AchievementGrid.tsx @@ -18,26 +18,34 @@ export function AchievementGrid({ achievements, className = "" }: AchievementGri const totalCount = achievements.length; return ( - -
-

Achievements

-
- {unlockedCount} / {totalCount} Unlocked + +
+

+ + Achievements +

+
+ + {unlockedCount} / {totalCount} + + Unlocked
{achievements.length > 0 ? ( -
+
{achievements.map((achievement) => ( ))}
) : ( - } - title="No achievements yet" - description="Keep working out to unlock your first achievement!" - /> +
+ } + title="No achievements yet" + description="Keep working out to unlock your first achievement!" + /> +
)} ); diff --git a/src/components/profile/PersonalRecords.tsx b/src/components/profile/PersonalRecords.tsx index 34a33c3..755a1dc 100644 --- a/src/components/profile/PersonalRecords.tsx +++ b/src/components/profile/PersonalRecords.tsx @@ -19,30 +19,32 @@ export function PersonalRecords({ className = "" }: PersonalRecordsProps) { }); return ( - -
- -

Personal Records

+ +
+

+ + Personal Records +

- {isLoading &&

Loading records...

} - {isError &&

Could not load records.

} + {isLoading &&

Loading records...

} + {isError &&

Could not load records.

} {!isLoading && !isError && records.length === 0 && ( -

Log your first workout or run to set your records!

+

Log your first workout or run to set your records!

)} {!isLoading && !isError && records.length > 0 && ( -
+
{records.map((record, idx) => (
- + {record.label} - + {record.value}
diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx index b52ac7b..5cac13c 100644 --- a/src/components/profile/ProfileHeader.tsx +++ b/src/components/profile/ProfileHeader.tsx @@ -3,7 +3,7 @@ import { Card } from "@/components/ui/Card"; import { ProgressBar } from "@/components/ui/ProgressBar"; import { calcXPPercent, formatDate } from "@/lib/utils"; import type { User, DashboardStats } from "@/lib/types"; -import { Dumbbell, MapPin, Zap, Flame } from "lucide-react"; +import { Dumbbell, MapPin, Zap, Flame, Trophy } from "lucide-react"; import { UserAvatar } from "@/components/ui/UserAvatar"; export interface ProfileHeaderProps { @@ -19,62 +19,66 @@ export function ProfileHeader({ user, stats, className = "" }: ProfileHeaderProp const xpPercent = calcXPPercent(user.xp, user.xpToNextLevel); return ( - -
- {/* Avatar */} - - - {/* Info & XP */} -
-
-
-

- {user.name} -

-

- Member since {formatDate(user.joinDate)} -

-
-
- 👑 - Level {user.level} -
+ + {/* Top section: Centered stack to fit perfectly in 1/3 col layouts */} +
+ {/* Avatar with athletic styling */} +
+
+
+
+
+ + {/* Identity & Level */} +

+ {user.name} +

+ +
+ + + Level {user.level} + +
+ +

+ Member since {formatDate(user.joinDate)} +

- {/* XP Bar */} -
-
- XP Progress - - {user.xp.toLocaleString()} / {user.xpToNextLevel.toLocaleString()} XP - -
- + {/* XP Bar */} +
+
+ XP Progress + + {user.xp.toLocaleString()} / {user.xpToNextLevel.toLocaleString()} +
+
- {/* Lifetime Stats Grid */} -
-
- - {user.totalWorkouts} - Workouts + {/* Lifetime Stats Grid - 2x2 HUD Style for narrow column support */} +
+
+ + {user.totalWorkouts} + Workouts
-
- - {user.totalDistance} - Kilometers +
+ + {user.totalDistance} + Kilometers
-
- - {stats.lifetimeXP.toLocaleString()} - Lifetime XP +
+ + {stats.lifetimeXP.toLocaleString()} + Lifetime XP
-
- - {user.streak} - Day Streak +
+ + {user.streak} + Day Streak
diff --git a/src/components/quests/QuestCard.tsx b/src/components/quests/QuestCard.tsx index dc4a045..5fcf8dc 100644 --- a/src/components/quests/QuestCard.tsx +++ b/src/components/quests/QuestCard.tsx @@ -1,98 +1,138 @@ import { Card } from "@/components/ui/Card"; import { ProgressBar } from "@/components/ui/ProgressBar"; import type { Quest } from "@/lib/types"; -import { CheckCircle, Target } from "lucide-react"; +import { CheckCircle, Target, Zap } from "lucide-react"; import { QuestIconMap } from "@/lib/constants/quest-icons"; -import { useState, } from "react"; +import { useState } from "react"; export interface QuestCardProps { - quest: Quest; - className?: string; - onClaim?: (id: string) => void; + quest: Quest; + className?: string; + onClaim?: (id: string) => void; } /** * Displays a single quest with progress/completion state. + * Styled with Modern Athletic HUD aesthetic. */ export function QuestCard({ quest, className = "", onClaim }: QuestCardProps) { - const Icon = QuestIconMap[quest.icon] || Target; + const Icon = QuestIconMap[quest.icon] || Target; - const [isClaiming, setIsClaiming] = useState(false); - const [error, setError] = useState(null); - const isCompleted = quest.completed; + const [isClaiming, setIsClaiming] = useState(false); + const [error, setError] = useState(null); + const isCompleted = quest.completed; + const isClaimed = quest.claimed; - return ( - -
-
-
- -
-
-

- {quest.title} -

-

- {quest.description} -

-
-
+ return ( + + {/* Status bar at top */} +
-
- +{quest.xpReward} XP -
+
+ {/* Header row: icon + title + XP badge */} +
+
+
+
+
+

+ {quest.title} +

+

+ {quest.description} +

+
+
-
- {isCompleted ? ( - quest.claimed ? ( -
- - Claimed ✓ -
- ) : ( -
- - {error && {error}} -
- ) - ) : ( -
-
- Progress - {quest.progress} / {quest.target} -
+
+ + + +{quest.xpReward} + +
+
- -
+ {/* Footer: progress or claim CTA */} +
+ {isCompleted ? ( + isClaimed ? ( +
+ + + Claimed + +
+ ) : ( +
+ + {error && ( + + {error} + )} +
+ ) + ) : ( +
+
+ + Progress + + + {quest.progress}{" "} + / {quest.target} + +
+
- - ); + )} +
+
+ + ); } diff --git a/src/components/quests/QuestSection.tsx b/src/components/quests/QuestSection.tsx index ae4d204..a7869f3 100644 --- a/src/components/quests/QuestSection.tsx +++ b/src/components/quests/QuestSection.tsx @@ -1,41 +1,55 @@ import React from "react"; +import { Card } from "@/components/ui/Card"; import { QuestCard } from "./QuestCard"; import { EmptyState } from "@/components/ui/EmptyState"; import type { Quest } from "@/lib/types"; import { Target } from "lucide-react"; export interface QuestSectionProps { - title: string; - quests: readonly Quest[]; - icon?: React.ReactNode; - className?: string; - onClaim?: (id: string) => void; + title: string; + quests: readonly Quest[]; + icon?: React.ReactNode; + className?: string; + onClaim?: (id: string) => void; } /** * Groups quests by category and renders a grid of QuestCards. */ -export function QuestSection({ title, quests, icon, className = "", onClaim }: QuestSectionProps) { - return ( -
-
- {icon && {icon}} -

{title}

-
- - {quests.length > 0 ? ( -
- {quests.map((quest) => ( - - ))} -
- ) : ( - } - title={`No ${title.toLowerCase()}`} - description="Check back later for new challenges!" - /> - )} -
- ); +export function QuestSection({ + title, + quests, + icon, + className = "", + onClaim, +}: QuestSectionProps) { + return ( + +
+

+ {icon && {icon}} + {title} +

+ + {quests.length} {quests.length === 1 ? "Quest" : "Quests"} + +
+ + {quests.length > 0 ? ( +
+ {quests.map((quest) => ( + + ))} +
+ ) : ( +
+ } + title={`No ${title.toLowerCase()}`} + description="Check back later for new challenges!" + /> +
+ )} +
+ ); } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 6804a9e..aeedf60 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -6,11 +6,10 @@ export interface ButtonProps extends React.ButtonHTMLAttributes = { - primary: "bg-accent-green text-background hover:opacity-90", - secondary: "bg-card-hover text-foreground hover:bg-border", - outline: "border border-border text-foreground hover:bg-card-hover", + primary: "bg-accent-green text-black hover:bg-accent-green/90", + secondary: "bg-card text-foreground border border-white/5 hover:border-white/20", + outline: "border border-border text-foreground hover:bg-card hover:border-accent-green/50", }; export function Button({ @@ -19,8 +18,7 @@ export function Button({ className = "", ...props }: ButtonProps) { - // Base styles all buttons share, plus focus/disabled states - const baseStyles = "px-6 py-3 font-medium rounded-xl transition-default text-base inline-flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-accent-green/50 disabled:opacity-50 disabled:cursor-not-allowed hover-lift"; + const baseStyles = "px-6 py-3 font-bold rounded-xl transition-default text-base inline-flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-accent-green/50 disabled:opacity-50 disabled:cursor-not-allowed active-press uppercase tracking-wide"; return ( - -
-
+ return ( + setIsExpanded(!isExpanded)} + className={`flex flex-col gap-4 cursor-pointer hover:border-accent-green/30 active-press transition-default ${className}`} + > + {/* Header: icon + title + focus */} +
+
+ +
+
+

+ {workout.title} +

+
+ + {primaryFocus} + + • + + {workout.exercises.length} exercises + +
+
+
+ + +
+ +
+
+
- {/* Exercise list */} -
- {workout.exercises.map((exercise, index) => ( -
- - {exercise.name} - - - {exercise.sets} × {exercise.reps} - {exercise.weight !== null - ? ` @ ${exercise.weight} kg` - : " BW"} - -
- ))} + {/* Exercise list (Accordion) */} + {isExpanded && ( +
+ {workout.exercises.map((exercise, index) => ( +
+ + {exercise.name} + + + {exercise.sets} × {exercise.reps} + {exercise.weight !== null ? ` @ ${exercise.weight} kg` : " BW"} +
+ ))} +
+ )} - {/* Footer: duration, date, XP */} -
-
- {formatDuration(workout.duration)} - • - {formatDate(workout.date)} -
- - +{workout.xpEarned} XP - -
- - ); + {/* Footer: duration, date, XP */} +
+
+ {formatDuration(workout.duration)} + • + {formatDate(workout.date)} +
+ + +{workout.xpEarned} XP + +
+ + ); }