diff --git a/architecture_review.md b/architecture_review.md index f95fddd..2a1f538 100644 --- a/architecture_review.md +++ b/architecture_review.md @@ -330,7 +330,7 @@ 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: -### 1. Interactive Human Anatomy UI +### 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-human-anatomy-ui-design.md b/docs/superpowers/specs/2026-07-31-human-anatomy-ui-design.md new file mode 100644 index 0000000..0ce6ce7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-human-anatomy-ui-design.md @@ -0,0 +1,71 @@ +# Interactive Human Anatomy UI - Design Spec + +## 1. Goal and Purpose +Implement an interactive, visual representation of the human muscular system mapping to the `TargetMuscle` enum. This gamifies the workout experience by providing immediate visual feedback during workouts and tracking recovery/volume over time. + +## 2. Design System & Aesthetics (UI-UX-Pro-Max) +- **Theme**: Dark Mode (OLED) - Deep blacks (`#020617`, `#0F172A`) with high-contrast glowing accents. +- **Typography**: `Barlow Condensed` for labels, athletic and energetic. +- **Visual Effects**: + - Minimal glow (`text-shadow: 0 0 10px`, `drop-shadow` on SVGs) for "pumped" or active muscles. + - Smooth transitions (300ms `ease-in-out`) for color shifts. +- **Color Scale (Heatmap / Recovery)**: + - **Exhausted / High Volume (Active)**: Neon Red (`#EF4444`) with glow. + - **Recovering / Medium Volume**: Amber/Orange (`#F59E0B`). + - **Recovered / Low Volume**: Neon Green (`#22C55E`). + - **Inactive / Baseline**: Muted Blue/Slate (`#1E293B` or `#334155`). + +## 3. Core Architecture: Inline SVG Component +We will build a single, highly controllable React component: ``. +- **Implementation**: An inline SVG containing `` elements for each major muscle group. +- **Mapping**: Each path will have an `id` or `data-muscle` attribute mapping directly to the `TargetMuscle` enum (`Chest`, `Back`, `Legs`, `Arms`, `Core`). +- **Handling Non-Muscles**: + - `Cardio`: Represented as a glowing heart icon in the center of the chest or an aura around the body. + - `FullBody`: Triggers an overarching aura or simultaneous glow of all muscle groups. +- **State Injection**: The component accepts a dictionary of muscle states, determining the fill color and glow intensity of each SVG path. + +```tsx +// Proposed Interface +type MuscleIntensity = "inactive" | "low" | "medium" | "high"; + +interface AnatomyModelProps { + frontIntensities: Record; + backIntensities: Record; + view: "front" | "back" | "both"; // Determines which SVG to render +} +``` + +## 4. Contextual Behaviors + +### A. The 7-Day Heatmap (Dashboard) +- **Data Source**: Aggregates `Workout[]` from the past 7 days. +- **Logic**: Calculates total sets/reps or XP per `TargetMuscle`. Maps the volume to the `MuscleIntensity` scale. +- **Visuals**: A static but glowing representation showing where the user has focused their training. + +### B. Live Session "Pump" Tracker (Workout View) +- **Data Source**: The active workout session state (Zustand or React Context). +- **Logic**: As the user adds an `Exercise` (e.g., Bench Press -> `Chest`), the `Chest` intensity immediately spikes to "high". +- **Visuals**: Employs a pulsing CSS animation (`animate-pulse` or custom keyframes) to indicate an active "pump". + +### C. Recovery Monitor (Profile/Dashboard) +- **Data Source**: Historical workouts, computing time elapsed since a muscle was last trained. +- **Logic**: + - < 24 hours: Red (Exhausted) + - 24 - 48 hours: Orange (Recovering) + - > 72 hours: Green (Fully Recovered / Ready) +- **Visuals**: Smooth gradient colors based on the exact timestamp, allowing users to visually see their recovery progress. + +## 5. Implementation Steps +1. **SVG Sourcing & Prep**: Acquire a clean, minimalist vector of human anatomy (front and back). Group and name paths strictly according to `TargetMuscle`. +2. **Component Scaffold**: Create the `` in `src/components/ui/`. +3. **Domain Evaluators**: Create pure functions in `src/lib/domain/` to calculate: + - `calcMuscleVolume(workouts, days)` -> Heatmap data. + - `calcMuscleRecovery(workouts, now)` -> Recovery data. +4. **Integration**: Embed the component into the Dashboard and Workout entry forms. + +## 6. Open Questions / Grilling +*I need your input on the following to finalize the plan:* + +1. **SVG Views**: Do we want the UI to automatically flip between Front and Back views on a timer, or should the user toggle it manually with a button? +2. **"Cardio" & "Full Body"**: Do you agree with the "Aura" approach for these non-muscle targets, or would you prefer a different visual indicator (like a separate stat bar)? +3. **Animations**: Should the "Live Pump" tracker use a heartbeat/pulse animation, or a steady bright glow? diff --git a/project_briefing.md b/project_briefing.md index 475f792..d4f7829 100644 --- a/project_briefing.md +++ b/project_briefing.md @@ -187,6 +187,9 @@ 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 +- **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 - **Friend System (fully implemented):** - Send/accept/decline friend requests by User ID - Remove friends diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 8df1af1..175b23f 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -5,6 +5,7 @@ import { XPCard } from "@/components/dashboard/XPCard"; import { ActivityTracker } from "@/components/dashboard/ActivityTracker"; import { DailyQuests } from "@/components/dashboard/DailyQuests"; import { RecentActivity } from "@/components/dashboard/RecentActivity"; +import { DashboardTabs } from "@/components/dashboard/DashboardTabs"; import { StatCard } from "@/components/ui/StatCard"; import { getAuthUserId } from "@/lib/auth/auth-helpers"; import { UserStateService } from "@/lib/services/users/user-state.service"; @@ -107,11 +108,8 @@ export default async function DashboardPage() { {/* Middle row: Activity Tracker Grid */} - {/* Bottom row: Quests & Recent Activity */} -
- - -
+ {/* Bottom row: Tabbed Quests, Activity, Heatmap */} + ); } \ No newline at end of file diff --git a/src/components/dashboard/DashboardTabs.tsx b/src/components/dashboard/DashboardTabs.tsx new file mode 100644 index 0000000..62aa0c9 --- /dev/null +++ b/src/components/dashboard/DashboardTabs.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import { DailyQuests } from "./DailyQuests"; +import { RecentActivity } from "./RecentActivity"; +import { MuscleHeatmap } from "./MuscleHeatmap"; +import { Quest, Workout } from "@/lib/types"; + +export function DashboardTabs({ quests, recentWorkouts }: { quests: Quest[], recentWorkouts: Workout[] }) { + const [activeTab, setActiveTab] = useState<"activity" | "quests" | "heatmap">("activity"); + + return ( +
+
+ + + +
+ +
+ {activeTab === "activity" && } + {activeTab === "quests" && } + {activeTab === "heatmap" && } +
+
+ ); +} diff --git a/src/components/dashboard/MuscleHeatmap.tsx b/src/components/dashboard/MuscleHeatmap.tsx new file mode 100644 index 0000000..68a6a29 --- /dev/null +++ b/src/components/dashboard/MuscleHeatmap.tsx @@ -0,0 +1,54 @@ +"use client"; +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AnatomyModel } from "../ui/AnatomyModel"; +import { calcMuscleVolume } from "@/lib/domain/muscle-evaluator"; + +import { Card } from "@/components/ui/Card"; +import { getWorkouts } from "@/lib/data/api-client"; + +export function MuscleHeatmap() { + const [view, setView] = useState<"front" | "back">("front"); + + const { data, isLoading, error } = useQuery({ + queryKey: ["workouts", { page: 1, limit: 30 }], + queryFn: () => getWorkouts(1, 30), + }); + + if (isLoading || !data) { + return Loading...; + } + + if (error) { + return ( + + Failed to load heatmap data. + + ); + } + + const intensities = calcMuscleVolume(data.data, 7); + + return ( + +

7-Day Volume Heatmap

+
+ + +
+
+ +
+
+ ); +} diff --git a/src/components/ui/AnatomyModel.tsx b/src/components/ui/AnatomyModel.tsx new file mode 100644 index 0000000..5f469c0 --- /dev/null +++ b/src/components/ui/AnatomyModel.tsx @@ -0,0 +1,85 @@ +"use client"; +import React from "react"; +import { TargetMuscle } from "@/lib/types"; +import { MuscleIntensity } from "@/lib/domain/muscle-evaluator"; + +interface Props { + intensities: Record; + activeMuscle?: TargetMuscle | null; + view?: "front" | "back"; +} + +const intensityStyles: Record = { + inactive: "fill-slate-800 drop-shadow-none", + low: "fill-green-500 drop-shadow-[0_0_8px_rgba(34,197,94,0.6)]", + medium: "fill-orange-500 drop-shadow-[0_0_12px_rgba(249,115,22,0.6)]", + high: "fill-red-500 drop-shadow-[0_0_15px_rgba(239,68,68,0.8)]", +}; + +export function AnatomyModel({ intensities, activeMuscle, view = "front" }: Props) { + const getStyle = (muscle: TargetMuscle) => { + const isLive = activeMuscle === muscle; + const baseStyle = intensityStyles[intensities[muscle]] || intensityStyles.inactive; + + let style = `${baseStyle} transition-all duration-300 stroke-slate-900 stroke-[3px]`; + if (isLive) { + style += " animate-pulse scale-[1.02] origin-center z-10 relative"; + } + return style; + }; + + const hasAura = activeMuscle === TargetMuscle.FullBody || intensities[TargetMuscle.FullBody] !== "inactive"; + const containerStyle = hasAura ? "filter drop-shadow-[0_0_20px_#22C55E]" : ""; + + return ( +
+ {/* + Stylized placeholder paths for the muscle groups mapping to the front/back references. + A designer can drop in precise SVG d="..." data later while keeping these IDs and classes. + */} + + {view === "front" ? ( + + {/* Head/Neck (Inactive base) */} + + + {/* Chest */} + + + {/* Core / Abs */} + + + {/* Arms (Left & Right) */} + + + + {/* Legs (Quads) */} + + + + {/* Cardio Aura (if active) */} + {(activeMuscle === TargetMuscle.Cardio || intensities[TargetMuscle.Cardio] !== "inactive") && ( + + )} + + ) : ( + + {/* Head/Neck (Inactive base) */} + + + {/* Back (Lats & Traps) */} + + + {/* Arms (Triceps/Shoulders from back) */} + + + + {/* Legs (Hamstrings/Calves) */} + + + + )} + +
+ ); +} diff --git a/src/components/workouts/WorkoutForm.tsx b/src/components/workouts/WorkoutForm.tsx index 07c7899..e08559d 100644 --- a/src/components/workouts/WorkoutForm.tsx +++ b/src/components/workouts/WorkoutForm.tsx @@ -12,6 +12,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { PREDEFINED_EXERCISES } from "@/lib/constants/exercises"; import { mergeAndSortExercises } from "@/lib/domain/exercise-rules"; import { CreateExerciseModal } from "./CreateExerciseModal"; +import { AnatomyModel } from "../ui/AnatomyModel"; +import { MuscleIntensity, calcMuscleVolume } from "@/lib/domain/muscle-evaluator"; const emptyExercise: Exercise = { name: "", @@ -37,6 +39,7 @@ export function WorkoutForm({ onCancel?: () => void; }) { const [isModalOpen, setIsModalOpen] = useState(false); + const [activeMuscle, setActiveMuscle] = useState(null); const queryClient = useQueryClient(); // Fetch custom exercises @@ -102,6 +105,18 @@ export function WorkoutForm({ onSuccess: onWorkoutLogged, }); + const currentIntensities = useMemo(() => { + const mockWorkout: Workout = { + id: "temp", + title: "temp", + duration: 1, + xpEarned: 0, + date: new Date().toISOString(), + exercises: fields.exercises.filter(ex => ex.name.trim() !== "") + }; + return calcMuscleVolume([mockWorkout], 1); + }, [fields.exercises]); + const addExercise = () => { setFields((prev) => ({ ...prev, exercises: [...prev.exercises, { ...emptyExercise }] })); }; @@ -148,38 +163,41 @@ export function WorkoutForm({ "w-full bg-background border border-border rounded-lg px-4 py-2.5 text-foreground text-sm placeholder:text-muted focus:border-accent-green focus:ring-1 focus:ring-accent-green/50 focus:outline-none transition-default"; return ( - <> - -
-

- {isEditMode ? "Edit Workout" : "Log Workout"} -

- {isEditMode && onCancel && ( - - )} -
- -
-
- - setFields((prev) => ({ ...prev, title: e.target.value }))} - className={inputBase} - /> +
+ + + {/* Left side: Form Inputs */} +
+
+

+ {isEditMode ? "Edit Workout" : "Log Workout"} +

+ {isEditMode && onCancel && ( + + )}
+ +
+ + setFields((prev) => ({ ...prev, title: e.target.value }))} + className={inputBase} + /> +
+
+ + {/* Right side: Compact Live Pump Tracker */} +
+

