Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion architecture_review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
71 changes: 71 additions & 0 deletions docs/superpowers/specs/2026-07-31-human-anatomy-ui-design.md
Original file line number Diff line number Diff line change
@@ -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: `<AnatomyModel />`.
- **Implementation**: An inline SVG containing `<path>` 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<TargetMuscle, MuscleIntensity>;
backIntensities: Record<TargetMuscle, MuscleIntensity>;
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 `<AnatomyModel />` 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?
3 changes: 3 additions & 0 deletions project_briefing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import { PageHeader } from "@/components/layout/PageHeader";
import { XPCard } from "@/components/dashboard/XPCard";
import { ActivityTracker } from "@/components/dashboard/ActivityTracker";
import { DailyQuests } from "@/components/dashboard/DailyQuests";

Check warning on line 6 in src/app/(app)/dashboard/page.tsx

View workflow job for this annotation

GitHub Actions / test

'DailyQuests' is defined but never used
import { RecentActivity } from "@/components/dashboard/RecentActivity";

Check warning on line 7 in src/app/(app)/dashboard/page.tsx

View workflow job for this annotation

GitHub Actions / test

'RecentActivity' is defined but never used
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";
Expand Down Expand Up @@ -107,11 +108,8 @@
{/* Middle row: Activity Tracker Grid */}
<ActivityTracker activeDays={activeDays} />

{/* Bottom row: Quests & Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<DailyQuests quests={dailyQuests} />
<RecentActivity workouts={recentWorkouts} />
</div>
{/* Bottom row: Tabbed Quests, Activity, Heatmap */}
<DashboardTabs quests={dailyQuests} recentWorkouts={recentWorkouts} />
</div>
);
}
42 changes: 42 additions & 0 deletions src/components/dashboard/DashboardTabs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-4 mt-6">
<div className="flex gap-2 bg-background p-1.5 rounded-xl border border-border w-full sm:w-fit overflow-x-auto no-scrollbar">
<button
onClick={() => setActiveTab("activity")}
className={`whitespace-nowrap px-6 py-2.5 rounded-lg text-sm font-semibold transition-default ${activeTab === 'activity' ? 'bg-card text-foreground shadow-sm' : 'text-muted hover:text-foreground'}`}
>
Recent Activity
</button>
<button
onClick={() => setActiveTab("quests")}
className={`whitespace-nowrap px-6 py-2.5 rounded-lg text-sm font-semibold transition-default ${activeTab === 'quests' ? 'bg-card text-foreground shadow-sm' : 'text-muted hover:text-foreground'}`}
>
Daily Quests
</button>
<button
onClick={() => setActiveTab("heatmap")}
className={`whitespace-nowrap px-6 py-2.5 rounded-lg text-sm font-semibold transition-default ${activeTab === 'heatmap' ? 'bg-card text-foreground shadow-sm' : 'text-muted hover:text-foreground'}`}
>
7-Day Heatmap
</button>
</div>

<div className="w-full">
{activeTab === "activity" && <RecentActivity workouts={recentWorkouts} />}
{activeTab === "quests" && <DailyQuests quests={quests} />}
{activeTab === "heatmap" && <MuscleHeatmap />}
</div>
</div>
);
}
54 changes: 54 additions & 0 deletions src/components/dashboard/MuscleHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -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 <Card className="animate-pulse flex items-center justify-center text-muted h-[400px] w-full">Loading...</Card>;
}

if (error) {
return (
<Card className="flex items-center justify-center h-[400px] w-full text-accent-red">
Failed to load heatmap data.
</Card>
);
}

const intensities = calcMuscleVolume(data.data, 7);

