From 015325253c3794e4d67a7545c909469f7b31a143 Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 10:35:14 +0800 Subject: [PATCH 01/12] style(theme): update globals.css to Modern Athletic design system --- src/app/globals.css | 49 +++++++++++++++++---------------------------- src/app/layout.tsx | 17 +++++++++++----- 2 files changed, 30 insertions(+), 36 deletions(-) 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} From bc46b6b60f408224a713a51b5cb9cd196de03d01 Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 10:36:17 +0800 Subject: [PATCH 02/12] style(ui): refactor Button component to use active-press and athletic fonts --- src/components/ui/Button.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 ( - -
-
-
- +
+
+
+
-

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.

From a72dfd263b78e2f3c709c6aecb72b049520da73e Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 10:39:11 +0800 Subject: [PATCH 04/12] docs: add technical specification for Modern Athletic UI overhaul --- .../specs/2026-07-31-ui-overhaul-design.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-ui-overhaul-design.md 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. From 781660ed821d590d7d426c0fc8f85f95ae245241 Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 10:48:58 +0800 Subject: [PATCH 05/12] feat(ui): add inline accordion to WorkoutCard to compress history feed --- src/components/workouts/WorkoutCard.tsx | 79 +++++++++++++++---------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/components/workouts/WorkoutCard.tsx b/src/components/workouts/WorkoutCard.tsx index 3216b5a..e6eadfa 100644 --- a/src/components/workouts/WorkoutCard.tsx +++ b/src/components/workouts/WorkoutCard.tsx @@ -1,5 +1,7 @@ -import React from "react"; -import { Dumbbell, Edit, Trash2 } from "lucide-react"; +"use client"; + +import React, { useState } from "react"; +import { Dumbbell, Edit, Trash2, ChevronDown } from "lucide-react"; import { Card } from "@/components/ui/Card"; import { formatDate, formatDuration } from "@/lib/utils"; import type { Workout, Exercise } from "@/lib/types"; @@ -32,14 +34,16 @@ function getPrimaryFocus(exercises: Exercise[]): string { /** * Displays a single workout in a rich card layout. - * Shows title, dynamically inferred focus, exercise breakdown, and stats footer. + * Shows title, dynamically inferred focus, exercise breakdown (accordion), and stats footer. */ export function WorkoutCard({ workout, className = "", onDelete, onUpdate }: WorkoutCardProps) { const primaryFocus = getPrimaryFocus(workout.exercises); + const [isExpanded, setIsExpanded] = useState(false); 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 */}
@@ -52,53 +56,66 @@ export function WorkoutCard({ workout, className = "", onDelete, onUpdate }: Wor

{workout.title}

- - {primaryFocus} - +
+ + {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)} From 3f29974f5c7340f2e33006cb299a9e1a418567ae Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 10:52:59 +0800 Subject: [PATCH 06/12] style(ui): fix top alignment of workout history cards --- src/app/(app)/workouts/page.tsx | 2 -- 1 file changed, 2 deletions(-) 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

} From 48751f744f85e6e5d04df9f831c800f363703a25 Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 10:56:09 +0800 Subject: [PATCH 07/12] fix(dashboard): correct daily quest filtering logic to prevent dropped quests --- src/app/(app)/dashboard/page.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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; From 8559a4c17084e5c690b2e59ef15e2a8216417c5d Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 11:03:39 +0800 Subject: [PATCH 08/12] style(ui): redesign ProfileHeader to match Modern Athletic guidelines --- src/components/profile/ProfileHeader.tsx | 100 ++++++++++++----------- 1 file changed, 52 insertions(+), 48 deletions(-) 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
From a0efc0a28026f9058f45e14dff8f0662f89a8a4d Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 11:13:18 +0800 Subject: [PATCH 09/12] style: move PersonalRecords component and add top margin to improve layout alignment --- src/app/(app)/profile/page.tsx | 2 +- src/components/profile/PersonalRecords.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/components/profile/PersonalRecords.tsx b/src/components/profile/PersonalRecords.tsx index 34a33c3..c166167 100644 --- a/src/components/profile/PersonalRecords.tsx +++ b/src/components/profile/PersonalRecords.tsx @@ -19,7 +19,7 @@ export function PersonalRecords({ className = "" }: PersonalRecordsProps) { }); return ( - +

Personal Records

From 08deadc370639d9b1a3a419782a68c1f4bedc89c Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sat, 1 Aug 2026 15:17:49 +0800 Subject: [PATCH 10/12] feat: implement modern athletic HUD UI overhaul with consistent design tokens and spring-physics interactions --- architecture_review.md | 7 + project_briefing.md | 4 + src/components/profile/AchievementGrid.tsx | 30 +++-- src/components/profile/PersonalRecords.tsx | 24 ++-- src/components/quests/QuestCard.tsx | 143 ++++++++++++--------- src/components/quests/QuestSection.tsx | 32 +++-- 6 files changed, 142 insertions(+), 98 deletions(-) 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/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/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 c166167..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/quests/QuestCard.tsx b/src/components/quests/QuestCard.tsx index dc4a045..c88f9b6 100644 --- a/src/components/quests/QuestCard.tsx +++ b/src/components/quests/QuestCard.tsx @@ -1,9 +1,9 @@ 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; @@ -13,6 +13,7 @@ export interface QuestCardProps { /** * 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; @@ -20,78 +21,92 @@ export function QuestCard({ quest, className = "", onClaim }: QuestCardProps) { const [isClaiming, setIsClaiming] = useState(false); const [error, setError] = useState(null); const isCompleted = quest.completed; + const isClaimed = quest.claimed; return ( -
-
-
- -
-
-

- {quest.title} -

-

- {quest.description} -

+ {/* Status bar at top */} +
+ +
+ {/* Header row: icon + title + XP badge */} +
+
+
+ +
+
+

+ {quest.title} +

+

+ {quest.description} +

+
-
-
- +{quest.xpReward} XP +
+ + +{quest.xpReward} +
-
-
- {isCompleted ? ( - quest.claimed ? ( -
- - Claimed ✓ -
- ) : ( -
- - {error && {error}} -
- ) - ) : ( -
-
- Progress - {quest.progress} / {quest.target} + > + {isClaiming ? "Claiming..." : error ? "Error — Retry" : `Claim ${quest.xpReward} XP`} + + {error && {error}} +
+ ) + ) : ( +
+
+ Progress + + {quest.progress} / {quest.target} + +
+
- - -
- )} + )} +
); diff --git a/src/components/quests/QuestSection.tsx b/src/components/quests/QuestSection.tsx index ae4d204..c50f471 100644 --- a/src/components/quests/QuestSection.tsx +++ b/src/components/quests/QuestSection.tsx @@ -1,4 +1,5 @@ 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"; @@ -17,25 +18,32 @@ export interface QuestSectionProps { */ export function QuestSection({ title, quests, icon, className = "", onClaim }: QuestSectionProps) { return ( -
-
- {icon && {icon}} -

