Initial commit for Start9 packaging

This commit is contained in:
MacPro
2026-02-28 09:27:26 -06:00
commit 1b64c45c52
124 changed files with 15671 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Database
DATABASE_URL=file:./data/app.db
# API Keys
CLAUDE_API_KEY=your_claude_api_key_here
+57
View File
@@ -0,0 +1,57 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next
out
dist
build
# Production
.env.production.local
.env.local
# Misc
.DS_Store
*.pem
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Debug
.vscode
.idea
*.swp
*.swo
# Environment
.env
.env.*.local
# Database
data/*.db
data/*.db-wal
data/*.db-shm
prisma/dev.db
prisma/dev.db-wal
prisma/dev.db-shm
# IDE
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# Server
logs/
.server.pid
# OS
Thumbs.db
.DS_Store
+56
View File
@@ -0,0 +1,56 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
# Install dependencies
RUN npm ci || npm install
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build Next.js
RUN npm run build
# Runtime stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Copy built application from builder
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
# Create data directory for database
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["node", "server.js"]
+239
View File
@@ -0,0 +1,239 @@
# Workout Planner PWA Icons - Complete Index
## Quick Start
To regenerate icons anytime:
```bash
node scripts/generate-icons.js
```
## Project Structure
```
workout-planner/
├── scripts/
│ ├── generate-icons.js # Main icon generation script
│ └── ICON_GENERATOR_README.md # Generation documentation
├── public/
│ ├── manifest.json # Updated PWA manifest
│ └── icons/
│ ├── icon-72x72.png # Small devices
│ ├── icon-96x96.png # Android Chrome smaller
│ ├── icon-128x128.png # Chrome Web Store
│ ├── icon-144x144.png # Windows medium tiles
│ ├── icon-152x152.png # iPad retina
│ ├── icon-192x192.png # Android standard
│ ├── icon-192x192-maskable.png # Android adaptive
│ ├── icon-384x384.png # Large displays
│ ├── icon-512x512.png # Install prompts
│ ├── icon-512x512-maskable.png # Android adaptive large
│ ├── favicon.svg # Vector favicon
│ ├── README.md # Icon directory docs
│ └── HEAD_INTEGRATION_EXAMPLE.html # HTML integration guide
├── ICON_GENERATION_SUMMARY.txt # Complete technical summary
└── ICON_FILES_INDEX.md # This file
```
## File Descriptions
### Scripts
**generate-icons.js** (6.8 KB)
- Node.js icon generator script
- No external dependencies (built-in modules only)
- Generates all PNG icons from scratch
- Also generates SVG favicon
- Executable: `node scripts/generate-icons.js`
**ICON_GENERATOR_README.md** (3.5 KB)
- Complete technical documentation
- Usage instructions
- Customization guide
- Browser support information
### Generated Icons (public/icons/)
All PNG icons are RGBA format with proper PNG structure:
**Standard Icons (8 files)**
| Size | File | Use Case | File Size |
|------|------|----------|-----------|
| 72x72 | icon-72x72.png | Small devices, Windows tiles | 252 B |
| 96x96 | icon-96x96.png | Android Chrome smaller displays | 482 B |
| 128x128 | icon-128x128.png | Chrome Web Store | 665 B |
| 144x144 | icon-144x144.png | Windows medium tiles | 972 B |
| 152x152 | icon-152x152.png | iPad retina displays | 833 B |
| 192x192 | icon-192x192.png | Android Chrome standard, home screen | 1.3 KB |
| 384x384 | icon-384x384.png | Larger displays, splash screens | 4.2 KB |
| 512x512 | icon-512x512.png | Install prompts, app stores | 7.2 KB |
**Maskable Icons (2 files)**
| Size | File | Use Case | File Size |
|------|------|----------|-----------|
| 192x192 | icon-192x192-maskable.png | Android 8.0+ adaptive icons | 1.3 KB |
| 512x512 | icon-512x512-maskable.png | Android 8.0+ adaptive icons large | 7.2 KB |
**Vector Icon (1 file)**
| Format | File | Use Case | File Size |
|--------|------|----------|-----------|
| SVG | favicon.svg | Modern browser favicon | 252 B |
**Total Icons Size: ~25 KB**
### Documentation
**README.md** (3.8 KB, in public/icons/)
- Icon file descriptions
- Design specifications
- Integration instructions
- Browser support details
- Adaptive icon information
**HEAD_INTEGRATION_EXAMPLE.html** (2.8 KB)
- Copy-paste HTML code for integration
- Complete HTML5 structure example
- PWA manifest links
- Meta tags for theming
**ICON_GENERATION_SUMMARY.txt** (Large reference)
- Complete technical overview
- Implementation details
- Verification information
- Customization guide
### Configuration Files
**manifest.json** (Updated)
- PWA manifest with icon references
- Background color: #0A0A0A (dark luxury)
- Theme color: #FFFFFF (white)
- All 10 icon variants configured
- Icon purposes: "any" or "maskable"
## Design Specifications
**Color Scheme**
- Background: #0A0A0A (10, 10, 10 RGB)
- Foreground: #FFFFFF (255, 255, 255 RGB)
- Style: Clean, bold, geometric "W" letter
**Size Coverage**
- Mobile devices: 72px - 152px
- Standard web: 192px - 384px
- Large displays: 512px
- Maskable variants: 192px, 512px
- Vector: SVG favicon
**Platform Support**
- Chrome/Edge PWA
- Firefox PWA
- Safari iOS (Apple Touch Icon)
- Android app (with adaptive icons)
- Windows tiles
- Desktop browser tabs
## HTML Integration
Add to `<head>`:
```html
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<meta name="theme-color" content="#FFFFFF">
<meta name="background-color" content="#0A0A0A">
```
See `public/icons/HEAD_INTEGRATION_EXAMPLE.html` for complete HTML example.
## Technical Details
### PNG Generation Process
1. **Rendering**: Draw white rectangles forming "W" on black background
2. **PNG Format**: Proper PNG structure with IHDR, IDAT, IEND chunks
3. **Compression**: zlib deflate algorithm for ~90% size reduction
4. **Integrity**: CRC32 checksums for data validation
5. **Output**: Valid PNG files verified with `file` command
### Performance
- Generation time: < 1 second for all 11 icons
- Compression ratio: ~90% due to simple color scheme
- No external dependencies required
## Customization
### Change Colors
1. Edit `/scripts/generate-icons.js`:
```javascript
const BACKGROUND_COLOR = { r: 10, g: 10, b: 10 }; // Edit background
const TEXT_COLOR = { r: 255, g: 255, b: 255 }; // Edit foreground
```
2. Regenerate: `node scripts/generate-icons.js`
3. (Optional) Update `/public/manifest.json` theme colors
### Change Design
1. Edit `drawW()` function in `/scripts/generate-icons.js`
2. Modify bar positions, widths, or create new shapes
3. Regenerate: `node scripts/generate-icons.js`
## File Locations (Absolute Paths)
### Core Files
- Script: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/generate-icons.js`
- Icons: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/`
- Manifest: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/manifest.json`
### Documentation
- Generator README: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/ICON_GENERATOR_README.md`
- Icons README: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/README.md`
- HTML Example: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/HEAD_INTEGRATION_EXAMPLE.html`
- Summary: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/ICON_GENERATION_SUMMARY.txt`
- This Index: `/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/ICON_FILES_INDEX.md`
## Verification
All files have been verified:
- PNG signature correct (137 80 78 71...)
- Proper PNG structure (IHDR, IDAT, IEND chunks)
- CRC32 checksums validated
- Correct dimensions for all sizes
- File sizes optimized
- SVG favicon valid
## Usage Summary
| Task | Command | Location |
|------|---------|----------|
| Generate icons | `node scripts/generate-icons.js` | From project root |
| View icon docs | Read `public/icons/README.md` | - |
| Copy HTML code | See `public/icons/HEAD_INTEGRATION_EXAMPLE.html` | - |
| View generation docs | Read `scripts/ICON_GENERATOR_README.md` | - |
| Full reference | Read `ICON_GENERATION_SUMMARY.txt` | - |
## Browser Compatibility
- **Chrome/Edge**: Full PWA support with maskable icons
- **Firefox**: PWA installation support
- **Safari/iOS**: Apple Touch Icon support
- **Android**: Adaptive icon support with maskable variants
- **Windows**: App tile support
- **Desktop**: Favicon support across all browsers
## Next Steps
1. Add HTML manifest link to your pages
2. Test PWA installation on target platforms
3. Verify icon appearance on home screens
4. (Optional) Customize colors and design
5. Deploy to production
---
Generated: 2026-02-17
Project: Workout Planner - Dark Luxury Aesthetic
+185
View File
@@ -0,0 +1,185 @@
================================================================================
PWA ICON GENERATION COMPLETE
================================================================================
Project: Workout Planner - Dark Luxury Aesthetic
Generated: 2026-02-17
================================================================================
WHAT WAS CREATED
================================================================================
1. ICON GENERATION SCRIPT
Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/generate-icons.js
- Standalone Node.js script (no external dependencies needed)
- Uses only built-in Node.js modules: fs, zlib, path
- Generates valid PNG files with proper compression
- Uses PNG format (IHDR + IDAT chunks with zlib compression)
- Calculates CRC32 checksums for data integrity
2. GENERATED PNG ICON FILES (11 files)
Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/
Standard Icons:
- icon-72x72.png (252 bytes)
- icon-96x96.png (482 bytes)
- icon-128x128.png (665 bytes)
- icon-144x144.png (972 bytes)
- icon-152x152.png (833 bytes)
- icon-192x192.png (1.3 KB)
- icon-384x384.png (4.2 KB)
- icon-512x512.png (7.2 KB)
Maskable Icons (for Android adaptive icons):
- icon-192x192-maskable.png (1.3 KB)
- icon-512x512-maskable.png (7.2 KB)
SVG Favicon:
- favicon.svg (252 bytes)
3. DOCUMENTATION
Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/ICON_GENERATOR_README.md
- Complete usage guide
- Technical implementation details
- Integration instructions for HTML
4. UPDATED MANIFEST
Location: /sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/manifest.json
- Updated to reference all generated icon sizes
- Set background_color to #0A0A0A (near-black)
- Set theme_color to #FFFFFF (white)
- Added maskable icon variants for Android
- Updated description for luxury gym aesthetic
================================================================================
DESIGN SPECIFICATIONS
================================================================================
Color Scheme:
- Background: #0A0A0A (near-black, luxury aesthetic)
- Text/Icon: #FFFFFF (white, high contrast)
Icon Style:
- Stylized "W" letter (represents "Workout")
- Geometric design using rectangles/bars
- Clean, bold, modern appearance
- Scales proportionally across all sizes
Supported Sizes:
- 72x72 (Chrome Web Store, Windows tiles small)
- 96x96 (Android Chrome)
- 128x128 (Chrome Web Store)
- 144x144 (Windows tiles medium)
- 152x152 (iPad retina)
- 192x192 (Android Chrome, standard)
- 384x384 (Large displays)
- 512x512 (Splash screens, install prompts)
Maskable Variants:
- Android 8.0+ devices use "adaptive icons"
- Maskable icons can be displayed in various shapes
- 192x192 and 512x512 maskable variants provided
================================================================================
HOW TO USE
================================================================================
1. Run the Generator (if icons need to be regenerated):
$ node scripts/generate-icons.js
Output: All PNG files generated in public/icons/
2. HTML Integration:
Add to <head> section:
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<meta name="theme-color" content="#FFFFFF">
<meta name="background-color" content="#0A0A0A">
3. PWA Installation:
- Icons automatically used by browsers when adding to home screen
- Manifest.json defines icon purposes (any vs maskable)
- All common device sizes covered for optimal display
================================================================================
TECHNICAL IMPLEMENTATION
================================================================================
PNG Generation Process:
1. Allocate RGBA pixel buffer (size × size × 4 bytes)
2. Fill background with #0A0A0A
3. Draw stylized "W" using rectangle fill operations
4. Prepare PNG IHDR chunk (image header with metadata)
5. Prepare IDAT chunk:
- Add filter byte (0 = None) to each scanline
- Compress with zlib.deflateSync()
6. Calculate CRC32 checksum for data integrity
7. Combine signature + IHDR + IDAT + IEND chunks
8. Write to disk as valid PNG file
Performance:
- Fast generation: All icons generated in <1 second
- Efficient compression: zlib reduces RGBA pixel data by ~90%
- Small file sizes: Total icon set = ~25 KB
No External Dependencies:
- Uses only Node.js core modules
- No npm packages required
- Works in any Node.js environment
================================================================================
FILE LOCATIONS
================================================================================
Script:
/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/generate-icons.js
Generated Icons:
/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/icons/
Manifest:
/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/public/manifest.json
Documentation:
/sessions/pensive-sharp-rubin/mnt/Workout-log/workout-planner/scripts/ICON_GENERATOR_README.md
================================================================================
VERIFICATION
================================================================================
All PNG files verified as valid:
✓ Correct PNG signature (137 80 78 71...)
✓ IHDR chunk with correct dimensions
✓ IDAT chunk with zlib-compressed data
✓ IEND chunk for proper termination
✓ CRC32 checksums validated
File sizes optimized and verified with 'file' command.
================================================================================
NEXT STEPS
================================================================================
1. Test PWA installation on different browsers:
- Chrome/Edge: Install as app
- Firefox: Install as Progressive Web App
- Safari iOS: Add to Home Screen
- Android: Install with adaptive icon
2. Verify icon display in:
- Address bar
- Home screen
- App launcher
- Task switcher
3. Customize if needed:
- Edit BACKGROUND_COLOR in generate-icons.js
- Edit TEXT_COLOR for different colors
- Modify drawW() function for different letter design
- Re-run: node scripts/generate-icons.js
================================================================================
+62
View File
@@ -0,0 +1,62 @@
# Workout Planner
A self-hosted workout planner and logger. Plan training cycles, log daily workouts, search your history, and get AI-powered suggestions over time.
## Quick Start
```bash
# Install dependencies
npm install
# Set up the database
npx prisma db push
# Seed with exercises and default user
npm run db:seed
# Start development server
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
**Default login:** `admin@local` / `workout123`
## Access from Other Devices
To access from your phone or iPad on the same network:
```bash
npm run dev -- --hostname 0.0.0.0
```
Then open `http://<your-computer-ip>:3000` on your device. You can install it as a PWA (Add to Home Screen) for an app-like experience.
## Docker Deployment
```bash
docker compose up -d
```
## Tech Stack
- **Next.js 14** (App Router) — full-stack TypeScript
- **SQLite** + Prisma ORM — local database, no separate server
- **Tailwind CSS** — mobile-first responsive design
- **PWA** — installable on any device
## Project Structure
```
app/
auth/login/ — Login page
main/
dashboard/ — Quick stats and recent workouts
workouts/ — Workout history, logger, detail views
exercises/ — Exercise library
settings/ — Preferences and AI config
api/ — REST API routes
components/ — Reusable UI components
lib/ — Database queries, auth, utilities
prisma/ — Schema and seed data
```
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { prisma } from '@/lib/prisma';
export async function POST(_request: NextRequest) {
try {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get('sessionToken');
if (sessionCookie) {
// Delete the session from the database
await prisma.session.delete({
where: {
token: sessionCookie.value,
},
}).catch(() => {
// Session might not exist, that's ok
});
// Clear the cookie
cookieStore.delete('sessionToken');
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json(
{ error: 'An error occurred during logout' },
{ status: 500 }
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyPassword, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = loginSchema.parse(body);
// Look up user by email
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Verify the password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Create a session
const session = await createSession(user.id);
// Set the session cookie
const response = NextResponse.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
response.cookies.set('sessionToken', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return response;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
);
}
console.error('Login error:', error);
return NextResponse.json(
{ error: 'An error occurred during login' },
{ status: 500 }
);
}
}
@@ -0,0 +1,189 @@
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
/**
* GET /api/exercises/[id]
* Get exercise with history
*/
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
userId: user.id,
},
});
if (!exercise) {
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
// Get exercise history grouped by workout
const setLogs = await prisma.setLog.findMany({
where: {
exerciseId: params.id,
workout: {
userId: user.id,
},
},
include: {
workout: {
select: {
id: true,
date: true,
name: true,
},
},
},
orderBy: [
{ workout: { date: "desc" } },
{ setNumber: "asc" },
],
take: 100,
});
// Group by workout
const workoutMap = new Map<string, { workout: any; sets: any[] }>();
for (const log of setLogs) {
const key = log.workoutId;
if (!workoutMap.has(key)) {
workoutMap.set(key, { workout: log.workout, sets: [] });
}
workoutMap.get(key)!.sets.push(log);
}
const history = Array.from(workoutMap.values()).slice(0, 20);
return NextResponse.json({
exercise,
history,
});
} catch (error) {
console.error("GET /api/exercises/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
/**
* PATCH /api/exercises/[id]
* Edit exercise details
*/
const updateExerciseSchema = z.object({
name: z.string().min(1).optional(),
type: z.string().min(1).optional(),
muscleGroups: z.array(z.string()).optional(),
description: z.string().optional(),
inputFields: z.array(z.string().min(1)).optional(),
defaultWeightUnit: z.string().nullable().optional(),
});
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
userId: user.id,
},
});
if (!exercise) {
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
const body = await request.json();
const validated = updateExerciseSchema.parse(body);
const data: any = {};
if (validated.name !== undefined) data.name = validated.name;
if (validated.type !== undefined) data.type = validated.type;
if (validated.description !== undefined) data.description = validated.description;
if (validated.muscleGroups !== undefined)
data.muscleGroups = JSON.stringify(validated.muscleGroups);
if (validated.inputFields !== undefined)
data.inputFields = JSON.stringify(validated.inputFields);
if (validated.defaultWeightUnit !== undefined)
data.defaultWeightUnit = validated.defaultWeightUnit;
const updated = await prisma.exercise.update({
where: { id: params.id },
data,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid data", details: error.errors },
{ status: 400 }
);
}
console.error("PATCH /api/exercises/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
/**
* DELETE /api/exercises/[id]
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const exercise = await prisma.exercise.findFirst({
where: {
id: params.id,
userId: user.id,
},
});
if (!exercise) {
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
}
await prisma.setLog.deleteMany({
where: { exerciseId: params.id },
});
await prisma.exercise.delete({
where: { id: params.id },
});
return NextResponse.json({ message: "Exercise deleted successfully" });
} catch (error) {
console.error("DELETE /api/exercises/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import { getCurrentUser } from "@/lib/auth";
import { getExercises, createExercise } from "@/lib/db/exercises";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateExerciseSchema = z.object({
name: z.string().min(1, "Exercise name is required"),
type: z.string().min(1),
muscleGroups: z.array(z.string()).default([]),
description: z.string().optional(),
inputFields: z.array(z.string().min(1)).optional(),
defaultWeightUnit: z.string().nullable().optional(),
});
/**
* GET /api/exercises
*/
export async function GET(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
let exercises;
if (query) {
exercises = await prisma.exercise.findMany({
where: {
userId: user.id,
name: { contains: query },
},
orderBy: { name: "asc" },
});
} else {
exercises = await getExercises(user.id);
}
return NextResponse.json(exercises);
} catch (error) {
console.error("GET /api/exercises error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
/**
* POST /api/exercises
*/
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const validated = CreateExerciseSchema.parse(body);
const existing = await prisma.exercise.findUnique({
where: {
userId_name: {
userId: user.id,
name: validated.name,
},
},
});
if (existing) {
return NextResponse.json(
{ error: "Exercise already exists" },
{ status: 400 }
);
}
// Determine default inputFields based on type
let inputFields = validated.inputFields;
if (!inputFields) {
if (validated.type === "cardio") {
inputFields = ["sets", "duration", "calories"];
} else {
inputFields = ["sets", "reps", "weight"];
}
}
// Kettlebell defaults to kg
let defaultWeightUnit = validated.defaultWeightUnit;
if (defaultWeightUnit === undefined && validated.type === "kettlebell") {
defaultWeightUnit = "kg";
}
const exercise = await createExercise({
userId: user.id,
name: validated.name,
type: validated.type,
description: validated.description,
muscleGroups: JSON.stringify(validated.muscleGroups),
inputFields: JSON.stringify(inputFields),
defaultWeightUnit: defaultWeightUnit || null,
isCustom: true,
});
return NextResponse.json(exercise, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation error", details: error.errors },
{ status: 400 }
);
}
console.error("POST /api/exercises error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const revalidate = 0;
/**
* GET /api/health
* Health check endpoint — verifies both the server and database are operational.
* Used by StartOS health checks and Docker health checks.
* Excluded from auth middleware in middleware.ts.
*/
export async function GET() {
try {
// Verify database connectivity with a lightweight query
const userCount = await prisma.user.count();
return NextResponse.json({
status: "ok",
timestamp: Date.now(),
database: "connected",
users: userCount,
});
} catch (error) {
// Server is up but database is unreachable or corrupted
return NextResponse.json(
{
status: "error",
timestamp: Date.now(),
database: "disconnected",
error: error instanceof Error ? error.message : "Unknown database error",
},
{ status: 503 }
);
}
}
@@ -0,0 +1,281 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Exercise name mapping - CSV shorthand to DB names
const NAME_MAP: Record<string, string> = {
"Ab Wheel": "Ab Wheel Rollout",
"BB Upright Row": "Upright Row",
"Ball Situp": "Exercise Ball Situp",
"Bench": "Bench Press",
"DB Lateral Raise": "Lateral Raise",
"Dip": "Dips (Chest)",
"Face Pull": "Face Pulls",
"SA Lat Pulldown": "Lat Pulldown",
"SL Calf Raise": "Calf Raise",
"BB Row": "Barbell Row",
"DB Row": "Dumbbell Row",
"GHD": "Glute ham developer",
"Hamstring DL": "Hamstring deadlift",
"BB Curl": "Barbell Curl",
"BB Hip Bridge": "Hip Thrust",
"Cable Trap": "Rear delt",
"Chinup (Narrow)": "Chinup",
"Chinup Negatives": "Chinup",
"Squat (Foot Elevated)": "Squat",
"Ball Bicep Curl": "Dumbbell Curl",
"KB Hip Flexor": "Hip Flexor",
"Hamstring Deadlift": "Hamstring deadlift",
"Shoulder Press": "Overhead Press",
"CoC": "Captains of Crush",
"Hex DL": "Hex Bar Deadlift",
"KB Extension": "Kettlebell Leg Extension",
"Ski": "SkiErg",
};
interface ParsedSet {
setNumber: number;
weight?: number;
weightUnit: string;
reps?: number;
notes?: string;
}
interface ParsedExercise {
exerciseId: string;
exerciseName: string;
sets: ParsedSet[];
}
interface ParsedWorkout {
date: string;
exercises: ParsedExercise[];
}
interface ParseResponse {
workouts: ParsedWorkout[];
unmapped: string[];
}
function parseCSV(content: string): Array<Record<string, string>> {
const lines = content.trim().split("\n");
if (lines.length === 0) return [];
// Parse header
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
const rows = [];
// Parse data rows
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const values = line.split(",").map((v) => v.trim());
const row: Record<string, string> = {};
header.forEach((col, idx) => {
if (values[idx]) {
row[col] = values[idx];
}
});
rows.push(row);
}
return rows;
}
function getVariationNote(originalName: string): string | null {
if (originalName.includes("Narrow")) return "narrow";
if (originalName.includes("Negatives")) return "negatives";
if (originalName.includes("Foot Elevated")) return "foot elevated";
return null;
}
function resolveExerciseName(csvName: string): string {
// Check if it's in the name map
if (NAME_MAP[csvName]) {
return NAME_MAP[csvName];
}
// Return as-is for direct lookup
return csvName;
}
// Parse dates like "1/27/2026" or "2026-01-27" into ISO date string
function parseDate(dateStr: string): string {
// Try M/D/YYYY format
const mdyMatch = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (mdyMatch) {
const month = mdyMatch[1].padStart(2, "0");
const day = mdyMatch[2].padStart(2, "0");
const year = mdyMatch[3];
return `${year}-${month}-${day}T12:00:00.000Z`;
}
// Try ISO format
if (dateStr.includes("-")) {
return new Date(dateStr + "T12:00:00.000Z").toISOString();
}
// Fallback
return new Date(dateStr).toISOString();
}
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.name.endsWith(".csv")) {
return NextResponse.json(
{ error: "File must be a CSV file" },
{ status: 400 }
);
}
const content = await file.text();
const rows = parseCSV(content);
if (rows.length === 0) {
return NextResponse.json(
{ error: "CSV is empty or invalid format" },
{ status: 400 }
);
}
// Get all user exercises for matching
const exercises = await prisma.exercise.findMany({
where: { userId: user.id },
select: { id: true, name: true },
});
// Build case-insensitive lookup map
const exerciseMap = new Map<string, string>();
for (const ex of exercises) {
exerciseMap.set(ex.name.toLowerCase(), ex.id);
}
// Group rows by date
const workoutsByDate = new Map<string, Array<Record<string, string>>>();
const unmappedExercises = new Set<string>();
for (const row of rows) {
const date = row.date || row.date_str || row.workout_date || "";
const exerciseName = row.exercise || row.exercise_name || "";
if (!date || !exerciseName) {
continue;
}
if (!workoutsByDate.has(date)) {
workoutsByDate.set(date, []);
}
workoutsByDate.get(date)!.push(row);
// Check if exercise can be resolved
const resolvedName = resolveExerciseName(exerciseName);
const isKnown = exerciseMap.has(resolvedName.toLowerCase());
if (!isKnown) {
unmappedExercises.add(exerciseName);
}
}
// Build parsed workouts
const parsedWorkouts: ParsedWorkout[] = [];
for (const [date, rowsForDate] of workoutsByDate) {
const exercisesMap = new Map<
string,
{
exerciseId: string;
exerciseName: string;
sets: ParsedSet[];
}
>();
for (const row of rowsForDate) {
const csvExerciseName = row.exercise || row.exercise_name || "";
const resolvedName = resolveExerciseName(csvExerciseName);
const exerciseId =
exerciseMap.get(resolvedName.toLowerCase()) || "";
if (!exerciseId) {
unmappedExercises.add(csvExerciseName);
continue;
}
if (!exercisesMap.has(exerciseId)) {
exercisesMap.set(exerciseId, {
exerciseId,
exerciseName: resolvedName,
sets: [],
});
}
const exerciseData = exercisesMap.get(exerciseId)!;
const weight = row.weight ? parseFloat(row.weight) : undefined;
const reps = row.reps ? parseInt(row.reps, 10) : undefined;
let notes = row.notes || "";
// Detect weight unit from notes
let weightUnit = "lbs";
if (notes.toLowerCase().includes("kg")) {
weightUnit = "kg";
}
// Add variation note if applicable
const variationNote = getVariationNote(csvExerciseName);
if (variationNote) {
notes = notes
? `${notes} (${variationNote})`
: `(${variationNote})`;
}
const setNumber = exerciseData.sets.length + 1;
exerciseData.sets.push({
setNumber,
weight,
weightUnit,
reps,
notes: notes || undefined,
});
}
const workoutExercises = Array.from(exercisesMap.values());
if (workoutExercises.length > 0) {
parsedWorkouts.push({
date: parseDate(date),
exercises: workoutExercises,
});
}
}
// Sort by date ascending (oldest first)
parsedWorkouts.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
const response: ParseResponse = {
workouts: parsedWorkouts,
unmapped: Array.from(unmappedExercises),
};
return NextResponse.json(response);
} catch (error) {
console.error("CSV parsing error:", error);
return NextResponse.json(
{ error: "Failed to parse CSV file" },
{ status: 500 }
);
}
}
@@ -0,0 +1,112 @@
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const PreferencesSchema = z.object({
theme: z.enum(["light", "dark", "system"]).optional(),
defaultWeightUnit: z.enum(["lbs", "kg"]).optional(),
enableClaudeAI: z.boolean().optional(),
claudeApiKey: z.string().optional(),
});
/**
* GET /api/preferences
* Get user preferences
*/
export async function GET(_request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let preferences = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!preferences) {
// Create default preferences
preferences = await prisma.userPreferences.create({
data: {
userId: user.id,
theme: "system",
defaultWeightUnit: "lbs",
defaultRestSeconds: 90,
enableClaudeAI: false,
},
});
}
// Don't return API key in response
const { claudeApiKey, ...safePreferences } = preferences;
return NextResponse.json({
...safePreferences,
claudeApiKey: claudeApiKey ? "***" : undefined,
});
} catch (error) {
console.error("GET /api/preferences error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
/**
* POST /api/preferences
* Update user preferences
*/
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const validated = PreferencesSchema.parse(body);
// Get or create preferences
let preferences = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!preferences) {
preferences = await prisma.userPreferences.create({
data: {
userId: user.id,
...validated,
},
});
} else {
preferences = await prisma.userPreferences.update({
where: { userId: user.id },
data: validated,
});
}
// Don't return API key in response
const { claudeApiKey, ...safePreferences } = preferences;
return NextResponse.json({
...safePreferences,
claudeApiKey: claudeApiKey ? "***" : undefined,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: "Validation error",
details: error.errors,
},
{ status: 400 }
);
}
console.error("POST /api/preferences error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,176 @@
import { getCurrentUser } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { writeFile, copyFile, unlink } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
import { execSync } from "child_process";
/**
* POST /api/settings/import-db
* Upload a SQLite database file to replace the current one.
* Creates a backup of the existing DB before replacing.
* Validates the uploaded file is a valid SQLite database with the expected tables.
*/
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("database") as File | null;
if (!file) {
return NextResponse.json(
{ error: "No database file provided" },
{ status: 400 }
);
}
// Basic size check (SQLite DBs for this app should be under 100MB)
if (file.size > 100 * 1024 * 1024) {
return NextResponse.json(
{ error: "File too large (max 100MB)" },
{ status: 400 }
);
}
// Read the uploaded file into a buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Check SQLite magic bytes (first 16 bytes should start with "SQLite format 3\0")
const magic = buffer.slice(0, 16).toString("ascii");
if (!magic.startsWith("SQLite format 3")) {
return NextResponse.json(
{ error: "Invalid file — not a SQLite database" },
{ status: 400 }
);
}
// Determine the database file path from DATABASE_URL
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
let dbPath: string;
if (dbUrl.startsWith("file:")) {
dbPath = dbUrl.replace("file:", "");
// Handle relative paths
if (!path.isAbsolute(dbPath)) {
dbPath = path.resolve(process.cwd(), "prisma", dbPath.replace("./", ""));
}
} else {
dbPath = path.resolve(process.cwd(), "prisma", "data", "app.db");
}
// Write uploaded file to a temp location for validation
const tempPath = dbPath + ".upload-temp";
await writeFile(tempPath, buffer);
// Validate the uploaded DB has the expected tables
try {
const tables = execSync(
`sqlite3 "${tempPath}" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"`,
{ encoding: "utf-8", timeout: 10000 }
).trim();
const tableList = tables.split("\n").map((t) => t.trim());
const requiredTables = ["User", "Exercise", "Workout", "SetLog"];
const missingTables = requiredTables.filter(
(t) => !tableList.includes(t)
);
if (missingTables.length > 0) {
await unlink(tempPath);
return NextResponse.json(
{
error: `Invalid database — missing tables: ${missingTables.join(", ")}. This doesn't look like a Workout Planner database.`,
},
{ status: 400 }
);
}
// Run integrity check
const integrity = execSync(
`sqlite3 "${tempPath}" "PRAGMA integrity_check;"`,
{ encoding: "utf-8", timeout: 10000 }
).trim();
if (integrity !== "ok") {
await unlink(tempPath);
return NextResponse.json(
{ error: "Database integrity check failed — file may be corrupted" },
{ status: 400 }
);
}
} catch (err) {
// Clean up temp file
if (existsSync(tempPath)) await unlink(tempPath);
return NextResponse.json(
{ error: "Could not validate the uploaded database file" },
{ status: 400 }
);
}
// Get some stats from the uploaded DB for the response
let stats = { users: 0, exercises: 0, workouts: 0 };
try {
const userCount = execSync(
`sqlite3 "${tempPath}" "SELECT COUNT(*) FROM User;"`,
{ encoding: "utf-8" }
).trim();
const exerciseCount = execSync(
`sqlite3 "${tempPath}" "SELECT COUNT(*) FROM Exercise;"`,
{ encoding: "utf-8" }
).trim();
const workoutCount = execSync(
`sqlite3 "${tempPath}" "SELECT COUNT(*) FROM Workout;"`,
{ encoding: "utf-8" }
).trim();
stats = {
users: parseInt(userCount) || 0,
exercises: parseInt(exerciseCount) || 0,
workouts: parseInt(workoutCount) || 0,
};
} catch {
// Stats are optional, continue anyway
}
// Back up the current database
const backupPath = dbPath + ".backup-" + Date.now();
if (existsSync(dbPath)) {
await copyFile(dbPath, backupPath);
}
// Replace the current database with the uploaded one
await copyFile(tempPath, dbPath);
// Also remove WAL/SHM files if they exist (SQLite journal files)
for (const ext of ["-wal", "-shm", "-journal"]) {
const journalPath = dbPath + ext;
if (existsSync(journalPath)) {
await unlink(journalPath);
}
}
// Clean up temp file
await unlink(tempPath);
return NextResponse.json({
success: true,
message: "Database imported successfully. Please refresh the page.",
stats,
backup: path.basename(backupPath),
});
} catch (error) {
console.error("Database import error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "An error occurred during import",
},
{ status: 500 }
);
}
}
@@ -0,0 +1,239 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma, getCaloriesBurned, setCaloriesBurned } from "@/lib/prisma";
import { z } from "zod";
// GET: Get workout by ID
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const workout = await prisma.workout.findUnique({
where: { id: params.id },
include: {
setLogs: {
include: {
exercise: true,
},
orderBy: {
setNumber: "asc",
},
},
},
});
if (!workout) {
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
}
if (workout.userId !== user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
// Prisma client doesn't know about caloriesBurned — fetch via raw SQL
const caloriesBurned = await getCaloriesBurned(workout.id);
return NextResponse.json({ ...workout, caloriesBurned });
} catch (error) {
console.error("Failed to fetch workout:", error);
return NextResponse.json(
{ error: "Failed to fetch workout" },
{ status: 500 }
);
}
}
// PATCH: Update workout — supports metadata-only or full update with sets
const setSchema = z.object({
exerciseId: z.string().min(1),
setNumber: z.number().int().positive(),
reps: z.number().int().positive().optional().nullable(),
weight: z.number().optional().nullable(),
weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional().nullable(),
notes: z.string().optional().nullable(),
});
const updateWorkoutSchema = z.object({
name: z.string().optional(),
notes: z.string().optional().nullable(),
date: z.string().optional(), // ISO date string
durationMinutes: z.number().int().positive().optional().nullable(),
difficulty: z.number().int().min(1).max(10).optional().nullable(),
caloriesBurned: z.number().int().positive().optional().nullable(),
sets: z.array(setSchema).optional(), // if provided, replaces all sets
});
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const workout = await prisma.workout.findUnique({
where: { id: params.id },
select: { userId: true },
});
if (!workout) {
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
}
if (workout.userId !== user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const body = await request.json();
const validated = updateWorkoutSchema.parse(body);
// Extract caloriesBurned separately — handled via raw SQL
const caloriesValue = validated.caloriesBurned;
const hasCaloriesUpdate = validated.caloriesBurned !== undefined;
// Build the Prisma-compatible workout update data (no caloriesBurned)
const workoutData: Record<string, unknown> = {};
if (validated.name !== undefined) workoutData.name = validated.name;
if (validated.notes !== undefined) workoutData.notes = validated.notes || null;
if (validated.date !== undefined) workoutData.date = new Date(validated.date);
if (validated.durationMinutes !== undefined)
workoutData.durationMinutes = validated.durationMinutes;
if (validated.difficulty !== undefined)
workoutData.difficulty = validated.difficulty;
// If sets are provided, do a full replace inside a transaction
if (validated.sets) {
const result = await prisma.$transaction(async (tx) => {
// Update workout metadata (without caloriesBurned)
if (Object.keys(workoutData).length > 0) {
await tx.workout.update({ where: { id: params.id }, data: workoutData });
}
// Delete all existing sets
await tx.setLog.deleteMany({
where: { workoutId: params.id },
});
// Create new sets
if (validated.sets!.length > 0) {
await tx.setLog.createMany({
data: validated.sets!.map((set) => ({
workoutId: params.id,
exerciseId: set.exerciseId,
setNumber: set.setNumber,
reps: set.reps ?? undefined,
weight: set.weight ?? undefined,
weightUnit: set.weightUnit,
rpe: set.rpe ?? undefined,
notes: set.notes ?? undefined,
} as any)),
});
}
// Return full updated workout
return tx.workout.findUnique({
where: { id: params.id },
include: {
setLogs: {
include: { exercise: true },
orderBy: { setNumber: "asc" },
},
},
});
});
// Update caloriesBurned via raw SQL (outside transaction since Prisma doesn't know this column)
if (hasCaloriesUpdate) {
await setCaloriesBurned(params.id, caloriesValue ?? null);
}
const calories = await getCaloriesBurned(params.id);
return NextResponse.json({ ...result, caloriesBurned: calories });
}
// Metadata-only update
if (Object.keys(workoutData).length > 0) {
await prisma.workout.update({
where: { id: params.id },
data: workoutData,
});
}
// Update caloriesBurned via raw SQL
if (hasCaloriesUpdate) {
await setCaloriesBurned(params.id, caloriesValue ?? null);
}
const updated = await prisma.workout.findUnique({
where: { id: params.id },
include: {
setLogs: {
include: { exercise: true },
orderBy: { setNumber: "asc" },
},
},
});
const calories = await getCaloriesBurned(params.id);
return NextResponse.json({ ...updated, caloriesBurned: calories });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid data", details: error.errors },
{ status: 400 }
);
}
console.error("Failed to update workout:", error);
return NextResponse.json(
{ error: "Failed to update workout" },
{ status: 500 }
);
}
}
// DELETE: Delete workout
export async function DELETE(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const workout = await prisma.workout.findUnique({
where: { id: params.id },
select: { userId: true },
});
if (!workout) {
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
}
if (workout.userId !== user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
await prisma.workout.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete workout:", error);
return NextResponse.json(
{ error: "Failed to delete workout" },
{ status: 500 }
);
}
}
@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const addSetsSchema = z.object({
exerciseId: z.string().min(1),
sets: z.array(
z.object({
setNumber: z.number().int().positive(),
reps: z.number().int().positive().optional(),
weight: z.number().optional(),
weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional(),
durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(),
distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(),
notes: z.string().optional(),
})
),
});
// POST: Add an exercise's sets to an existing workout
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const workout = await prisma.workout.findUnique({
where: { id: params.id },
select: { userId: true },
});
if (!workout) {
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
}
if (workout.userId !== user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const body = await request.json();
const validated = addSetsSchema.parse(body);
// Delete existing sets for this exercise in this workout (replace mode)
await prisma.setLog.deleteMany({
where: {
workoutId: params.id,
exerciseId: validated.exerciseId,
},
});
// Create new sets
await prisma.setLog.createMany({
data: validated.sets.map((set) => ({
workoutId: params.id,
exerciseId: validated.exerciseId,
setNumber: set.setNumber,
reps: set.reps,
weight: set.weight,
weightUnit: set.weightUnit,
rpe: set.rpe,
durationSeconds: set.durationSeconds,
distance: set.distance,
distanceUnit: set.distanceUnit,
calories: set.calories,
notes: set.notes,
} as any)),
});
// Return updated workout
const updated = await prisma.workout.findUnique({
where: { id: params.id },
include: {
setLogs: {
include: { exercise: true },
orderBy: { setNumber: "asc" },
},
},
});
return NextResponse.json(updated, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid data", details: error.errors },
{ status: 400 }
);
}
console.error("Failed to add sets:", error);
return NextResponse.json(
{ error: "Failed to add sets" },
{ status: 500 }
);
}
}
// DELETE: Remove all sets for a specific exercise from a workout
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const workout = await prisma.workout.findUnique({
where: { id: params.id },
select: { userId: true },
});
if (!workout) {
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
}
if (workout.userId !== user.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const exerciseId = searchParams.get("exerciseId");
if (!exerciseId) {
return NextResponse.json(
{ error: "exerciseId query param required" },
{ status: 400 }
);
}
await prisma.setLog.deleteMany({
where: {
workoutId: params.id,
exerciseId,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to delete sets:", error);
return NextResponse.json(
{ error: "Failed to delete sets" },
{ status: 500 }
);
}
}
@@ -0,0 +1,216 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const importSchema = z.object({
images: z.array(z.string()).min(1, "At least one image is required"),
});
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages";
const SYSTEM_PROMPT = `You are analyzing photos of handwritten workout logs, Apple Notes, or other workout records. Extract all workout data you can find.
IMPORTANT RULES:
- If you can identify a date for the workout, include it as an ISO date string (YYYY-MM-DD)
- If no date is visible, set date to null
- Extract exercise names as closely as written
- For each exercise, extract all sets with whatever data is visible (reps, weight, duration, etc.)
- If you're unsure about an exercise name or value, set "uncertain": true and explain in "uncertainReason"
- Weight units: assume lbs unless kg or kilograms is explicitly written
- For cardio exercises (running, biking, rowing, assault bike, jump rope, etc.), look for duration, distance, and calories
- Be conservative — only include data you can actually read
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
{
"workouts": [
{
"date": "2025-01-15" or null,
"name": "Upper Body" or null,
"notes": "any overall notes" or null,
"exercises": [
{
"name": "Bench Press",
"type": "barbell" | "dumbbell" | "machine" | "cable" | "bodyweight" | "cardio" | "kettlebell" | "other",
"sets": [
{
"reps": 8,
"weight": 225,
"weightUnit": "lbs",
"durationSeconds": null,
"distance": null,
"distanceUnit": null,
"calories": null,
"rpe": null,
"notes": null
}
],
"notes": null,
"uncertain": false,
"uncertainReason": null
}
]
}
],
"confidence": "high" | "medium" | "low",
"warnings": ["list any legibility issues or assumptions made"]
}`;
export async function POST(request: Request) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get user's Claude API key from preferences
const preferences = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!preferences?.enableClaudeAI || !preferences?.claudeApiKey) {
return NextResponse.json(
{
error: "Claude AI is not configured. Please add your API key in Settings.",
code: "NO_API_KEY",
},
{ status: 400 }
);
}
const body = await request.json();
const validated = importSchema.parse(body);
// Build Claude API request with vision
const content: any[] = [
{
type: "text",
text: "Please analyze the following workout log image(s) and extract all workout data. Return ONLY valid JSON.",
},
];
// Add each image
for (const imageData of validated.images) {
// imageData could be a data URL or raw base64
let base64 = imageData;
let mediaType = "image/jpeg";
if (imageData.startsWith("data:")) {
const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/);
if (match) {
mediaType = match[1];
base64 = match[2];
}
}
content.push({
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64,
},
});
}
// Call Claude API
const claudeResponse = await fetch(CLAUDE_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": preferences.claudeApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: [
{
role: "user",
content,
},
],
}),
});
if (!claudeResponse.ok) {
const errorBody = await claudeResponse.text();
console.error("Claude API error:", claudeResponse.status, errorBody);
if (claudeResponse.status === 401) {
return NextResponse.json(
{ error: "Invalid Claude API key. Please check your key in Settings.", code: "INVALID_KEY" },
{ status: 400 }
);
}
if (claudeResponse.status === 429) {
return NextResponse.json(
{ error: "Claude API rate limit reached. Please try again in a moment.", code: "RATE_LIMITED" },
{ status: 429 }
);
}
return NextResponse.json(
{ error: "Failed to analyze images. Please try again.", code: "API_ERROR" },
{ status: 502 }
);
}
const claudeData = await claudeResponse.json();
// Extract text content from Claude's response
const textContent = claudeData.content?.find((c: any) => c.type === "text");
if (!textContent?.text) {
return NextResponse.json(
{ error: "No response from Claude. Please try again.", code: "EMPTY_RESPONSE" },
{ status: 502 }
);
}
// Parse the JSON response
let parsed;
try {
// Try to extract JSON from the response (Claude might wrap it in code fences)
let jsonText = textContent.text.trim();
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonText = jsonMatch[1].trim();
}
parsed = JSON.parse(jsonText);
} catch {
console.error("Failed to parse Claude response:", textContent.text);
return NextResponse.json(
{
error: "Could not parse the workout data. The image may be too unclear.",
code: "PARSE_ERROR",
raw: textContent.text.substring(0, 500),
},
{ status: 422 }
);
}
// Validate basic structure
if (!parsed.workouts || !Array.isArray(parsed.workouts)) {
return NextResponse.json(
{ error: "Invalid response structure from Claude.", code: "INVALID_STRUCTURE" },
{ status: 422 }
);
}
return NextResponse.json(parsed);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request data", details: error.errors },
{ status: 400 }
);
}
console.error("POST /api/workouts/import error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const setSchema = z.object({
reps: z.number().int().positive().optional(),
weight: z.number().positive().optional(),
weightUnit: z.string().optional(),
durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(),
distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(),
rpe: z.number().int().min(1).max(10).optional(),
notes: z.string().optional(),
});
const exerciseSchema = z.object({
name: z.string().min(1),
type: z.string().optional(),
existingExerciseId: z.string().optional(),
sets: z.array(setSchema),
});
const workoutSchema = z.object({
date: z.string(),
name: z.string().optional(),
notes: z.string().optional(),
exercises: z.array(exerciseSchema),
});
const saveImportSchema = z.object({
workouts: z.array(workoutSchema).min(1),
});
export async function POST(request: Request) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const validated = saveImportSchema.parse(body);
// Load all user exercises for matching
const existingExercises = await prisma.exercise.findMany({
where: { userId: user.id },
});
// Build a case-insensitive lookup map
const exerciseMap = new Map<string, typeof existingExercises[0]>();
for (const ex of existingExercises) {
exerciseMap.set(ex.name.toLowerCase(), ex);
}
const createdWorkoutIds: string[] = [];
for (const workoutData of validated.workouts) {
// Resolve exercise IDs (match existing or create new)
const resolvedExercises: Array<{
exerciseId: string;
sets: z.infer<typeof setSchema>[];
}> = [];
for (const ex of workoutData.exercises) {
let exerciseId: string;
if (ex.existingExerciseId) {
// User explicitly matched this to an existing exercise
exerciseId = ex.existingExerciseId;
} else {
// Try case-insensitive match
const matched = exerciseMap.get(ex.name.toLowerCase());
if (matched) {
exerciseId = matched.id;
} else {
// Create new exercise
const newExercise = await prisma.exercise.create({
data: {
userId: user.id,
name: ex.name,
type: ex.type || "other",
muscleGroups: JSON.stringify([]),
isCustom: true,
} as any,
});
exerciseId = newExercise.id;
// Add to map so subsequent references can find it
exerciseMap.set(ex.name.toLowerCase(), newExercise);
}
}
resolvedExercises.push({ exerciseId, sets: ex.sets });
}
// Create the workout with all sets
const setLogsData: any[] = [];
for (const resolved of resolvedExercises) {
resolved.sets.forEach((set, index) => {
setLogsData.push({
exerciseId: resolved.exerciseId,
setNumber: index + 1,
reps: set.reps || null,
weight: set.weight || null,
weightUnit: set.weightUnit || "lbs",
rpe: set.rpe || null,
durationSeconds: set.durationSeconds || null,
distance: set.distance || null,
distanceUnit: set.distanceUnit || null,
calories: set.calories || null,
notes: set.notes || null,
});
});
}
const workout = await prisma.workout.create({
data: {
userId: user.id,
date: new Date(workoutData.date),
name: workoutData.name || null,
notes: workoutData.notes || null,
setLogs: {
create: setLogsData,
},
} as any,
});
createdWorkoutIds.push(workout.id);
}
return NextResponse.json({
created: createdWorkoutIds,
count: createdWorkoutIds.length,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request data", details: error.errors },
{ status: 400 }
);
}
console.error("POST /api/workouts/import/save error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+192
View File
@@ -0,0 +1,192 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma";
// Schema now supports creating empty workouts (just date) or with sets
const createWorkoutSchema = z.object({
name: z.string().optional(),
notes: z.string().optional(),
durationMinutes: z.number().int().positive().optional(),
difficulty: z.number().int().min(1).max(10).optional(),
caloriesBurned: z.number().int().positive().optional(),
date: z.string().optional(), // ISO date string or date-only string
sets: z
.array(
z.object({
exerciseId: z.string(),
setNumber: z.number().int().positive(),
reps: z.number().int().positive().optional(),
weight: z.number().positive().optional(),
weightUnit: z.string().default("lbs"),
rpe: z.number().int().min(1).max(10).optional(),
durationSeconds: z.number().int().positive().optional(),
distance: z.number().positive().optional(),
distanceUnit: z.string().optional(),
calories: z.number().int().positive().optional(),
notes: z.string().optional(),
})
)
.optional()
.default([]),
});
// GET: List workouts with search/date filters
export async function GET(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get("q");
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
const offset = parseInt(searchParams.get("offset") || "0");
const where: any = {
userId: user.id,
};
if (query) {
where.name = {
contains: query,
};
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) {
where.date.gte = new Date(dateFrom);
}
if (dateTo) {
const toDate = new Date(dateTo);
toDate.setHours(23, 59, 59, 999);
where.date.lte = toDate;
}
}
const [workouts, total] = await Promise.all([
prisma.workout.findMany({
where,
include: {
setLogs: {
include: {
exercise: true,
},
orderBy: {
setNumber: "asc",
},
},
},
orderBy: {
date: "desc",
},
take: limit,
skip: offset,
}),
prisma.workout.count({ where }),
]);
// Supplement with caloriesBurned from raw SQL
const ids = workouts.map((w) => w.id);
const caloriesMap = await getCaloriesBurnedBulk(ids);
const enriched = workouts.map((w) => ({
...w,
caloriesBurned: caloriesMap[w.id] ?? null,
}));
return NextResponse.json({
data: enriched,
meta: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
});
} catch (error) {
console.error("Failed to fetch workouts:", error);
return NextResponse.json(
{ error: "Failed to fetch workouts" },
{ status: 500 }
);
}
}
// POST: Create workout (can be empty or with sets)
export async function POST(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const validated = createWorkoutSchema.parse(body);
const workoutDate = validated.date ? new Date(validated.date) : new Date();
// Extract caloriesBurned — handled via raw SQL after creation
const caloriesValue = validated.caloriesBurned;
const createData: any = {
userId: user.id,
name: validated.name || null,
notes: validated.notes,
durationMinutes: validated.durationMinutes,
difficulty: validated.difficulty,
// caloriesBurned handled separately via raw SQL
date: workoutDate,
setLogs:
validated.sets.length > 0
? {
create: validated.sets.map((set) => ({
exerciseId: set.exerciseId,
setNumber: set.setNumber,
reps: set.reps,
weight: set.weight,
weightUnit: set.weightUnit,
rpe: set.rpe,
durationSeconds: set.durationSeconds,
distance: set.distance,
distanceUnit: set.distanceUnit,
calories: set.calories,
notes: set.notes,
} as any)),
}
: undefined,
};
const includeOpts = {
setLogs: {
include: { exercise: true },
orderBy: { setNumber: "asc" as const },
},
};
const workout = await prisma.workout.create({ data: createData, include: includeOpts });
// Set caloriesBurned via raw SQL
if (caloriesValue !== undefined) {
await setCaloriesBurned(workout.id, caloriesValue);
}
return NextResponse.json({ ...workout, caloriesBurned: caloriesValue ?? null }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request data", details: error.errors },
{ status: 400 }
);
}
console.error("Failed to create workout:", error);
return NextResponse.json(
{ error: "Failed to create workout" },
{ status: 500 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
'use server';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { verifyPassword, createSession } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function loginAction(email: string, password: string) {
try {
// Look up user by email
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
return { error: 'Invalid email or password' };
}
// Verify the password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return { error: 'Invalid email or password' };
}
// Create a session
const session = await createSession(user.id);
// Set the session cookie
const cookieStore = await cookies();
cookieStore.set('sessionToken', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/',
});
return { success: true };
} catch (error) {
console.error('Login error:', error);
return { error: 'An error occurred during login' };
}
}
export async function redirectToDashboard() {
redirect('/main/dashboard');
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { loginAction } from './actions';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await loginAction(email, password);
if (result.error) {
setError(result.error);
setLoading(false);
return;
}
if (result.success) {
router.push('/main/dashboard');
}
} catch (err) {
setError('An unexpected error occurred');
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="bg-zinc-900 rounded border border-zinc-800 shadow-2xl">
<div className="flex flex-col space-y-2 p-8 text-center">
<h1 className="text-3xl font-bold leading-none tracking-tight text-white">
Workout Planner
</h1>
<p className="text-xs text-zinc-500 mt-2 uppercase tracking-widest">
Track. Lift. Dominate.
</p>
</div>
<div className="p-8 pt-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label htmlFor="email" className="text-xs font-semibold text-white uppercase tracking-wider">
Email
</label>
<input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
disabled={loading}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-xs font-semibold text-white uppercase tracking-wider">
Password
</label>
<input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
disabled={loading}
/>
</div>
{error && (
<div className="rounded bg-red-900/50 px-4 py-3 border border-red-800 text-sm text-red-400">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 px-4 rounded bg-white text-black font-bold text-sm uppercase tracking-wider transition-all duration-200 hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
<p className="text-center text-xs text-zinc-600 mt-6 uppercase tracking-wider">
Demo: admin@example.com / password
</p>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--sidebar-width: 240px;
--nav-height: 64px;
--bottom-nav-height: 64px;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-black text-white;
background-color: #0a0a0a;
}
/* Premium heading typography */
h1, h2, h3 {
font-family: var(--font-display), sans-serif;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* Scrollbar styling for dark mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: #262626;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #404040;
}
/* App shell layout utilities */
@layer components {
.app-content {
@apply w-full md:pl-[var(--sidebar-width)];
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata, Viewport } from 'next';
import { Space_Grotesk, Bebas_Neue } from 'next/font/google';
import './globals.css';
import Script from 'next/script';
const spaceGrotesk = Space_Grotesk({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
});
const bebasNeue = Bebas_Neue({
weight: '400',
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
});
export const viewport: Viewport = {
themeColor: '#0A0A0A',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata: Metadata = {
title: 'Workout Planner',
description: 'Track. Lift. Dominate.',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Workout',
},
icons: {
icon: '/icons/favicon.svg',
apple: '/icons/icon-192x192.png',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`dark ${spaceGrotesk.variable} ${bebasNeue.variable}`}>
<head>
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body className="bg-[#0A0A0A] text-white antialiased font-sans">
{children}
<Script src="/sw-register.js" strategy="lazyOnload" />
</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
'use server';
import { cookies } from 'next/headers';
export async function logoutAction() {
const cookieStore = await cookies();
cookieStore.delete('sessionToken');
}
+194
View File
@@ -0,0 +1,194 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth";
import {
getWeeklyWorkoutCount,
getMonthlyWorkoutCount,
getYearlyWorkoutCount,
getWeeklyVolume,
} from "@/lib/db/stats";
import { getRecentWorkouts } from "@/lib/db/workouts";
import { ActivitySquare, Calendar, CalendarDays, History, Plus } from "lucide-react";
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
const [weeklyCount, monthlyCount, yearlyCount, _weeklyVolume, recentWorkouts] =
await Promise.all([
getWeeklyWorkoutCount(user.id),
getMonthlyWorkoutCount(user.id),
getYearlyWorkoutCount(user.id),
getWeeklyVolume(user.id),
getRecentWorkouts(user.id, 5),
]);
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header with greeting */}
<div className="px-4 py-6 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold text-white">
Welcome back, {user.name || "Trainer"}!
</h1>
<p className="text-zinc-400 mt-2">
Keep pushing your limits and achieving your goals.
</p>
</div>
</div>
{/* Main content */}
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 sm:gap-4 mb-8">
{/* This Week */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
This Week
</p>
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
{weeklyCount}
</p>
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
workouts
</p>
</div>
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
<ActivitySquare className="text-white w-5 h-5 sm:w-6 sm:h-6" />
</div>
</div>
</div>
{/* This Month */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
This Month
</p>
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
{monthlyCount}
</p>
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
workouts
</p>
</div>
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
<Calendar className="text-white w-5 h-5 sm:w-6 sm:h-6" />
</div>
</div>
</div>
{/* This Year */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
This Year
</p>
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
{yearlyCount}
</p>
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
workouts
</p>
</div>
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
<CalendarDays className="text-white w-5 h-5 sm:w-6 sm:h-6" />
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<Link
href="/main/workouts/new"
className="flex-1 bg-white hover:bg-gray-100 text-black font-medium py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<Plus className="w-5 h-5" />
Log Workout
</Link>
<Link
href="/main/workouts"
className="flex-1 bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<History className="w-5 h-5" />
View History
</Link>
</div>
{/* Recent Workouts */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg">
<div className="px-6 py-4 border-b border-zinc-800">
<h2 className="text-lg font-bold text-white">
Recent Workouts
</h2>
</div>
{recentWorkouts.length === 0 ? (
<div className="px-6 py-12 text-center">
<ActivitySquare className="w-12 h-12 text-zinc-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
No workouts yet
</h3>
<p className="text-zinc-400 mb-6">
Start your fitness journey by logging your first workout!
</p>
<Link
href="/main/workouts/new"
className="inline-block bg-white hover:bg-gray-100 text-black font-medium py-2 px-4 rounded-lg transition"
>
Log First Workout
</Link>
</div>
) : (
<div className="divide-y divide-zinc-800">
{recentWorkouts.map((workout) => (
<Link
key={workout.id}
href={`/main/workouts/${workout.id}`}
className="px-6 py-4 hover:bg-zinc-800 transition block"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-white">
{workout.name || "Unnamed Workout"}
</h3>
<p className="text-sm text-zinc-400 mt-1">
{new Date(workout.date).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
<p className="text-sm text-zinc-500 mt-1">
{(workout as any).setLogs.length} sets
{workout.durationMinutes &&
` · ${workout.durationMinutes} min`}
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">
{(workout as any).setLogs.length}
</div>
<p className="text-xs text-zinc-400 mt-1">
{(workout as any).setLogs.length === 1 ? "set" : "sets"}
</p>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,659 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { formatSetsSummary } from "@/lib/formatSets";
import {
ChevronLeft,
Loader,
Pencil,
Trash2,
Check,
X,
Dumbbell,
TrendingUp,
Calendar,
} from "lucide-react";
import { Exercise as PrismaExercise } from "@prisma/client";
// Extended type until Prisma client is regenerated with new fields
type Exercise = PrismaExercise & {
inputFields?: string;
defaultWeightUnit?: string | null;
};
const EXERCISE_TYPES = [
"barbell",
"dumbbell",
"machine",
"cable",
"bodyweight",
"cardio",
"kettlebell",
"other",
];
const MUSCLE_GROUPS = [
"chest",
"back",
"shoulders",
"quads",
"hamstrings",
"glutes",
"biceps",
"triceps",
"forearms",
"core",
"calves",
"full body",
"cardio",
];
const INPUT_FIELD_OPTIONS = [
{ value: "sets", label: "Sets" },
{ value: "reps", label: "Reps" },
{ value: "weight", label: "Weight" },
{ value: "duration", label: "Duration" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "notes", label: "Notes" },
];
interface WorkoutHistory {
workout: { id: string; date: string; name: string | null };
sets: Array<{
id: string;
setNumber: number;
reps: number | null;
weight: number | null;
weightUnit: string;
rpe: number | null;
durationSeconds: number | null;
distance: number | null;
calories: number | null;
notes: string | null;
}>;
}
export default function ExerciseDetailPage() {
const params = useParams();
const router = useRouter();
const exerciseId = params.id as string;
const [exercise, setExercise] = useState<Exercise | null>(null);
const [history, setHistory] = useState<WorkoutHistory[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// Edit form state
const [editName, setEditName] = useState("");
const [editType, setEditType] = useState("");
const [editMuscleGroups, setEditMuscleGroups] = useState<string[]>([]);
const [editInputFields, setEditInputFields] = useState<string[]>([]);
const [editDefaultUnit, setEditDefaultUnit] = useState<string | null>(null);
// Custom "+" add state
const [addingType, setAddingType] = useState(false);
const [newTypeText, setNewTypeText] = useState("");
const [addingMuscle, setAddingMuscle] = useState(false);
const [newMuscleText, setNewMuscleText] = useState("");
const [addingField, setAddingField] = useState(false);
const [newFieldText, setNewFieldText] = useState("");
const [customTypes, setCustomTypes] = useState<string[]>([]);
const [customMuscles, setCustomMuscles] = useState<string[]>([]);
const [customFields, setCustomFields] = useState<{ value: string; label: string }[]>([]);
useEffect(() => {
fetchExercise();
}, [exerciseId]);
const fetchExercise = async () => {
try {
const res = await fetch(`/api/exercises/${exerciseId}`);
if (!res.ok) throw new Error("Not found");
const data = await res.json();
setExercise(data.exercise);
setHistory(data.history || []);
// Populate edit form
setEditName(data.exercise.name);
setEditType(data.exercise.type);
const mg = JSON.parse(data.exercise.muscleGroups || "[]") as string[];
setEditMuscleGroups(mg);
const ifs = JSON.parse(data.exercise.inputFields || '["sets","reps","weight"]') as string[];
setEditInputFields(ifs);
setEditDefaultUnit(data.exercise.defaultWeightUnit);
// Detect custom values not in default lists
const knownTypes = EXERCISE_TYPES;
if (!knownTypes.includes(data.exercise.type)) {
setCustomTypes((prev) => prev.includes(data.exercise.type) ? prev : [...prev, data.exercise.type]);
}
const knownMuscles = MUSCLE_GROUPS;
mg.forEach((m: string) => {
if (!knownMuscles.includes(m)) {
setCustomMuscles((prev) => prev.includes(m) ? prev : [...prev, m]);
}
});
const knownFields = INPUT_FIELD_OPTIONS.map((f) => f.value);
ifs.forEach((f: string) => {
if (!knownFields.includes(f)) {
setCustomFields((prev) =>
prev.some((cf) => cf.value === f)
? prev
: [...prev, { value: f, label: f.charAt(0).toUpperCase() + f.slice(1) }]
);
}
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/exercises/${exerciseId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editName,
type: editType,
muscleGroups: editMuscleGroups,
inputFields: editInputFields,
defaultWeightUnit: editDefaultUnit,
}),
});
if (!res.ok) throw new Error("Failed to save");
const updated = await res.json();
setExercise(updated);
setEditing(false);
} catch (err) {
console.error(err);
alert("Failed to save changes");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm("Delete this exercise and all its history?")) return;
setDeleting(true);
try {
const res = await fetch(`/api/exercises/${exerciseId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete");
router.push("/main/exercises");
} catch (err) {
console.error(err);
alert("Failed to delete exercise");
setDeleting(false);
}
};
const toggleMuscleGroup = (group: string) => {
setEditMuscleGroups((prev) =>
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
);
};
const toggleInputField = (field: string) => {
setEditInputFields((prev) =>
prev.includes(field)
? prev.filter((f) => f !== field)
: [...prev, field]
);
};
if (loading) {
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
<Loader className="w-8 h-8 animate-spin text-zinc-500" />
</div>
);
}
if (!exercise) {
return (
<div className="min-h-screen bg-[#0A0A0A] p-6">
<Link
href="/main/exercises"
className="text-zinc-400 hover:text-white flex items-center gap-1 mb-4"
>
<ChevronLeft className="w-5 h-5" />
Back
</Link>
<p className="text-zinc-500">Exercise not found</p>
</div>
);
}
const muscleGroups = JSON.parse(exercise.muscleGroups || "[]") as string[];
const inputFields = JSON.parse(
exercise.inputFields || '["sets","reps","weight"]'
) as string[];
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 px-4 py-4">
<div className="max-w-2xl mx-auto flex items-center gap-4">
<Link
href="/main/exercises"
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-xl font-bold text-white flex-1 truncate">
{exercise.name}
</h1>
{!editing && (
<div className="flex items-center gap-2">
<button
onClick={() => setEditing(true)}
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
>
<Pencil className="w-5 h-5" />
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="p-2 hover:bg-zinc-900 rounded-lg text-red-500 hover:text-red-400 disabled:opacity-50"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
)}
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* Edit Mode */}
{editing ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-5">
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Name
</label>
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-white/20"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Equipment
</label>
<div className="flex flex-wrap gap-2">
{[...EXERCISE_TYPES, ...customTypes].map((type) => (
<button
key={type}
onClick={() => setEditType(type)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editType === type
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
{addingType ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newTypeText.trim().toLowerCase();
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
setCustomTypes((p) => [...p, val]);
setEditType(val);
}
setNewTypeText("");
setAddingType(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newTypeText}
onChange={(e) => setNewTypeText(e.target.value)}
onBlur={() => {
const val = newTypeText.trim().toLowerCase();
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
setCustomTypes((p) => [...p, val]);
setEditType(val);
}
setNewTypeText("");
setAddingType(false);
}}
placeholder="New type"
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingType(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Muscle Groups
</label>
<div className="flex flex-wrap gap-2">
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => (
<button
key={group}
onClick={() => toggleMuscleGroup(group)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editMuscleGroups.includes(group)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</button>
))}
{addingMuscle ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newMuscleText.trim().toLowerCase();
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
setCustomMuscles((p) => [...p, val]);
}
if (val && !editMuscleGroups.includes(val)) {
setEditMuscleGroups((p) => [...p, val]);
}
setNewMuscleText("");
setAddingMuscle(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newMuscleText}
onChange={(e) => setNewMuscleText(e.target.value)}
onBlur={() => {
const val = newMuscleText.trim().toLowerCase();
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
setCustomMuscles((p) => [...p, val]);
}
if (val && !editMuscleGroups.includes(val)) {
setEditMuscleGroups((p) => [...p, val]);
}
setNewMuscleText("");
setAddingMuscle(false);
}}
placeholder="New group"
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingMuscle(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Input Fields
</label>
<p className="text-xs text-zinc-600 mb-2">
Choose which fields are relevant when logging this exercise
</p>
<div className="flex flex-wrap gap-2">
{[...INPUT_FIELD_OPTIONS, ...customFields].map((field) => (
<button
key={field.value}
onClick={() => toggleInputField(field.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editInputFields.includes(field.value)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{field.label}
</button>
))}
{addingField ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newFieldText.trim().toLowerCase();
if (
val &&
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
!customFields.some((f) => f.value === val)
) {
setCustomFields((p) => [
...p,
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
]);
}
if (val && !editInputFields.includes(val)) {
setEditInputFields((p) => [...p, val]);
}
setNewFieldText("");
setAddingField(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newFieldText}
onChange={(e) => setNewFieldText(e.target.value)}
onBlur={() => {
const val = newFieldText.trim().toLowerCase();
if (
val &&
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
!customFields.some((f) => f.value === val)
) {
setCustomFields((p) => [
...p,
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
]);
}
if (val && !editInputFields.includes(val)) {
setEditInputFields((p) => [...p, val]);
}
setNewFieldText("");
setAddingField(false);
}}
placeholder="New field"
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingField(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Default Weight Unit
</label>
<div className="flex gap-2">
{[
{ value: null, label: "User Default" },
{ value: "lbs", label: "Pounds" },
{ value: "kg", label: "Kilograms" },
].map((opt) => (
<button
key={opt.label}
onClick={() => setEditDefaultUnit(opt.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
editDefaultUnit === opt.value
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 bg-white text-black font-bold rounded-lg hover:bg-zinc-200 disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? (
<Loader className="w-5 h-5 animate-spin" />
) : (
<Check className="w-5 h-5" />
)}
Save
</button>
<button
onClick={() => setEditing(false)}
className="flex-1 py-3 bg-zinc-800 text-white font-medium rounded-lg hover:bg-zinc-700 flex items-center justify-center gap-2"
>
<X className="w-5 h-5" />
Cancel
</button>
</div>
</div>
) : (
/* View Mode */
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 p-3 rounded-lg">
<Dumbbell className="w-6 h-6 text-zinc-400" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-white">
{exercise.name}
</h2>
<span className="inline-block mt-1 px-2 py-0.5 bg-zinc-800 text-zinc-300 rounded text-xs font-medium">
{exercise.type.charAt(0).toUpperCase() +
exercise.type.slice(1)}
</span>
</div>
</div>
{muscleGroups.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{muscleGroups.map((group) => (
<span
key={group}
className="px-2 py-1 bg-zinc-800 text-zinc-400 rounded text-xs"
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</span>
))}
</div>
)}
<div className="border-t border-zinc-800 pt-4 mt-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">
Tracked Fields
</p>
<div className="flex flex-wrap gap-2">
{inputFields.map((field) => (
<span
key={field}
className="px-2 py-1 bg-zinc-800 text-zinc-300 rounded text-xs font-medium"
>
{field.charAt(0).toUpperCase() + field.slice(1)}
</span>
))}
</div>
</div>
{exercise.defaultWeightUnit && (
<p className="text-xs text-zinc-500 mt-3">
Default unit:{" "}
<span className="text-zinc-300">
{exercise.defaultWeightUnit}
</span>
</p>
)}
</div>
)}
{/* History */}
<div>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5 text-zinc-400" />
History
</h3>
{history.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center">
<TrendingUp className="w-10 h-10 text-zinc-700 mx-auto mb-3" />
<p className="text-zinc-500">No history yet</p>
<p className="text-zinc-600 text-sm mt-1">
Start logging this exercise to see your progress
</p>
</div>
) : (
<div className="space-y-1">
{history.map((entry) => {
const summary = formatSetsSummary(
entry.sets.map((s: any) => ({ weight: s.weight, reps: s.reps, weightUnit: s.weightUnit }))
);
const dateStr = new Date(entry.workout.date).toLocaleDateString(
"en-US",
{ month: "short", day: "numeric" }
);
return (
<Link
key={entry.workout.id}
href={`/main/workouts/${entry.workout.id}`}
className="flex items-baseline gap-2 px-3 py-1.5 rounded-md hover:bg-zinc-800/60 transition"
>
<span className="text-xs text-zinc-500 flex-shrink-0 tabular-nums">
{dateStr}
</span>
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
<span className="text-xs text-zinc-500 flex-shrink-0">
{entry.sets.length} {entry.sets.length === 1 ? "set" : "sets"}
</span>
{summary && (
<>
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
<span className="text-sm text-zinc-300 truncate">
{summary}
</span>
</>
)}
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import ExercisesClient from "@/components/exercises/ExercisesClient";
export default async function ExercisesPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-white">Exercises</h1>
<p className="text-zinc-500 text-sm mt-1">
Browse and manage your exercise library
</p>
</div>
</div>
<ExercisesClient />
</div>
);
}
@@ -0,0 +1,612 @@
"use client";
import { useState, useRef } from "react";
import { ChevronLeft, Upload, Trash2, Check, X } from "lucide-react";
import Link from "next/link";
interface ParsedSet {
setNumber: number;
weight?: number;
weightUnit: string;
reps?: number;
notes?: string;
}
interface ParsedExercise {
exerciseId: string;
exerciseName: string;
sets: ParsedSet[];
}
interface ParsedWorkout {
date: string;
exercises: ParsedExercise[];
}
interface WorkoutState extends ParsedWorkout {
status: "pending" | "approved" | "skipped";
}
export default function ImportCSVPage() {
const [workouts, setWorkouts] = useState<WorkoutState[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [unmapped, setUnmapped] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentWorkout = workouts[currentIndex];
const approved = workouts.filter((w) => w.status === "approved").length;
const skipped = workouts.filter((w) => w.status === "skipped").length;
const remaining = workouts.filter((w) => w.status === "pending").length;
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file) return;
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/import/parse", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to parse CSV");
}
const data = await response.json();
const initialWorkouts: WorkoutState[] = data.workouts.map(
(w: ParsedWorkout) => ({
...w,
status: "pending" as const,
})
);
setWorkouts(initialWorkouts);
setUnmapped(data.unmapped || []);
setCurrentIndex(0);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to parse CSV");
} finally {
setLoading(false);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (fileInputRef.current) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInputRef.current.files = dataTransfer.files;
const event = new Event("change", { bubbles: true });
fileInputRef.current.dispatchEvent(event);
}
}
};
const updateSet = (
exerciseIdx: number,
setIdx: number,
field: keyof ParsedSet,
value: any
) => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
const workout = updatedWorkouts[currentIndex];
const set = workout.exercises[exerciseIdx].sets[setIdx];
if (field === "setNumber") {
set[field] = value ? parseInt(value, 10) : 0;
} else if (field === "reps") {
set[field] = value ? parseInt(value, 10) : undefined;
} else if (field === "weight") {
set[field] = value ? parseFloat(value) : undefined;
} else {
(set[field] as any) = value;
}
setWorkouts(updatedWorkouts);
};
const deleteSet = (exerciseIdx: number, setIdx: number) => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
const workout = updatedWorkouts[currentIndex];
const exercise = workout.exercises[exerciseIdx];
// Remove the set
exercise.sets.splice(setIdx, 1);
// Renumber remaining sets
exercise.sets.forEach((set, idx) => {
set.setNumber = idx + 1;
});
// If no sets left, remove the exercise
if (exercise.sets.length === 0) {
workout.exercises.splice(exerciseIdx, 1);
}
setWorkouts(updatedWorkouts);
};
const deleteExercise = (exerciseIdx: number) => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
const workout = updatedWorkouts[currentIndex];
workout.exercises.splice(exerciseIdx, 1);
setWorkouts(updatedWorkouts);
};
const approveWorkout = async () => {
if (!currentWorkout) return;
try {
setLoading(true);
// Transform workout to API format
const setLogs = [];
for (const exercise of currentWorkout.exercises) {
for (const set of exercise.sets) {
setLogs.push({
exerciseId: exercise.exerciseId,
setNumber: set.setNumber,
weight: set.weight || null,
weightUnit: set.weightUnit,
reps: set.reps || null,
notes: set.notes || null,
});
}
}
const response = await fetch("/api/workouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date: currentWorkout.date,
sets: setLogs,
}),
});
if (!response.ok) {
throw new Error("Failed to save workout");
}
// Mark as approved and move to next
const updatedWorkouts = [...workouts];
updatedWorkouts[currentIndex].status = "approved";
setWorkouts(updatedWorkouts);
// Find next pending workout
const nextPending = updatedWorkouts.findIndex(
(w) => w.status === "pending"
);
if (nextPending !== -1) {
setCurrentIndex(nextPending);
} else {
setCurrentIndex(currentIndex + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save workout");
} finally {
setLoading(false);
}
};
const skipWorkout = () => {
if (!currentWorkout) return;
const updatedWorkouts = [...workouts];
updatedWorkouts[currentIndex].status = "skipped";
setWorkouts(updatedWorkouts);
// Find next pending workout
const nextPending = updatedWorkouts.findIndex(
(w, idx) => w.status === "pending" && idx > currentIndex
);
if (nextPending !== -1) {
setCurrentIndex(nextPending);
} else {
setCurrentIndex(currentIndex + 1);
}
};
const deleteWorkout = () => {
const updatedWorkouts = workouts.filter((_, idx) => idx !== currentIndex);
setWorkouts(updatedWorkouts);
if (updatedWorkouts.length > 0) {
setCurrentIndex(Math.min(currentIndex, updatedWorkouts.length - 1));
}
};
// Upload step
if (workouts.length === 0) {
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href="/main/workouts"
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-display text-white tracking-wider">
Import Workouts
</h1>
</div>
</div>
{/* Upload Area */}
<div className="max-w-4xl mx-auto px-4 py-12">
{error && (
<div className="mb-6 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<p className="text-red-200">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-sm text-red-300 hover:text-red-200"
>
Dismiss
</button>
</div>
)}
<div className="bg-zinc-900 rounded-lg p-12 border-2 border-dashed border-zinc-700 hover:border-zinc-600 transition-colors cursor-pointer"
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-zinc-800 rounded-lg">
<Upload className="w-8 h-8 text-zinc-400" />
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">
Upload CSV File
</h2>
<p className="text-zinc-400 mb-4">
Drag and drop your CSV file here or click to select
</p>
<p className="text-sm text-zinc-500">
CSV columns: date, exercise, weight, reps, notes
</p>
</div>
{loading && (
<p className="text-zinc-400 text-sm">Parsing CSV...</p>
)}
</div>
</div>
{/* Example Format */}
<div className="mt-12 bg-zinc-900 rounded-lg p-6 border border-zinc-800">
<h3 className="text-lg font-semibold text-white mb-4">
CSV Format Example
</h3>
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
{`date,exercise,weight,reps,notes
2025-02-15,Bench,225,5,good form
2025-02-15,Bench,225,5,
2025-02-15,Bench,225,3,
2025-02-16,Squat,315,8,30kg per leg
2025-02-16,Squat,315,6,`}
</pre>
</div>
</div>
</div>
);
}
// Review step
if (!currentWorkout) {
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href="/main/workouts"
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-display text-white tracking-wider">
Import Complete
</h1>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="bg-zinc-900 rounded-lg p-8 border border-zinc-800 text-center">
<Check className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-white mb-2">
All Done!
</h2>
<p className="text-zinc-400 mb-6">
{approved} workouts approved, {skipped} skipped
</p>
<Link
href="/main/workouts"
className="inline-block px-6 py-2 bg-white text-black font-semibold rounded-lg hover:bg-zinc-200 transition-colors"
>
View Workouts
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<button
onClick={() => setWorkouts([])}
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</button>
<h1 className="text-2xl font-display text-white tracking-wider">
Review Workouts
</h1>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">
{approved} approved, {skipped} skipped, {remaining} remaining
</span>
<span className="text-zinc-500">
{currentIndex + 1} of {workouts.length}
</span>
</div>
<div className="w-full bg-zinc-800 h-2 rounded-full overflow-hidden">
<div
className="h-full bg-white transition-all duration-300"
style={{
width: `${((approved + skipped) / workouts.length) * 100}%`,
}}
/>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-6">
{error && (
<div className="mb-6 p-4 bg-red-900/20 border border-red-800 rounded-lg">
<p className="text-red-200">{error}</p>
</div>
)}
{/* Unmapped Exercises Warning */}
{unmapped.length > 0 && (
<div className="mb-6 p-4 bg-yellow-900/20 border border-yellow-800 rounded-lg">
<p className="text-yellow-200 font-semibold mb-2">
Unmapped exercises (not in your database):
</p>
<div className="flex flex-wrap gap-2">
{unmapped.map((name) => (
<span
key={name}
className="px-3 py-1 bg-yellow-900/30 border border-yellow-700 rounded text-sm text-yellow-200"
>
{name}
</span>
))}
</div>
</div>
)}
{/* Workout Card */}
<div className="bg-zinc-900 rounded-lg border border-zinc-800 overflow-hidden">
{/* Date Header */}
<div className="bg-zinc-800 px-6 py-4 border-b border-zinc-700">
<h2 className="text-xl font-semibold text-white">
{new Date(currentWorkout.date).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</h2>
</div>
{/* Exercises */}
<div className="divide-y divide-zinc-800">
{currentWorkout.exercises.map((exercise, exIdx) => (
<div key={exIdx} className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
{exercise.exerciseName}
</h3>
<button
onClick={() => deleteExercise(exIdx)}
className="p-2 hover:bg-zinc-800 rounded text-zinc-400 hover:text-red-400 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
{/* Sets Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-700">
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Set
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Weight
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Unit
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Reps
</th>
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
Notes
</th>
<th className="text-right py-2 px-3 text-zinc-400 font-medium">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{exercise.sets.map((set, setIdx) => (
<tr key={setIdx}>
<td className="py-3 px-3">
<input
type="number"
min="1"
value={set.setNumber}
onChange={(e) =>
updateSet(exIdx, setIdx, "setNumber", e.target.value)
}
className="w-12 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-center"
/>
</td>
<td className="py-3 px-3">
<input
type="number"
step="0.5"
placeholder="—"
value={set.weight || ""}
onChange={(e) =>
updateSet(exIdx, setIdx, "weight", e.target.value)
}
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white"
/>
</td>
<td className="py-3 px-3">
<select
value={set.weightUnit}
onChange={(e) =>
updateSet(
exIdx,
setIdx,
"weightUnit",
e.target.value
)
}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm"
>
<option>lbs</option>
<option>kg</option>
</select>
</td>
<td className="py-3 px-3">
<input
type="number"
min="1"
placeholder="—"
value={set.reps || ""}
onChange={(e) =>
updateSet(exIdx, setIdx, "reps", e.target.value)
}
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white"
/>
</td>
<td className="py-3 px-3">
<input
type="text"
placeholder="—"
value={set.notes || ""}
onChange={(e) =>
updateSet(exIdx, setIdx, "notes", e.target.value)
}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm"
/>
</td>
<td className="py-3 px-3 text-right">
<button
onClick={() => deleteSet(exIdx, setIdx)}
className="p-1 hover:bg-zinc-800 rounded text-zinc-400 hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex gap-3 justify-between">
<button
onClick={deleteWorkout}
className="px-4 py-2 bg-red-900/20 border border-red-800 text-red-200 rounded-lg hover:bg-red-900/30 transition-colors font-medium"
>
Delete Workout
</button>
<div className="flex gap-3">
<button
onClick={skipWorkout}
disabled={loading}
className="px-6 py-2 bg-zinc-800 border border-zinc-700 text-white rounded-lg hover:bg-zinc-700 transition-colors font-medium disabled:opacity-50"
>
Skip
</button>
<button
onClick={approveWorkout}
disabled={loading || currentWorkout.exercises.length === 0}
className="px-6 py-2 bg-white text-black rounded-lg hover:bg-zinc-200 transition-colors font-medium disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Saving..." : "Approve"}
<Check className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import ImportCSVPage from "./page-csv";
export const metadata = {
title: "Import Workouts",
description: "Import workouts from CSV",
};
export default async function ImportPage() {
const user = await getCurrentUser();
if (!user) redirect("/auth/login");
return <ImportCSVPage />;
}
+24
View File
@@ -0,0 +1,24 @@
import { getCurrentUser } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Navigation from './navigation';
export default async function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/auth/login');
}
return (
<div className="min-h-screen flex flex-col bg-[#0A0A0A]">
<Navigation userName={user.name || user.email || 'User'} />
<main className="flex-1 app-content pb-20 md:pb-0">
{children}
</main>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
Dumbbell,
ListChecks,
Upload,
Settings,
LogOut,
} from 'lucide-react';
import { logoutAction } from './actions';
interface NavigationProps {
userName: string;
}
const navLinks = [
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
{ href: '/main/import', label: 'Import', icon: Upload },
{ href: '/main/settings', label: 'Settings', icon: Settings },
];
export default function Navigation({ userName }: NavigationProps) {
const pathname = usePathname();
const router = useRouter();
const isActive = (href: string) => {
return pathname === href || pathname.startsWith(href + '/');
};
const handleLogout = async () => {
await logoutAction();
router.push('/auth/login');
};
return (
<>
{/* Desktop Sidebar */}
<aside className="hidden md:flex fixed left-0 top-0 h-screen w-[var(--sidebar-width)] border-r border-zinc-800 bg-[#0A0A0A] flex-col">
<div className="p-6 border-b border-zinc-800">
<h2 className="text-3xl font-display text-white tracking-wider">Workout</h2>
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-widest font-sans">Planner</p>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<a
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
active
? 'bg-white text-black font-semibold'
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{link.label}</span>
</a>
);
})}
</nav>
<div className="border-t border-zinc-800 p-4 space-y-4">
<div className="px-4 py-2">
<p className="text-xs text-zinc-600 uppercase tracking-widest">User</p>
<p className="font-semibold text-white truncate mt-1">{userName}</p>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 w-full justify-start text-red-500 hover:text-red-400 hover:bg-red-950/30"
>
<LogOut className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">Logout</span>
</button>
</div>
</aside>
{/* Mobile Bottom Nav */}
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<a
key={link.href}
href={link.href}
className={`flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors duration-200 ${
active
? 'text-white bg-zinc-900'
: 'text-zinc-500 hover:text-white'
}`}
>
<Icon className="w-6 h-6" />
<span className="text-xs">{link.label}</span>
</a>
);
})}
<button
onClick={handleLogout}
className="flex flex-col items-center justify-center flex-1 h-full gap-1 text-red-500 hover:text-red-400 transition-colors duration-200"
>
<LogOut className="w-6 h-6" />
<span className="text-xs">Logout</span>
</button>
</nav>
</header>
</>
);
}
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import SettingsForm from "@/components/settings/SettingsForm";
export default async function SettingsPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="text-zinc-500 text-sm mt-1">
Manage your preferences and account
</p>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6">
<SettingsForm user={user} />
</div>
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import {
ChevronLeft,
Trash2,
Loader,
Pencil,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { WorkoutWithSets } from "@/types";
import { formatSetsSummary } from "@/lib/formatSets";
function buildSetSummary(set: {
weight?: number | null;
weightUnit?: string | null;
reps?: number | null;
rpe?: number | null;
notes?: string | null;
durationSeconds?: number | null;
distance?: number | null;
calories?: number | null;
}) {
const parts: string[] = [];
if (set.weight) parts.push(`${set.weight} ${set.weightUnit === "kg" ? "kg" : "lbs"}`);
if (set.reps) parts.push(`${set.reps} reps`);
if ((set as any).durationSeconds) parts.push(`${(set as any).durationSeconds}s`);
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
if (set.rpe) parts.push(`RPE ${set.rpe}`);
if (set.notes) parts.push(set.notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
}
export default function WorkoutDetailPage() {
const params = useParams();
const router = useRouter();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const [expandedExercise, setExpandedExercise] = useState<string | null>(null);
const workoutId = params.id as string;
useEffect(() => {
const fetchWorkout = async () => {
try {
const response = await fetch(`/api/workouts/${workoutId}`);
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
setWorkout(data);
} catch (error) {
console.error("Error fetching workout:", error);
} finally {
setLoading(false);
}
};
fetchWorkout();
}, [workoutId]);
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this workout?")) return;
setDeleting(true);
try {
const response = await fetch(`/api/workouts/${workoutId}`, { method: "DELETE" });
if (!response.ok) throw new Error("Failed to delete");
router.push("/main/workouts");
} catch (error) {
console.error("Error deleting workout:", error);
alert("Failed to delete workout");
setDeleting(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
<Loader className="w-8 h-8 animate-spin text-white" />
</div>
);
}
if (!workout) {
return (
<div className="min-h-screen bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-6">
<Link href="/main/workouts" className="inline-flex items-center gap-2 text-white hover:text-gray-200 mb-4">
<ChevronLeft className="w-5 h-5" /> Back
</Link>
<p className="text-zinc-400">Workout not found</p>
</div>
</div>
);
}
// Format date
const date = new Date(workout.date);
const formattedDate = date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
// Group sets by exercise (preserving order)
const exerciseGroups: Array<{ exerciseId: string; exerciseName: string; sets: typeof workout.setLogs }> = [];
const seen = new Set<string>();
for (const set of workout.setLogs) {
if (!seen.has(set.exercise.id)) {
seen.add(set.exercise.id);
exerciseGroups.push({
exerciseId: set.exercise.id,
exerciseName: set.exercise.name,
sets: workout.setLogs.filter((s) => s.exercise.id === set.exercise.id),
});
}
}
// Build post-workout stats
const stats: string[] = [];
if (workout.durationMinutes) stats.push(`${workout.durationMinutes} min`);
if (workout.difficulty) stats.push(`Difficulty ${workout.difficulty}/10`);
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link href="/main/workouts" className="p-2 hover:bg-zinc-800 rounded-lg -ml-2" aria-label="Back">
<ChevronLeft className="w-6 h-6 text-white" />
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold text-white truncate">
{workout.name || "Unnamed Workout"}
</h1>
<p className="text-sm text-zinc-400">{formattedDate}</p>
</div>
<Link
href={`/main/workouts/new?edit=${workoutId}`}
className="p-2 hover:bg-zinc-800 text-zinc-400 hover:text-white rounded-lg transition-colors"
aria-label="Edit workout"
>
<Pencil className="w-5 h-5" />
</Link>
<button
onClick={handleDelete}
disabled={deleting}
className="p-2 hover:bg-zinc-800 text-red-500 rounded-lg -mr-2 disabled:opacity-50"
aria-label="Delete workout"
>
{deleting ? <Loader className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-4 space-y-2">
{/* Exercises — compact collapsible cards */}
{exerciseGroups.map((group) => {
const isExpanded = expandedExercise === group.exerciseId;
const setsWithData = group.sets.filter((s) => s.reps || s.weight);
const summary = formatSetsSummary(setsWithData) ||
`${group.sets.length} set${group.sets.length !== 1 ? "s" : ""}`;
return (
<div key={group.exerciseId} className="border border-zinc-800 rounded-lg bg-zinc-900">
{/* Exercise header */}
<button
type="button"
onClick={() => setExpandedExercise(isExpanded ? null : group.exerciseId)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-zinc-800/50 transition-colors rounded-lg"
>
<div className="text-left flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<h4 className="font-semibold text-white text-sm truncate">
{group.exerciseName}
</h4>
<span className="text-xs text-zinc-500 flex-shrink-0">
{group.sets.length} set{group.sets.length !== 1 ? "s" : ""}
</span>
</div>
{!isExpanded && setsWithData.length > 0 && (
<p className="text-xs text-zinc-400 mt-0.5 truncate">{summary}</p>
)}
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
) : (
<ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
)}
</button>
{/* Expanded sets — same style as locked SetRow */}
{isExpanded && (
<div className="border-t border-zinc-800 p-3 space-y-2">
{group.sets.map((set) => (
<div key={set.id} className="flex items-center gap-1.5">
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
{set.setNumber}
</div>
<p className="text-sm text-zinc-300 truncate flex-1">
{buildSetSummary(set)}
</p>
</div>
))}
</div>
)}
</div>
);
})}
{/* Post-workout info (notes, duration, difficulty) */}
{(workout.notes || stats.length > 0) && (
<div className="border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2.5 space-y-1.5">
{stats.length > 0 && (
<p className="text-xs text-zinc-400">{stats.join(" · ")}</p>
)}
{workout.notes && (
<p className="text-sm text-zinc-300">{workout.notes}</p>
)}
</div>
)}
{/* Edit button at bottom */}
<div className="pt-4 pb-8">
<Link
href={`/main/workouts/new?edit=${workoutId}`}
className="w-full py-3 border border-zinc-700 text-white font-semibold rounded-lg hover:bg-zinc-800 flex items-center justify-center gap-2 transition"
>
<Pencil className="w-5 h-5" />
Edit Workout
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { getCurrentUser } from "@/lib/auth";
import { getExercises } from "@/lib/db/exercises";
import { getWorkoutById } from "@/lib/db/workouts";
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
export const metadata = {
title: "Log Workout",
description: "Log a new workout",
};
export default async function NewWorkoutPage({
searchParams,
}: {
searchParams: { edit?: string };
}) {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
const exercises = await getExercises(user.id);
// If ?edit=WORKOUT_ID, fetch existing workout for editing
let editWorkout: EditWorkoutData | undefined;
if (searchParams.edit) {
const workout = await getWorkoutById(searchParams.edit);
if (workout && workout.userId === user.id) {
// Group sets by exercise
const grouped: Record<string, EditWorkoutData["exercises"][number]> = {};
for (const set of workout.setLogs) {
const exId = set.exercise.id;
if (!grouped[exId]) {
grouped[exId] = {
exercise: set.exercise,
sets: [],
};
}
grouped[exId].sets.push({
setNumber: set.setNumber,
reps: set.reps ?? undefined,
weight: set.weight ?? undefined,
rpe: set.rpe ?? undefined,
notes: set.notes ?? undefined,
});
}
editWorkout = {
id: workout.id,
name: workout.name || "",
date: workout.date.toISOString(),
durationMinutes: workout.durationMinutes,
difficulty: workout.difficulty,
caloriesBurned: (workout as any).caloriesBurned ?? null,
notes: workout.notes,
exercises: Object.values(grouped),
};
}
}
const isEditing = !!editWorkout;
return (
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href={isEditing ? `/main/workouts/${editWorkout!.id}` : "/main/workouts"}
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
aria-label="Back"
>
<ChevronLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-display text-white tracking-wider">
{isEditing ? "Edit Workout" : "Log Workout"}
</h1>
</div>
</div>
{/* Form */}
<div className="max-w-2xl mx-auto px-4 py-6 pb-12">
<WorkoutForm
exercises={exercises}
recentlyUsedExercises={[]}
editWorkout={editWorkout}
/>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { Plus, Activity, Upload } from "lucide-react";
import { getCurrentUser } from "@/lib/auth";
import { getWorkouts } from "@/lib/db/workouts";
import WorkoutCard from "@/components/workouts/WorkoutCard";
interface PageProps {
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
}
export const metadata = {
title: "Workout History",
description: "View your workout history",
};
export default async function WorkoutsPage({ searchParams }: PageProps) {
const user = await getCurrentUser();
if (!user) {
redirect("/auth/login");
}
// Parse search params
const query = searchParams.q || "";
const dateFrom = searchParams.dateFrom
? new Date(searchParams.dateFrom)
: undefined;
const dateTo = searchParams.dateTo
? new Date(searchParams.dateTo)
: undefined;
// Fetch workouts
const workouts = await getWorkouts(user.id, {
query,
dateFrom,
dateTo,
limit: 50,
});
return (
<div className="min-h-screen bg-[#0A0A0A]">
{/* Header */}
<div className="border-b border-zinc-800 sticky top-0 z-40">
<div className="max-w-2xl mx-auto px-4 py-4 sm:py-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold text-white">
Workout History
</h1>
<Link
href="/main/import"
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white transition"
title="Import workouts from CSV"
>
<Upload className="w-5 h-5" />
</Link>
</div>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 py-6">
{/* Search and filters */}
<form className="mb-6 space-y-4">
{/* Search bar */}
<div>
<input
type="text"
name="q"
placeholder="Search workouts..."
defaultValue={query}
className="w-full px-4 py-3 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white placeholder-zinc-500 text-base"
/>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-3 sm:gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-1">From</label>
<input
type="date"
name="dateFrom"
defaultValue={searchParams.dateFrom || ""}
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
/>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1">To</label>
<input
type="date"
name="dateTo"
defaultValue={searchParams.dateTo || ""}
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
/>
</div>
</div>
{/* Submit buttons */}
<div className="flex gap-2">
<button
type="submit"
className="flex-1 px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-gray-100 touch-target"
>
Filter
</button>
<Link
href="/main/workouts"
className="flex-1 px-4 py-2 bg-zinc-800 text-white rounded-lg font-medium hover:bg-zinc-700 text-center touch-target"
>
Clear
</Link>
</div>
</form>
{/* Workout list */}
{workouts.length === 0 ? (
<div className="text-center py-12">
<div className="flex justify-center mb-4">
<Activity className="w-12 h-12 text-zinc-600" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">
No workouts yet
</h2>
<p className="text-zinc-400 mb-6">
{query || dateFrom || dateTo
? "No workouts match your filters. Try adjusting your search."
: "Start tracking your fitness journey by logging your first workout."}
</p>
<Link
href="/main/workouts/new"
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 touch-target"
>
<Plus className="w-5 h-5" />
Log Your First Workout
</Link>
</div>
) : (
<div className="space-y-3 pb-20 sm:pb-6">
{workouts.map((workout) => (
<WorkoutCard key={workout.id} workout={workout} />
))}
</div>
)}
</div>
{/* Floating action button for mobile, regular button for desktop */}
{workouts.length > 0 && (
<>
{/* Mobile FAB */}
<div className="fixed bottom-6 right-6 sm:hidden">
<Link
href="/main/workouts/new"
className="flex items-center justify-center w-14 h-14 bg-white text-black rounded-full shadow-lg hover:bg-gray-100 active:bg-gray-200 touch-target"
aria-label="Log new workout"
>
<Plus className="w-6 h-6" />
</Link>
</div>
{/* Desktop button */}
<div className="hidden sm:block fixed bottom-6 right-6">
<Link
href="/main/workouts/new"
className="flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 shadow-lg"
>
<Plus className="w-5 h-5" />
Log Workout
</Link>
</div>
</>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/main/dashboard');
}
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
import { Exercise } from "@prisma/client";
import { Loader2 } from "lucide-react";
const EXERCISE_TYPES = [
"barbell",
"dumbbell",
"machine",
"cable",
"bodyweight",
"cardio",
"kettlebell",
"other",
];
const MUSCLE_GROUPS = [
"chest",
"back",
"shoulders",
"biceps",
"triceps",
"forearms",
"quads",
"hamstrings",
"glutes",
"calves",
"core",
"cardio",
];
const INPUT_FIELD_OPTIONS = [
{ value: "sets", label: "Sets" },
{ value: "reps", label: "Reps" },
{ value: "weight", label: "Weight" },
{ value: "duration", label: "Duration" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
];
interface AddExerciseFormProps {
onExerciseAdded: (exercise: Exercise) => void;
}
export default function AddExerciseForm({
onExerciseAdded,
}: AddExerciseFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: "",
type: "barbell",
muscleGroups: [] as string[],
inputFields: ["sets", "reps", "weight"] as string[],
description: "",
});
const handleMuscleGroupToggle = (group: string) => {
setFormData((prev) => {
const groups = prev.muscleGroups.includes(group)
? prev.muscleGroups.filter((g) => g !== group)
: [...prev.muscleGroups, group];
return { ...prev, muscleGroups: groups };
});
};
const handleInputFieldToggle = (field: string) => {
setFormData((prev) => {
const fields = prev.inputFields.includes(field)
? prev.inputFields.filter((f) => f !== field)
: [...prev.inputFields, field];
return { ...prev, inputFields: fields };
});
};
// Auto-set sensible input fields when type changes
const handleTypeChange = (type: string) => {
let defaultFields = ["sets", "reps", "weight"];
if (type === "cardio") {
defaultFields = ["sets", "duration", "calories"];
} else if (type === "bodyweight") {
defaultFields = ["sets", "reps"];
}
setFormData((prev) => ({
...prev,
type,
inputFields: defaultFields,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const response = await fetch("/api/exercises", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
type: formData.type,
muscleGroups: formData.muscleGroups,
inputFields: formData.inputFields,
description: formData.description || undefined,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to add exercise");
}
const exercise = await response.json();
onExerciseAdded(exercise);
setFormData({
name: "",
type: "barbell",
muscleGroups: [],
inputFields: ["sets", "reps", "weight"],
description: "",
});
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-900/50 border border-red-800 rounded-lg p-3 text-red-400 text-sm">
{error}
</div>
)}
{/* Name */}
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Exercise Name
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/20"
placeholder="e.g., Barbell Bench Press"
/>
</div>
{/* Equipment */}
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Equipment
</label>
<div className="flex flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => handleTypeChange(type)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
formData.type === type
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
</div>
</div>
{/* Input Fields */}
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-1">
Tracked Fields
</label>
<p className="text-xs text-zinc-600 mb-2">
What data do you log for this exercise?
</p>
<div className="flex flex-wrap gap-2">
{INPUT_FIELD_OPTIONS.map((field) => (
<button
key={field.value}
type="button"
onClick={() => handleInputFieldToggle(field.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
formData.inputFields.includes(field.value)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{field.label}
</button>
))}
</div>
</div>
{/* Muscle Groups */}
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Muscle Groups
</label>
<div className="flex flex-wrap gap-2">
{MUSCLE_GROUPS.map((group) => (
<button
key={group}
type="button"
onClick={() => handleMuscleGroupToggle(group)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
formData.muscleGroups.includes(group)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</button>
))}
</div>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
Description (optional)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/20 resize-none"
placeholder="Notes about form, tips, or variations..."
rows={2}
/>
</div>
{/* Submit */}
<button
type="submit"
disabled={loading || !formData.name}
className="w-full bg-white hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 text-black font-bold py-3 px-4 rounded-lg transition flex items-center justify-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Adding..." : "Add Exercise"}
</button>
</form>
);
}
@@ -0,0 +1,67 @@
"use client";
import { Exercise } from "@prisma/client";
import Link from "next/link";
import { Dumbbell } from "lucide-react";
const TYPE_COLORS: Record<string, string> = {
barbell: "bg-red-900/50 text-red-400",
dumbbell: "bg-blue-900/50 text-blue-400",
machine: "bg-purple-900/50 text-purple-400",
cable: "bg-amber-900/50 text-amber-400",
bodyweight: "bg-green-900/50 text-green-400",
cardio: "bg-orange-900/50 text-orange-400",
kettlebell: "bg-yellow-900/50 text-yellow-400",
other: "bg-zinc-800 text-zinc-400",
};
export default function ExerciseCard({ exercise }: { exercise: Exercise }) {
const muscleGroups = JSON.parse(exercise.muscleGroups || "[]") as string[];
const typeColor = TYPE_COLORS[exercise.type] || TYPE_COLORS.other;
return (
<Link href={`/main/exercises/${exercise.id}`}>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg hover:border-zinc-700 transition p-4 h-full flex flex-col cursor-pointer">
<div className="flex items-start gap-3 mb-3">
<div className="bg-zinc-800 p-2 rounded-lg">
<Dumbbell className="w-5 h-5 text-zinc-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white truncate">
{exercise.name}
</h3>
<div
className={`inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium ${typeColor}`}
>
{exercise.type.charAt(0).toUpperCase() + exercise.type.slice(1)}
</div>
</div>
</div>
{exercise.description && (
<p className="text-sm text-zinc-500 mb-3 line-clamp-2">
{exercise.description}
</p>
)}
{muscleGroups.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-auto">
{muscleGroups.slice(0, 3).map((group) => (
<span
key={group}
className="inline-block px-2 py-0.5 bg-zinc-800 text-zinc-400 rounded text-xs"
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</span>
))}
{muscleGroups.length > 3 && (
<span className="inline-block px-2 py-0.5 bg-zinc-800 text-zinc-500 rounded text-xs">
+{muscleGroups.length - 3}
</span>
)}
</div>
)}
</div>
</Link>
);
}
@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Search, Plus, Loader2, Dumbbell, X } from "lucide-react";
import ExerciseCard from "@/components/exercises/ExerciseCard";
import AddExerciseForm from "@/components/exercises/AddExerciseForm";
import { Exercise } from "@prisma/client";
import { scoreExercise } from "@/lib/exerciseSearch";
export default function ExercisesClient() {
const [exercises, setExercises] = useState<Exercise[]>([]);
const [filteredExercises, setFilteredExercises] = useState<Exercise[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [selectedMuscleGroup, setSelectedMuscleGroup] = useState<string | null>(
null
);
const [selectedType, setSelectedType] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
// Build filter tags dynamically from all exercises
const muscleGroups = useMemo(() => {
const set = new Set<string>();
for (const ex of exercises) {
try {
const groups = JSON.parse(ex.muscleGroups || "[]") as string[];
groups.forEach((g) => set.add(g.toLowerCase()));
} catch {}
}
return Array.from(set).sort();
}, [exercises]);
const equipmentTypes = useMemo(() => {
const set = new Set<string>();
for (const ex of exercises) {
if (ex.type) set.add(ex.type.toLowerCase());
}
return Array.from(set).sort();
}, [exercises]);
useEffect(() => {
const fetchExercises = async () => {
try {
const response = await fetch("/api/exercises");
const data = await response.json();
setExercises(data);
setFilteredExercises(data);
} catch (error) {
console.error("Failed to fetch exercises:", error);
} finally {
setLoading(false);
}
};
fetchExercises();
}, []);
useEffect(() => {
let filtered = exercises;
// Apply muscle group filter
if (selectedMuscleGroup) {
filtered = filtered.filter((ex) => {
try {
const muscleGroups = JSON.parse(ex.muscleGroups || "[]");
return muscleGroups.includes(selectedMuscleGroup);
} catch {
return false;
}
});
}
// Apply equipment type filter
if (selectedType) {
filtered = filtered.filter(
(ex) => ex.type?.toLowerCase() === selectedType
);
}
// Apply fuzzy search with abbreviation expansion
if (searchQuery.trim()) {
filtered = filtered
.map((ex) => ({ exercise: ex, score: scoreExercise(searchQuery, ex.name) }))
.filter((item) => item.score >= 0)
.sort((a, b) => a.score - b.score)
.map((item) => item.exercise);
} else {
filtered = filtered.sort((a, b) => a.name.localeCompare(b.name));
}
setFilteredExercises(filtered);
}, [searchQuery, selectedMuscleGroup, selectedType, exercises]);
const handleExerciseAdded = (newExercise: Exercise) => {
setExercises([...exercises, newExercise]);
setShowAddForm(false);
};
return (
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6">
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 w-5 h-5 text-zinc-500" />
<input
type="text"
placeholder="Search exercises..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-10 py-2.5 bg-zinc-900 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/20"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-3 p-0.5 rounded text-zinc-500 hover:text-white transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="bg-white hover:bg-zinc-200 text-black font-medium py-2.5 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<Plus className="w-5 h-5" />
Add Exercise
</button>
</div>
{/* Add Exercise Form */}
{showAddForm && (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 mb-6">
<h2 className="text-lg font-bold text-white mb-4">
Add Custom Exercise
</h2>
<AddExerciseForm onExerciseAdded={handleExerciseAdded} />
</div>
)}
{/* Equipment Type Filter */}
<div className="mb-3 flex flex-wrap gap-2">
<span className="text-xs text-zinc-600 uppercase tracking-wider self-center mr-1">Equipment</span>
{equipmentTypes.map((type) => (
<button
key={type}
onClick={() =>
setSelectedType(selectedType === type ? null : type)
}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
selectedType === type
? "bg-white text-black"
: "bg-zinc-900 text-zinc-400 hover:text-white"
}`}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
</div>
{/* Muscle Group Filter */}
<div className="mb-6 flex flex-wrap gap-2">
<span className="text-xs text-zinc-600 uppercase tracking-wider self-center mr-1">Muscle</span>
{muscleGroups.map((group) => (
<button
key={group}
onClick={() =>
setSelectedMuscleGroup(
selectedMuscleGroup === group ? null : group
)
}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
selectedMuscleGroup === group
? "bg-white text-black"
: "bg-zinc-900 text-zinc-400 hover:text-white"
}`}
>
{group.charAt(0).toUpperCase() + group.slice(1)}
</button>
))}
</div>
{/* Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-zinc-500 animate-spin" />
</div>
) : filteredExercises.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-12 text-center">
<Dumbbell className="w-12 h-12 text-zinc-700 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
No exercises found
</h3>
<p className="text-zinc-500">
{searchQuery || selectedMuscleGroup || selectedType
? "Try adjusting your filters"
: "Add your first exercise to get started"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredExercises.map((exercise) => (
<ExerciseCard key={exercise.id} exercise={exercise} />
))}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,456 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { User } from "@prisma/client";
import { Loader2, Eye, EyeOff, Upload, AlertTriangle, CheckCircle2 } from "lucide-react";
interface UserPreferences {
theme: string;
defaultWeightUnit: string;
defaultRestSeconds: number;
enableClaudeAI: boolean;
claudeApiKey?: string;
}
export default function SettingsForm({ user }: { user: User }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [preferences, setPreferences] = useState<UserPreferences>({
theme: "system",
defaultWeightUnit: "lbs",
defaultRestSeconds: 90,
enableClaudeAI: false,
});
useEffect(() => {
const fetchPreferences = async () => {
try {
const response = await fetch("/api/preferences");
if (response.ok) {
const data = await response.json();
setPreferences(data);
}
} catch (err) {
console.error("Failed to fetch preferences:", err);
}
};
fetchPreferences();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
setLoading(true);
try {
const response = await fetch("/api/preferences", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(preferences),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to save preferences");
}
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Status Messages */}
{error && (
<div className="bg-red-900/30 border border-red-800 rounded-lg p-4 text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="bg-green-900/30 border border-green-800 rounded-lg p-4 text-green-400 text-sm">
Settings saved successfully!
</div>
)}
{/* Profile Section */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<h2 className="text-lg font-bold text-white mb-4">Profile</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Name
</label>
<input
type="text"
value={user.name || ""}
disabled
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-zinc-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Email
</label>
<input
type="email"
value={user.email}
disabled
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-zinc-500"
/>
</div>
</div>
</div>
{/* Preferences Section */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<h2 className="text-lg font-bold text-white mb-4">Preferences</h2>
<div className="space-y-4">
{/* Weight Unit */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Default Weight Unit
</label>
<div className="flex gap-2">
{["lbs", "kg"].map((unit) => (
<button
key={unit}
type="button"
onClick={() =>
setPreferences((prev) => ({
...prev,
defaultWeightUnit: unit,
}))
}
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
preferences.defaultWeightUnit === unit
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white"
}`}
>
{unit === "lbs" ? "Pounds (lbs)" : "Kilograms (kg)"}
</button>
))}
</div>
<p className="text-xs text-zinc-600 mt-1.5">
Kettlebell exercises always default to kg
</p>
</div>
{/* Theme */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Theme
</label>
<select
value={preferences.theme}
onChange={(e) =>
setPreferences((prev) => ({
...prev,
theme: e.target.value,
}))
}
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</div>
{/* Claude AI Section */}
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<h2 className="text-lg font-bold text-white mb-4">
Claude AI Integration
</h2>
<p className="text-sm text-zinc-500 mb-4">
Enable Claude AI to get personalized workout recommendations and
program optimization suggestions.
</p>
<div className="space-y-4">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-300">
Enable Claude AI
</span>
<button
type="button"
onClick={() =>
setPreferences((prev) => ({
...prev,
enableClaudeAI: !prev.enableClaudeAI,
}))
}
className={`relative w-11 h-6 rounded-full transition ${
preferences.enableClaudeAI ? "bg-white" : "bg-zinc-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform ${
preferences.enableClaudeAI
? "translate-x-5 bg-black"
: "translate-x-0 bg-zinc-400"
}`}
/>
</button>
</div>
{/* API Key Input - Only show if enabled */}
{preferences.enableClaudeAI && (
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Claude API Key
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={preferences.claudeApiKey || ""}
onChange={(e) =>
setPreferences((prev) => ({
...prev,
claudeApiKey: e.target.value,
}))
}
placeholder="sk-..."
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-2.5 text-zinc-500 hover:text-zinc-300"
>
{showApiKey ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<p className="text-xs text-zinc-600 mt-1">
Get your API key from{" "}
<a
href="https://console.anthropic.com"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:text-white underline"
>
console.anthropic.com
</a>
</p>
</div>
)}
</div>
</div>
{/* Save Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-white hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 text-black font-medium py-2.5 px-4 rounded-lg transition flex items-center justify-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Saving..." : "Save Settings"}
</button>
{/* Database Import Section */}
<DatabaseImport />
</form>
);
}
// ---------- Database Import Component ----------
function DatabaseImport() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importSuccess, setImportSuccess] = useState<{
message: string;
stats: { users: number; exercises: number; workouts: number };
} | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportError(null);
setImportSuccess(null);
setSelectedFile(file);
setConfirmOpen(true);
};
const handleImport = async () => {
if (!selectedFile) return;
setImporting(true);
setImportError(null);
setImportSuccess(null);
setConfirmOpen(false);
try {
const formData = new FormData();
formData.append("database", selectedFile);
const response = await fetch("/api/settings/import-db", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Import failed");
}
setImportSuccess({
message: data.message,
stats: data.stats,
});
// Clear the file input
if (fileInputRef.current) fileInputRef.current.value = "";
setSelectedFile(null);
} catch (err) {
setImportError(
err instanceof Error ? err.message : "An error occurred during import"
);
} finally {
setImporting(false);
}
};
const handleCancel = () => {
setConfirmOpen(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
<h2 className="text-lg font-bold text-white mb-1">Import Database</h2>
<p className="text-sm text-zinc-500 mb-4">
Upload an existing Workout Planner database file (app.db) to restore
your workout history. A backup of the current database will be created
automatically.
</p>
{/* Error message */}
{importError && (
<div className="bg-red-900/30 border border-red-800 rounded-lg p-3 mb-4 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-red-400">{importError}</span>
</div>
)}
{/* Success message */}
{importSuccess && (
<div className="bg-green-900/30 border border-green-800 rounded-lg p-3 mb-4">
<div className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-green-400">{importSuccess.message}</p>
<p className="text-xs text-green-500 mt-1">
Imported: {importSuccess.stats.users} user(s),{" "}
{importSuccess.stats.exercises} exercises,{" "}
{importSuccess.stats.workouts} workouts
</p>
</div>
</div>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-3 w-full py-2 bg-green-800/50 text-green-300 text-sm font-medium rounded-lg hover:bg-green-800/70 transition"
>
Refresh Page to Load Imported Data
</button>
</div>
)}
{/* Confirmation dialog */}
{confirmOpen && selectedFile && (
<div className="bg-yellow-900/20 border border-yellow-800/50 rounded-lg p-4 mb-4">
<div className="flex items-start gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-yellow-400 font-medium">
Replace current database?
</p>
<p className="text-xs text-yellow-600 mt-1">
File: {selectedFile.name} (
{(selectedFile.size / 1024).toFixed(0)} KB)
</p>
<p className="text-xs text-yellow-600 mt-0.5">
Your current database will be backed up before replacement.
</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleImport}
className="flex-1 py-2 bg-yellow-700/50 text-yellow-200 text-sm font-medium rounded-lg hover:bg-yellow-700/70 transition"
>
Yes, Import
</button>
<button
type="button"
onClick={handleCancel}
className="flex-1 py-2 bg-zinc-800 text-zinc-400 text-sm font-medium rounded-lg hover:bg-zinc-700 transition"
>
Cancel
</button>
</div>
</div>
)}
{/* File input and upload button */}
{!confirmOpen && (
<div>
<input
ref={fileInputRef}
type="file"
accept=".db,.sqlite,.sqlite3"
onChange={handleFileSelect}
className="hidden"
id="db-import-input"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full py-3 border border-dashed border-zinc-700 rounded-lg text-zinc-400 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
>
{importing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Upload className="w-4 h-4" />
Select Database File (.db)
</>
)}
</button>
<p className="text-[10px] text-zinc-600 mt-2 text-center">
Located at prisma/data/app.db in your local project
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,731 @@
"use client";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { Search, Plus, X, Dumbbell } from "lucide-react";
import { Exercise } from "@prisma/client";
import { scoreExercise } from "@/lib/exerciseSearch";
const MUSCLE_GROUPS = [
"Chest",
"Back",
"Shoulders",
"Quads",
"Hamstrings",
"Glutes",
"Biceps",
"Triceps",
"Forearms",
"Core",
"Calves",
"Full Body",
"Cardio",
];
const EXERCISE_TYPES = [
{ value: "barbell", label: "Barbell" },
{ value: "dumbbell", label: "Dumbbell" },
{ value: "machine", label: "Machine" },
{ value: "cable", label: "Cable" },
{ value: "bodyweight", label: "Bodyweight" },
{ value: "cardio", label: "Cardio" },
{ value: "kettlebell", label: "Kettlebell" },
{ value: "other", label: "Other" },
];
interface ExercisePickerProps {
exercises: Exercise[];
recentlyUsed?: string[];
onSelect: (exercise: Exercise) => void;
onExerciseCreated?: (exercise: Exercise) => void;
}
// Search utilities imported from shared module
export default function ExercisePicker({
exercises,
recentlyUsed = [],
onSelect,
onExerciseCreated,
}: ExercisePickerProps) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(0);
const [showCreateForm, setShowCreateForm] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newType, setNewType] = useState("barbell");
const [newMuscleGroups, setNewMuscleGroups] = useState<string[]>([]);
const [newInputFields, setNewInputFields] = useState<string[]>(["sets", "reps", "weight"]);
// Derive custom types/muscles/fields from existing exercises
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
const derivedCustomTypes = useMemo(() => {
const set = new Set<string>();
for (const ex of exercises) {
const t = (ex.type || "").toLowerCase();
if (t && !knownTypeValues.includes(t)) set.add(t);
}
return Array.from(set).map((v) => ({ value: v, label: v.charAt(0).toUpperCase() + v.slice(1) }));
}, [exercises]);
const derivedCustomMuscles = useMemo(() => {
const set = new Set<string>();
for (const ex of exercises) {
try {
const groups = JSON.parse(ex.muscleGroups || "[]") as string[];
groups.forEach((g) => {
const gl = g.toLowerCase();
if (!knownMuscleValues.includes(gl)) set.add(g.charAt(0).toUpperCase() + g.slice(1).toLowerCase());
});
} catch {}
}
return Array.from(set);
}, [exercises]);
const derivedCustomFields = useMemo(() => {
const set = new Set<string>();
for (const ex of exercises) {
try {
const fields = JSON.parse((ex as any).inputFields || "[]") as string[];
fields.forEach((f) => { if (!knownFieldValues.includes(f)) set.add(f); });
} catch {}
}
return Array.from(set).map((v) => ({ value: v, label: v.charAt(0).toUpperCase() + v.slice(1) }));
}, [exercises]);
// Custom "+" add state (session-only additions on top of derived)
const [addingType, setAddingType] = useState(false);
const [newTypeText, setNewTypeText] = useState("");
const [sessionCustomTypes, setSessionCustomTypes] = useState<{ value: string; label: string }[]>([]);
const [addingMuscle, setAddingMuscle] = useState(false);
const [newMuscleText, setNewMuscleText] = useState("");
const [sessionCustomMuscles, setSessionCustomMuscles] = useState<string[]>([]);
const [addingField, setAddingField] = useState(false);
const [newFieldText, setNewFieldText] = useState("");
const [sessionCustomFields, setSessionCustomFields] = useState<{ value: string; label: string }[]>([]);
// Merge derived + session-added
const customTypes = useMemo(() => {
const all = [...derivedCustomTypes];
for (const t of sessionCustomTypes) {
if (!all.some((a) => a.value === t.value)) all.push(t);
}
return all;
}, [derivedCustomTypes, sessionCustomTypes]);
const customMuscles = useMemo(() => {
const all = [...derivedCustomMuscles];
for (const m of sessionCustomMuscles) {
if (!all.map((a) => a.toLowerCase()).includes(m.toLowerCase())) all.push(m);
}
return all;
}, [derivedCustomMuscles, sessionCustomMuscles]);
const customFieldOptions = useMemo(() => {
const all = [...derivedCustomFields];
for (const f of sessionCustomFields) {
if (!all.some((a) => a.value === f.value)) all.push(f);
}
return all;
}, [derivedCustomFields, sessionCustomFields]);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const filteredExercises = useMemo(() => {
if (!query.trim()) {
const recent = exercises.filter((ex) => recentlyUsed.includes(ex.id));
const others = exercises
.filter((ex) => !recentlyUsed.includes(ex.id))
.sort((a, b) => a.name.localeCompare(b.name));
return [...recent, ...others].slice(0, 15);
}
const scored = exercises
.map((ex) => ({ exercise: ex, score: scoreExercise(query, ex.name) }))
.filter((item) => item.score >= 0)
.sort((a, b) => a.score - b.score);
return scored.map((s) => s.exercise).slice(0, 10);
}, [exercises, query, recentlyUsed]);
const exactMatch = exercises.some(
(ex) => ex.name.toLowerCase() === query.trim().toLowerCase()
);
const showCreateOption = query.trim().length > 0 && !exactMatch;
const totalItems = filteredExercises.length + (showCreateOption ? 1 : 0);
useEffect(() => {
setHighlightIndex(0);
}, [query]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (dropdownRef.current) {
const highlighted = dropdownRef.current.querySelector(
'[data-highlighted="true"]'
);
if (highlighted) {
highlighted.scrollIntoView({ block: "nearest" });
}
}
}, [highlightIndex]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightIndex((prev) => Math.min(prev + 1, totalItems - 1));
break;
case "ArrowUp":
e.preventDefault();
setHighlightIndex((prev) => Math.max(prev - 1, 0));
break;
case "Enter":
e.preventDefault();
if (highlightIndex < filteredExercises.length) {
handleSelect(filteredExercises[highlightIndex]);
} else if (showCreateOption) {
openCreateForm();
}
break;
case "Escape":
setIsOpen(false);
inputRef.current?.blur();
break;
}
},
[isOpen, highlightIndex, filteredExercises, totalItems, showCreateOption]
);
const handleSelect = (exercise: Exercise) => {
onSelect(exercise);
setQuery("");
setIsOpen(false);
inputRef.current?.blur();
};
const openCreateForm = () => {
setNewName(query.trim());
setNewType("barbell");
setNewMuscleGroups([]);
setNewInputFields(["sets", "reps", "weight"]);
setShowCreateForm(true);
setIsOpen(false);
};
const handleCreate = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
const response = await fetch("/api/exercises", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName.trim(),
type: newType,
muscleGroups: newMuscleGroups,
inputFields: newInputFields,
}),
});
if (!response.ok) {
const err = await response.json();
alert(err.error || "Failed to create exercise");
return;
}
const exercise: Exercise = await response.json();
onExerciseCreated?.(exercise);
onSelect(exercise);
setShowCreateForm(false);
setQuery("");
} catch (error) {
console.error("Failed to create exercise:", error);
alert("Failed to create exercise. Please try again.");
} finally {
setCreating(false);
}
};
const toggleMuscleGroup = (group: string) => {
setNewMuscleGroups((prev) =>
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
);
};
const toggleInputField = (field: string) => {
setNewInputFields((prev) => {
// "sets" is always included
if (field === "sets") return prev;
return prev.includes(field)
? prev.filter((f) => f !== field)
: [...prev, field];
});
};
const handleTypeChange = (type: string) => {
setNewType(type);
// Auto-set sensible input field defaults based on type
if (type === "cardio") {
setNewInputFields(["sets", "duration", "distance", "calories"]);
} else {
setNewInputFields(["sets", "reps", "weight"]);
}
};
const highlightMatch = (name: string) => {
if (!query.trim()) return name;
const q = query.toLowerCase();
const idx = name.toLowerCase().indexOf(q);
if (idx >= 0) {
return (
<>
{name.slice(0, idx)}
<span className="font-bold text-white">
{name.slice(idx, idx + q.length)}
</span>
{name.slice(idx + q.length)}
</>
);
}
return name;
};
const parseMuscleGroups = (json: string | null): string[] => {
if (!json) return [];
try {
return JSON.parse(json);
} catch {
return [];
}
};
// --- Create Form ---
if (showCreateForm) {
return (
<div className="border border-zinc-700 rounded-lg bg-zinc-900 p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-zinc-400" />
New Exercise
</h4>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="p-1 hover:bg-zinc-800 rounded"
>
<X className="w-4 h-4 text-zinc-500" />
</button>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Name
</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
autoFocus
/>
</div>
{/* Equipment */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Equipment
</label>
<div className="flex flex-wrap gap-2">
{[...EXERCISE_TYPES, ...customTypes].map((t) => (
<button
key={t.value}
type="button"
onClick={() => handleTypeChange(t.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
newType === t.value
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white border border-zinc-700"
}`}
>
{t.label}
</button>
))}
{addingType ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newTypeText.trim().toLowerCase();
if (val && !EXERCISE_TYPES.some((t) => t.value === val) && !customTypes.some((t) => t.value === val)) {
setSessionCustomTypes((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
handleTypeChange(val);
}
setNewTypeText("");
setAddingType(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newTypeText}
onChange={(e) => setNewTypeText(e.target.value)}
onBlur={() => {
const val = newTypeText.trim().toLowerCase();
if (val && !EXERCISE_TYPES.some((t) => t.value === val) && !customTypes.some((t) => t.value === val)) {
setSessionCustomTypes((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
handleTypeChange(val);
}
setNewTypeText("");
setAddingType(false);
}}
placeholder="New type"
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingType(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
{/* Muscle Groups */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Muscle Groups
</label>
<div className="flex flex-wrap gap-2">
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => (
<button
key={group}
type="button"
onClick={() => toggleMuscleGroup(group.toLowerCase())}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
newMuscleGroups.includes(group.toLowerCase())
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white border border-zinc-700"
}`}
>
{group}
</button>
))}
{addingMuscle ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newMuscleText.trim();
const valLower = val.toLowerCase();
if (val && !MUSCLE_GROUPS.map((g) => g.toLowerCase()).includes(valLower) && !customMuscles.map((g) => g.toLowerCase()).includes(valLower)) {
const display = val.charAt(0).toUpperCase() + val.slice(1);
setSessionCustomMuscles((p) => [...p, display]);
}
if (valLower && !newMuscleGroups.includes(valLower)) {
setNewMuscleGroups((p) => [...p, valLower]);
}
setNewMuscleText("");
setAddingMuscle(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newMuscleText}
onChange={(e) => setNewMuscleText(e.target.value)}
onBlur={() => {
const val = newMuscleText.trim();
const valLower = val.toLowerCase();
if (val && !MUSCLE_GROUPS.map((g) => g.toLowerCase()).includes(valLower) && !customMuscles.map((g) => g.toLowerCase()).includes(valLower)) {
const display = val.charAt(0).toUpperCase() + val.slice(1);
setSessionCustomMuscles((p) => [...p, display]);
}
if (valLower && !newMuscleGroups.includes(valLower)) {
setNewMuscleGroups((p) => [...p, valLower]);
}
setNewMuscleText("");
setAddingMuscle(false);
}}
placeholder="New group"
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingMuscle(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
{/* Input Fields */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Tracking Fields
</label>
<div className="flex flex-wrap gap-2">
{[
{ value: "reps", label: "Reps" },
{ value: "weight", label: "Weight" },
{ value: "duration", label: "Time" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "notes", label: "Notes" },
...customFieldOptions,
].map((field) => (
<button
key={field.value}
type="button"
onClick={() => toggleInputField(field.value)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
newInputFields.includes(field.value)
? "bg-white text-black"
: "bg-zinc-800 text-zinc-400 hover:text-white border border-zinc-700"
}`}
>
{field.label}
</button>
))}
{addingField ? (
<form
onSubmit={(e) => {
e.preventDefault();
const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
}
if (val && !newInputFields.includes(val)) {
setNewInputFields((p) => [...p, val]);
}
setNewFieldText("");
setAddingField(false);
}}
className="flex items-center gap-1"
>
<input
autoFocus
value={newFieldText}
onChange={(e) => setNewFieldText(e.target.value)}
onBlur={() => {
const val = newFieldText.trim().toLowerCase();
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
}
if (val && !newInputFields.includes(val)) {
setNewInputFields((p) => [...p, val]);
}
setNewFieldText("");
setAddingField(false);
}}
placeholder="New field"
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
/>
</form>
) : (
<button
type="button"
onClick={() => setAddingField(true)}
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
>
+
</button>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleCreate}
disabled={!newName.trim() || creating}
className="flex-1 py-2.5 bg-white text-black font-semibold rounded-lg hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed transition"
>
{creating ? "Creating..." : "Create & Add"}
</button>
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2.5 border border-zinc-700 rounded-lg text-zinc-400 hover:text-white hover:border-zinc-600 transition"
>
Cancel
</button>
</div>
</div>
);
}
// --- Main Autocomplete Input ---
return (
<div ref={containerRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Type to search or add an exercise..."
className="w-full pl-10 pr-10 py-3 border border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-white/20 text-base bg-zinc-800 text-white placeholder:text-zinc-500"
autoComplete="off"
/>
{query && (
<button
type="button"
onClick={() => {
setQuery("");
setIsOpen(false);
inputRef.current?.focus();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded text-zinc-500 hover:text-white transition-colors"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Dropdown */}
{isOpen && (query.trim() || filteredExercises.length > 0) && (
<div
ref={dropdownRef}
className="absolute z-50 w-full mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl max-h-72 overflow-y-auto"
>
{/* Recently used label */}
{!query.trim() &&
recentlyUsed.length > 0 &&
filteredExercises.some((ex) =>
recentlyUsed.includes(ex.id)
) && (
<div className="px-3 py-1.5 text-xs font-semibold text-zinc-500 uppercase tracking-wide bg-zinc-800/50 border-b border-zinc-800">
Recently Used
</div>
)}
{filteredExercises.map((exercise, index) => {
const isRecent = recentlyUsed.includes(exercise.id);
const muscles = parseMuscleGroups(exercise.muscleGroups);
const isFirstNonRecent =
!query.trim() &&
!isRecent &&
index > 0 &&
recentlyUsed.includes(filteredExercises[index - 1].id);
return (
<div key={exercise.id}>
{isFirstNonRecent && (
<div className="px-3 py-1.5 text-xs font-semibold text-zinc-500 uppercase tracking-wide bg-zinc-800/50 border-t border-b border-zinc-800">
All Exercises
</div>
)}
<button
type="button"
data-highlighted={highlightIndex === index}
onClick={() => handleSelect(exercise)}
onMouseEnter={() => setHighlightIndex(index)}
className={`w-full text-left px-3 py-2.5 flex items-center gap-3 transition-colors ${
highlightIndex === index
? "bg-zinc-800"
: "hover:bg-zinc-800/50"
}`}
>
<Dumbbell className="w-4 h-4 text-zinc-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-zinc-300">
{highlightMatch(exercise.name)}
</div>
{muscles.length > 0 && (
<div className="text-xs text-zinc-600 mt-0.5">
{muscles.join(" · ")} · {exercise.type}
</div>
)}
</div>
</button>
</div>
);
})}
{/* Create new exercise option */}
{showCreateOption && (
<button
type="button"
data-highlighted={highlightIndex === filteredExercises.length}
onClick={openCreateForm}
onMouseEnter={() =>
setHighlightIndex(filteredExercises.length)
}
className={`w-full text-left px-3 py-3 flex items-center gap-3 border-t border-zinc-800 transition-colors ${
highlightIndex === filteredExercises.length
? "bg-zinc-800"
: "hover:bg-zinc-800/50"
}`}
>
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<Plus className="w-4 h-4 text-white" />
</div>
<div>
<div className="text-sm font-medium text-white">
Create &ldquo;{query.trim()}&rdquo;
</div>
<div className="text-xs text-zinc-500">
Add as a new exercise
</div>
</div>
</button>
)}
{/* No results */}
{filteredExercises.length === 0 && !showCreateOption && (
<div className="px-3 py-6 text-center text-sm text-zinc-500">
No exercises found
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,406 @@
"use client";
import { Trash2, Check, Pencil, CornerDownLeft } from "lucide-react";
import { useState, useCallback } from "react";
export type InputField = "sets" | "reps" | "weight" | "duration" | "distance" | "calories" | "notes";
export interface SetRowProps {
setNumber: number;
inputFields?: InputField[];
weightUnit?: string;
initialReps?: number;
initialWeight?: number;
initialRpe?: number;
initialNotes?: string;
initialDuration?: number;
initialDistance?: number;
initialCalories?: number;
initialLocked?: boolean;
autoFocus?: boolean;
onUpdate: (data: {
reps?: number;
weight?: number;
rpe?: number;
notes?: string;
durationSeconds?: number;
distance?: number;
calories?: number;
}) => void;
onConfirm?: () => void;
onNextSet?: (currentValues: {
weight?: string;
reps?: string;
rpe?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
}) => void;
onDelete: () => void;
}
export default function SetRow({
setNumber,
inputFields = ["sets", "reps", "weight"],
weightUnit = "lbs",
initialReps,
initialWeight,
initialRpe,
initialNotes,
initialDuration,
initialDistance,
initialCalories,
initialLocked = false,
autoFocus = false,
onUpdate,
onConfirm,
onNextSet,
onDelete,
}: SetRowProps) {
const [reps, setReps] = useState(initialReps?.toString() || "");
const [weight, setWeight] = useState(initialWeight?.toString() || "");
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
const [notes, setNotes] = useState(initialNotes || "");
const [duration, setDuration] = useState(initialDuration?.toString() || "");
const [distance, setDistance] = useState(initialDistance?.toString() || "");
const [calories, setCalories] = useState(initialCalories?.toString() || "");
const [showNotes, setShowNotes] = useState(!!initialNotes);
const [locked, setLocked] = useState(initialLocked);
const showReps = inputFields.includes("reps");
const showWeight = inputFields.includes("weight");
const showDuration = inputFields.includes("duration");
const showDistance = inputFields.includes("distance");
const showCalories = inputFields.includes("calories");
const showNotesField = inputFields.includes("notes");
const emitUpdate = useCallback(
(overrides: {
reps?: string;
weight?: string;
rpe?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
}) => {
const r = overrides.reps ?? reps;
const w = overrides.weight ?? weight;
const p = overrides.rpe ?? rpe;
const n = overrides.notes ?? notes;
const dur = overrides.duration ?? duration;
const dist = overrides.distance ?? distance;
const cal = overrides.calories ?? calories;
onUpdate({
reps: r ? parseInt(r) : undefined,
weight: w ? parseFloat(w) : undefined,
rpe: p ? parseInt(p) : undefined,
notes: n || undefined,
durationSeconds: dur ? parseInt(dur) : undefined,
distance: dist ? parseFloat(dist) : undefined,
calories: cal ? parseInt(cal) : undefined,
});
},
[reps, weight, rpe, notes, duration, distance, calories, onUpdate]
);
const handleConfirm = () => {
emitUpdate({});
setLocked(true);
onConfirm?.();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleConfirm();
}
};
const handleUnlock = () => {
setLocked(false);
};
const handleNextSet = () => {
emitUpdate({});
setLocked(true);
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories });
};
// Build a summary string for the locked view
const buildSummary = () => {
const parts: string[] = [];
if (showWeight && weight) parts.push(`${weight} ${weightUnit}`);
if (showReps && reps) parts.push(`${reps} reps`);
if (showDuration && duration) parts.push(`${duration}s`);
if (showDistance && distance) parts.push(`${distance} mi`);
if (showCalories && calories) parts.push(`${calories} cal`);
if (rpe) parts.push(`RPE ${rpe}`);
if (showNotesField && notes) parts.push(notes);
return parts.length > 0 ? parts.join(" · ") : "No data";
};
// ---------- LOCKED VIEW ----------
if (locked) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
{/* Set number badge */}
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
{setNumber}
</div>
{/* Summary text */}
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">{buildSummary()}</p>
{!showNotesField && notes && (
<p className="text-[10px] text-zinc-500 truncate mt-0.5">{notes}</p>
)}
</div>
{/* Edit (unlock) button */}
<button
type="button"
onClick={handleUnlock}
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-white hover:bg-zinc-800"
aria-label="Edit set"
>
<Pencil className="w-4 h-4" />
</button>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
className="p-1.5 text-red-500 hover:bg-red-950/30 rounded-md flex-shrink-0 active:bg-red-900/30"
aria-label="Delete set"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
}
// Determine which field gets autoFocus
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null;
// ---------- EDIT VIEW ----------
return (
<div className="space-y-1.5" onKeyDown={handleKeyDown}>
<div className="flex items-end gap-1.5">
{/* Set number badge */}
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0 mb-0.5">
{setNumber}
</div>
{/* Weight input */}
{showWeight && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Weight
</label>
<input
type="number"
step="0.5"
autoFocus={autoFocus && firstField === "weight"}
value={weight}
onChange={(e) => {
const val = e.target.value;
setWeight(val);
emitUpdate({ weight: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Reps input */}
{showReps && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Reps
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "reps"}
value={reps}
onChange={(e) => {
const val = e.target.value;
setReps(val);
emitUpdate({ reps: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Duration input (seconds) */}
{showDuration && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Time (s)
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "duration"}
value={duration}
onChange={(e) => {
const val = e.target.value;
setDuration(val);
emitUpdate({ duration: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Distance input */}
{showDistance && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Dist (mi)
</label>
<input
type="number"
step="0.1"
autoFocus={autoFocus && firstField === "distance"}
value={distance}
onChange={(e) => {
const val = e.target.value;
setDistance(val);
emitUpdate({ distance: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Calories input */}
{showCalories && (
<div className="flex-1 min-w-[55px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
Cals
</label>
<input
type="number"
autoFocus={autoFocus && firstField === "calories"}
value={calories}
onChange={(e) => {
const val = e.target.value;
setCalories(val);
emitUpdate({ calories: val });
}}
placeholder="0"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* RPE select — always shown */}
<div className="flex-1 min-w-[50px] max-w-[60px]">
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
RPE
</label>
<select
value={rpe}
onChange={(e) => {
const val = e.target.value;
setRpe(val);
emitUpdate({ rpe: val });
}}
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="">-</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
</select>
</div>
{/* Next set button — confirm + add new pre-filled set */}
{onNextSet && (
<button
type="button"
onClick={handleNextSet}
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-blue-400 hover:bg-blue-950/30"
aria-label="Save and start next set"
>
<CornerDownLeft className="w-4 h-4" />
</button>
)}
{/* Confirm button */}
<button
type="button"
onClick={handleConfirm}
className="p-1.5 rounded-md flex-shrink-0 transition-colors text-zinc-400 hover:text-green-400 hover:bg-green-950/30"
aria-label="Confirm set"
>
<Check className="w-4 h-4" />
</button>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
className="p-1.5 text-red-500 hover:bg-red-950/30 rounded-md flex-shrink-0 active:bg-red-900/30"
aria-label="Delete set"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Notes — always visible when configured as input field */}
{showNotesField && (
<div className="ml-8">
<input
type="text"
value={notes}
onChange={(e) => {
const val = e.target.value;
setNotes(val);
emitUpdate({ notes: val });
}}
placeholder="e.g. weighted vest, ankle weights..."
className="w-full px-2 py-1 border border-zinc-700 rounded-md text-xs bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{/* Toggle notes button (fallback when notes is not a configured input field) */}
{!showNotesField && showNotes && (
<div className="ml-8">
<input
type="text"
value={notes}
onChange={(e) => {
const val = e.target.value;
setNotes(val);
emitUpdate({ notes: val });
}}
placeholder="Add notes (optional)"
className="w-full px-2 py-1 border border-zinc-700 rounded-md text-xs bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
)}
{!showNotesField && !showNotes && (
<button
type="button"
onClick={() => setShowNotes(true)}
className="text-[10px] text-zinc-500 hover:text-zinc-300 ml-8"
>
+ Add notes
</button>
)}
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import { WorkoutWithSets } from "@/types";
import { formatSetsSummary } from "@/lib/formatSets";
interface WorkoutCardProps {
workout: WorkoutWithSets;
}
export default function WorkoutCard({ workout }: WorkoutCardProps) {
const [expanded, setExpanded] = useState(false);
// Calculate total volume
const totalVolume = workout.setLogs.reduce((sum, set) => {
if (set.weight && set.reps) return sum + set.weight * set.reps;
return sum;
}, 0);
const uniqueExercises = new Set(workout.setLogs.map((s) => s.exerciseId)).size;
const date = new Date(workout.date);
const formattedDate = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const caloriesBurned = (workout as any).caloriesBurned as number | null | undefined;
// Build compact stats chips
const stats: string[] = [];
stats.push(`${uniqueExercises} exercise${uniqueExercises !== 1 ? "s" : ""}`);
const totalSets = workout.setLogs.length;
stats.push(`${totalSets} set${totalSets !== 1 ? "s" : ""}`);
if (totalVolume > 0) stats.push(`${Math.round(totalVolume)} lbs`);
if (workout.durationMinutes) stats.push(`${workout.durationMinutes} min`);
if (caloriesBurned) stats.push(`${caloriesBurned} cal`);
if (workout.difficulty) stats.push(`${workout.difficulty}/10`);
// Group sets by exercise
const exerciseGroups = (() => {
const groups: Array<{
exerciseId: string;
exerciseName: string;
sets: Array<{ weight?: number | null; reps?: number | null; weightUnit?: string | null }>;
setCount: number;
}> = [];
const indexMap = new Map<string, number>();
for (const set of workout.setLogs) {
const existing = indexMap.get(set.exerciseId);
if (existing !== undefined) {
groups[existing].sets.push({ weight: set.weight, reps: set.reps, weightUnit: set.weightUnit });
groups[existing].setCount++;
} else {
indexMap.set(set.exerciseId, groups.length);
groups.push({
exerciseId: set.exerciseId,
exerciseName: set.exercise.name,
sets: [{ weight: set.weight, reps: set.reps, weightUnit: set.weightUnit }],
setCount: 1,
});
}
}
return groups;
})();
return (
<div className="border border-zinc-800 bg-zinc-900 rounded-lg overflow-hidden">
{/* Single-line card */}
<div className="flex items-center gap-2 px-3 py-2.5">
{/* Clickable area — navigates to detail page */}
<Link
href={`/main/workouts/${workout.id}`}
className="flex-1 min-w-0 flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-xs text-zinc-500 flex-shrink-0">
{formattedDate}
</span>
<span className="font-medium text-white text-sm truncate flex-shrink min-w-0">
{workout.name || "Unnamed Workout"}
</span>
<span className="text-xs text-zinc-500 flex-shrink-0 hidden sm:inline">
·
</span>
<span className="text-xs text-zinc-400 truncate hidden sm:inline">
{stats.join(" · ")}
</span>
</Link>
{/* Expand/collapse chevron */}
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="p-1 rounded-md text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700/50 transition-colors flex-shrink-0"
aria-label={expanded ? "Hide details" : "Show details"}
>
{expanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
</div>
{/* Mobile stats row (visible only on small screens) */}
<div className="px-3 pb-2 -mt-1 sm:hidden">
<Link href={`/main/workouts/${workout.id}`}>
<span className="text-xs text-zinc-400">
{stats.join(" · ")}
</span>
</Link>
</div>
{/* Expanded exercise detail */}
{expanded && (
<div className="border-t border-zinc-800 px-3 py-2.5 space-y-1.5">
{exerciseGroups.map((group) => {
const summary = formatSetsSummary(group.sets);
return (
<div key={group.exerciseId} className="flex items-baseline gap-2">
<span className="text-sm font-medium text-white truncate flex-shrink min-w-0">
{group.exerciseName}
</span>
<span className="text-xs text-zinc-500 flex-shrink-0">
{group.setCount}s
</span>
{summary && (
<span className="text-xs text-zinc-400 truncate">
{summary}
</span>
)}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,898 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Exercise } from "@prisma/client";
import { ChevronDown, ChevronUp, Loader, Trash2, Plus, Save, Pencil, Check, ArrowUp, ArrowDown, Clock, X } from "lucide-react";
import ExercisePicker from "./ExercisePicker";
import SetRow, { InputField } from "./SetRow";
import { formatSetsSummary } from "@/lib/formatSets";
// --------------- Exercise History Popup ---------------
function ExerciseHistoryPopup({
exerciseId,
onClose,
}: {
exerciseId: string;
onClose: () => void;
}) {
const [history, setHistory] = useState<
Array<{ workout: { id: string; date: string; name?: string }; sets: Array<{ weight?: number; reps?: number; weightUnit?: string }> }>
>([]);
const [loading, setLoading] = useState(true);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/exercises/${exerciseId}`);
if (res.ok) {
const data = await res.json();
if (!cancelled) setHistory(data.history || []);
}
} catch {}
if (!cancelled) setLoading(false);
})();
return () => { cancelled = true; };
}, [exerciseId]);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onClose]);
return (
<div
ref={popupRef}
className="absolute left-0 right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl max-h-64 overflow-y-auto"
>
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Recent History</span>
<button type="button" onClick={onClose} className="p-0.5 text-zinc-500 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader className="w-4 h-4 animate-spin text-zinc-500" />
</div>
) : history.length === 0 ? (
<p className="text-xs text-zinc-500 text-center py-4">No history yet</p>
) : (
<div className="divide-y divide-zinc-800/50">
{history.slice(0, 10).map((entry) => {
const d = new Date(entry.workout.date);
const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
const summary = formatSetsSummary(entry.sets);
return (
<div key={entry.workout.id} className="px-3 py-2">
<div className="flex items-baseline gap-2">
<span className="text-[11px] text-zinc-500 flex-shrink-0">{dateStr}</span>
<span className="text-[11px] text-zinc-600">·</span>
<span className="text-[11px] text-zinc-500">{entry.sets.length} set{entry.sets.length !== 1 ? "s" : ""}</span>
</div>
{summary && (
<p className="text-xs text-zinc-300 mt-0.5">{summary}</p>
)}
</div>
);
})}
</div>
)}
</div>
);
}
function parseInputFields(exercise: Exercise): InputField[] {
try {
const raw = (exercise as any).inputFields;
if (raw && typeof raw === "string") {
return JSON.parse(raw);
}
} catch {}
return ["sets", "reps", "weight"];
}
interface ExerciseWithSets {
exercise: Exercise;
sets: Array<{
setNumber: number;
reps?: number;
weight?: number;
rpe?: number;
notes?: string;
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
}>;
}
export interface EditWorkoutData {
id: string;
name: string;
date: string; // ISO string
durationMinutes?: number | null;
difficulty?: number | null;
caloriesBurned?: number | null;
notes?: string | null;
exercises: Array<{
exercise: Exercise;
sets: Array<{
setNumber: number;
reps?: number;
weight?: number;
rpe?: number;
notes?: string;
}>;
}>;
}
interface WorkoutFormProps {
exercises: Exercise[];
recentlyUsedExercises?: string[];
editWorkout?: EditWorkoutData;
}
export default function WorkoutForm({
exercises: initialExercises,
recentlyUsedExercises = [],
editWorkout,
}: WorkoutFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [exercises, setExercises] = useState<Exercise[]>(initialExercises);
const [workoutName, setWorkoutName] = useState(editWorkout?.name || "");
const [workoutDate, setWorkoutDate] = useState(() => {
if (editWorkout?.date) {
return new Date(editWorkout.date).toISOString().split("T")[0];
}
return new Date().toISOString().split("T")[0];
});
const [duration, setDuration] = useState(editWorkout?.durationMinutes?.toString() || "");
const [difficulty, setDifficulty] = useState(editWorkout?.difficulty?.toString() || "");
const [workoutCalories, setWorkoutCalories] = useState(editWorkout?.caloriesBurned?.toString() || "");
const [notes, setNotes] = useState(editWorkout?.notes || "");
const [notesLocked, setNotesLocked] = useState(!!editWorkout?.notes);
const [addedExercises, setAddedExercises] = useState<ExerciseWithSets[]>(
editWorkout?.exercises || []
);
const [expandedExercise, setExpandedExercise] = useState<string | null>(null);
const [historyPopupExercise, setHistoryPopupExercise] = useState<string | null>(null);
// Header lock state (name + date lock after first save, or immediately for edits)
const [headerLocked, setHeaderLocked] = useState(!!editWorkout);
// Auto-save state — if editing, start with existing workout ID
const [savedWorkoutId, setSavedWorkoutId] = useState<string | null>(editWorkout?.id || null);
const [autoSaving, setAutoSaving] = useState(false);
const [showSavedFlash, setShowSavedFlash] = useState(false);
const savingRef = useRef(false);
const savedFlashTimer = useRef<NodeJS.Timeout | null>(null);
// Flash "Saved ✓" briefly after each successful save
const triggerSavedFlash = useCallback(() => {
setShowSavedFlash(true);
if (savedFlashTimer.current) clearTimeout(savedFlashTimer.current);
savedFlashTimer.current = setTimeout(() => setShowSavedFlash(false), 2000);
}, []);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (savedFlashTimer.current) clearTimeout(savedFlashTimer.current);
};
}, []);
// ---------- Build payload from current state ----------
const buildPayload = useCallback(
(currentExercises?: ExerciseWithSets[]) => {
const exs = currentExercises ?? addedExercises;
return {
name: workoutName,
durationMinutes: duration ? parseInt(duration) : undefined,
difficulty: difficulty ? parseInt(difficulty) : undefined,
caloriesBurned: workoutCalories ? parseInt(workoutCalories) : undefined,
notes: notes || undefined,
date: new Date(workoutDate + "T12:00:00").toISOString(),
sets: exs.flatMap((e) =>
e.sets.map((s) => ({
exerciseId: e.exercise.id,
setNumber: s.setNumber,
reps: s.reps,
weight: s.weight,
weightUnit: (e.exercise as any).defaultWeightUnit || "lbs",
rpe: s.rpe,
notes: s.notes,
}))
),
};
},
[workoutName, workoutDate, duration, difficulty, workoutCalories, notes, addedExercises]
);
// ---------- Auto-save: create or update ----------
const autoSave = useCallback(
async (overrideExercises?: ExerciseWithSets[]) => {
if (savingRef.current) return;
savingRef.current = true;
setAutoSaving(true);
try {
const payload = buildPayload(overrideExercises);
if (!savedWorkoutId) {
// First save — POST to create
const response = await fetch("/api/workouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.ok) {
const created = await response.json();
setSavedWorkoutId(created.id);
triggerSavedFlash();
setHeaderLocked(true);
}
} else {
// Subsequent save — PATCH to update
const response = await fetch(`/api/workouts/${savedWorkoutId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
durationMinutes: payload.durationMinutes ?? null,
difficulty: payload.difficulty ?? null,
caloriesBurned: payload.caloriesBurned ?? null,
notes: payload.notes ?? null,
}),
});
if (response.ok) {
triggerSavedFlash();
setHeaderLocked(true);
}
}
} catch (error) {
console.error("Auto-save failed:", error);
} finally {
savingRef.current = false;
setAutoSaving(false);
}
},
[buildPayload, savedWorkoutId, triggerSavedFlash]
);
// ---------- Exercise handlers ----------
const handleAddExercise = (exercise: Exercise) => {
if (addedExercises.some((e) => e.exercise.id === exercise.id)) {
setExpandedExercise(exercise.id);
return;
}
setAddedExercises((prev) => [
...prev,
{
exercise,
sets: [{ setNumber: 1, reps: undefined, weight: undefined }],
},
]);
setExpandedExercise(exercise.id);
};
const handleExerciseCreated = (exercise: Exercise) => {
setExercises((prev) => [...prev, exercise]);
};
const handleMoveExercise = (exerciseId: string, direction: "up" | "down") => {
setAddedExercises((prev) => {
const idx = prev.findIndex((e) => e.exercise.id === exerciseId);
if (idx < 0) return prev;
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= prev.length) return prev;
const updated = [...prev];
[updated[idx], updated[swapIdx]] = [updated[swapIdx], updated[idx]];
return updated;
});
};
const handleRemoveExercise = (exerciseId: string) => {
setAddedExercises((prev) => {
const updated = prev.filter((e) => e.exercise.id !== exerciseId);
// Auto-save after removing exercise
setTimeout(() => autoSave(updated), 0);
return updated;
});
if (expandedExercise === exerciseId) setExpandedExercise(null);
};
const handleAddSet = (exerciseId: string) => {
setAddedExercises((prev) =>
prev.map((e) => {
if (e.exercise.id === exerciseId) {
const nextSetNumber =
Math.max(...e.sets.map((s) => s.setNumber), 0) + 1;
return {
...e,
sets: [
...e.sets,
{ setNumber: nextSetNumber, reps: undefined, weight: undefined },
],
};
}
return e;
})
);
};
const handleRemoveSet = (exerciseId: string, setNumber: number) => {
setAddedExercises((prev) => {
const updated = prev.map((e) => {
if (e.exercise.id === exerciseId) {
return {
...e,
sets: e.sets.filter((s) => s.setNumber !== setNumber),
};
}
return e;
});
// Auto-save after removing set
setTimeout(() => autoSave(updated), 0);
return updated;
});
};
const handleUpdateSet = (
exerciseId: string,
setNumber: number,
data: { reps?: number; weight?: number; rpe?: number; notes?: string }
) => {
setAddedExercises((prev) =>
prev.map((e) => {
if (e.exercise.id === exerciseId) {
return {
...e,
sets: e.sets.map((s) =>
s.setNumber === setNumber ? { ...s, ...data } : s
),
};
}
return e;
})
);
};
// Called when user confirms a set (check icon) — triggers auto-save
// Also clears forceEdit so the set stays locked when the exercise is collapsed/re-expanded
const handleSetConfirmed = (exerciseId?: string, setNumber?: number) => {
if (exerciseId && setNumber !== undefined) {
setAddedExercises((prev) =>
prev.map((e) => {
if (e.exercise.id === exerciseId) {
return {
...e,
sets: e.sets.map((s) =>
s.setNumber === setNumber ? { ...s, forceEdit: false } : s
),
};
}
return e;
})
);
}
// Small delay to let state settle after the SetRow's emitUpdate
setTimeout(() => autoSave(), 50);
};
// Called when user taps "next set" arrow — confirm current set + add new pre-filled set
const handleNextSet = (
exerciseId: string,
currentValues: {
weight?: string;
reps?: string;
rpe?: string;
notes?: string;
duration?: string;
distance?: string;
calories?: string;
}
) => {
setAddedExercises((prev) =>
prev.map((e) => {
if (e.exercise.id === exerciseId) {
const nextSetNumber =
Math.max(...e.sets.map((s) => s.setNumber), 0) + 1;
return {
...e,
// Clear forceEdit on all existing sets (they're confirmed now)
sets: [
...e.sets.map((s) => (s.forceEdit ? { ...s, forceEdit: false } : s)),
{
setNumber: nextSetNumber,
weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined,
reps: undefined, // User typically changes reps per set
rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined,
notes: currentValues.notes || undefined,
forceEdit: true, // Start in edit mode even though weight is pre-filled
},
],
};
}
return e;
})
);
// Auto-save after confirming the current set
setTimeout(() => autoSave(), 50);
};
// Called when user saves notes — triggers auto-save
const handleNotesSave = () => {
setNotesLocked(true);
setTimeout(() => autoSave(), 50);
};
// ---------- Save and Close ----------
const handleSaveAndClose = async (e: React.FormEvent) => {
e.preventDefault();
if (addedExercises.length === 0) {
alert("Please add at least one exercise");
return;
}
setLoading(true);
try {
// Wait for any in-flight auto-save to finish to avoid race conditions
// (both would delete-all-sets + recreate simultaneously)
while (savingRef.current) {
await new Promise((r) => setTimeout(r, 100));
}
// Prevent auto-saves from starting while we do the final save
savingRef.current = true;
const payload = buildPayload();
if (!savedWorkoutId) {
// Never saved before — create
const response = await fetch("/api/workouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("Failed to save workout");
} else {
// Already saved — final update
const response = await fetch(`/api/workouts/${savedWorkoutId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
durationMinutes: payload.durationMinutes ?? null,
difficulty: payload.difficulty ?? null,
caloriesBurned: payload.caloriesBurned ?? null,
notes: payload.notes ?? null,
}),
});
if (!response.ok) throw new Error("Failed to save workout");
}
// Navigate back: to detail page if editing, otherwise to list
if (editWorkout) {
router.push(`/main/workouts/${savedWorkoutId || editWorkout.id}`);
} else {
router.push("/main/workouts");
}
} catch (error) {
console.error("Error saving workout:", error);
alert("Failed to save workout. Please try again.");
} finally {
savingRef.current = false;
setLoading(false);
}
};
return (
<form
onSubmit={handleSaveAndClose}
onKeyDown={(e) => {
// Prevent Enter from submitting form when editing inputs
if (e.key === "Enter") {
const target = e.target as HTMLElement;
const tag = target.tagName.toLowerCase();
// Allow submit only from the submit button itself
if (tag === "input" || tag === "textarea" || tag === "select") {
e.preventDefault();
}
}
}}
className="space-y-6"
>
{/* Auto-save indicator — fixed height to prevent layout shift */}
<div className="h-4 flex items-center justify-center">
<div className={`flex items-center gap-1.5 text-[10px] transition-opacity duration-300 ${autoSaving || showSavedFlash ? "opacity-100" : "opacity-0"} ${autoSaving ? "text-zinc-500" : "text-green-500"}`}>
{autoSaving ? (
<>
<Loader className="w-3 h-3 animate-spin" />
<span>Saving...</span>
</>
) : (
<>
<Check className="w-3 h-3" />
<span>Saved</span>
</>
)}
</div>
</div>
{/* Workout name & date */}
{headerLocked ? (
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white truncate">{workoutName || "Unnamed Workout"}</h2>
<p className="text-sm text-zinc-400">
{new Date(workoutDate + "T12:00:00").toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
</div>
<button
type="button"
onClick={() => setHeaderLocked(false)}
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors flex-shrink-0"
aria-label="Edit name and date"
>
<Pencil className="w-4 h-4" />
</button>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Workout Name
</label>
<input
type="text"
value={workoutName}
onChange={(e) => setWorkoutName(e.target.value)}
placeholder="- -"
className="w-full px-4 py-3 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Date
</label>
<input
type="date"
value={workoutDate}
onChange={(e) => setWorkoutDate(e.target.value)}
className="w-full px-4 py-3 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 [color-scheme:dark]"
/>
</div>
</>
)}
{/* Exercises */}
<div>
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">
Exercises
</h3>
{/* Added exercises */}
{addedExercises.length > 0 && (
<div className="space-y-2 mb-4">
{addedExercises.map((item, exIdx) => (
<div
key={item.exercise.id}
className="border border-zinc-800 rounded-lg bg-zinc-900 relative"
>
{/* Exercise header — compact with reorder */}
<div className="flex items-center">
{/* Reorder buttons */}
{addedExercises.length > 1 && (
<div className="flex flex-col pl-1.5 -mr-1 flex-shrink-0">
<button
type="button"
onClick={() => handleMoveExercise(item.exercise.id, "up")}
disabled={exIdx === 0}
className="p-0.5 text-zinc-600 hover:text-zinc-300 disabled:opacity-20 disabled:hover:text-zinc-600 transition-colors"
aria-label="Move exercise up"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleMoveExercise(item.exercise.id, "down")}
disabled={exIdx === addedExercises.length - 1}
className="p-0.5 text-zinc-600 hover:text-zinc-300 disabled:opacity-20 disabled:hover:text-zinc-600 transition-colors"
aria-label="Move exercise down"
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
)}
<button
type="button"
onClick={() =>
setExpandedExercise(
expandedExercise === item.exercise.id
? null
: item.exercise.id
)
}
className="flex-1 flex items-center justify-between px-3 py-2.5 hover:bg-zinc-800/50 transition-colors rounded-lg min-w-0"
>
<div className="text-left flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<h4 className="font-semibold text-white text-sm truncate">
{item.exercise.name}
</h4>
<span className="text-xs text-zinc-500 flex-shrink-0">
{item.sets.length} set{item.sets.length !== 1 ? "s" : ""}
</span>
</div>
{item.sets.some((s) => s.reps || s.weight) && (
<p className="text-xs text-zinc-400 mt-0.5 truncate">
{formatSetsSummary(item.sets)}
</p>
)}
</div>
{expandedExercise === item.exercise.id ? (
<ChevronUp className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
) : (
<ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
)}
</button>
{/* History popup toggle */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setHistoryPopupExercise(
historyPopupExercise === item.exercise.id ? null : item.exercise.id
);
}}
className="p-2 mr-1 rounded-md flex-shrink-0 transition-colors text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
aria-label="View exercise history"
title="View history"
>
<Clock className="w-4 h-4" />
</button>
</div>
{/* Exercise history popup */}
{historyPopupExercise === item.exercise.id && (
<ExerciseHistoryPopup
exerciseId={item.exercise.id}
onClose={() => setHistoryPopupExercise(null)}
/>
)}
{/* Exercise body (expanded) */}
{expandedExercise === item.exercise.id && (
<div className="border-t border-zinc-800 p-3 space-y-3">
<div className="space-y-2">
{item.sets.map((set, idx) => (
<SetRow
key={`${item.exercise.id}-${set.setNumber}`}
setNumber={set.setNumber}
inputFields={parseInputFields(item.exercise)}
weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"}
initialReps={set.reps}
initialWeight={set.weight}
initialRpe={set.rpe}
initialNotes={set.notes}
initialLocked={set.forceEdit ? false : !!(set.reps || set.weight)}
autoFocus={set.forceEdit || (idx === item.sets.length - 1 && !set.reps && !set.weight)}
onUpdate={(data) =>
handleUpdateSet(
item.exercise.id,
set.setNumber,
data
)
}
onConfirm={() => handleSetConfirmed(item.exercise.id, set.setNumber)}
onNextSet={(vals) => handleNextSet(item.exercise.id, vals)}
onDelete={() =>
handleRemoveSet(item.exercise.id, set.setNumber)
}
/>
))}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAddSet(item.exercise.id)}
className="flex-1 py-1.5 border border-dashed border-zinc-700 rounded-lg text-zinc-400 text-sm font-medium hover:text-white hover:border-zinc-600 transition-colors flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
Add Set
</button>
<button
type="button"
onClick={() => handleRemoveExercise(item.exercise.id)}
className="py-1.5 px-3 text-red-500 text-sm font-medium hover:bg-red-950/30 rounded-lg transition-colors flex items-center gap-1.5"
>
<Trash2 className="w-3.5 h-3.5" />
Remove
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Inline exercise search */}
<ExercisePicker
exercises={exercises}
recentlyUsed={recentlyUsedExercises}
onSelect={handleAddExercise}
onExerciseCreated={handleExerciseCreated}
/>
</div>
{/* Post-workout: Notes, Duration, Calories, Difficulty */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider">
Post-Workout
</h3>
{/* Notes — lockable */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] font-medium text-zinc-500">Notes</label>
{notes && (
<button
type="button"
onClick={notesLocked ? () => setNotesLocked(false) : handleNotesSave}
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors"
>
{notesLocked ? "Edit" : "Save"}
</button>
)}
</div>
{notesLocked ? (
<button
type="button"
onClick={() => setNotesLocked(false)}
className="w-full text-left px-3 py-2 border border-zinc-700 rounded-md bg-zinc-900 text-sm text-zinc-300 hover:bg-zinc-800 transition-colors"
>
<p className="line-clamp-2">{notes}</p>
</button>
) : (
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Type notes as you go..."
className="w-full px-3 py-2 border border-zinc-700 rounded-md bg-zinc-800 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600 resize-none"
rows={2}
/>
)}
</div>
<div className="grid grid-cols-3 gap-3">
{/* Duration */}
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-[10px] font-medium text-zinc-500 whitespace-nowrap">Duration</label>
{duration && (
<button type="button" onClick={() => setDuration("")} className="text-[9px] text-zinc-600 hover:text-zinc-400"></button>
)}
</div>
{duration ? (
<input
type="number"
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="min"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
) : (
<button
type="button"
onClick={() => setDuration("45")}
className="w-full py-1.5 border border-dashed border-zinc-700 rounded-md text-zinc-500 text-xs hover:text-white hover:border-zinc-600 transition-colors"
>
N/A
</button>
)}
</div>
{/* Calories */}
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-[10px] font-medium text-zinc-500 whitespace-nowrap">Calories</label>
{workoutCalories && (
<button type="button" onClick={() => setWorkoutCalories("")} className="text-[9px] text-zinc-600 hover:text-zinc-400"></button>
)}
</div>
{workoutCalories ? (
<input
type="number"
value={workoutCalories}
onChange={(e) => setWorkoutCalories(e.target.value)}
placeholder="kcal"
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
/>
) : (
<button
type="button"
onClick={() => setWorkoutCalories("300")}
className="w-full py-1.5 border border-dashed border-zinc-700 rounded-md text-zinc-500 text-xs hover:text-white hover:border-zinc-600 transition-colors"
>
N/A
</button>
)}
</div>
{/* Difficulty */}
<div>
<div className="flex items-center gap-2 mb-1">
<label className="text-[10px] font-medium text-zinc-500 whitespace-nowrap">Difficulty</label>
{difficulty && (
<button type="button" onClick={() => setDifficulty("")} className="text-[9px] text-zinc-600 hover:text-zinc-400"></button>
)}
</div>
{difficulty ? (
<select
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={String(i + 1)}>{i + 1}/10</option>
))}
</select>
) : (
<button
type="button"
onClick={() => setDifficulty("5")}
className="w-full py-1.5 border border-dashed border-zinc-700 rounded-md text-zinc-500 text-xs hover:text-white hover:border-zinc-600 transition-colors"
>
N/A
</button>
)}
</div>
</div>
</div>
{/* Save and Close button */}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-white text-black font-semibold rounded-lg hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition"
>
{loading ? (
<>
<Loader className="w-5 h-5 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save and Close
</>
)}
</button>
</form>
);
}
+52
View File
@@ -0,0 +1,52 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: workout-planner-app
ports:
- '3000:3000'
environment:
- NODE_ENV=development
- DATABASE_URL=file:./data/app.db
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
volumes:
- ./app:/app/app
- ./components:/app/components
- ./lib:/app/lib
- ./prisma:/app/prisma
- ./data:/app/data
- /app/node_modules
command: npm run dev
healthcheck:
test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:3000']
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
prisma-studio:
build:
context: .
dockerfile: Dockerfile
container_name: workout-planner-studio
ports:
- '5555:5555'
environment:
- NODE_ENV=development
- DATABASE_URL=file:./data/app.db
volumes:
- ./prisma:/app/prisma
- ./data:/app/data
- /app/node_modules
command: npx prisma studio
depends_on:
- app
profiles:
- debug
volumes:
data:
driver: local
+183
View File
@@ -0,0 +1,183 @@
# Workout Planner — File & Folder Reference
_Last updated: February 18, 2026_
## Project Root (`workout-planner/`)
| File | Purpose |
|------|---------|
| `package.json` | Dependencies: Next.js 14, React 18, Prisma, Tailwind, Zod, Lucide, bcryptjs |
| `next.config.js` | Next.js configuration |
| `tailwind.config.ts` | Tailwind setup with zinc color palette |
| `tsconfig.json` | TypeScript config with `@/` path alias |
| `middleware.ts` | Route protection — redirects unauthenticated requests away from `/main/*` |
| `postcss.config.js` | PostCSS for Tailwind |
| `.env` / `.env.local` | Database URL and secrets (not committed) |
| `.env.example` | Template for env vars |
| `Dockerfile` | Container build for production |
| `docker-compose.yml` | Docker orchestration with volume mount for DB |
| `.gitignore` | Standard Next.js + Prisma ignores |
## `app/` — Next.js App Router Pages & API
### Root Layout & Entry
| File | Purpose |
|------|---------|
| `app/layout.tsx` | Root HTML layout, global fonts, meta tags, PWA registration |
| `app/globals.css` | Tailwind imports, CSS variables (`--sidebar-width`, `--bottom-nav-height`) |
| `app/page.tsx` | Landing page — redirects to `/main/dashboard` if logged in |
### Auth (`app/auth/`)
| File | Purpose |
|------|---------|
| `app/auth/login/page.tsx` | Login form (email + password) |
| `app/auth/login/actions.ts` | Server action for login — validates credentials, creates session, sets cookie |
### Main App (`app/main/`)
All pages under `/main/` require authentication (enforced by middleware).
| File | Purpose |
|------|---------|
| `app/main/layout.tsx` | Authenticated layout with sidebar (desktop) and bottom nav (mobile) |
| `app/main/navigation.tsx` | Navigation component — 5 links: Dashboard, Workouts, Exercises, Import, Settings + logout |
| `app/main/actions.ts` | Server actions (logout) |
### Dashboard
| File | Purpose |
|------|---------|
| `app/main/dashboard/page.tsx` | Summary stats, recent workouts, personal bests |
### Workouts
| File | Purpose |
|------|---------|
| `app/main/workouts/page.tsx` | Workout history list with date filters, upload button linking to import |
| `app/main/workouts/new/page.tsx` | Create new workout — pick exercises, log sets |
| `app/main/workouts/[id]/page.tsx` | View/edit a single workout |
### Exercises
| File | Purpose |
|------|---------|
| `app/main/exercises/page.tsx` | Exercise library list with search and equipment type filter pills |
| `app/main/exercises/[id]/page.tsx` | Exercise detail — edit name/type/muscles/fields, view workout history for this exercise |
### Import
| File | Purpose |
|------|---------|
| `app/main/import/page.tsx` | Server component wrapper — auth check, renders `ImportCSVPage` |
| `app/main/import/page-csv.tsx` | Client component (~610 lines) — full CSV import flow: upload → review queue → approve/skip each workout |
### Settings
| File | Purpose |
|------|---------|
| `app/main/settings/page.tsx` | Server component wrapper for settings form |
### API Routes (`app/api/`)
| Route | Methods | Purpose |
|-------|---------|---------|
| `app/api/auth/route.ts` | POST | Login — validate credentials, create session, return token |
| `app/api/auth/logout/route.ts` | POST | Logout — delete session |
| `app/api/exercises/route.ts` | GET, POST | List exercises (with search), create new exercise |
| `app/api/exercises/[id]/route.ts` | GET, PUT, DELETE | Get/update/delete a single exercise |
| `app/api/workouts/route.ts` | GET, POST | List workouts (with date filters, pagination), create workout with sets |
| `app/api/workouts/[id]/route.ts` | GET, PUT, DELETE | Get/update/delete a single workout |
| `app/api/workouts/[id]/sets/route.ts` | POST, PUT, DELETE | Manage individual sets within a workout |
| `app/api/workouts/import/route.ts` | POST | Import endpoint (used by import page) |
| `app/api/workouts/import/save/route.ts` | POST | Save a single approved imported workout to DB |
| `app/api/import/parse/route.ts` | POST | Parse CSV upload — maps exercise names, groups by date, returns structured data with unmapped names |
| `app/api/preferences/route.ts` | GET, POST | Get/update user preferences (theme, weight unit, Claude AI settings) |
| `app/api/health/route.ts` | GET | Health check endpoint |
## `components/` — Reusable UI Components
### Exercises
| File | Purpose |
|------|---------|
| `components/exercises/AddExerciseForm.tsx` | Form for creating new exercises with type, muscle groups, and tracked fields |
| `components/exercises/ExerciseCard.tsx` | Card component for exercise list items |
| `components/exercises/ExercisesClient.tsx` | Client-side exercises list with search/filter state |
### Workouts
| File | Purpose |
|------|---------|
| `components/workouts/WorkoutForm.tsx` | Full workout logging form — exercise selection, set entry, notes |
| `components/workouts/WorkoutCard.tsx` | Expandable workout card for history list — shows date, stats line (exercises, total sets, duration), expandable exercise details with grouped set summaries |
| `components/workouts/ExercisePicker.tsx` | Modal/form for selecting an exercise from the library when logging a workout, includes inline exercise creation |
| `components/workouts/SetRow.tsx` | Single set row in the workout form — inputs for reps, weight, unit dropdown, RPE, duration, distance, calories, notes. Fields shown are controlled by exercise's `inputFields` |
### Import
| File | Purpose |
|------|---------|
| `components/import/WorkoutImportClient.tsx` | Import-related client component |
### Settings
| File | Purpose |
|------|---------|
| `components/settings/SettingsForm.tsx` | Settings form — theme selector, weight unit, Claude AI toggle + API key field |
## `lib/` — Shared Utilities & Data Access
| File | Purpose |
|------|---------|
| `lib/prisma.ts` | Singleton Prisma client instance |
| `lib/auth.ts` | Auth utilities: `hashPassword`, `verifyPassword`, `createSession`, `validateSession`, `getCurrentUser` |
| `lib/formatSets.ts` | `formatSetsSummary()` — groups consecutive same-weight sets into compact display (e.g., "245 x 3/3/3") |
| `lib/exerciseSearch.ts` | Exercise search/filter logic |
| `lib/utils.ts` | General utility functions (e.g., `cn()` for class merging with clsx + tailwind-merge) |
| `lib/db/exercises.ts` | Database queries for exercises |
| `lib/db/workouts.ts` | Database queries for workouts |
| `lib/db/stats.ts` | Dashboard statistics queries |
## `prisma/` — Database Schema & Data
| File | Purpose |
|------|---------|
| `prisma/schema.prisma` | Full data model — 13 models (User, Session, Exercise, Workout, SetLog, Program, ProgramWeek, ProgramDay, ProgramExercise, Equipment, ContentItem, ContentChunk, AISuggestion, UserPreferences) |
| `prisma/seed.ts` | Database seeding script |
| `prisma/data/app.db` | **The actual SQLite database file** (not `prisma/dev.db`) |
| `prisma/dev.db` | Stale/empty — do not use |
## `types/` — TypeScript Type Definitions
| File | Purpose |
|------|---------|
| `types/index.ts` | Shared types: `WorkoutWithSets`, `SetLogWithExercise`, `ExerciseWithStats`, `DashboardStats`, `SearchFilters`, `PaginationMeta`, `ParsedSet`, `ParsedExercise`, `ParsedWorkout`, `ImportParseResponse`, `ReviewedWorkout` |
## `public/` — Static Assets & PWA
| File | Purpose |
|------|---------|
| `public/manifest.json` | PWA manifest — app name, icons, theme color |
| `public/sw.js` | Service worker for offline caching |
| `public/sw-register.js` | Service worker registration script |
| `public/icons/` | App icons at multiple sizes (72512px) including maskable variants, plus SVG favicon |
## `scripts/` — Server Management
| File | Purpose |
|------|---------|
| `scripts/start.sh` | Start the Next.js server (production) |
| `scripts/stop.sh` | Stop the server using stored PID |
| `scripts/rebuild.sh` | Rebuild and restart |
| `scripts/setup-autostart.sh` | Configure the app to start on boot |
| `scripts/generate-icons.js` | Generate PWA icons from SVG source |
## `import-data/` — Historical Workout Data
| File | Purpose |
|------|---------|
| `import-data/workout-log-feb2026.csv` | Parsed handwritten workout logs (JanFeb 2026, ~362 rows). Format: `date,exercise,weight,reps,notes`. More pages to be added. |
## `docs/` — Project Documentation
| File | Purpose |
|------|---------|
| `docs/PROJECT_OVERVIEW.md` | High-level overview: features, tech stack, future phases, design philosophy |
| `docs/FILE_REFERENCE.md` | This file — every file and folder explained |
## `logs/` — Server Logs
| File | Purpose |
|------|---------|
| `logs/server.log` | Server stdout |
| `logs/server-error.log` | Server stderr |
+133
View File
@@ -0,0 +1,133 @@
# Workout Planner — Project Overview
_Last updated: February 18, 2026_
## What This Is
A self-hosted workout logging and planning app built for personal use. The core idea is a mobile-first tool for tracking strength training workouts — what exercises you did, how many sets/reps, at what weight — with a dark, minimal UI that stays out of your way.
The app runs on a local server (Raspberry Pi, NAS, or any Docker host) with no dependency on third-party services. All data lives in a local SQLite database.
## Tech Stack
- **Framework**: Next.js 14 (App Router, server components + client components)
- **Database**: SQLite via Prisma ORM (`prisma/data/app.db`)
- **Styling**: Tailwind CSS, dark mode only (zinc palette, `bg-[#0A0A0A]` base)
- **Auth**: Cookie-based sessions with bcrypt password hashing, 30-day token expiry
- **Icons**: Lucide React
- **Validation**: Zod schemas on all API routes
- **PWA**: Service worker + manifest for mobile install
- **Deployment**: Docker + docker-compose, with shell scripts for start/stop/rebuild
## Current Features (What's Built)
### Workout Logging
The main feature. You log a workout by picking exercises from your library, entering sets with weight/reps, and saving. The app records the date, all set data, optional notes, difficulty rating, and duration.
Sets are displayed in a compact grouped format: consecutive sets at the same weight are collapsed (e.g., "245 x 3/3/3" instead of three separate lines). This is handled by the shared `formatSetsSummary()` helper in `lib/formatSets.ts`.
Each set supports: reps, weight (lbs or kg), RPE (1-10), duration (seconds), distance, calories, and notes. Not every field is shown — the exercise's `inputFields` array controls which fields appear in the logging UI.
### Exercise Library
~101 exercises currently in the database, each with equipment type, muscle group tags, and configurable tracked fields. Exercises are per-user and can be custom.
Equipment types include: barbell, dumbbell, bodyweight, cable, kettlebell, machine, cardio, eq bar, hex bar, grippers, ring, and other. These are extensible — the API accepts any string for type, muscle groups, and input fields (the "+" button feature).
Each exercise detail page shows a history of all logged workouts for that exercise, displayed as a clean list with date, set count, and grouped weight summary.
### CSV Import
A pipeline for importing historical workout data from CSV files. The workflow was designed around the specific use case of photographing handwritten workout logs, having an LLM convert them to CSV format, then importing into the app.
**Import flow**: Upload CSV → API parses and maps exercise names → review each workout one-at-a-time in an editable form → approve to save to DB, or skip/delete. No data touches the database until explicitly approved.
The parser (`app/api/import/parse/route.ts`) includes a 26-entry name mapping table (`NAME_MAP`) that translates CSV shorthand to database exercise names (e.g., "BB Row" → "Barbell Row", "CoC" → "Captains of Crush"). It handles M/D/YYYY and ISO date formats, detects kg units from notes, and returns a list of unmapped exercise names so they can be created before import.
### Dashboard
Shows summary stats: total workouts, total volume, recent workouts. The dashboard queries are in `lib/db/stats.ts`.
### Workout History
Paginated list of past workouts, each rendered as an expandable card showing exercises and their set summaries. Filter by date range. Total set count displayed per workout. Upload button in the header links to the import page.
### Exercise Search & Filtering
Exercises list supports real-time search and filter pills for equipment type. The filter tags are dynamic — derived from the actual exercise data rather than a hardcoded list.
### Settings
Theme preference (light/dark/system), default weight unit (lbs/kg), optional Claude AI integration toggle with API key field. The rest timer setting was removed as unused.
### Custom Values ("+" Buttons)
Exercise types, tracked fields, and muscle groups all support adding custom values via inline "+" buttons. The API validates with `z.string()` rather than `z.enum()`, so any value is accepted and persisted.
### PWA Support
Service worker and manifest for installing as a mobile app. Icons generated at multiple sizes (72px through 512px, including maskable variants).
## Data Model (Key Entities)
- **User** — email/password auth, one user per instance in practice
- **Exercise** — name, type (equipment), muscleGroups (JSON array), inputFields (JSON array), per-user
- **Workout** — date, optional name/notes/duration/difficulty/calories
- **SetLog** — links a workout to an exercise, stores reps/weight/weightUnit/RPE/duration/distance/calories/notes
- **UserPreferences** — theme, default weight unit, Claude AI settings
- **Program/ProgramWeek/ProgramDay/ProgramExercise** — schema exists but not yet implemented in UI
- **Equipment** — schema exists but not yet implemented in UI
- **ContentItem/ContentChunk** — schema exists for future knowledge base feature
- **AISuggestion** — schema exists for future AI coaching feature
## Future Phases (Planned)
### Phase 1: More Historical Data Import
Continue photographing and importing handwritten workout logs. The CSV format and import flow are ready; just need more pages transcribed.
### Phase 2: Training Programs
The database schema already supports structured programs (Program → Weeks → Days → Exercises with sets/reps/RPE targets). The UI for creating, viewing, and following programs has not been built yet.
### Phase 3: Analytics & Progress Tracking
Visualizations for tracking progress over time — charts for weight progression per exercise, volume trends, frequency heatmaps, personal records timeline. The data is all there; just needs frontend visualization.
### Phase 4: AI Coaching (Claude Integration)
The settings already have an "Enable Claude AI" toggle and API key field. The plan is to use Claude to analyze workout history and provide suggestions — exercise recommendations, program adjustments, identifying plateaus, recovery recommendations. The `AISuggestion` model and `ContentItem`/`ContentChunk` models support this.
### Phase 5: Equipment Inventory
Track what equipment is available in the home gym. The `Equipment` model exists with name, type, quantity, weight fields. Could inform exercise recommendations and program generation.
### Phase 6: Content Library
Upload training PDFs, link YouTube videos, store training knowledge. The `ContentItem` and `ContentChunk` models support chunked text storage with page numbers and video timestamps for future RAG-style search.
## Design Philosophy
- **Dark mode only in practice** — the zinc/black palette is the primary design language. Light mode exists in the theme toggle but the app is designed dark-first.
- **Mobile-first** — bottom navigation bar on mobile, sidebar on desktop. Touch-friendly tap targets.
- **Minimal chrome** — data-dense views, compact set summaries, no unnecessary decoration.
- **Client-side state for in-progress work** — workout forms and import review queue keep data in React state until explicitly saved. No auto-save to DB.
- **Flexible schema** — exercise types, muscle groups, and tracked fields are open strings, not enums. New values can be added without code changes.
## Key Conventions
- **Weight units**: Most exercises default to lbs. Kettlebell exercises, Turkish Get Ups, and windmills default to kg. The `defaultWeightUnit` field on Exercise controls this.
- **Set summary format**: `formatSetsSummary()` is the single source of truth for displaying sets compactly. Used in workout cards, exercise history, and import review.
- **Date display**: Dates show year (e.g., "Jan 27, 2026") to avoid ambiguity across year boundaries.
- **Enter key behavior**: In set logging forms, Enter advances to the next field rather than submitting the form.
- **API patterns**: All API routes use Zod validation, return JSON, and follow REST conventions. Auth is via `getCurrentUser()` which reads the session cookie.
## Running the App
```bash
# Development
npm run dev
# Production (Docker)
docker-compose up -d
# Or via scripts
./scripts/start.sh # start the server
./scripts/stop.sh # stop it
./scripts/rebuild.sh # rebuild and restart
```
Database lives at `prisma/data/app.db`. Environment variables in `.env` / `.env.local` set the `DATABASE_URL` connection string.
## Important Notes for Handoff
- The actual database file is `prisma/data/app.db`, NOT `prisma/dev.db` (which exists but is empty/stale).
- SQLite on some mounted filesystems (Docker volumes, network mounts) can have journal mode issues. If you get "disk I/O error", try copying the DB locally, modifying with `PRAGMA journal_mode=OFF`, then copying back.
- The import CSV file at `import-data/workout-log-feb2026.csv` contains parsed data from handwritten logs covering January-February 2026. More pages will be added over time.
- Exercise name mapping in the import parser (`NAME_MAP` in `app/api/import/parse/route.ts`) should be updated as new shorthand names are encountered in CSV data.
@@ -0,0 +1,362 @@
date,exercise,weight,reps,notes
2/2/2026,Squat,165,5,
2/2/2026,Zercher Squat,95,13,
2/2/2026,Zercher Squat,95,17,
2/2/2026,Zercher Squat,95,17,
2/2/2026,Zercher Squat,95,10,
2/2/2026,Zercher Squat,95,10,
2/2/2026,Hamstring Deadlift,50,10,
2/2/2026,Hamstring Deadlift,50,10,
2/2/2026,Hamstring Deadlift,50,10,
2/2/2026,KB Sidestep,20,10,
2/2/2026,KB Sidestep,20,10,
2/2/2026,KB Sidestep,20,10,
2/2/2026,SL Calf Raise,,17,bodyweight
2/2/2026,SL Calf Raise,,17,bodyweight
2/2/2026,SL Calf Raise,,17,bodyweight
2/2/2026,Bulgarian Split Squat,15,10,
2/2/2026,Bulgarian Split Squat,15,10,
2/2/2026,Bulgarian Split Squat,15,10,
2/2/2026,Bulgarian Split Squat,15,10,
2/3/2026,Face Pull,72,10,
2/3/2026,DB Row,50,10,
2/3/2026,DB Row,60,10,
2/3/2026,DB Row,60,11,
2/3/2026,KB Press,24,10,
2/3/2026,KB Press,24,11,
2/3/2026,Cable Row,49,10,
2/3/2026,Cable Row,49,11,
2/3/2026,EQ Bar Incline Bench,16,7,16kg KB each side
2/3/2026,EQ Bar Incline Bench,31,11,16kg KB + 15lb DB each side
2/3/2026,EQ Bar Incline Bench,31,10,16kg KB + 15lb DB each side
2/3/2026,EQ Bar Incline Bench,16,21,16kg KB each side
2/3/2026,BB Upright Row,65,17,
2/3/2026,BB Upright Row,65,21,
2/3/2026,Cable Fly,53,17,
2/3/2026,Cable Fly,53,13,
2/3/2026,DB Lateral Raise,10,11,
2/3/2026,DB Lateral Raise,10,10,
2/9/2026,Squat (Foot Elevated),135,3,
2/9/2026,Squat (Foot Elevated),165,7,
2/9/2026,Squat (Foot Elevated),185,7,
2/9/2026,Squat (Foot Elevated),205,7,
2/9/2026,Squat (Foot Elevated),205,7,
2/9/2026,Squat (Foot Elevated),205,7,
2/9/2026,Hamstring DL,60,10,
2/9/2026,Hamstring DL,60,10,
2/9/2026,Hamstring DL,60,10,
2/9/2026,Hamstring DL,60,10,
2/9/2026,KB Extension,16,10,kg
2/9/2026,KB Extension,16,10,kg
2/9/2026,KB Extension,16,17,kg
2/9/2026,KB Extension,16,21,kg
2/9/2026,SL Calf Raise,,17,bodyweight
2/9/2026,SL Calf Raise,,13,bodyweight
2/9/2026,SL Calf Raise,,21,bodyweight
2/9/2026,SL Calf Raise,,21,bodyweight
2/9/2026,Bulgarian Split Squat,,10,bodyweight
2/9/2026,Bulgarian Split Squat,15,10,
2/9/2026,Bulgarian Split Squat,25,10,
2/9/2026,Bulgarian Split Squat,30,12,
2/9/2026,Adductor Bench,,21,bodyweight
2/9/2026,Adductor Bench,,21,bodyweight
2/10/2026,EQ Bar Incline Bench,16,7,16kg KB each side
2/10/2026,EQ Bar Incline Bench,31,13,16kg KB + 15lb DB each side
2/10/2026,EQ Bar Incline Bench,31,13,16kg KB + 15lb DB each side
2/10/2026,Ring Row,,10,bodyweight
2/10/2026,Ring Row,,10,bodyweight
2/10/2026,Ring Row,,11,bodyweight
2/10/2026,Ring Row,,11,bodyweight
2/10/2026,Low to High Crossover,16,17,
2/10/2026,Low to High Crossover,16,17,
2/10/2026,Chinup,,7,bodyweight
2/10/2026,Chinup,,7,bodyweight
2/10/2026,Chinup,,7,bodyweight
2/10/2026,KB Press,32,17,
2/10/2026,KB Press,32,17,
2/10/2026,Ab Wheel,,17,bodyweight
2/10/2026,Ab Wheel,,21,bodyweight
2/10/2026,SA Tricep Extension,16,21,
2/10/2026,SA Tricep Extension,22,17,
2/11/2026,Zercher Squat,75,10,
2/11/2026,Zercher Squat,95,10,
2/11/2026,Zercher Squat,115,10,
2/11/2026,Zercher Squat,115,10,
2/11/2026,Zercher Squat,115,10,
2/11/2026,Zercher Squat,115,10,
2/11/2026,Zercher Squat,115,10,
2/11/2026,SL Calf Raise,,21,bodyweight
2/11/2026,SL Calf Raise,,21,bodyweight
2/12/2026,TGU,36,2,kg
2/12/2026,TGU,40,2,kg
2/12/2026,TGU,44,2,kg
2/12/2026,TGU,44,2,kg
2/12/2026,TGU,44,2,kg
2/12/2026,Rear Delt,16,10,
2/12/2026,Rear Delt,16,11,
2/12/2026,Shoulder Press,75,7,
2/12/2026,Shoulder Press,95,7,
2/12/2026,Shoulder Press,115,6,
2/12/2026,Shoulder Press,135,3,
2/12/2026,Shoulder Press,135,3,
2/12/2026,Shoulder Press,135,3,
2/12/2026,Tuck Inversion,,2,bodyweight
2/12/2026,Tuck Inversion,,2,bodyweight
2/12/2026,Tuck Inversion,,2,bodyweight
2/12/2026,Neck Circuit,10,17,
2/14/2026,Deadlift,185,6,
2/14/2026,Deadlift,225,5,
2/14/2026,Deadlift,275,5,
2/14/2026,Deadlift,275,5,
2/14/2026,Deadlift,275,6,
2/14/2026,Deadlift,275,5,
2/14/2026,Fire Hydrant,,17,bodyweight
2/14/2026,Fire Hydrant,,17,bodyweight
2/14/2026,Fire Hydrant,,17,bodyweight
2/14/2026,SL Deadlift,36,10,
2/14/2026,SL Deadlift,40,10,
2/14/2026,Chinup (Narrow),,5,vest
1/27/2026,Squat,135,7,
1/27/2026,Squat,185,7,
1/27/2026,Squat,185,5,
1/27/2026,Squat,225,5,
1/27/2026,Squat,225,5,
1/27/2026,Squat,225,5,
1/27/2026,Squat,225,6,
1/27/2026,Bulgarian Split Squat,15,10,
1/27/2026,Bulgarian Split Squat,15,10,
1/27/2026,Bulgarian Split Squat,15,10,
1/27/2026,KB Extension,16,17,kg
1/27/2026,KB Extension,16,21,kg
1/27/2026,SL Calf Raise,,17,bodyweight
1/27/2026,SL Calf Raise,,21,bodyweight
1/29/2026,Hamstring Deadlift,50,10,
1/29/2026,Hex DL,240,5,
1/29/2026,Hex DL,295,7,
1/29/2026,Hex DL,295,7,
1/29/2026,Hex DL,295,7,
1/29/2026,Bench,135,5,
1/29/2026,Bench,185,7,
1/29/2026,Bench,185,8,
1/29/2026,Bench,185,6,
1/29/2026,Tuck Inversion,,2,bodyweight
1/29/2026,Ring Row,,7,bodyweight
1/29/2026,Ring Row,,7,bodyweight
1/29/2026,Ring Row,,7,bodyweight
1/29/2026,Ball Situp,,17,bodyweight
1/29/2026,Ball Situp,,13,bodyweight
1/29/2026,Alt Leg Lift,,10,bodyweight
1/29/2026,Alt Leg Lift,,10,bodyweight
1/29/2026,Cable Trap,16,7,
1/29/2026,Cable Trap,16,10,
1/29/2026,SA Landmine Press,25,10,
1/29/2026,SA Landmine Press,25,10,
1/29/2026,SA Landmine Press,25,10,
1/29/2026,Landmine Pull and Press,25,10,
1/29/2026,Landmine Pull and Press,25,11,
1/29/2026,Wide Grip Pull Up,,5,bodyweight
1/29/2026,Wide Grip Pull Up,,5,bodyweight
1/29/2026,Wide Grip Pull Up,,5,bodyweight
1/30/2026,Face Pull,66,10,
1/30/2026,KB Press,20,7,
1/30/2026,KB Press,24,7,
1/30/2026,KB Press,32,3,
1/30/2026,KB Press,32,3,
1/30/2026,KB Press,32,3,
1/30/2026,Side Lying Press,20,7,
1/30/2026,Side Lying Press,24,7,
1/30/2026,Side Lying Press,24,13,
1/30/2026,Side Lying Press,24,17,
1/30/2026,TGU,44,1,kg
1/30/2026,TGU,48,1,kg
1/30/2026,TGU,48,1,kg
1/30/2026,BB Reverse Curl,45,17,bar only
1/30/2026,BB Reverse Curl,55,13,
1/30/2026,BB Reverse Curl,60,13,
1/30/2026,CoC,0.5,7,Captains of Crush #0.5
1/30/2026,CoC,1,5,Captains of Crush #1
1/30/2026,CoC,1,5,Captains of Crush #1
1/30/2026,SA Tricep Extension,27,10,
1/30/2026,SA Tricep Extension,27,11,
1/30/2026,Ball Bicep Curl,27,10,
1/30/2026,Ball Bicep Curl,27,11,
1/30/2026,Neck Circuit,10,17,
1/30/2026,Neck Circuit,10,17,
1/30/2026,Bench Dip,,21,bodyweight
1/30/2026,Bench Dip,,21,bodyweight
1/30/2026,Assault Bike,,,77 cal - 10/20 intervals
1/18/2026,Squat,135,7,
1/18/2026,Squat,155,5,
1/18/2026,Squat,170,5,
1/18/2026,Squat,185,11,work set
1/18/2026,Squat,185,10,work set
1/18/2026,DB Step Back Lunge,40,7,
1/18/2026,DB Step Back Lunge,60,7,
1/18/2026,DB Step Back Lunge,80,7,work set
1/18/2026,DB Step Back Lunge,80,10,work set - max
1/18/2026,Bench,135,7,
1/18/2026,Bench,165,5,
1/18/2026,Bench,185,9,work set
1/18/2026,Bench,185,8,work set
1/18/2026,SA Lat Pulldown,60,7,
1/18/2026,SA Lat Pulldown,71,10,work set
1/18/2026,SA Lat Pulldown,71,11,work set
1/18/2026,Ab Wheel,,11,vest
1/18/2026,Ab Wheel,,10,vest
1/18/2026,SL Calf Raise,,17,vest
1/18/2026,SL Calf Raise,,13,vest
1/18/2026,KB Press,20,5,
1/18/2026,KB Press,24,11,work set
1/18/2026,KB Press,24,10,work set
1/19/2026,KB Extension,12,13,kg
1/19/2026,KB Extension,12,17,kg
1/19/2026,KB Extension,12,17,kg
1/19/2026,KB Extension,12,21,kg - ankle weight
1/19/2026,KB Extension,12,21,kg - ankle weight
1/19/2026,Knee Raise,,21,ankle weight
1/19/2026,Knee Raise,,17,ankle weight
1/19/2026,Hip Flexor,12,10,
1/19/2026,Hip Flexor,12,10,
1/19/2026,Hip Flexor,12,10,
1/19/2026,Adductor Bench,,17,bodyweight
1/19/2026,Adductor Bench,,21,bodyweight
1/19/2026,GHD,,7,bodyweight
1/19/2026,GHD,,7,bodyweight
1/19/2026,GHD,,7,bodyweight
1/20/2026,Ab KB Drag,12,10,
1/20/2026,Ab KB Drag,12,11,
1/20/2026,Dip,,13,bodyweight
1/20/2026,Dip,,17,bodyweight
1/20/2026,SA Tricep Extension,22,11,
1/20/2026,SA Tricep Extension,22,10,
1/20/2026,CoC,0.5,7,Captains of Crush
1/20/2026,CoC,1,6,Captains of Crush
1/20/2026,CoC,1,7,Captains of Crush
1/20/2026,CoC,1.5,3,Captains of Crush
1/20/2026,CoC,1.5,2,Captains of Crush
1/20/2026,CoC,1,10,Captains of Crush
1/20/2026,Ab Mat,,17,bodyweight
1/20/2026,Ab Mat,,13,bodyweight
1/20/2026,SA DB Curl,30,11,
1/20/2026,SA DB Curl,30,10,
1/20/2026,EQ Military Press,16,7,16kg KB each side
1/20/2026,EQ Military Press,16,10,16kg KB each side
1/20/2026,Tuck Inversion,,2,bodyweight
1/20/2026,Tuck Inversion,,2,bodyweight
1/20/2026,Cable Trap,16,10,
1/20/2026,Cable Trap,16,7,
1/20/2026,Ab Scissors,,,,2 sets
1/20/2026,Ab Scissors,,,,2 sets
1/20/2026,Chinup Negatives,,2,bodyweight
1/20/2026,Chinup Negatives,,2,bodyweight
1/22/2026,TGU,44,1,kg
1/22/2026,TGU,44,1,kg
1/22/2026,TGU,44,1,kg
1/22/2026,EQ Military Press,16,10,16kg KB each side
1/22/2026,EQ Military Press,16,10,16kg KB each side
1/22/2026,EQ Military Press,16,10,16kg KB each side
1/22/2026,BB Row,95,21,
1/22/2026,BB Row,95,17,
1/22/2026,Windmill,12,6,kg
1/22/2026,Half Kneel Windmill,16,7,kg
1/22/2026,Half Kneel Windmill,16,8,kg
1/22/2026,Side Lying Press,20,21,
1/22/2026,Side Lying Press,24,21,
1/22/2026,Ball Situp,,17,bodyweight
1/22/2026,Ball Situp,,17,bodyweight
1/22/2026,Ball Situp,,17,bodyweight
1/22/2026,Neck Circuit,10,17,
1/22/2026,Neck Circuit,10,17,
1/22/2026,Neck Circuit,10,17,
1/2/2026,Face Pull,68,10,
1/2/2026,Cable Trap,16,10,
1/2/2026,EQ Bar Incline Bench,16,13,16kg KB each side
1/2/2026,EQ Bar Incline Bench,21,10,16kg KB + 5lb each side
1/2/2026,EQ Bar Incline Bench,26,10,16kg KB + 10lb each side
1/2/2026,EQ Bar Incline Bench,26,12,16kg KB + 10lb each side
1/2/2026,EQ Bar Incline Bench,31,10,16kg KB + 15lb each side
1/2/2026,Chinup,,10,
1/2/2026,Chinup,,7,
1/2/2026,Chinup,,7,
1/2/2026,Chinup,,5,slow negatives
1/2/2026,Chinup,,5,slow negatives
1/2/2026,Alternating Step Cable Cross,44,16,
1/2/2026,Alternating Step Cable Cross,44,16,
1/2/2026,Alternating Step Cable Cross,44,16,
1/2/2026,Tuck Inversion,,2,vest
1/2/2026,Tuck Inversion,,2,vest
1/2/2026,Ab Wheel,,7,vest
1/2/2026,Ab Wheel,,7,vest
1/2/2026,Ab Wheel,,7,vest
1/2/2026,Ring Dip,,3,bodyweight
1/2/2026,Ring Dip,,3,bodyweight
1/2/2026,Ring Dip,,3,bodyweight
1/2/2026,Overhead Tricep Extension,44,13,
1/2/2026,Overhead Tricep Extension,44,13,
1/2/2026,Overhead Tricep Extension,44,13,
1/2/2026,BB Curl,45,17,bar only
1/2/2026,BB Curl,45,17,bar only
1/2/2026,BB Curl,45,17,bar only
1/2/2026,Band Pushup,,7,black/gray band
1/2/2026,Band Pushup,,7,black/gray band
1/2/2026,Band Pushup,,7,black/gray band
1/3/2026,TGU,36,1,kg
1/3/2026,TGU,40,1,kg
1/3/2026,TGU,44,1,kg
1/3/2026,TGU,48,1,kg
1/3/2026,TGU,48,1,kg
1/3/2026,TGU,48,1,kg
1/3/2026,Deadlift,185,5,
1/3/2026,Deadlift,225,5,
1/3/2026,Deadlift,275,5,
1/3/2026,Deadlift,275,5,
1/3/2026,Deadlift,275,5,
1/3/2026,Deadlift,275,6,
1/3/2026,Assault Bike,,,72 cal - 10/20 intervals
1/4/2026,Assault Bike,,,5 min - target zone 4 cardio
1/4/2026,Ski,,,5 min - target zone 4 cardio
1/4/2026,Jump Rope,,,5 min - target zone 4 cardio
1/4/2026,KB Swing,24,5,kg - single arm every 60 sec / arm every 45 sec - 5 min
1/4/2026,Neck Circuit,10,17,
1/6/2026,Squat,135,7,
1/6/2026,Squat,165,7,
1/6/2026,Squat,165,7,chains - 207 total
1/6/2026,Squat,165,7,chains - 207 total
1/6/2026,Squat,165,7,chains - 207 total
1/6/2026,Bulgarian Split Squat,30,10,
1/6/2026,Bulgarian Split Squat,30,10,
1/6/2026,Bulgarian Split Squat,30,10,
1/6/2026,KB Hip Flexor,12,10,kg
1/6/2026,KB Hip Flexor,12,10,kg
1/6/2026,KB Hip Flexor,12,10,kg
1/6/2026,KB Extension,12,10,kg
1/6/2026,KB Extension,12,10,kg
1/6/2026,KB Extension,12,10,kg
1/6/2026,Adductor Bench,,10,bodyweight
1/6/2026,Adductor Bench,,10,bodyweight
1/6/2026,Adductor Bench,,10,bodyweight
1/6/2026,BB Hip Bridge,135,13,
1/6/2026,BB Hip Bridge,135,13,
1/6/2026,BB Hip Bridge,135,13,
1/7/2026,Face Pull,68,10,
1/7/2026,Cable Trap,16,8,
1/7/2026,EQ Bar Incline Bench,16,10,16kg KB each side
1/7/2026,EQ Bar Incline Bench,26,10,16kg KB + 10lb each side
1/7/2026,EQ Bar Incline Bench,26,10,16kg KB + 10lb each side
1/7/2026,EQ Bar Incline Bench,31,13,16kg KB + 15lb each side
1/7/2026,EQ Bar Incline Bench,31,9,16kg KB + 15lb each side
1/7/2026,EQ Bar Incline Bench,31,8,16kg KB + 15lb each side
1/7/2026,Chinup,,8,
1/7/2026,Chinup,,6,
1/7/2026,Chinup,,6,
1/7/2026,Chinup,,6,
1/7/2026,Alternating Step Cable Cross,44,16,
1/7/2026,Alternating Step Cable Cross,44,16,
1/7/2026,Alternating Step Cable Cross,44,16,
1/7/2026,Tuck Inversion,,2,vest
1/7/2026,Tuck Inversion,,2,vest
1/7/2026,Ab Wheel,,7,vest
1/7/2026,Ab Wheel,,7,vest
1/7/2026,Ab Wheel,,7,vest
1/7/2026,Ring Dip,,7,bodyweight
1/7/2026,Ring Dip,,7,bodyweight
1 date exercise weight reps notes
2 2/2/2026 Squat 165 5
3 2/2/2026 Zercher Squat 95 13
4 2/2/2026 Zercher Squat 95 17
5 2/2/2026 Zercher Squat 95 17
6 2/2/2026 Zercher Squat 95 10
7 2/2/2026 Zercher Squat 95 10
8 2/2/2026 Hamstring Deadlift 50 10
9 2/2/2026 Hamstring Deadlift 50 10
10 2/2/2026 Hamstring Deadlift 50 10
11 2/2/2026 KB Sidestep 20 10
12 2/2/2026 KB Sidestep 20 10
13 2/2/2026 KB Sidestep 20 10
14 2/2/2026 SL Calf Raise 17 bodyweight
15 2/2/2026 SL Calf Raise 17 bodyweight
16 2/2/2026 SL Calf Raise 17 bodyweight
17 2/2/2026 Bulgarian Split Squat 15 10
18 2/2/2026 Bulgarian Split Squat 15 10
19 2/2/2026 Bulgarian Split Squat 15 10
20 2/2/2026 Bulgarian Split Squat 15 10
21 2/3/2026 Face Pull 72 10
22 2/3/2026 DB Row 50 10
23 2/3/2026 DB Row 60 10
24 2/3/2026 DB Row 60 11
25 2/3/2026 KB Press 24 10
26 2/3/2026 KB Press 24 11
27 2/3/2026 Cable Row 49 10
28 2/3/2026 Cable Row 49 11
29 2/3/2026 EQ Bar Incline Bench 16 7 16kg KB each side
30 2/3/2026 EQ Bar Incline Bench 31 11 16kg KB + 15lb DB each side
31 2/3/2026 EQ Bar Incline Bench 31 10 16kg KB + 15lb DB each side
32 2/3/2026 EQ Bar Incline Bench 16 21 16kg KB each side
33 2/3/2026 BB Upright Row 65 17
34 2/3/2026 BB Upright Row 65 21
35 2/3/2026 Cable Fly 53 17
36 2/3/2026 Cable Fly 53 13
37 2/3/2026 DB Lateral Raise 10 11
38 2/3/2026 DB Lateral Raise 10 10
39 2/9/2026 Squat (Foot Elevated) 135 3
40 2/9/2026 Squat (Foot Elevated) 165 7
41 2/9/2026 Squat (Foot Elevated) 185 7
42 2/9/2026 Squat (Foot Elevated) 205 7
43 2/9/2026 Squat (Foot Elevated) 205 7
44 2/9/2026 Squat (Foot Elevated) 205 7
45 2/9/2026 Hamstring DL 60 10
46 2/9/2026 Hamstring DL 60 10
47 2/9/2026 Hamstring DL 60 10
48 2/9/2026 Hamstring DL 60 10
49 2/9/2026 KB Extension 16 10 kg
50 2/9/2026 KB Extension 16 10 kg
51 2/9/2026 KB Extension 16 17 kg
52 2/9/2026 KB Extension 16 21 kg
53 2/9/2026 SL Calf Raise 17 bodyweight
54 2/9/2026 SL Calf Raise 13 bodyweight
55 2/9/2026 SL Calf Raise 21 bodyweight
56 2/9/2026 SL Calf Raise 21 bodyweight
57 2/9/2026 Bulgarian Split Squat 10 bodyweight
58 2/9/2026 Bulgarian Split Squat 15 10
59 2/9/2026 Bulgarian Split Squat 25 10
60 2/9/2026 Bulgarian Split Squat 30 12
61 2/9/2026 Adductor Bench 21 bodyweight
62 2/9/2026 Adductor Bench 21 bodyweight
63 2/10/2026 EQ Bar Incline Bench 16 7 16kg KB each side
64 2/10/2026 EQ Bar Incline Bench 31 13 16kg KB + 15lb DB each side
65 2/10/2026 EQ Bar Incline Bench 31 13 16kg KB + 15lb DB each side
66 2/10/2026 Ring Row 10 bodyweight
67 2/10/2026 Ring Row 10 bodyweight
68 2/10/2026 Ring Row 11 bodyweight
69 2/10/2026 Ring Row 11 bodyweight
70 2/10/2026 Low to High Crossover 16 17
71 2/10/2026 Low to High Crossover 16 17
72 2/10/2026 Chinup 7 bodyweight
73 2/10/2026 Chinup 7 bodyweight
74 2/10/2026 Chinup 7 bodyweight
75 2/10/2026 KB Press 32 17
76 2/10/2026 KB Press 32 17
77 2/10/2026 Ab Wheel 17 bodyweight
78 2/10/2026 Ab Wheel 21 bodyweight
79 2/10/2026 SA Tricep Extension 16 21
80 2/10/2026 SA Tricep Extension 22 17
81 2/11/2026 Zercher Squat 75 10
82 2/11/2026 Zercher Squat 95 10
83 2/11/2026 Zercher Squat 115 10
84 2/11/2026 Zercher Squat 115 10
85 2/11/2026 Zercher Squat 115 10
86 2/11/2026 Zercher Squat 115 10
87 2/11/2026 Zercher Squat 115 10
88 2/11/2026 SL Calf Raise 21 bodyweight
89 2/11/2026 SL Calf Raise 21 bodyweight
90 2/12/2026 TGU 36 2 kg
91 2/12/2026 TGU 40 2 kg
92 2/12/2026 TGU 44 2 kg
93 2/12/2026 TGU 44 2 kg
94 2/12/2026 TGU 44 2 kg
95 2/12/2026 Rear Delt 16 10
96 2/12/2026 Rear Delt 16 11
97 2/12/2026 Shoulder Press 75 7
98 2/12/2026 Shoulder Press 95 7
99 2/12/2026 Shoulder Press 115 6
100 2/12/2026 Shoulder Press 135 3
101 2/12/2026 Shoulder Press 135 3
102 2/12/2026 Shoulder Press 135 3
103 2/12/2026 Tuck Inversion 2 bodyweight
104 2/12/2026 Tuck Inversion 2 bodyweight
105 2/12/2026 Tuck Inversion 2 bodyweight
106 2/12/2026 Neck Circuit 10 17
107 2/14/2026 Deadlift 185 6
108 2/14/2026 Deadlift 225 5
109 2/14/2026 Deadlift 275 5
110 2/14/2026 Deadlift 275 5
111 2/14/2026 Deadlift 275 6
112 2/14/2026 Deadlift 275 5
113 2/14/2026 Fire Hydrant 17 bodyweight
114 2/14/2026 Fire Hydrant 17 bodyweight
115 2/14/2026 Fire Hydrant 17 bodyweight
116 2/14/2026 SL Deadlift 36 10
117 2/14/2026 SL Deadlift 40 10
118 2/14/2026 Chinup (Narrow) 5 vest
119 1/27/2026 Squat 135 7
120 1/27/2026 Squat 185 7
121 1/27/2026 Squat 185 5
122 1/27/2026 Squat 225 5
123 1/27/2026 Squat 225 5
124 1/27/2026 Squat 225 5
125 1/27/2026 Squat 225 6
126 1/27/2026 Bulgarian Split Squat 15 10
127 1/27/2026 Bulgarian Split Squat 15 10
128 1/27/2026 Bulgarian Split Squat 15 10
129 1/27/2026 KB Extension 16 17 kg
130 1/27/2026 KB Extension 16 21 kg
131 1/27/2026 SL Calf Raise 17 bodyweight
132 1/27/2026 SL Calf Raise 21 bodyweight
133 1/29/2026 Hamstring Deadlift 50 10
134 1/29/2026 Hex DL 240 5
135 1/29/2026 Hex DL 295 7
136 1/29/2026 Hex DL 295 7
137 1/29/2026 Hex DL 295 7
138 1/29/2026 Bench 135 5
139 1/29/2026 Bench 185 7
140 1/29/2026 Bench 185 8
141 1/29/2026 Bench 185 6
142 1/29/2026 Tuck Inversion 2 bodyweight
143 1/29/2026 Ring Row 7 bodyweight
144 1/29/2026 Ring Row 7 bodyweight
145 1/29/2026 Ring Row 7 bodyweight
146 1/29/2026 Ball Situp 17 bodyweight
147 1/29/2026 Ball Situp 13 bodyweight
148 1/29/2026 Alt Leg Lift 10 bodyweight
149 1/29/2026 Alt Leg Lift 10 bodyweight
150 1/29/2026 Cable Trap 16 7
151 1/29/2026 Cable Trap 16 10
152 1/29/2026 SA Landmine Press 25 10
153 1/29/2026 SA Landmine Press 25 10
154 1/29/2026 SA Landmine Press 25 10
155 1/29/2026 Landmine Pull and Press 25 10
156 1/29/2026 Landmine Pull and Press 25 11
157 1/29/2026 Wide Grip Pull Up 5 bodyweight
158 1/29/2026 Wide Grip Pull Up 5 bodyweight
159 1/29/2026 Wide Grip Pull Up 5 bodyweight
160 1/30/2026 Face Pull 66 10
161 1/30/2026 KB Press 20 7
162 1/30/2026 KB Press 24 7
163 1/30/2026 KB Press 32 3
164 1/30/2026 KB Press 32 3
165 1/30/2026 KB Press 32 3
166 1/30/2026 Side Lying Press 20 7
167 1/30/2026 Side Lying Press 24 7
168 1/30/2026 Side Lying Press 24 13
169 1/30/2026 Side Lying Press 24 17
170 1/30/2026 TGU 44 1 kg
171 1/30/2026 TGU 48 1 kg
172 1/30/2026 TGU 48 1 kg
173 1/30/2026 BB Reverse Curl 45 17 bar only
174 1/30/2026 BB Reverse Curl 55 13
175 1/30/2026 BB Reverse Curl 60 13
176 1/30/2026 CoC 0.5 7 Captains of Crush #0.5
177 1/30/2026 CoC 1 5 Captains of Crush #1
178 1/30/2026 CoC 1 5 Captains of Crush #1
179 1/30/2026 SA Tricep Extension 27 10
180 1/30/2026 SA Tricep Extension 27 11
181 1/30/2026 Ball Bicep Curl 27 10
182 1/30/2026 Ball Bicep Curl 27 11
183 1/30/2026 Neck Circuit 10 17
184 1/30/2026 Neck Circuit 10 17
185 1/30/2026 Bench Dip 21 bodyweight
186 1/30/2026 Bench Dip 21 bodyweight
187 1/30/2026 Assault Bike 77 cal - 10/20 intervals
188 1/18/2026 Squat 135 7
189 1/18/2026 Squat 155 5
190 1/18/2026 Squat 170 5
191 1/18/2026 Squat 185 11 work set
192 1/18/2026 Squat 185 10 work set
193 1/18/2026 DB Step Back Lunge 40 7
194 1/18/2026 DB Step Back Lunge 60 7
195 1/18/2026 DB Step Back Lunge 80 7 work set
196 1/18/2026 DB Step Back Lunge 80 10 work set - max
197 1/18/2026 Bench 135 7
198 1/18/2026 Bench 165 5
199 1/18/2026 Bench 185 9 work set
200 1/18/2026 Bench 185 8 work set
201 1/18/2026 SA Lat Pulldown 60 7
202 1/18/2026 SA Lat Pulldown 71 10 work set
203 1/18/2026 SA Lat Pulldown 71 11 work set
204 1/18/2026 Ab Wheel 11 vest
205 1/18/2026 Ab Wheel 10 vest
206 1/18/2026 SL Calf Raise 17 vest
207 1/18/2026 SL Calf Raise 13 vest
208 1/18/2026 KB Press 20 5
209 1/18/2026 KB Press 24 11 work set
210 1/18/2026 KB Press 24 10 work set
211 1/19/2026 KB Extension 12 13 kg
212 1/19/2026 KB Extension 12 17 kg
213 1/19/2026 KB Extension 12 17 kg
214 1/19/2026 KB Extension 12 21 kg - ankle weight
215 1/19/2026 KB Extension 12 21 kg - ankle weight
216 1/19/2026 Knee Raise 21 ankle weight
217 1/19/2026 Knee Raise 17 ankle weight
218 1/19/2026 Hip Flexor 12 10
219 1/19/2026 Hip Flexor 12 10
220 1/19/2026 Hip Flexor 12 10
221 1/19/2026 Adductor Bench 17 bodyweight
222 1/19/2026 Adductor Bench 21 bodyweight
223 1/19/2026 GHD 7 bodyweight
224 1/19/2026 GHD 7 bodyweight
225 1/19/2026 GHD 7 bodyweight
226 1/20/2026 Ab KB Drag 12 10
227 1/20/2026 Ab KB Drag 12 11
228 1/20/2026 Dip 13 bodyweight
229 1/20/2026 Dip 17 bodyweight
230 1/20/2026 SA Tricep Extension 22 11
231 1/20/2026 SA Tricep Extension 22 10
232 1/20/2026 CoC 0.5 7 Captains of Crush
233 1/20/2026 CoC 1 6 Captains of Crush
234 1/20/2026 CoC 1 7 Captains of Crush
235 1/20/2026 CoC 1.5 3 Captains of Crush
236 1/20/2026 CoC 1.5 2 Captains of Crush
237 1/20/2026 CoC 1 10 Captains of Crush
238 1/20/2026 Ab Mat 17 bodyweight
239 1/20/2026 Ab Mat 13 bodyweight
240 1/20/2026 SA DB Curl 30 11
241 1/20/2026 SA DB Curl 30 10
242 1/20/2026 EQ Military Press 16 7 16kg KB each side
243 1/20/2026 EQ Military Press 16 10 16kg KB each side
244 1/20/2026 Tuck Inversion 2 bodyweight
245 1/20/2026 Tuck Inversion 2 bodyweight
246 1/20/2026 Cable Trap 16 10
247 1/20/2026 Cable Trap 16 7
248 1/20/2026 Ab Scissors 2 sets
249 1/20/2026 Ab Scissors 2 sets
250 1/20/2026 Chinup Negatives 2 bodyweight
251 1/20/2026 Chinup Negatives 2 bodyweight
252 1/22/2026 TGU 44 1 kg
253 1/22/2026 TGU 44 1 kg
254 1/22/2026 TGU 44 1 kg
255 1/22/2026 EQ Military Press 16 10 16kg KB each side
256 1/22/2026 EQ Military Press 16 10 16kg KB each side
257 1/22/2026 EQ Military Press 16 10 16kg KB each side
258 1/22/2026 BB Row 95 21
259 1/22/2026 BB Row 95 17
260 1/22/2026 Windmill 12 6 kg
261 1/22/2026 Half Kneel Windmill 16 7 kg
262 1/22/2026 Half Kneel Windmill 16 8 kg
263 1/22/2026 Side Lying Press 20 21
264 1/22/2026 Side Lying Press 24 21
265 1/22/2026 Ball Situp 17 bodyweight
266 1/22/2026 Ball Situp 17 bodyweight
267 1/22/2026 Ball Situp 17 bodyweight
268 1/22/2026 Neck Circuit 10 17
269 1/22/2026 Neck Circuit 10 17
270 1/22/2026 Neck Circuit 10 17
271 1/2/2026 Face Pull 68 10
272 1/2/2026 Cable Trap 16 10
273 1/2/2026 EQ Bar Incline Bench 16 13 16kg KB each side
274 1/2/2026 EQ Bar Incline Bench 21 10 16kg KB + 5lb each side
275 1/2/2026 EQ Bar Incline Bench 26 10 16kg KB + 10lb each side
276 1/2/2026 EQ Bar Incline Bench 26 12 16kg KB + 10lb each side
277 1/2/2026 EQ Bar Incline Bench 31 10 16kg KB + 15lb each side
278 1/2/2026 Chinup 10
279 1/2/2026 Chinup 7
280 1/2/2026 Chinup 7
281 1/2/2026 Chinup 5 slow negatives
282 1/2/2026 Chinup 5 slow negatives
283 1/2/2026 Alternating Step Cable Cross 44 16
284 1/2/2026 Alternating Step Cable Cross 44 16
285 1/2/2026 Alternating Step Cable Cross 44 16
286 1/2/2026 Tuck Inversion 2 vest
287 1/2/2026 Tuck Inversion 2 vest
288 1/2/2026 Ab Wheel 7 vest
289 1/2/2026 Ab Wheel 7 vest
290 1/2/2026 Ab Wheel 7 vest
291 1/2/2026 Ring Dip 3 bodyweight
292 1/2/2026 Ring Dip 3 bodyweight
293 1/2/2026 Ring Dip 3 bodyweight
294 1/2/2026 Overhead Tricep Extension 44 13
295 1/2/2026 Overhead Tricep Extension 44 13
296 1/2/2026 Overhead Tricep Extension 44 13
297 1/2/2026 BB Curl 45 17 bar only
298 1/2/2026 BB Curl 45 17 bar only
299 1/2/2026 BB Curl 45 17 bar only
300 1/2/2026 Band Pushup 7 black/gray band
301 1/2/2026 Band Pushup 7 black/gray band
302 1/2/2026 Band Pushup 7 black/gray band
303 1/3/2026 TGU 36 1 kg
304 1/3/2026 TGU 40 1 kg
305 1/3/2026 TGU 44 1 kg
306 1/3/2026 TGU 48 1 kg
307 1/3/2026 TGU 48 1 kg
308 1/3/2026 TGU 48 1 kg
309 1/3/2026 Deadlift 185 5
310 1/3/2026 Deadlift 225 5
311 1/3/2026 Deadlift 275 5
312 1/3/2026 Deadlift 275 5
313 1/3/2026 Deadlift 275 5
314 1/3/2026 Deadlift 275 6
315 1/3/2026 Assault Bike 72 cal - 10/20 intervals
316 1/4/2026 Assault Bike 5 min - target zone 4 cardio
317 1/4/2026 Ski 5 min - target zone 4 cardio
318 1/4/2026 Jump Rope 5 min - target zone 4 cardio
319 1/4/2026 KB Swing 24 5 kg - single arm every 60 sec / arm every 45 sec - 5 min
320 1/4/2026 Neck Circuit 10 17
321 1/6/2026 Squat 135 7
322 1/6/2026 Squat 165 7
323 1/6/2026 Squat 165 7 chains - 207 total
324 1/6/2026 Squat 165 7 chains - 207 total
325 1/6/2026 Squat 165 7 chains - 207 total
326 1/6/2026 Bulgarian Split Squat 30 10
327 1/6/2026 Bulgarian Split Squat 30 10
328 1/6/2026 Bulgarian Split Squat 30 10
329 1/6/2026 KB Hip Flexor 12 10 kg
330 1/6/2026 KB Hip Flexor 12 10 kg
331 1/6/2026 KB Hip Flexor 12 10 kg
332 1/6/2026 KB Extension 12 10 kg
333 1/6/2026 KB Extension 12 10 kg
334 1/6/2026 KB Extension 12 10 kg
335 1/6/2026 Adductor Bench 10 bodyweight
336 1/6/2026 Adductor Bench 10 bodyweight
337 1/6/2026 Adductor Bench 10 bodyweight
338 1/6/2026 BB Hip Bridge 135 13
339 1/6/2026 BB Hip Bridge 135 13
340 1/6/2026 BB Hip Bridge 135 13
341 1/7/2026 Face Pull 68 10
342 1/7/2026 Cable Trap 16 8
343 1/7/2026 EQ Bar Incline Bench 16 10 16kg KB each side
344 1/7/2026 EQ Bar Incline Bench 26 10 16kg KB + 10lb each side
345 1/7/2026 EQ Bar Incline Bench 26 10 16kg KB + 10lb each side
346 1/7/2026 EQ Bar Incline Bench 31 13 16kg KB + 15lb each side
347 1/7/2026 EQ Bar Incline Bench 31 9 16kg KB + 15lb each side
348 1/7/2026 EQ Bar Incline Bench 31 8 16kg KB + 15lb each side
349 1/7/2026 Chinup 8
350 1/7/2026 Chinup 6
351 1/7/2026 Chinup 6
352 1/7/2026 Chinup 6
353 1/7/2026 Alternating Step Cable Cross 44 16
354 1/7/2026 Alternating Step Cable Cross 44 16
355 1/7/2026 Alternating Step Cable Cross 44 16
356 1/7/2026 Tuck Inversion 2 vest
357 1/7/2026 Tuck Inversion 2 vest
358 1/7/2026 Ab Wheel 7 vest
359 1/7/2026 Ab Wheel 7 vest
360 1/7/2026 Ab Wheel 7 vest
361 1/7/2026 Ring Dip 7 bodyweight
362 1/7/2026 Ring Dip 7 bodyweight
+100
View File
@@ -0,0 +1,100 @@
import bcryptjs from "bcryptjs";
import { prisma } from "./prisma";
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { cookies } from "next/headers";
import { User } from "@prisma/client";
/**
* Hash a password using bcryptjs
*/
export async function hashPassword(password: string): Promise<string> {
const salt = await bcryptjs.genSalt(10);
return bcryptjs.hash(password, salt);
}
/**
* Verify a password against its hash
*/
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcryptjs.compare(password, hash);
}
/**
* Create a session token for a user (30-day expiration)
*/
export async function createSession(
userId: string
): Promise<{ token: string; expiresAt: Date }> {
const token = Buffer.from(
`${userId}:${Date.now()}:${Math.random()}`
).toString("hex");
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await prisma.session.create({
data: {
token,
userId,
expiresAt,
},
});
return { token, expiresAt };
}
/**
* Validate a session token and return the associated user
*/
export async function validateSession(token: string): Promise<User | null> {
const session = await prisma.session.findUnique({
where: { token },
include: { user: true },
});
if (!session) {
return null;
}
// Check if session has expired
if (session.expiresAt < new Date()) {
await prisma.session.delete({ where: { token } });
return null;
}
return session.user;
}
/**
* Delete a session token
*/
export async function deleteSession(token: string): Promise<void> {
await prisma.session.delete({
where: { token },
});
}
/**
* Get session from cookies object
*/
export async function getSessionFromCookies(
cookieStore: ReadonlyRequestCookies
): Promise<User | null> {
const sessionToken = cookieStore.get("sessionToken")?.value;
if (!sessionToken) {
return null;
}
return validateSession(sessionToken);
}
/**
* Get the current user from request cookies
*/
export async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies();
return getSessionFromCookies(cookieStore);
}
+111
View File
@@ -0,0 +1,111 @@
import { prisma } from "../prisma";
import { Exercise, SetLog } from "@prisma/client";
/**
* Get all exercises for a user
*/
export async function getExercises(userId: string): Promise<Exercise[]> {
return prisma.exercise.findMany({
where: { userId },
orderBy: {
name: "asc",
},
});
}
/**
* Get a single exercise by ID
*/
export async function getExerciseById(id: string): Promise<Exercise | null> {
return prisma.exercise.findUnique({
where: { id },
});
}
/**
* Create a new exercise
*/
export async function createExercise(data: {
userId: string;
name: string;
type?: string;
description?: string;
muscleGroups?: string;
inputFields?: string;
defaultWeightUnit?: string | null;
isCustom?: boolean;
}): Promise<Exercise> {
return prisma.exercise.create({
data: {
userId: data.userId,
name: data.name,
type: data.type || "other",
description: data.description,
muscleGroups: data.muscleGroups || JSON.stringify([]),
isCustom: data.isCustom || false,
// These fields exist in schema but Prisma client may not be regenerated yet
...(data.inputFields ? { inputFields: data.inputFields } : {}),
...(data.defaultWeightUnit ? { defaultWeightUnit: data.defaultWeightUnit } : {}),
} as any,
});
}
/**
* Get all set logs for an exercise (history)
*/
export async function getExerciseHistory(
exerciseId: string,
userId: string
): Promise<SetLog[]> {
return prisma.setLog.findMany({
where: {
exerciseId,
workout: {
userId,
},
},
orderBy: {
createdAt: "desc",
},
});
}
/**
* Get the personal best (highest weight) for an exercise
*/
export async function getPersonalBest(
exerciseId: string,
userId: string
): Promise<{ weight: number; reps: number; date: Date } | null> {
const set = await prisma.setLog.findFirst({
where: {
exerciseId,
workout: {
userId,
},
},
orderBy: [
{
weight: "desc",
},
{
reps: "desc",
},
],
select: {
weight: true,
reps: true,
createdAt: true,
},
});
if (!set) {
return null;
}
return {
weight: set.weight || 0,
reps: set.reps || 0,
date: set.createdAt,
};
}
+219
View File
@@ -0,0 +1,219 @@
import { prisma } from "../prisma";
import { DashboardStats } from "@/types";
/**
* Get number of workouts this week (Mon-Sun)
*/
export async function getWeeklyWorkoutCount(userId: string): Promise<number> {
const now = new Date();
const dayOfWeek = now.getDay();
const daysBack = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Monday is 0
const mondayStart = new Date(now);
mondayStart.setDate(mondayStart.getDate() - daysBack);
mondayStart.setHours(0, 0, 0, 0);
const sundayEnd = new Date(mondayStart);
sundayEnd.setDate(sundayEnd.getDate() + 6);
sundayEnd.setHours(23, 59, 59, 999);
const count = await prisma.workout.count({
where: {
userId,
date: {
gte: mondayStart,
lte: sundayEnd,
},
},
});
return count;
}
/**
* Get total workouts ever for user
*/
export async function getTotalWorkoutCount(userId: string): Promise<number> {
return prisma.workout.count({
where: { userId },
});
}
/**
* Get number of workouts this month (1st to end of month)
*/
export async function getMonthlyWorkoutCount(userId: string): Promise<number> {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
monthStart.setHours(0, 0, 0, 0);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
monthEnd.setHours(23, 59, 59, 999);
return prisma.workout.count({
where: {
userId,
date: {
gte: monthStart,
lte: monthEnd,
},
},
});
}
/**
* Get number of workouts this year (Jan 1 to Dec 31)
*/
export async function getYearlyWorkoutCount(userId: string): Promise<number> {
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
yearStart.setHours(0, 0, 0, 0);
const yearEnd = new Date(now.getFullYear(), 11, 31);
yearEnd.setHours(23, 59, 59, 999);
return prisma.workout.count({
where: {
userId,
date: {
gte: yearStart,
lte: yearEnd,
},
},
});
}
/**
* Get current streak (consecutive days with workouts, working backwards from today)
*/
export async function getCurrentStreak(userId: string): Promise<number> {
const workouts = await prisma.workout.findMany({
where: { userId },
select: { date: true },
orderBy: { date: "desc" },
});
if (workouts.length === 0) {
return 0;
}
let streak = 0;
let currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
for (const workout of workouts) {
const workoutDate = new Date(workout.date);
workoutDate.setHours(0, 0, 0, 0);
const daysDiff = Math.floor(
(currentDate.getTime() - workoutDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysDiff === streak) {
streak++;
} else {
break;
}
}
return streak;
}
/**
* Get total volume (sum of reps * weight) for this week
*/
export async function getWeeklyVolume(userId: string): Promise<number> {
const now = new Date();
const dayOfWeek = now.getDay();
const daysBack = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const mondayStart = new Date(now);
mondayStart.setDate(mondayStart.getDate() - daysBack);
mondayStart.setHours(0, 0, 0, 0);
const sundayEnd = new Date(mondayStart);
sundayEnd.setDate(sundayEnd.getDate() + 6);
sundayEnd.setHours(23, 59, 59, 999);
const setLogs = await prisma.setLog.findMany({
where: {
workout: {
userId,
date: {
gte: mondayStart,
lte: sundayEnd,
},
},
},
select: {
reps: true,
weight: true,
},
});
const totalVolume = setLogs.reduce((sum, set) => {
const reps = set.reps || 0;
const weight = set.weight || 0;
return sum + reps * weight;
}, 0);
return totalVolume;
}
/**
* Get all dashboard stats combined
*/
export async function getDashboardStats(userId: string): Promise<DashboardStats> {
const [
totalWorkouts,
_weeklyWorkoutCount,
_currentStreak,
weeklyVolume,
recentWorkouts,
] = await Promise.all([
getTotalWorkoutCount(userId),
getWeeklyWorkoutCount(userId),
getCurrentStreak(userId),
getWeeklyVolume(userId),
prisma.workout.findMany({
where: { userId },
include: {
setLogs: {
include: {
exercise: true,
},
orderBy: {
setNumber: "asc",
},
},
},
orderBy: {
date: "desc",
},
take: 5,
}),
]);
// Calculate total sets and reps from recent workouts
let totalSets = 0;
let totalReps = 0;
const personalBests: DashboardStats["personalBests"] = [];
for (const workout of recentWorkouts) {
for (const set of (workout as any).setLogs) {
totalSets++;
if (set.reps) {
totalReps += set.reps;
}
}
}
return {
totalWorkouts,
totalVolume: weeklyVolume,
totalSets,
totalReps,
personalBests,
recentWorkouts: recentWorkouts as any,
};
}
+160
View File
@@ -0,0 +1,160 @@
import { prisma } from "../prisma";
import { Workout } from "@prisma/client";
import { SearchFilters } from "@/types";
/**
* Get all workouts for a user with optional filters
*/
export async function getWorkouts(
userId: string,
filters?: SearchFilters
) {
const {
query,
exerciseId,
dateFrom,
dateTo,
limit = 50,
offset = 0,
} = filters || {};
const where: any = {
userId,
};
if (query) {
where.name = {
contains: query,
};
}
if (dateFrom || dateTo) {
where.date = {};
if (dateFrom) where.date.gte = dateFrom;
if (dateTo) where.date.lte = dateTo;
}
const workouts = await prisma.workout.findMany({
where,
include: {
setLogs: {
include: {
exercise: true,
},
orderBy: {
setNumber: "asc",
},
},
},
orderBy: {
date: "desc",
},
take: limit,
skip: offset,
});
// Filter by exerciseId if provided
if (exerciseId) {
return workouts.filter((workout) =>
workout.setLogs.some((set) => set.exerciseId === exerciseId)
);
}
return workouts;
}
/**
* Get a single workout by ID with all its sets
*/
export async function getWorkoutById(id: string) {
return prisma.workout.findUnique({
where: { id },
include: {
setLogs: {
include: {
exercise: true,
},
orderBy: {
setNumber: "asc",
},
},
},
});
}
/**
* Create a new workout
*/
export async function createWorkout(data: {
userId: string;
name: string;
date?: Date;
notes?: string;
duration?: number;
}): Promise<Workout> {
return prisma.workout.create({
data: {
userId: data.userId,
name: data.name,
date: data.date || new Date(),
notes: data.notes,
durationMinutes: data.duration,
},
});
}
/**
* Delete a workout and all its associated sets
*/
export async function deleteWorkout(id: string): Promise<void> {
await prisma.setLog.deleteMany({
where: { workoutId: id },
});
await prisma.workout.delete({
where: { id },
});
}
/**
* Get recent workouts for a user
*/
export async function getRecentWorkouts(
userId: string,
limit: number = 10
) {
return prisma.workout.findMany({
where: { userId },
include: {
setLogs: {
include: {
exercise: true,
},
orderBy: {
setNumber: "asc",
},
},
},
orderBy: {
date: "desc",
},
take: limit,
});
}
/**
* Search workouts by name, date range, or exercise
*/
export async function searchWorkouts(
userId: string,
query: string,
dateFrom?: Date,
dateTo?: Date
) {
return getWorkouts(userId, {
query,
dateFrom,
dateTo,
limit: 100,
});
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Shared exercise search utilities — fuzzy matching + abbreviation expansion.
* Used by both ExercisePicker (log workout) and ExercisesClient (exercise library).
*/
// Common gym abbreviations
const ABBREVIATIONS: Record<string, string> = {
kb: "kettlebell",
db: "dumbbell",
bb: "barbell",
ghd: "glute ham developer",
rdl: "romanian deadlift",
ohp: "overhead press",
};
/**
* Expand a search query into multiple variants using known abbreviations.
* e.g. "kb swing" → ["kb swing", "kettlebell swing"]
*/
export function expandAbbreviations(query: string): string[] {
const lower = query.toLowerCase().trim();
const variants: string[] = [lower];
// Check if the whole query is an abbreviation
if (ABBREVIATIONS[lower]) {
variants.push(ABBREVIATIONS[lower]);
}
// Check if the query starts with an abbreviation followed by a space
for (const [abbr, full] of Object.entries(ABBREVIATIONS)) {
if (lower.startsWith(abbr + " ")) {
variants.push(full + lower.slice(abbr.length));
}
}
return variants;
}
/**
* Score how well a query matches a target string.
* Lower = better match. Returns -1 for no match.
*
* Priority: exact match (0) > starts with (1) > word starts with (2) > substring (3) > fuzzy chars (4+)
*/
export function fuzzyScore(query: string, target: string): number {
const q = query.toLowerCase();
const t = target.toLowerCase();
if (t === q) return 0;
if (t.startsWith(q)) return 1;
const words = t.split(/\s+/);
if (words.some((w) => w.startsWith(q))) return 2;
if (t.includes(q)) return 3;
// Fuzzy character match
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) qi++;
}
return qi === q.length ? 4 + (t.length - q.length) : -1;
}
/**
* Search exercises using fuzzy matching + abbreviation expansion.
* Returns scored exercises sorted by best match, or -1 for no match.
*/
export function scoreExercise(query: string, exerciseName: string): number {
const variants = expandAbbreviations(query);
const scores = variants
.map((q) => fuzzyScore(q, exerciseName))
.filter((s) => s >= 0);
return scores.length > 0 ? Math.min(...scores) : -1;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Format an array of sets into a compact grouped summary.
*
* Consecutive sets with the same weight are collapsed:
* "135 x 5, 185 x 5, 205 x 5, 225 x 3, 245 x 3/3/3"
*
* When weightUnit is provided (per-set or as a default), it is appended:
* "16kg x 5/5/5" or "245 x 3/3/3" (lbs omitted since it's the common default)
*
* Sets without weight show just reps; sets without reps are skipped.
*/
export function formatSetsSummary(
sets: Array<{ weight?: number | null; reps?: number | null; weightUnit?: string | null }>,
defaultUnit?: string
): string {
const valid = sets.filter((s) => s.reps);
if (valid.length === 0) return "";
// Group consecutive sets by weight
const groups: Array<{ weight: number | null | undefined; weightUnit: string | null | undefined; reps: number[] }> =
[];
for (const s of valid) {
const unit = s.weightUnit || defaultUnit || null;
const last = groups[groups.length - 1];
if (last && last.weight === s.weight && last.weightUnit === unit) {
last.reps.push(s.reps!);
} else {
groups.push({ weight: s.weight, weightUnit: unit, reps: [s.reps!] });
}
}
return groups
.map((g) => {
const repsStr = g.reps.join("/");
if (g.weight) {
const unit = g.weightUnit === "kg" ? "kg" : "";
return `${g.weight}${unit} x ${repsStr}`;
}
return repsStr;
})
.join(", ");
}
+47
View File
@@ -0,0 +1,47 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") global.prisma = prisma;
/**
* caloriesBurned is in the DB schema but NOT in the generated Prisma client.
* These helpers use raw SQL to read/write it until Prisma client can be regenerated.
*/
export async function getCaloriesBurned(workoutId: string): Promise<number | null> {
const rows = await prisma.$queryRawUnsafe<Array<{ caloriesBurned: number | null }>>(
`SELECT caloriesBurned FROM Workout WHERE id = ?`,
workoutId
);
return rows[0]?.caloriesBurned ?? null;
}
export async function setCaloriesBurned(workoutId: string, calories: number | null): Promise<void> {
await prisma.$executeRawUnsafe(
`UPDATE Workout SET caloriesBurned = ? WHERE id = ?`,
calories,
workoutId
);
}
export async function getCaloriesBurnedBulk(workoutIds: string[]): Promise<Record<string, number | null>> {
if (workoutIds.length === 0) return {};
const placeholders = workoutIds.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ id: string; caloriesBurned: number | null }>>(
`SELECT id, caloriesBurned FROM Workout WHERE id IN (${placeholders})`,
...workoutIds
);
const map: Record<string, number | null> = {};
for (const r of rows) {
map[r.id] = r.caloriesBurned;
}
return map;
}
+74
View File
@@ -0,0 +1,74 @@
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { SetLog } from "@prisma/client";
/**
* Merge classnames using clsx and tailwind-merge
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Format a date to a readable string (e.g., "Feb 16, 2026")
*/
export function formatDate(date: Date | string): string {
const dateObj = typeof date === "string" ? new Date(date) : date;
return dateObj.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
/**
* Format weight with unit (e.g., "150 lbs")
*/
export function formatWeight(weight: number, unit: string): string {
return `${weight} ${unit}`;
}
/**
* Calculate total volume (sets * reps * weight)
*/
export function calculateVolume(sets: SetLog[]): number {
return sets.reduce((total, set) => {
const reps = set.reps || 0;
const weight = set.weight || 0;
return total + reps * weight;
}, 0);
}
/**
* Calculate estimated 1-rep max using Epley formula
* 1RM = weight * (1 + reps / 30)
*/
export function calculateE1RM(weight: number, reps: number): number {
if (reps === 1) return weight;
return Math.round(weight * (1 + reps / 30) * 100) / 100;
}
/**
* Get the start and end of a week for a given date
*/
export function getWeekRange(date: Date): { start: Date; end: Date } {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Adjust to Monday
const start = new Date(d.setDate(diff));
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return { start, end };
}
/**
* Generate a random UUID
*/
export function generateId(): string {
return crypto.randomUUID();
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Get session token from cookies
const sessionToken = request.cookies.get("sessionToken")?.value;
// Protect /main/* routes — redirect to login if no cookie
if (pathname.startsWith("/main")) {
if (!sessionToken) {
return NextResponse.redirect(new URL("/auth/login", request.url));
}
return NextResponse.next();
}
// Protect /api/* routes (except /api/auth and /api/health)
if (pathname.startsWith("/api")) {
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/health")) {
return NextResponse.next();
}
if (!sessionToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: ["/main/:path*", "/api/:path*"],
};
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+31
View File
@@ -0,0 +1,31 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
images: {
unoptimized: false,
},
headers: async () => {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
],
},
];
},
};
module.exports = nextConfig;
+2199
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "workout-planner",
"version": "1.0.0",
"description": "A modern workout planning application built with Next.js",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@prisma/client": "^5.0.0",
"bcryptjs": "^2.4.3",
"tailwindcss": "^3.3.0",
"postcss": "^8.4.31",
"autoprefixer": "^10.4.16",
"clsx": "^2.0.0",
"tailwind-merge": "^2.2.0",
"lucide-react": "^0.294.0",
"next-themes": "^0.2.1",
"zod": "^3.22.4"
},
"devDependencies": {
"prisma": "^5.0.0",
"@types/bcryptjs": "^2.4.2",
"typescript": "^5.2.2",
"@types/node": "^20.5.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"tsx": "^4.7.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
+281
View File
@@ -0,0 +1,281 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
sessions Session[]
exercises Exercise[]
workouts Workout[]
programs Program[]
equipment Equipment[]
contentItems ContentItem[]
aiSuggestions AISuggestion[]
userPreferences UserPreferences?
@@index([email])
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
}
model Exercise {
id String @id @default(cuid())
userId String
name String
description String?
muscleGroups String // JSON array stored as text: ["chest", "triceps"]
type String // barbell, dumbbell, machine, cable, bodyweight, cardio, kettlebell, other
inputFields String @default("[\"sets\",\"reps\",\"weight\"]") // JSON array: sets, reps, weight, duration, distance, calories
defaultWeightUnit String? // null = use user pref; "kg" for kettlebells, etc.
isCustom Boolean @default(false)
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
setLogs SetLog[]
programExercises ProgramExercise[]
@@unique([userId, name])
@@index([userId])
@@index([type])
}
model Workout {
id String @id @default(cuid())
userId String
date DateTime
name String?
notes String?
durationMinutes Int?
difficulty Int? // 1-10 scale
caloriesBurned Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
setLogs SetLog[]
@@index([userId])
@@index([date])
}
model SetLog {
id String @id @default(cuid())
workoutId String
exerciseId String
setNumber Int
reps Int?
weight Float?
weightUnit String @default("lbs")
rpe Int? // Rate of Perceived Exertion (1-10)
durationSeconds Int? // for timed exercises (assault bike, jump rope, planks)
distance Float? // for distance-based exercises
distanceUnit String? // "mi", "km", "m"
calories Int? // for cardio machines that report calories
notes String?
createdAt DateTime @default(now())
// Relations
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
exercise Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
@@index([workoutId])
@@index([exerciseId])
}
model Program {
id String @id @default(cuid())
userId String
name String
description String?
type String // hypertrophy, strength, power, endurance, recovery
durationWeeks Int
startDate DateTime
endDate DateTime?
isActive Boolean @default(false)
aiGenerated Boolean @default(false)
aiNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
weeks ProgramWeek[]
aiSuggestions AISuggestion[]
@@index([userId])
@@index([isActive])
}
model ProgramWeek {
id String @id @default(cuid())
programId String
weekNumber Int
phase String?
description String?
createdAt DateTime @default(now())
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
days ProgramDay[]
@@unique([programId, weekNumber])
@@index([programId])
}
model ProgramDay {
id String @id @default(cuid())
weekId String
dayOfWeek Int // 0-6 (Sunday-Saturday)
name String?
description String?
createdAt DateTime @default(now())
// Relations
week ProgramWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
exercises ProgramExercise[]
@@unique([weekId, dayOfWeek])
@@index([weekId])
}
model ProgramExercise {
id String @id @default(cuid())
dayId String
exerciseId String
order Int
sets Int?
repsMin Int?
repsMax Int?
rpe Int?
restSeconds Int?
notes String?
createdAt DateTime @default(now())
// Relations
day ProgramDay @relation(fields: [dayId], references: [id], onDelete: Cascade)
exercise Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
@@unique([dayId, order])
@@index([dayId])
@@index([exerciseId])
}
model Equipment {
id String @id @default(cuid())
userId String
name String
type String // rack, barbell, dumbbell, plate, bench, etc.
quantity Int
weight Float? // for plates, dumbbells, etc.
weightUnit String @default("lbs")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
@@index([userId])
}
model ContentItem {
id String @id @default(cuid())
userId String
title String
source String // pdf_upload, youtube, manual_entry
sourceUrl String?
content String?
fullText String? // Full searchable text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chunks ContentChunk[]
@@index([userId])
@@index([source])
}
model ContentChunk {
id String @id @default(cuid())
contentItemId String
chunkNumber Int
text String
pageNumber Int?
timestamp Float? // For video timestamps
createdAt DateTime @default(now())
// Relations
contentItem ContentItem @relation(fields: [contentItemId], references: [id], onDelete: Cascade)
@@index([contentItemId])
}
model AISuggestion {
id String @id @default(cuid())
userId String
programId String?
type String // exercise_recommendation, workout_adjustment, program_optimization, etc.
suggestion String
priority Int // 1-5 scale
accepted Boolean?
source String // rules, claude
metadata String? // JSON metadata
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([programId])
@@index([type])
}
model UserPreferences {
id String @id @default(cuid())
userId String @unique
theme String @default("system") // light, dark, system
defaultWeightUnit String @default("lbs")
defaultRestSeconds Int @default(90)
enableClaudeAI Boolean @default(false)
claudeApiKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
+405
View File
@@ -0,0 +1,405 @@
import { PrismaClient } from "@prisma/client";
import * as bcryptjs from "bcryptjs";
const prisma = new PrismaClient();
interface ExerciseData {
name: string;
description: string;
muscleGroups: string[];
type: string;
}
async function main() {
// Create default user
const hashedPassword = await bcryptjs.hash("workout123", 10);
const user = await prisma.user.upsert({
where: { email: "admin@local" },
update: {},
create: {
email: "admin@local",
passwordHash: hashedPassword,
name: "Admin User",
},
});
console.log("Created/verified user:", user.id);
// Create user preferences
await prisma.userPreferences.upsert({
where: { userId: user.id },
update: {},
create: {
userId: user.id,
theme: "system",
defaultWeightUnit: "lbs",
defaultRestSeconds: 90,
enableClaudeAI: false,
},
});
console.log("Created/verified user preferences");
// Define exercises by muscle group
const exercises: ExerciseData[] = [
// Chest
{
name: "Bench Press",
description: "Barbell bench press for chest development",
muscleGroups: ["chest", "triceps", "shoulders"],
type: "barbell",
},
{
name: "Incline Bench Press",
description: "Incline barbell bench press targeting upper chest",
muscleGroups: ["chest", "shoulders", "triceps"],
type: "barbell",
},
{
name: "Dumbbell Fly",
description: "Chest fly using dumbbells for stretch and squeeze",
muscleGroups: ["chest"],
type: "dumbbell",
},
{
name: "Cable Crossover",
description: "Cable machine chest fly for constant tension",
muscleGroups: ["chest"],
type: "cable",
},
{
name: "Push-ups",
description: "Bodyweight chest exercise",
muscleGroups: ["chest", "triceps", "shoulders"],
type: "bodyweight",
},
{
name: "Dips (Chest)",
description: "Weighted or bodyweight dips for chest and triceps",
muscleGroups: ["chest", "triceps"],
type: "bodyweight",
},
{
name: "Incline Dumbbell Press",
description: "Incline dumbbell bench press for upper chest",
muscleGroups: ["chest", "shoulders", "triceps"],
type: "dumbbell",
},
{
name: "Decline Bench Press",
description: "Decline barbell bench press for lower chest emphasis",
muscleGroups: ["chest", "triceps"],
type: "barbell",
},
// Back
{
name: "Deadlift",
description: "Compound barbell deadlift for full posterior chain",
muscleGroups: ["back", "legs", "glutes"],
type: "barbell",
},
{
name: "Barbell Row",
description: "Barbell bent-over row for back strength",
muscleGroups: ["back", "biceps"],
type: "barbell",
},
{
name: "Pull-ups",
description: "Bodyweight pull-ups for back and biceps",
muscleGroups: ["back", "biceps"],
type: "bodyweight",
},
{
name: "Lat Pulldown",
description: "Machine lat pulldown for back width",
muscleGroups: ["back", "biceps"],
type: "machine",
},
{
name: "Cable Row",
description: "Seated cable row for back strength",
muscleGroups: ["back", "biceps"],
type: "cable",
},
{
name: "T-Bar Row",
description: "T-bar row for mid-back thickness",
muscleGroups: ["back", "biceps"],
type: "barbell",
},
{
name: "Dumbbell Row",
description: "Single-arm dumbbell row for back development",
muscleGroups: ["back", "biceps"],
type: "dumbbell",
},
{
name: "Face Pulls",
description: "Cable face pulls for rear delts and upper back",
muscleGroups: ["back", "shoulders"],
type: "cable",
},
// Shoulders
{
name: "Overhead Press",
description: "Standing barbell overhead press for shoulder strength",
muscleGroups: ["shoulders", "triceps"],
type: "barbell",
},
{
name: "Lateral Raise",
description: "Dumbbell lateral raise for shoulder width",
muscleGroups: ["shoulders"],
type: "dumbbell",
},
{
name: "Front Raise",
description: "Front raise for anterior shoulder development",
muscleGroups: ["shoulders"],
type: "dumbbell",
},
{
name: "Reverse Fly",
description: "Rear delt fly for posterior shoulder",
muscleGroups: ["shoulders", "back"],
type: "dumbbell",
},
{
name: "Arnold Press",
description: "Arnold press for full shoulder development",
muscleGroups: ["shoulders", "triceps"],
type: "dumbbell",
},
{
name: "Upright Row",
description: "Barbell upright row for shoulder and trap development",
muscleGroups: ["shoulders", "traps"],
type: "barbell",
},
{
name: "Shrugs",
description: "Barbell shrugs for trap development",
muscleGroups: ["traps"],
type: "barbell",
},
{
name: "Cable Lateral Raise",
description: "Cable lateral raise for shoulder isolation",
muscleGroups: ["shoulders"],
type: "cable",
},
// Legs
{
name: "Squat",
description: "Barbell back squat for overall leg development",
muscleGroups: ["legs", "glutes", "quads"],
type: "barbell",
},
{
name: "Front Squat",
description: "Front squat with emphasis on quadriceps",
muscleGroups: ["quads", "glutes"],
type: "barbell",
},
{
name: "Leg Press",
description: "Machine leg press for lower body strength",
muscleGroups: ["legs", "glutes", "quads"],
type: "machine",
},
{
name: "Romanian Deadlift",
description: "RDL for hamstring and lower back development",
muscleGroups: ["hamstrings", "glutes", "back"],
type: "barbell",
},
{
name: "Leg Curl",
description: "Machine leg curl for hamstring isolation",
muscleGroups: ["hamstrings"],
type: "machine",
},
{
name: "Leg Extension",
description: "Machine leg extension for quadriceps isolation",
muscleGroups: ["quads"],
type: "machine",
},
{
name: "Calf Raise",
description: "Standing calf raise for calf development",
muscleGroups: ["calves"],
type: "machine",
},
{
name: "Bulgarian Split Squat",
description: "Single-leg squat variation for leg strength",
muscleGroups: ["legs", "glutes", "quads"],
type: "dumbbell",
},
{
name: "Lunges",
description: "Dumbbell lunges for unilateral leg development",
muscleGroups: ["legs", "glutes", "quads"],
type: "dumbbell",
},
{
name: "Hip Thrust",
description: "Barbell hip thrust for glute development",
muscleGroups: ["glutes"],
type: "barbell",
},
// Arms
{
name: "Barbell Curl",
description: "Barbell curl for bicep strength",
muscleGroups: ["biceps"],
type: "barbell",
},
{
name: "Dumbbell Curl",
description: "Dumbbell bicep curl for arm development",
muscleGroups: ["biceps"],
type: "dumbbell",
},
{
name: "Hammer Curl",
description: "Hammer curl for bicep and forearm development",
muscleGroups: ["biceps", "forearms"],
type: "dumbbell",
},
{
name: "Tricep Pushdown",
description: "Cable tricep pushdown for tricep isolation",
muscleGroups: ["triceps"],
type: "cable",
},
{
name: "Skull Crusher",
description: "Lying tricep extension for tricep development",
muscleGroups: ["triceps"],
type: "dumbbell",
},
{
name: "Overhead Tricep Extension",
description: "Standing tricep extension for arm development",
muscleGroups: ["triceps"],
type: "dumbbell",
},
{
name: "Preacher Curl",
description: "Preacher curl for bicep isolation",
muscleGroups: ["biceps"],
type: "machine",
},
{
name: "Concentration Curl",
description: "Seated concentration curl for bicep peak",
muscleGroups: ["biceps"],
type: "dumbbell",
},
// Core
{
name: "Plank",
description: "Bodyweight plank for core stability",
muscleGroups: ["core", "abs"],
type: "bodyweight",
},
{
name: "Hanging Leg Raise",
description: "Hanging leg raise for lower abs",
muscleGroups: ["abs", "core"],
type: "bodyweight",
},
{
name: "Cable Crunch",
description: "Cable crunch for abdominal development",
muscleGroups: ["abs"],
type: "cable",
},
{
name: "Ab Wheel Rollout",
description: "Ab wheel for core and abs",
muscleGroups: ["core", "abs"],
type: "other",
},
{
name: "Russian Twist",
description: "Russian twist for obliques and core",
muscleGroups: ["obliques", "core"],
type: "bodyweight",
},
{
name: "Pallof Press",
description: "Pallof press for anti-rotation core stability",
muscleGroups: ["core", "obliques"],
type: "cable",
},
// Cardio
{
name: "Running",
description: "Running for cardiovascular endurance",
muscleGroups: ["cardio"],
type: "cardio",
},
{
name: "Rowing",
description: "Rowing machine for full-body cardio",
muscleGroups: ["cardio"],
type: "cardio",
},
{
name: "Jump Rope",
description: "Jump rope for cardio and footwork",
muscleGroups: ["cardio"],
type: "cardio",
},
{
name: "Cycling",
description: "Cycling for lower body cardio",
muscleGroups: ["cardio"],
type: "cardio",
},
];
// Create or update exercises
for (const exercise of exercises) {
await prisma.exercise.upsert({
where: {
userId_name: {
userId: user.id,
name: exercise.name,
},
},
update: {},
create: {
userId: user.id,
name: exercise.name,
description: exercise.description,
muscleGroups: JSON.stringify(exercise.muscleGroups),
type: exercise.type,
isCustom: false,
},
});
}
console.log(`Created/verified ${exercises.length} exercises`);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#0A0A0A"/>
<text x="96" y="132" font-size="120" font-weight="bold" text-anchor="middle" fill="white" font-family="Arial, sans-serif">W</text>
</svg>

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

+74
View File
@@ -0,0 +1,74 @@
{
"name": "Workout Planner",
"short_name": "Workout",
"description": "Track. Lift. Dominate.",
"start_url": "/main/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#0A0A0A",
"theme_color": "#0A0A0A",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192x192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["fitness", "health"]
}
+12
View File
@@ -0,0 +1,12 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
console.log('SW registered:', reg.scope);
})
.catch((err) => {
console.log('SW registration failed:', err);
});
});
}
+78
View File
@@ -0,0 +1,78 @@
const CACHE_NAME = 'workout-planner-v1';
// Assets to pre-cache for offline shell
const PRECACHE_URLS = [
'/icons/favicon.svg',
'/icons/icon-192x192.png',
'/manifest.json',
];
// Install: pre-cache critical assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
// Fetch: network-first for API/pages, cache-first for static assets
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// Skip API routes — always go to network
if (url.pathname.startsWith('/api/')) return;
// Static assets (icons, fonts, images): cache-first
if (
url.pathname.startsWith('/icons/') ||
url.pathname.startsWith('/_next/static/') ||
url.pathname.endsWith('.svg') ||
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.woff2')
) {
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
});
})
);
return;
}
// Pages: network-first with offline fallback
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request))
);
});
@@ -0,0 +1,113 @@
# PWA Icon Generator
This script generates PWA (Progressive Web App) icon PNG files for the Workout Planner app using only Node.js built-in modules.
## Features
- **No external dependencies**: Uses only Node.js built-in modules (`fs`, `zlib`, `path`)
- **PNG format with proper compression**: Generates valid PNG files with zlib compression
- **Multiple sizes**: Creates icons for all common PWA sizes:
- 72x72, 96x96, 128x128, 144x144, 152x152 (small devices)
- 192x192, 384x384, 512x512 (larger devices)
- **Maskable icons**: Creates adaptive icon variants (192x192 and 512x512) for Android
- **SVG favicon**: Generates a bonus SVG favicon for browsers
## Design
The icons feature:
- **Dark luxury aesthetic**: #0A0A0A (near-black) background
- **Stylized "W" letter**: White (#FFFFFF) geometric design representing "Workout"
- **Clean, bold design**: Perfect for a gym/fitness application
## Usage
```bash
node scripts/generate-icons.js
```
This will generate all icon files in `public/icons/` directory:
- `icon-{size}x{size}.png` - Regular icons for various sizes
- `icon-{size}x{size}-maskable.png` - Maskable variants for adaptive icons
- `favicon.svg` - SVG favicon for browsers
## Integration with PWA
The generated icons are automatically referenced in `public/manifest.json`. The manifest includes:
- All standard icon sizes for different devices
- Maskable icons for Android adaptive icon support
- Proper MIME types and purposes
### HTML Integration
Add this to your `<head>`:
```html
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192x192.png">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">
<!-- Theme color -->
<meta name="theme-color" content="#FFFFFF">
<meta name="background-color" content="#0A0A0A">
```
## Technical Details
### PNG Generation
The script uses raw PNG format generation:
1. Creates pixel buffer with RGBA color values
2. Generates PNG header (IHDR chunk) with image dimensions and color info
3. Prepares image data with filter bytes for each row
4. Compresses data using zlib deflate algorithm
5. Generates IDAT chunk with compressed data
6. Calculates CRC32 checksums for data integrity
7. Outputs valid PNG file with proper structure
### Custom Drawing
The `drawW()` function:
- Fills background with #0A0A0A
- Draws a stylized "W" using vertical bars/rectangles
- Proportionally scales to any icon size
- Uses simple, fast rectangle fill operations
## Regenerating Icons
If you modify the design colors or want to regenerate:
1. Edit the color constants in `generate-icons.js`:
- `BACKGROUND_COLOR` - Icon background (default: #0A0A0A)
- `TEXT_COLOR` - "W" letter color (default: #FFFFFF)
2. Modify the `drawW()` function to change the letter design
3. Run the script again:
```bash
node scripts/generate-icons.js
```
All icons will be regenerated with the new design.
## File Sizes
The generated PNG files are highly optimized:
- Small sizes (72-152px): ~250-1000 bytes
- Medium size (192px): ~1.3 KB
- Large size (384px): ~4.2 KB
- Extra large (512px): ~7.2 KB
The compact file sizes are due to zlib compression of the simple color scheme.
## Browser Support
These icons work on:
- Chrome/Edge PWA installation
- Firefox PWA installation
- Safari iOS add-to-home-screen
- Android adaptive icons (maskable variants)
- Desktop browser tabs (favicon)
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
/**
* PWA Icon Generator for Workout App
* Generates PNG icons without external dependencies
* Uses only Node.js built-in modules (fs, zlib, path)
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
// Configuration
const BACKGROUND_COLOR = { r: 10, g: 10, b: 10 }; // #0A0A0A
const TEXT_COLOR = { r: 255, g: 255, b: 255 }; // White
const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
const MASKABLE_SIZES = [192, 512];
const OUTPUT_DIR = path.join(__dirname, '..', 'public', 'icons');
/**
* Create output directory if it doesn't exist
*/
function ensureOutputDir() {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
}
/**
* Draw a stylized "W" character on a pixel buffer
* Uses simple geometric shapes to create the letter
*/
function drawW(buffer, width, height) {
const canvasWidth = width;
const canvasHeight = height;
// Helper to set a pixel
const setPixel = (x, y, r, g, b, a = 255) => {
if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) return;
const idx = (y * canvasWidth + x) * 4;
buffer[idx] = r;
buffer[idx + 1] = g;
buffer[idx + 2] = b;
buffer[idx + 3] = a;
};
// Helper to draw a filled rectangle (fast)
const fillRect = (x, y, w, h, r, g, b) => {
const x0 = Math.max(0, Math.floor(x));
const y0 = Math.max(0, Math.floor(y));
const x1 = Math.min(canvasWidth, Math.ceil(x + w));
const y1 = Math.min(canvasHeight, Math.ceil(y + h));
for (let yi = y0; yi < y1; yi++) {
for (let xi = x0; xi < x1; xi++) {
setPixel(xi, yi, r, g, b);
}
}
};
// Fill background
fillRect(0, 0, canvasWidth, canvasHeight, BACKGROUND_COLOR.r, BACKGROUND_COLOR.g, BACKGROUND_COLOR.b);
// Calculate dimensions for the "W"
const padding = Math.floor(canvasHeight * 0.1);
const letterWidth = canvasWidth - padding * 2;
const letterHeight = canvasHeight - padding * 2;
const startX = padding;
const startY = padding;
// Draw a stylized "W" using rectangles (five vertical bars forming W shape)
const barWidth = letterWidth * 0.12;
const spacing = letterWidth / 4;
// Create a V-V-V pattern (W shape)
// First V: two diagonal bars meeting at a point
const v1LeftX = startX + spacing * 0.2;
const v1RightX = startX + spacing * 0.8;
const v1MidX = (v1LeftX + v1RightX) / 2;
const v1BottomY = startY + letterHeight;
const v1TopY = startY;
// Left bar of first V
fillRect(v1LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
// Right bar of first V
fillRect(v1RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
// Second V
const v2LeftX = startX + spacing * 0.9;
const v2RightX = startX + spacing * 1.5;
fillRect(v2LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
fillRect(v2RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
// Third V (right side)
const v3LeftX = startX + spacing * 1.6;
const v3RightX = startX + spacing * 2.2;
fillRect(v3LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
fillRect(v3RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
}
/**
* Create a PNG file from raw pixel data
* Uses PNG file format with zlib compression
*/
function createPNG(width, height, pixelBuffer, outputPath) {
// PNG signature
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
// IHDR chunk (image header)
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData[8] = 8; // bit depth
ihdrData[9] = 6; // color type (RGBA)
ihdrData[10] = 0; // compression method
ihdrData[11] = 0; // filter method
ihdrData[12] = 0; // interlace method
const ihdr = createChunk('IHDR', ihdrData);
// IDAT chunk (image data)
// Each row needs a filter byte (0 = None)
const rawData = Buffer.alloc(height * (1 + width * 4));
for (let y = 0; y < height; y++) {
rawData[y * (1 + width * 4)] = 0; // filter type
const rowStart = y * (1 + width * 4) + 1;
pixelBuffer.copy(rawData, rowStart, y * width * 4, (y + 1) * width * 4);
}
const compressed = zlib.deflateSync(rawData);
const idat = createChunk('IDAT', compressed);
// IEND chunk (image end)
const iend = createChunk('IEND', Buffer.alloc(0));
// Combine all chunks
const png = Buffer.concat([pngSignature, ihdr, idat, iend]);
fs.writeFileSync(outputPath, png);
}
/**
* Create a PNG chunk with proper CRC
*/
function createChunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuffer = Buffer.from(type, 'ascii');
const chunkData = Buffer.concat([typeBuffer, data]);
// Calculate CRC32
const crc = calculateCRC32(chunkData);
const crcBuffer = Buffer.alloc(4);
crcBuffer.writeUInt32BE(crc, 0);
return Buffer.concat([length, chunkData, crcBuffer]);
}
/**
* Calculate CRC32 checksum for PNG chunks
*/
function calculateCRC32(data) {
const table = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (let i = 0; i < data.length; i++) {
crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* Generate all icon sizes
*/
function generateIcons() {
ensureOutputDir();
console.log('Generating PWA icons for Workout App...\n');
ICON_SIZES.forEach((size) => {
// Regular icon
const buffer = Buffer.alloc(size * size * 4);
drawW(buffer, size, size);
const outputPath = path.join(OUTPUT_DIR, `icon-${size}x${size}.png`);
createPNG(size, size, buffer, outputPath);
console.log(`✓ Created ${path.basename(outputPath)}`);
// Maskable variant (for adaptive icons on Android)
if (MASKABLE_SIZES.includes(size)) {
const maskableBuffer = Buffer.alloc(size * size * 4);
drawW(maskableBuffer, size, size);
const maskablePath = path.join(OUTPUT_DIR, `icon-${size}x${size}-maskable.png`);
createPNG(size, size, maskableBuffer, maskablePath);
console.log(`✓ Created ${path.basename(maskablePath)}`);
}
});
// Create a favicon SVG as well
const svgPath = path.join(OUTPUT_DIR, 'favicon.svg');
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" fill="#0A0A0A"/>
<text x="96" y="132" font-size="120" font-weight="bold" text-anchor="middle" fill="white" font-family="Arial, sans-serif">W</text>
</svg>`;
fs.writeFileSync(svgPath, svgContent);
console.log(`✓ Created ${path.basename(svgPath)}`);
console.log(`\nAll icons generated successfully in ${OUTPUT_DIR}`);
}
// Run the generator
generateIcons();
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Rebuild and restart the Workout Planner
# Usage: ./scripts/rebuild.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
echo "Stopping server..."
"$SCRIPT_DIR/stop.sh"
echo ""
echo "Applying database migrations..."
npx prisma db push
echo ""
echo "Building production bundle..."
npm run build
echo ""
echo "Starting server..."
"$SCRIPT_DIR/start.sh"
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# Set up macOS Launch Agent so the Workout Planner starts automatically on login.
# Usage: ./scripts/setup-autostart.sh
#
# This creates a Launch Agent plist that runs scripts/start.sh on login.
# To remove: launchctl unload ~/Library/LaunchAgents/com.workout-planner.plist
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PLIST_NAME="com.workout-planner"
PLIST_DIR="$HOME/Library/LaunchAgents"
PLIST_PATH="$PLIST_DIR/$PLIST_NAME.plist"
LOG_DIR="$PROJECT_DIR/logs"
NODE_PATH="$(which node)"
echo "=== Workout Planner — Auto-Start Setup ==="
echo ""
echo "Project: $PROJECT_DIR"
echo "Node: $NODE_PATH"
echo "Logs: $LOG_DIR"
echo ""
# Check node exists
if [ -z "$NODE_PATH" ]; then
echo "ERROR: node not found in PATH."
echo "Make sure Node.js is installed (e.g. via nvm, homebrew, or nodejs.org)."
exit 1
fi
# Build first if needed
if [ ! -d "$PROJECT_DIR/.next" ]; then
echo "No production build found. Building now..."
cd "$PROJECT_DIR"
npm run build
echo ""
fi
# Apply prisma migrations
echo "Applying database schema..."
cd "$PROJECT_DIR"
npx prisma db push --accept-data-loss 2>/dev/null || npx prisma db push
echo ""
mkdir -p "$PLIST_DIR"
mkdir -p "$LOG_DIR"
# Create the plist
cat > "$PLIST_PATH" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$PLIST_NAME</string>
<key>ProgramArguments</key>
<array>
<string>$NODE_PATH</string>
<string>$PROJECT_DIR/node_modules/.bin/next</string>
<string>start</string>
<string>-p</string>
<string>3000</string>
</array>
<key>WorkingDirectory</key>
<string>$PROJECT_DIR</string>
<key>EnvironmentVariables</key>
<dict>
<key>NODE_ENV</key>
<string>production</string>
<key>PATH</key>
<string>$(dirname "$NODE_PATH"):/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG_DIR/server.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/server-error.log</string>
</dict>
</plist>
EOF
echo "Created: $PLIST_PATH"
echo ""
# Unload if already loaded, then load
launchctl unload "$PLIST_PATH" 2>/dev/null || true
launchctl load "$PLIST_PATH"
echo "=== Done! ==="
echo ""
echo "The server will now start automatically when you log in."
echo "It's also running right now at: http://localhost:3000"
echo ""
echo "Useful commands:"
echo " Stop: launchctl unload ~/Library/LaunchAgents/$PLIST_NAME.plist"
echo " Start: launchctl load ~/Library/LaunchAgents/$PLIST_NAME.plist"
echo " Logs: tail -f $LOG_DIR/server.log"
echo " Rebuild: ./scripts/rebuild.sh"
echo " Remove: launchctl unload ~/Library/LaunchAgents/$PLIST_NAME.plist && rm ~/Library/LaunchAgents/$PLIST_NAME.plist"
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Start the Workout Planner production server
# Usage: ./scripts/start.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
LOG_DIR="$PROJECT_DIR/logs"
PID_FILE="$PROJECT_DIR/.server.pid"
mkdir -p "$LOG_DIR"
# Check if already running
if [ -f "$PID_FILE" ]; then
EXISTING_PID=$(cat "$PID_FILE")
if kill -0 "$EXISTING_PID" 2>/dev/null; then
echo "Server already running (PID $EXISTING_PID)"
echo "http://localhost:3000"
exit 0
fi
rm -f "$PID_FILE"
fi
cd "$PROJECT_DIR"
# Build if no .next directory exists
if [ ! -d ".next" ]; then
echo "Building production bundle..."
npm run build 2>&1 | tee "$LOG_DIR/build.log"
fi
# Start production server in background
echo "Starting server..."
NODE_ENV=production nohup npx next start -p 3000 \
> "$LOG_DIR/server.log" 2>&1 &
echo $! > "$PID_FILE"
echo "Server started (PID $(cat "$PID_FILE"))"
echo "http://localhost:3000"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Stop the Workout Planner server
# Usage: ./scripts/stop.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PID_FILE="$PROJECT_DIR/.server.pid"
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo "Stopping server (PID $PID)..."
kill "$PID"
rm -f "$PID_FILE"
echo "Server stopped."
else
echo "Server not running (stale PID file)."
rm -f "$PID_FILE"
fi
else
echo "No PID file found. Server may not be running."
# Try to find and kill any next server on port 3000
PIDS=$(lsof -ti:3000 2>/dev/null)
if [ -n "$PIDS" ]; then
echo "Found process(es) on port 3000: $PIDS"
echo "Kill them? (y/n)"
read -r REPLY
if [ "$REPLY" = "y" ]; then
echo "$PIDS" | xargs kill
echo "Killed."
fi
fi
fi
+27
View File
@@ -0,0 +1,27 @@
import type { Config } from 'tailwindcss';
import defaultTheme from 'tailwindcss/defaultTheme';
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)', ...defaultTheme.fontFamily.sans],
display: ['var(--font-display)', 'sans-serif'],
},
spacing: {
'128': '32rem',
},
borderRadius: {
'4xl': '2rem',
},
},
},
plugins: [],
};
export default config;
+54
View File
@@ -0,0 +1,54 @@
{
"compilerOptions": {
"target": "es2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"allowJs": true,
"noEmit": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long
+129
View File
@@ -0,0 +1,129 @@
import {
Workout,
SetLog,
Exercise,
} from "@prisma/client";
/**
* SetLog with associated Exercise
*/
export type SetLogWithExercise = SetLog & {
exercise: Exercise;
};
/**
* Workout with all its sets and exercises
*/
export type WorkoutWithSets = Workout & {
setLogs: SetLogWithExercise[];
};
/**
* Exercise with last used date and personal best info
*/
export type ExerciseWithStats = Exercise & {
lastUsed?: Date | null;
personalBest?: {
weight: number;
reps: number;
date: Date;
} | null;
};
/**
* Dashboard statistics
*/
export type DashboardStats = {
totalWorkouts: number;
totalVolume: number;
totalSets: number;
totalReps: number;
personalBests: Array<{
exerciseId: string;
exerciseName: string;
weight: number;
reps: number;
date: Date;
}>;
recentWorkouts: WorkoutWithSets[];
};
/**
* Search and filter options
*/
export type SearchFilters = {
query?: string;
exerciseId?: string;
dateFrom?: Date;
dateTo?: Date;
limit?: number;
offset?: number;
};
/**
* Pagination metadata
*/
export type PaginationMeta = {
total: number;
limit: number;
offset: number;
hasMore: boolean;
};
/**
* Paginated response wrapper
*/
export type PaginatedResponse<T> = {
data: T[];
meta: PaginationMeta;
};
/**
* Import feature — parsed workout data from Claude vision
*/
export type ParsedSet = {
reps?: number | null;
weight?: number | null;
weightUnit?: string | null;
durationSeconds?: number | null;
distance?: number | null;
distanceUnit?: string | null;
calories?: number | null;
rpe?: number | null;
notes?: string | null;
};
export type ParsedExercise = {
name: string;
type?: string;
sets: ParsedSet[];
notes?: string;
/** Flag from Claude when it's unsure about this exercise */
uncertain?: boolean;
uncertainReason?: string;
};
export type ParsedWorkout = {
date: string; // ISO date string
name?: string;
notes?: string;
exercises: ParsedExercise[];
};
export type ImportParseResponse = {
workouts: ParsedWorkout[];
confidence: "high" | "medium" | "low";
warnings: string[];
};
export type ReviewedWorkout = {
date: string;
name?: string;
notes?: string;
exercises: {
name: string;
type?: string;
existingExerciseId?: string; // if matched to library
sets: ParsedSet[];
}[];
};