Live Pump Tracker

+
+ +
+
- +
); } diff --git a/src/lib/domain/__tests__/muscle-evaluator.test.ts b/src/lib/domain/__tests__/muscle-evaluator.test.ts new file mode 100644 index 0000000..da4eb9f --- /dev/null +++ b/src/lib/domain/__tests__/muscle-evaluator.test.ts @@ -0,0 +1,54 @@ +import { calcMuscleVolume } from "../muscle-evaluator"; +import { TargetMuscle, Workout } from "../../types"; +import { describe, it, expect } from "vitest"; + +describe("muscle-evaluator", () => { + describe("calcMuscleVolume", () => { + it("should calculate correct muscle intensity based on pure sets and reps", () => { + const mockWorkouts: Workout[] = [{ + id: "1", + title: "Push Day", + duration: 60, + xpEarned: 100, + date: new Date().toISOString(), + exercises: [ + { name: "Bench Press", targetMuscle: TargetMuscle.Chest, sets: 5, reps: 10, weight: 100 }, // 50 reps -> medium + { name: "Tricep Pushdown", targetMuscle: TargetMuscle.Arms, sets: 3, reps: 15, weight: 50 }, // 45 reps -> medium + { name: "Pushups", targetMuscle: TargetMuscle.Chest, sets: 5, reps: 20, weight: null } // 100 reps + 50 above = 150 -> high + ] + }]; + + const result = calcMuscleVolume(mockWorkouts, 7); + + // Chest is 150 total reps -> high + expect(result[TargetMuscle.Chest]).toBe("high"); + + // Arms is 45 total reps -> medium + expect(result[TargetMuscle.Arms]).toBe("medium"); + + // Legs is 0 reps -> inactive + expect(result[TargetMuscle.Legs]).toBe("inactive"); + }); + + it("should filter out workouts older than the specified days", () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); // 10 days ago + + const mockWorkouts: Workout[] = [{ + id: "1", + title: "Leg Day (Old)", + duration: 60, + xpEarned: 100, + date: oldDate.toISOString(), + exercises: [ + { name: "Squats", targetMuscle: TargetMuscle.Legs, sets: 10, reps: 20, weight: 100 } // 200 reps + ] + }]; + + const result = calcMuscleVolume(mockWorkouts, 7); + + // Should be inactive because the workout is 10 days old, but we only asked for 7 days + expect(result[TargetMuscle.Legs]).toBe("inactive"); + }); + }); +}); diff --git a/src/lib/domain/muscle-evaluator.ts b/src/lib/domain/muscle-evaluator.ts new file mode 100644 index 0000000..f111dd4 --- /dev/null +++ b/src/lib/domain/muscle-evaluator.ts @@ -0,0 +1,38 @@ +import { TargetMuscle, Workout } from "../types"; + +export type MuscleIntensity = "inactive" | "low" | "medium" | "high"; + +export function calcMuscleVolume(workouts: Workout[], days: number): Record { + const now = new Date(); + const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + + const recentWorkouts = workouts.filter(w => new Date(w.date) >= cutoff); + + const counts: Record = { + [TargetMuscle.Chest]: 0, + [TargetMuscle.Back]: 0, + [TargetMuscle.Legs]: 0, + [TargetMuscle.Arms]: 0, + [TargetMuscle.Core]: 0, + [TargetMuscle.Cardio]: 0, + [TargetMuscle.FullBody]: 0, + }; + + for (const w of recentWorkouts) { + for (const ex of w.exercises) { + counts[ex.targetMuscle] += (ex.sets * ex.reps); + } + } + + const intensities = {} as Record; + for (const [muscle, count] of Object.entries(counts)) { + let intensity: MuscleIntensity = "inactive"; + if (count > 0 && count <= 30) intensity = "low"; + else if (count > 30 && count <= 100) intensity = "medium"; + else if (count > 100) intensity = "high"; + + intensities[muscle as TargetMuscle] = intensity; + } + + return intensities; +}