Initial commit for Start9 packaging
@@ -0,0 +1,5 @@
|
||||
# Database
|
||||
DATABASE_URL=file:./data/app.db
|
||||
|
||||
# API Keys
|
||||
CLAUDE_API_KEY=your_claude_api_key_here
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
================================================================================
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function logoutAction() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('sessionToken');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{query.trim()}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 (72–512px) 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 (Jan–Feb 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 |
|
||||
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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*"],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 665 B |
|
After Width: | Height: | Size: 972 B |
|
After Width: | Height: | Size: 833 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 482 B |
@@ -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"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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[];
|
||||
}[];
|
||||
};
|
||||