{title}

+ +
+

+ {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!" - /> +
+ } + title={`No ${title.toLowerCase()}`} + description="Check back later for new challenges!" + /> +
)} -
+ ); } From 473471a18c373b88aa27cfe0cab545646864de05 Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sun, 2 Aug 2026 08:37:32 +0800 Subject: [PATCH 11/12] refactor: refactor QuestCard styling for consistent code formatting --- src/components/quests/QuestCard.tsx | 207 +++++++++++++---------- src/components/quests/QuestSection.tsx | 74 ++++---- src/components/workouts/WorkoutCard.tsx | 215 ++++++++++++------------ 3 files changed, 266 insertions(+), 230 deletions(-) diff --git a/src/components/quests/QuestCard.tsx b/src/components/quests/QuestCard.tsx index c88f9b6..5fcf8dc 100644 --- a/src/components/quests/QuestCard.tsx +++ b/src/components/quests/QuestCard.tsx @@ -6,9 +6,9 @@ import { QuestIconMap } from "@/lib/constants/quest-icons"; import { useState } from "react"; export interface QuestCardProps { - quest: Quest; - className?: string; - onClaim?: (id: string) => void; + quest: Quest; + className?: string; + onClaim?: (id: string) => void; } /** @@ -16,98 +16,123 @@ export interface QuestCardProps { * 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 isClaimed = quest.claimed; + const [isClaiming, setIsClaiming] = useState(false); + const [error, setError] = useState(null); + const isCompleted = quest.completed; + const isClaimed = quest.claimed; - return ( - - {/* Status bar at top */} -
+ return ( + + {/* Status bar at top */} +
-
- {/* Header row: icon + title + XP badge */} -
-
-
- -
-
-

- {quest.title} -

-

- {quest.description} -

-
-
+
+ {/* Header row: icon + title + XP badge */} +
+
+
+ +
+
+

+ {quest.title} +

+

+ {quest.description} +

+
+
-
- - +{quest.xpReward} -
-
+
+ + + +{quest.xpReward} + +
+
- {/* Footer: progress or claim CTA */} -
- {isCompleted ? ( - isClaimed ? ( -
- - Claimed -
- ) : ( -
- - {error && {error}} -
- ) - ) : ( -
-
- Progress - - {quest.progress} / {quest.target} - -
- -
- )} -
+ {/* 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 c50f471..a7869f3 100644 --- a/src/components/quests/QuestSection.tsx +++ b/src/components/quests/QuestSection.tsx @@ -6,44 +6,50 @@ 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} {quests.length === 1 ? "Quest" : "Quests"} - -
+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!" - /> -
- )} -
- ); + {quests.length > 0 ? ( +
+ {quests.map((quest) => ( + + ))} +
+ ) : ( +
+ } + title={`No ${title.toLowerCase()}`} + description="Check back later for new challenges!" + /> +
+ )} +
+ ); } diff --git a/src/components/workouts/WorkoutCard.tsx b/src/components/workouts/WorkoutCard.tsx index e6eadfa..dac5534 100644 --- a/src/components/workouts/WorkoutCard.tsx +++ b/src/components/workouts/WorkoutCard.tsx @@ -7,123 +7,128 @@ import { formatDate, formatDuration } from "@/lib/utils"; import type { Workout, Exercise } from "@/lib/types"; export interface WorkoutCardProps { - workout: Workout; - className?: string; - onDelete?: (id: string) => void; - onUpdate?: (id: string) => void; + workout: Workout; + className?: string; + onDelete?: (id: string) => void; + onUpdate?: (id: string) => void; } function getPrimaryFocus(exercises: Exercise[]): string { - if (!exercises || exercises.length === 0) return "Mixed Workout"; - - const counts: Record = {}; - let maxCount = 0; - let focus = ""; + if (!exercises || exercises.length === 0) return "Mixed Workout"; - for (const ex of exercises) { - if (!ex.targetMuscle) continue; - counts[ex.targetMuscle] = (counts[ex.targetMuscle] || 0) + 1; - if (counts[ex.targetMuscle] > maxCount) { - maxCount = counts[ex.targetMuscle]; - focus = ex.targetMuscle; - } + const counts: Record = {}; + let maxCount = 0; + let focus = ""; + + for (const ex of exercises) { + if (!ex.targetMuscle) continue; + counts[ex.targetMuscle] = (counts[ex.targetMuscle] || 0) + 1; + if (counts[ex.targetMuscle] > maxCount) { + maxCount = counts[ex.targetMuscle]; + focus = ex.targetMuscle; } - - return focus ? `${focus} Day` : "Mixed Workout"; + } + + return focus ? `${focus} Day` : "Mixed Workout"; } /** * Displays a single workout in a rich card layout. * Shows title, dynamically inferred focus, exercise breakdown (accordion), and stats footer. */ -export function WorkoutCard({ workout, className = "", onDelete, onUpdate }: WorkoutCardProps) { - const primaryFocus = getPrimaryFocus(workout.exercises); - const [isExpanded, setIsExpanded] = useState(false); +export function WorkoutCard({ + workout, + className = "", + onDelete, + onUpdate, +}: WorkoutCardProps) { + const primaryFocus = getPrimaryFocus(workout.exercises); + const [isExpanded, setIsExpanded] = useState(false); - 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 -
-
-
- - -
- -
-
-
+ 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 (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 - + {/* 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 + +
+ + ); } From ea97355fbe64892fea680b24cf823f2dcc2ab2b6 Mon Sep 17 00:00:00 2001 From: sidadrian3 Date: Sun, 2 Aug 2026 08:43:47 +0800 Subject: [PATCH 12/12] feat: add health check endpoint and apply code formatting to runs API route --- src/app/api/health/route.ts | 1 + src/app/api/runs/route.ts | 75 +++++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 src/app/api/health/route.ts 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); + } }