return (
<Card className="flex flex-col items-center w-full p-6">
<h3 className="text-xl font-bold font-condensed mb-4 text-foreground uppercase tracking-wider">7-Day Volume Heatmap</h3>
<div className="flex gap-2 mb-6 bg-background p-1 rounded-full border border-border">
<button
onClick={() => setView("front")}
className={`px-4 py-1.5 rounded-full text-sm font-semibold transition-default ${view === 'front' ? 'bg-accent-green/20 text-accent-green shadow' : 'text-muted hover:text-foreground'}`}
>
Front
</button>
<button
onClick={() => setView("back")}
className={`px-4 py-1.5 rounded-full text-sm font-semibold transition-default ${view === 'back' ? 'bg-accent-green/20 text-accent-green shadow' : 'text-muted hover:text-foreground'}`}
>
Back
</button>
</div>
<div className="w-full max-w-[200px] lg:max-w-[250px] mx-auto">
<AnatomyModel intensities={intensities} view={view} />
</div>
</Card>
);
}
85 changes: 85 additions & 0 deletions src/components/ui/AnatomyModel.tsx
Original file line number Diff line number Diff line change
@@ -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<TargetMuscle, MuscleIntensity>;
activeMuscle?: TargetMuscle | null;
view?: "front" | "back";
}

const intensityStyles: Record<MuscleIntensity, string> = {
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 (
<div className={`relative w-full max-w-sm mx-auto aspect-1/2 ${containerStyle}`}>
{/*
Stylized placeholder paths for the muscle groups mapping to the front/back references.
A designer can drop in precise SVG <path> d="..." data later while keeping these IDs and classes.
*/}
<svg viewBox="0 0 200 400" className="w-full h-full overflow-visible">
{view === "front" ? (
<g id="front-view">
{/* Head/Neck (Inactive base) */}
<path d="M85 30 C85 10, 115 10, 115 30 L110 50 L90 50 Z" className="fill-slate-700 stroke-slate-900 stroke-[3px]" />

{/* Chest */}
<path id="chest" d="M70 70 Q100 80, 130 70 L125 110 Q100 120, 75 110 Z" className={getStyle(TargetMuscle.Chest)} />

{/* Core / Abs */}
<path id="core" d="M75 115 L125 115 L115 180 Q100 190, 85 180 Z" className={getStyle(TargetMuscle.Core)} />

{/* Arms (Left & Right) */}
<path id="left-arm" d="M65 70 L40 140 L55 145 L70 100 Z" className={getStyle(TargetMuscle.Arms)} />
<path id="right-arm" d="M135 70 L160 140 L145 145 L130 100 Z" className={getStyle(TargetMuscle.Arms)} />

{/* Legs (Quads) */}
<path id="left-leg" d="M80 185 L55 280 L75 280 L95 190 Z" className={getStyle(TargetMuscle.Legs)} />
<path id="right-leg" d="M120 185 L145 280 L125 280 L105 190 Z" className={getStyle(TargetMuscle.Legs)} />

{/* Cardio Aura (if active) */}
{(activeMuscle === TargetMuscle.Cardio || intensities[TargetMuscle.Cardio] !== "inactive") && (
<circle cx="100" cy="95" r="15" className="fill-red-500/50 animate-ping" />
)}
</g>
) : (
<g id="back-view">
{/* Head/Neck (Inactive base) */}
<path d="M85 30 C85 10, 115 10, 115 30 L110 50 L90 50 Z" className="fill-slate-700 stroke-slate-900 stroke-[3px]" />

{/* Back (Lats & Traps) */}
<path id="back" d="M60 70 L140 70 L115 160 Q100 170, 85 160 Z" className={getStyle(TargetMuscle.Back)} />

{/* Arms (Triceps/Shoulders from back) */}
<path id="left-arm-back" d="M55 70 L35 140 L50 145 L65 100 Z" className={getStyle(TargetMuscle.Arms)} />
<path id="right-arm-back" d="M145 70 L165 140 L150 145 L135 100 Z" className={getStyle(TargetMuscle.Arms)} />

{/* Legs (Hamstrings/Calves) */}
<path id="left-leg-back" d="M85 165 L60 280 L80 280 L95 180 Z" className={getStyle(TargetMuscle.Legs)} />
<path id="right-leg-back" d="M115 165 L140 280 L120 280 L105 180 Z" className={getStyle(TargetMuscle.Legs)} />
</g>
)}
</svg>
</div>
);
}
Loading
Loading