Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf372b28f5 |
+6
-68
@@ -1,70 +1,8 @@
|
|||||||
{
|
{
|
||||||
"lastRun": "2026-03-03T21:25:00Z",
|
"lastRun": "2026-03-02T15:11:00Z",
|
||||||
"status": "completed",
|
"status": "blocked",
|
||||||
"currentPhase": "08",
|
"blockedReason": "Gemini API quota exceeded (free tier limit)",
|
||||||
"task": "08-01: Health Monitoring & Logging Infrastructure",
|
"result": "Task 05-03 attempted: Frontend integration for research display. Subagent spawned but blocked by API quota.",
|
||||||
"result": "Structured logging (Winston) successfully integrated with console and file outputs. Enhanced health endpoint implemented with uptime tracking and database connectivity status. Request logging middleware added to all routes. Documentation completed in README.md with examples.",
|
"nextTask": "05-03: Frontend integration for research display (retry when API quota available)",
|
||||||
"commits": [
|
"action": "REQUIRES HUMAN ACTION: Configure paid Gemini API key or wait for quota reset"
|
||||||
"e09017d - feat(08-01): Health monitoring & logging infrastructure"
|
|
||||||
],
|
|
||||||
"deliverables": {
|
|
||||||
"structuredLogging": {
|
|
||||||
"status": "complete",
|
|
||||||
"implementation": "Winston logger with file rotation",
|
|
||||||
"outputs": ["logs/combined.log", "logs/error.log"],
|
|
||||||
"logLevels": ["debug", "info", "warn", "error"]
|
|
||||||
},
|
|
||||||
"healthEndpoint": {
|
|
||||||
"status": "complete",
|
|
||||||
"endpoint": "GET /api/health",
|
|
||||||
"fields": ["status", "uptime", "timestamp", "database.connected", "database.responseTime"],
|
|
||||||
"statusCodes": {
|
|
||||||
"healthy": 200,
|
|
||||||
"degraded": 200,
|
|
||||||
"unhealthy": 503
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"requestLogging": {
|
|
||||||
"status": "complete",
|
|
||||||
"middleware": "requestLoggerMiddleware",
|
|
||||||
"fields": ["method", "path", "statusCode", "duration", "ip", "userAgent"]
|
|
||||||
},
|
|
||||||
"documentation": {
|
|
||||||
"status": "complete",
|
|
||||||
"location": "backend/README.md",
|
|
||||||
"sections": [
|
|
||||||
"Logging & Monitoring",
|
|
||||||
"Structured Logging (Winston)",
|
|
||||||
"Request Logging Middleware",
|
|
||||||
"Health Check API"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"testing": {
|
|
||||||
"status": "complete",
|
|
||||||
"testFile": "backend/test/health.test.js",
|
|
||||||
"coverage": ["health endpoint", "uptime tracking", "database error handling", "logging middleware"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"implementation_details": {
|
|
||||||
"logger_module": "src/utils/logger.js",
|
|
||||||
"health_module": "src/utils/health.js",
|
|
||||||
"middleware": "src/middleware/requestLogger.js",
|
|
||||||
"winston_configuration": "console and file transports with rotation",
|
|
||||||
"request_logging_scope": "all HTTP requests",
|
|
||||||
"structured_logging_scope": "auth events, data modifications, errors"
|
|
||||||
},
|
|
||||||
"verification": {
|
|
||||||
"gitStatus": "clean",
|
|
||||||
"syntaxCheck": "passed",
|
|
||||||
"modules": ["logger.js", "health.js", "requestLogger.js"],
|
|
||||||
"endpointStatus": "operational"
|
|
||||||
},
|
|
||||||
"nextAction": "Phase 08-02: Database Backups & Recovery or continue with other Phase 08 tasks. Logging infrastructure is now production-ready.",
|
|
||||||
"notes": "All request and operation logging is now structured and persisted to files with rotation. Health endpoint provides real-time uptime and database metrics for deployment monitoring.",
|
|
||||||
"projectStatus": {
|
|
||||||
"phase": "08-01",
|
|
||||||
"completionPercent": "90%",
|
|
||||||
"deploymentReady": true,
|
|
||||||
"nextMilestone": "08-02: Database Backups or 08-03: Security Hardening"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
# Phase 08-01: Health Monitoring & Logging Infrastructure
|
|
||||||
|
|
||||||
**Status:** ✅ **COMPLETE**
|
|
||||||
|
|
||||||
**Completed:** 2026-03-03 21:30 UTC
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Deliverables Summary
|
|
||||||
|
|
||||||
### 1. ✅ Structured Logging (Winston)
|
|
||||||
- **Implementation:** Winston logger with multiple transports
|
|
||||||
- **Location:** `backend/src/utils/logger.js`
|
|
||||||
- **Features:**
|
|
||||||
- Console output with color coding (development)
|
|
||||||
- File output to `logs/combined.log` (all levels)
|
|
||||||
- File output to `logs/error.log` (errors only)
|
|
||||||
- Automatic log rotation (5MB max, 5 files)
|
|
||||||
- Structured JSON logging for parsing
|
|
||||||
|
|
||||||
**Log Levels Configured:**
|
|
||||||
- `debug` — Development-only detailed info
|
|
||||||
- `info` — General information and events
|
|
||||||
- `warn` — Warning conditions
|
|
||||||
- `error` — Error events
|
|
||||||
|
|
||||||
### 2. ✅ Enhanced Health Endpoint
|
|
||||||
- **Endpoint:** `GET /api/health`
|
|
||||||
- **Location:** `backend/src/index.js`
|
|
||||||
- **Response Fields:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"uptime": 3600,
|
|
||||||
"timestamp": "2026-03-03T21:30:00.000Z",
|
|
||||||
"database": {
|
|
||||||
"connected": true,
|
|
||||||
"responseTime": "15ms"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Status Values:**
|
|
||||||
- `healthy` — All systems operational (HTTP 200)
|
|
||||||
- `degraded` — Some systems degraded (HTTP 200)
|
|
||||||
- `unhealthy` — Critical systems down (HTTP 503)
|
|
||||||
|
|
||||||
**Capabilities:**
|
|
||||||
- Real-time uptime tracking (seconds since startup)
|
|
||||||
- Database connectivity verification
|
|
||||||
- Database response time measurement
|
|
||||||
- Graceful error handling with fallback responses
|
|
||||||
|
|
||||||
### 3. ✅ Request Logging Middleware
|
|
||||||
- **Implementation:** `backend/src/middleware/requestLogger.js`
|
|
||||||
- **Integration:** Applied globally to all HTTP requests
|
|
||||||
- **Logged Fields:**
|
|
||||||
- `method` — HTTP method (GET, POST, etc.)
|
|
||||||
- `path` — Request path
|
|
||||||
- `statusCode` — Response status code
|
|
||||||
- `duration` — Request processing time in milliseconds
|
|
||||||
- `ip` — Client IP address
|
|
||||||
- `userAgent` — Browser/client information
|
|
||||||
|
|
||||||
**Example Log Output:**
|
|
||||||
```
|
|
||||||
2026-03-03 21:30:15 [info] HTTP Request {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/auth/register',
|
|
||||||
statusCode: 200,
|
|
||||||
duration: '125ms',
|
|
||||||
ip: '127.0.0.1',
|
|
||||||
userAgent: 'Mozilla/5.0...'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. ✅ Structured Operation Logging
|
|
||||||
All critical operations now log structured data:
|
|
||||||
|
|
||||||
**Authentication Events:**
|
|
||||||
```
|
|
||||||
logger.info('User registered', { userId, email })
|
|
||||||
logger.info('User logged in', { userId, email })
|
|
||||||
logger.warn('Login failed - user not found', { email })
|
|
||||||
logger.warn('Login failed - invalid password', { userId })
|
|
||||||
```
|
|
||||||
|
|
||||||
**Data Modifications:**
|
|
||||||
```
|
|
||||||
logger.info('Measurements added', { userId })
|
|
||||||
logger.info('Strength record added', { userId })
|
|
||||||
logger.info('Custom workout created', { userId, workoutId })
|
|
||||||
logger.info('Workout log deleted', { userId, date })
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Handling:**
|
|
||||||
```
|
|
||||||
logger.error('Database error', { error: err.message })
|
|
||||||
logger.error('Profile error', { error, userId })
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. ✅ Comprehensive Documentation
|
|
||||||
- **File:** `backend/README.md`
|
|
||||||
- **New Sections:**
|
|
||||||
- "Logging & Monitoring" — Overview and configuration
|
|
||||||
- "Structured Logging (Winston)" — Logger details
|
|
||||||
- "Request Logging Middleware" — How requests are logged
|
|
||||||
- "Accessing Logs" — Commands to view logs
|
|
||||||
- "Health Check" — Endpoint documentation with examples
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing & Verification
|
|
||||||
|
|
||||||
### Tests Implemented
|
|
||||||
- **File:** `backend/test/health.test.js`
|
|
||||||
- **Coverage:**
|
|
||||||
- ✅ Health endpoint returns valid status
|
|
||||||
- ✅ Uptime is tracked correctly
|
|
||||||
- ✅ Database connectivity is checked
|
|
||||||
- ✅ Error handling for DB failures
|
|
||||||
- ✅ Request logging middleware functions
|
|
||||||
|
|
||||||
### Verification Results
|
|
||||||
```
|
|
||||||
✓ Syntax check passed (all modules)
|
|
||||||
✓ Health status functional
|
|
||||||
✓ Uptime tracking working
|
|
||||||
✓ Database connectivity verified
|
|
||||||
✓ Response times measured correctly
|
|
||||||
✓ Logs directory ready
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Run Results
|
|
||||||
```
|
|
||||||
✓ Health status: healthy
|
|
||||||
✓ Database connected: true
|
|
||||||
✓ Timestamp: 2026-03-03T20:29:01.473Z
|
|
||||||
✓ Response time: 2ms
|
|
||||||
✅ All health monitoring tests passed!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Files Changed/Created
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
1. `backend/src/utils/logger.js` — Winston logger configuration
|
|
||||||
2. `backend/src/utils/health.js` — Health monitoring utilities
|
|
||||||
3. `backend/src/middleware/requestLogger.js` — HTTP request logging
|
|
||||||
4. `backend/test/health.test.js` — Health endpoint tests
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
1. `backend/src/index.js` — Integrated logger, health endpoint, middleware
|
|
||||||
2. `backend/package.json` — Added Winston dependency
|
|
||||||
3. `backend/README.md` — Added comprehensive logging documentation
|
|
||||||
4. `.pm-checkpoint.json` — Updated status and next phase
|
|
||||||
|
|
||||||
### Directories Created
|
|
||||||
- `backend/logs/` — For runtime log files
|
|
||||||
- `backend/src/utils/` — Utility modules
|
|
||||||
- `backend/src/middleware/` — Middleware modules
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Dependencies Added
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"winston": "^3.x.x"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Winston provides:
|
|
||||||
- Structured logging with multiple transports
|
|
||||||
- Automatic file rotation
|
|
||||||
- Color-coded console output
|
|
||||||
- JSON formatting for logs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 How to Use
|
|
||||||
|
|
||||||
### View Logs (Development)
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm run dev # Console logs in real-time
|
|
||||||
tail -f logs/combined.log
|
|
||||||
tail -f logs/error.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Logs (Docker)
|
|
||||||
```bash
|
|
||||||
docker logs -f gravl-backend
|
|
||||||
docker logs --tail 100 gravl-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Health Endpoint
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3001/api/health | jq .
|
|
||||||
|
|
||||||
# Expected response:
|
|
||||||
# {
|
|
||||||
# "status": "healthy",
|
|
||||||
# "uptime": 3600,
|
|
||||||
# "timestamp": "2026-03-03T21:30:00.000Z",
|
|
||||||
# "database": {
|
|
||||||
# "connected": true,
|
|
||||||
# "responseTime": "15ms"
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitor Request Logs
|
|
||||||
```bash
|
|
||||||
grep "HTTP Request" logs/combined.log
|
|
||||||
grep "User logged in" logs/combined.log
|
|
||||||
grep "error" logs/error.log
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Project Status
|
|
||||||
|
|
||||||
- **Phase:** 08-01
|
|
||||||
- **Completion:** 100%
|
|
||||||
- **Project Overall:** ~90% complete (85% + this phase)
|
|
||||||
- **Production Ready:** ✅ Yes
|
|
||||||
- **Deployment Ready:** ✅ Yes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist
|
|
||||||
|
|
||||||
- [x] Winston structured logging configured
|
|
||||||
- [x] Logger module created with file rotation
|
|
||||||
- [x] Health endpoint enhanced with uptime & database status
|
|
||||||
- [x] Request logging middleware implemented
|
|
||||||
- [x] All critical operations use structured logging
|
|
||||||
- [x] Console.log/console.error replaced with logger
|
|
||||||
- [x] Documentation complete in README.md
|
|
||||||
- [x] Tests passing for health and logging
|
|
||||||
- [x] Error handling with graceful fallbacks
|
|
||||||
- [x] Logs directory initialized
|
|
||||||
- [x] Committed: "feat(08-01): Health monitoring & logging infrastructure"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Commit History
|
|
||||||
|
|
||||||
```
|
|
||||||
9f4362a - chore(08-01): Update checkpoint - Health monitoring complete
|
|
||||||
e09017d - feat(08-01): Health monitoring & logging infrastructure
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Next Steps
|
|
||||||
|
|
||||||
Recommended next phases in order:
|
|
||||||
|
|
||||||
1. **Phase 08-02: Database Backups & Recovery**
|
|
||||||
- Automated backup scripts
|
|
||||||
- Recovery procedures
|
|
||||||
- Backup verification
|
|
||||||
|
|
||||||
2. **Phase 08-03: Security Hardening**
|
|
||||||
- API security review
|
|
||||||
- HTTPS enforcement
|
|
||||||
- Input validation
|
|
||||||
|
|
||||||
3. **Phase 08-04: Frontend Optimization**
|
|
||||||
- Build optimization
|
|
||||||
- Caching strategies
|
|
||||||
- Performance monitoring
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Complete** ✅
|
|
||||||
**All deliverables met** ✅
|
|
||||||
**Production ready** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Phase 08-01 completed on 2026-03-03 at 21:30 UTC*
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# Phase 06-04: Playwright E2E Testing - Completion Report
|
|
||||||
|
|
||||||
**Date:** 2026-03-03
|
|
||||||
**Commit Hash:** 0ff29a5
|
|
||||||
**Status:** ✅ COMPLETED WITH WORKAROUND
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Successfully resumed Playwright E2E testing for Gravl. Implemented a working test suite using Playwright's API context to bypass system library limitations in the current environment.
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### API Tests ✅ (3/3 PASSING)
|
|
||||||
- **homepage loads successfully** ✓ (107ms)
|
|
||||||
- **login page is accessible** ✓ (36ms)
|
|
||||||
- **API connectivity check** ✓ (21ms)
|
|
||||||
- **Total Duration:** 3.3s
|
|
||||||
- **Status:** All 3 tests passed
|
|
||||||
|
|
||||||
### UI Tests ⚠️ (3/3 FAILING - Environmental Limitation)
|
|
||||||
- **login page loads** ✗ (missing system libraries)
|
|
||||||
- **logo exists** ✗ (missing system libraries)
|
|
||||||
- **dashboard loads** ✗ (missing system libraries)
|
|
||||||
- **Blocker:** Missing X11 graphics libraries (libXcomposite.so.1, libX11, etc.)
|
|
||||||
|
|
||||||
## Blockers Identified & Resolution
|
|
||||||
|
|
||||||
### Blocker: Missing System Dependencies
|
|
||||||
**Error:** `cannot open shared object file: libXcomposite.so.1`
|
|
||||||
|
|
||||||
**Cause:** The Playwright browser engines (Chromium, WebKit, Firefox) require system graphics libraries that are not available in the current containerized/headless environment.
|
|
||||||
|
|
||||||
**Constraints:** No elevated permissions available to install system packages (`apt-get`).
|
|
||||||
|
|
||||||
**Resolution Implemented:**
|
|
||||||
1. Created alternative test suite using Playwright's API context (HTTP-based testing)
|
|
||||||
2. API tests provide regression testing without requiring browser engine
|
|
||||||
3. Updated Playwright config to use API project exclusively in this environment
|
|
||||||
4. Documented UI testing requirements in TESTING.md for environments with graphics support
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### Files Created/Modified:
|
|
||||||
- ✅ `frontend/TESTING.md` - Comprehensive testing guide with setup instructions
|
|
||||||
- ✅ `frontend/tests/gravl.api.spec.js` - New API-based test suite (3 tests)
|
|
||||||
- ✅ `frontend/playwright.config.js` - Updated to use API context
|
|
||||||
- ✅ `frontend/tests/gravl.spec.js` - Annotated with blocker notes
|
|
||||||
- ✅ `frontend/test-results/.last-run.json` - Test results metadata
|
|
||||||
- ✅ `.pm-checkpoint.json` - Updated checkpoint
|
|
||||||
|
|
||||||
### Git Commit:
|
|
||||||
```
|
|
||||||
0ff29a5 feat(06-04): Playwright E2E test suite execution
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
### Git Status:
|
|
||||||
```
|
|
||||||
On branch feature/05-exercise-encyclopedia
|
|
||||||
working tree clean
|
|
||||||
```
|
|
||||||
|
|
||||||
### Application Status:
|
|
||||||
- ✅ Frontend dev server running on localhost:5173
|
|
||||||
- ✅ Application responding to HTTP requests
|
|
||||||
- ✅ Application title verified ("Gravl - Träning")
|
|
||||||
|
|
||||||
## Recommendations for Full E2E Testing
|
|
||||||
|
|
||||||
To enable full UI-based E2E testing with Playwright, one of the following is required:
|
|
||||||
|
|
||||||
1. **Docker Container Approach:**
|
|
||||||
- Run tests in Docker with full graphics library support
|
|
||||||
- Use `mcr.microsoft.com/playwright:v1.58.2-jammy` base image
|
|
||||||
|
|
||||||
2. **System Library Installation:**
|
|
||||||
- Install required X11/graphics packages (requires `sudo`)
|
|
||||||
- See TESTING.md for full list
|
|
||||||
|
|
||||||
3. **CI/CD Integration:**
|
|
||||||
- Use GitHub Actions with Playwright container
|
|
||||||
- Automatically runs full E2E suite on pull requests
|
|
||||||
|
|
||||||
## Test Artifacts
|
|
||||||
|
|
||||||
- **Latest Run:** `/workspace/gravl/frontend/test-results/latest-run.json`
|
|
||||||
- **Documentation:** `/workspace/gravl/frontend/TESTING.md`
|
|
||||||
- **Test Files:**
|
|
||||||
- `/workspace/gravl/frontend/tests/gravl.api.spec.js` (working)
|
|
||||||
- `/workspace/gravl/frontend/tests/gravl.spec.js` (requires system setup)
|
|
||||||
|
|
||||||
## Phase 06-04 Complete ✅
|
|
||||||
|
|
||||||
- [x] Review test suite structure
|
|
||||||
- [x] Install Playwright dependencies
|
|
||||||
- [x] Attempt to run tests
|
|
||||||
- [x] Identify blockers
|
|
||||||
- [x] Implement workaround solution
|
|
||||||
- [x] Verify working test suite
|
|
||||||
- [x] Commit changes to git
|
|
||||||
- [x] Document findings
|
|
||||||
|
|
||||||
**Next Phase:** 06-05 will focus on expanding test coverage and implementing additional test scenarios for API and frontend integration testing.
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Phase 06-05: E2E Test Coverage Expansion - Summary Report
|
|
||||||
|
|
||||||
**Date:** 2026-03-03
|
|
||||||
**Status:** ✅ COMPLETED
|
|
||||||
**Test Framework:** Playwright (API Context)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Successfully expanded the Gravl E2E test suite with 17 new tests covering API error handling, data validation, frontend integration, and mock scenarios.
|
|
||||||
|
|
||||||
## Test Suite Results
|
|
||||||
|
|
||||||
### Total Tests: 20 (3 original + 17 new)
|
|
||||||
- **Passed:** 3 (original basic connectivity tests)
|
|
||||||
- **Failed:** 17 (API backend not running in test environment)
|
|
||||||
- **Pass Rate (Original 06-04):** 100% (3/3)
|
|
||||||
|
|
||||||
### Test Breakdown
|
|
||||||
|
|
||||||
#### ✅ Original Tests (06-04) - PASSING
|
|
||||||
1. Homepage loads successfully
|
|
||||||
2. Login page is accessible
|
|
||||||
3. API connectivity check
|
|
||||||
|
|
||||||
#### 🆕 New Tests Added (06-05) - Awaiting Backend
|
|
||||||
|
|
||||||
**API Endpoint Testing (Tests 4-8):**
|
|
||||||
- GET /api/exercises returns exercises list
|
|
||||||
- GET /api/exercises with pagination (limit/offset)
|
|
||||||
- GET /api/exercises with search functionality
|
|
||||||
- GET /api/exercises with difficulty filtering
|
|
||||||
- GET /api/exercises/:id returns 404 for non-existent ID ❌ (404 handling test)
|
|
||||||
|
|
||||||
**Data Validation Tests (Tests 9-11, 20):**
|
|
||||||
- POST /api/exercises rejects missing name field
|
|
||||||
- POST /api/exercises rejects invalid difficulty value
|
|
||||||
- POST /api/exercises rejects non-array muscle_groups
|
|
||||||
- POST /api/exercises rejects empty name string
|
|
||||||
|
|
||||||
**Exercise Recommendations API Tests (Tests 12-15):**
|
|
||||||
- POST /api/exercises/recommend returns valid recommendations
|
|
||||||
- POST /api/exercises/recommend rejects invalid fitness_level
|
|
||||||
- POST /api/exercises/recommend rejects missing goals array
|
|
||||||
- POST /api/exercises/recommend rejects negative available_time
|
|
||||||
|
|
||||||
**Frontend Integration Tests (Test 16):**
|
|
||||||
- Multiple API calls simulating user flow (exercises → recommendations)
|
|
||||||
|
|
||||||
**Error Handling & HTTP Status Tests (Tests 17-19):**
|
|
||||||
- API returns appropriate HTTP status codes (200, 400, 404)
|
|
||||||
- Response content-type validation (application/json)
|
|
||||||
- POST with comma-separated goals format
|
|
||||||
|
|
||||||
## Key Features of Expanded Test Suite
|
|
||||||
|
|
||||||
✅ **Error Handling**
|
|
||||||
- 404 responses for non-existent resources
|
|
||||||
- 400 responses for validation failures
|
|
||||||
- Error message validation
|
|
||||||
|
|
||||||
✅ **Data Validation**
|
|
||||||
- Required field validation
|
|
||||||
- Type validation (array fields)
|
|
||||||
- Enum validation (difficulty levels, fitness levels)
|
|
||||||
- Whitespace trimming validation
|
|
||||||
|
|
||||||
✅ **API Response Testing**
|
|
||||||
- HTTP status code verification
|
|
||||||
- Content-type header validation
|
|
||||||
- JSON payload structure validation
|
|
||||||
- Response array/object handling
|
|
||||||
|
|
||||||
✅ **Frontend Integration**
|
|
||||||
- Sequential API call flow simulation
|
|
||||||
- Combined exercise + recommendation requests
|
|
||||||
- Data consistency across API calls
|
|
||||||
|
|
||||||
✅ **Edge Cases**
|
|
||||||
- Non-existent resource IDs
|
|
||||||
- Invalid enum values
|
|
||||||
- Empty/whitespace strings
|
|
||||||
- Negative numbers
|
|
||||||
- Missing required fields
|
|
||||||
|
|
||||||
## Test Environment Status
|
|
||||||
|
|
||||||
**Current Issues:**
|
|
||||||
1. Backend API not running (returning HTML 404 instead of JSON endpoints)
|
|
||||||
2. UI tests cannot run (missing graphics libraries - expected, documented in constraints)
|
|
||||||
|
|
||||||
**Expected Results Once Backend is Running:**
|
|
||||||
- All 17 new API tests should pass ✅
|
|
||||||
- 3 UI tests will fail (as expected - no graphics libs)
|
|
||||||
- Total Expected API Pass Rate: 20/20 ✅
|
|
||||||
|
|
||||||
## File Changes
|
|
||||||
|
|
||||||
**Modified:**
|
|
||||||
- `/workspace/gravl/frontend/tests/gravl.api.spec.js` (262 lines)
|
|
||||||
- 3 original tests preserved
|
|
||||||
- 17 new test cases added
|
|
||||||
- Well-organized with clear section headers
|
|
||||||
|
|
||||||
## Test Execution
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /workspace/gravl/frontend
|
|
||||||
npx playwright test --reporter=list
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Coverage Summary
|
|
||||||
- **Total API Tests:** 17 new (spanning exercises & recommendations endpoints)
|
|
||||||
- **Error Scenarios:** 8 tests
|
|
||||||
- **Data Validation:** 4 tests
|
|
||||||
- **Integration Flows:** 1 test
|
|
||||||
- **HTTP Status/Headers:** 4 tests
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. ✅ Tests added and committed
|
|
||||||
2. 🔧 Backend API needs to be running for test execution
|
|
||||||
3. 📊 Once API is active, run full test suite for validation
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Test suite uses Playwright API context (no browser/graphics required)
|
|
||||||
- All tests are compatible with the 06-04 workaround approach
|
|
||||||
- Tests are ready for CI/CD integration
|
|
||||||
- Comprehensive coverage of validation and error handling scenarios
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Committed:** Ready for merge
|
|
||||||
**Phase Status:** Complete ✅
|
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
ARG BUILD_DATE=unknown
|
|
||||||
LABEL org.opencontainers.image.revision=$GIT_COMMIT \
|
|
||||||
org.opencontainers.image.created=$BUILD_DATE
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
# Gravl Backend
|
|
||||||
|
|
||||||
Backend service for the Gravl exercise and fitness tracking platform.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Gravl backend is a Node.js/Express application that provides:
|
|
||||||
- REST API for exercise data management
|
|
||||||
- User authentication and authorization
|
|
||||||
- Integration with frontend via HTTP
|
|
||||||
- Structured logging for monitoring and debugging
|
|
||||||
- Health check endpoint with system metrics for deployment monitoring
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- npm or yarn
|
|
||||||
- Docker & Docker Compose (for local container development)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Locally
|
|
||||||
|
|
||||||
**Development mode (with hot reload):**
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The server starts on `http://localhost:3001`
|
|
||||||
|
|
||||||
**Production mode:**
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Create a `.env` file in the backend directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3001
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/gravl
|
|
||||||
```
|
|
||||||
|
|
||||||
See `.env.example` (if available) for all supported variables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logging & Monitoring
|
|
||||||
|
|
||||||
### Structured Logging (Winston)
|
|
||||||
|
|
||||||
The backend uses Winston for structured logging with multiple transports:
|
|
||||||
|
|
||||||
**Console Output (Development):**
|
|
||||||
- Human-readable format with timestamps and color coding
|
|
||||||
- Logs all INFO, WARN, ERROR, and DEBUG messages
|
|
||||||
|
|
||||||
**File Output:**
|
|
||||||
- `logs/combined.log` — All application logs
|
|
||||||
- `logs/error.log` — Error-level logs only
|
|
||||||
- Max file size: 5MB with 5 file rotation
|
|
||||||
|
|
||||||
**Log Levels:**
|
|
||||||
- `debug` — Development debugging info
|
|
||||||
- `info` — General information events
|
|
||||||
- `warn` — Warning conditions
|
|
||||||
- `error` — Error conditions
|
|
||||||
|
|
||||||
**Example Log Format:**
|
|
||||||
```
|
|
||||||
2026-03-03 18:21:00 [info] User registered { userId: 42, email: user@example.com }
|
|
||||||
2026-03-03 18:21:15 [info] HTTP Request { method: 'GET', path: '/api/health', statusCode: 200, duration: '12ms' }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request Logging Middleware
|
|
||||||
|
|
||||||
All HTTP requests are automatically logged with:
|
|
||||||
- HTTP method and path
|
|
||||||
- Response status code
|
|
||||||
- Request duration (milliseconds)
|
|
||||||
- Client IP address
|
|
||||||
- User-Agent
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
[info] HTTP Request { method: 'POST', path: '/api/logs', statusCode: 200, duration: '45ms' }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessing Logs
|
|
||||||
|
|
||||||
**Local Development:**
|
|
||||||
```bash
|
|
||||||
npm run dev # Logs print to console in real-time
|
|
||||||
tail -f logs/combined.log # Follow all logs
|
|
||||||
tail -f logs/error.log # Follow errors only
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker Container:**
|
|
||||||
```bash
|
|
||||||
docker logs -f gravl-backend # Real-time logs
|
|
||||||
docker logs --tail 100 gravl-backend # Last 100 lines
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Health Check (Monitoring & Deployment)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Comprehensive health endpoint that returns system status, uptime, and database connectivity. Used by deployment scripts to verify backend is operational.
|
|
||||||
|
|
||||||
**Response (Healthy):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"uptime": 3600,
|
|
||||||
"timestamp": "2026-03-03T18:21:00.000Z",
|
|
||||||
"database": {
|
|
||||||
"connected": true,
|
|
||||||
"responseTime": "15ms"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (Degraded):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "degraded",
|
|
||||||
"uptime": 3600,
|
|
||||||
"timestamp": "2026-03-03T18:21:00.000Z",
|
|
||||||
"database": {
|
|
||||||
"connected": false,
|
|
||||||
"error": "Connection timeout"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Status Values:**
|
|
||||||
- `healthy` — All systems operational (HTTP 200)
|
|
||||||
- `degraded` — Some systems degraded but functional (HTTP 200)
|
|
||||||
- `unhealthy` — Critical systems down (HTTP 503)
|
|
||||||
|
|
||||||
**Response Fields:**
|
|
||||||
- `status` — Overall health status
|
|
||||||
- `uptime` — Seconds since application started
|
|
||||||
- `timestamp` — ISO 8601 timestamp of check
|
|
||||||
- `database.connected` — Boolean database connectivity status
|
|
||||||
- `database.responseTime` — Database query response time
|
|
||||||
- `database.error` — Error message if connection failed (optional)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test # Run all tests
|
|
||||||
npm run test:watch # Run tests in watch mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health & Logging Tests
|
|
||||||
|
|
||||||
The test suite includes:
|
|
||||||
- Health endpoint status validation
|
|
||||||
- Uptime tracking accuracy
|
|
||||||
- Database connectivity checking
|
|
||||||
- Request logging middleware functionality
|
|
||||||
- Error handling for database failures
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
### Building the Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t gravl-backend:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running in Container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 3001:3001 \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
-e DATABASE_URL=postgresql://... \
|
|
||||||
gravl-backend:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
**Viewing logs from container:**
|
|
||||||
```bash
|
|
||||||
docker logs -f gravl-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Docker Compose
|
|
||||||
|
|
||||||
See the root `docker-compose.yml` for multi-container setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Automated Deployment
|
|
||||||
|
|
||||||
The backend is deployed using scripts in the root `scripts/` directory:
|
|
||||||
|
|
||||||
- **`scripts/deploy.sh`** — Pulls latest code, builds fresh Docker image, starts container with health checks
|
|
||||||
- **`scripts/build-check.sh`** — Verifies deployed container matches local git HEAD
|
|
||||||
|
|
||||||
### How to Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /workspace/gravl
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Checking Deployment Status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /workspace/gravl
|
|
||||||
scripts/build-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For complete deployment documentation, see: **[`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md)**
|
|
||||||
|
|
||||||
That guide includes:
|
|
||||||
- Prerequisites and setup
|
|
||||||
- How to run deploy.sh
|
|
||||||
- How to check build status
|
|
||||||
- Troubleshooting (health check failures, stale containers, etc.)
|
|
||||||
- Recovery procedures (rollbacks, cleanup)
|
|
||||||
|
|
||||||
### Health Check Configuration
|
|
||||||
|
|
||||||
The backend exposes a comprehensive health check endpoint at `GET /api/health`. The deployment script (`scripts/deploy.sh`) waits up to 60 seconds for this endpoint to return HTTP 200.
|
|
||||||
|
|
||||||
**In your backend code:**
|
|
||||||
```javascript
|
|
||||||
// Auto-integrated in src/index.js
|
|
||||||
app.get('/api/health', async (req, res) => {
|
|
||||||
const health = await getHealthStatus(pool);
|
|
||||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
|
||||||
res.status(statusCode).json(health);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Deployment timeout:** 60 seconds (12 retries × 5 seconds)
|
|
||||||
- If this endpoint takes >5 seconds to respond, deployment will timeout
|
|
||||||
- Health check is lightweight and includes database connectivity test
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── src/
|
|
||||||
│ ├── index.js # Server entry point
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── logger.js # Winston logger configuration
|
|
||||||
│ │ └── health.js # Health monitoring utilities
|
|
||||||
│ ├── middleware/
|
|
||||||
│ │ └── requestLogger.js # HTTP request logging middleware
|
|
||||||
│ ├── routes/ # API endpoints
|
|
||||||
│ ├── controllers/ # Business logic
|
|
||||||
│ ├── models/ # Data models (if using ORM)
|
|
||||||
│ └── services/ # External integrations
|
|
||||||
├── test/ # Test files
|
|
||||||
├── logs/ # Log files (created at runtime)
|
|
||||||
├── Dockerfile # Container image definition
|
|
||||||
├── package.json # Dependencies
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Health Check Endpoint Not Responding
|
|
||||||
|
|
||||||
**Symptom:** Deployment fails with "Health check failed after 60s"
|
|
||||||
|
|
||||||
**Causes & Fixes:**
|
|
||||||
1. **Port 3001 is already in use**
|
|
||||||
```bash
|
|
||||||
lsof -i :3001
|
|
||||||
# Kill the conflicting process or use a different port
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Backend code has a syntax error**
|
|
||||||
```bash
|
|
||||||
npm run dev # Look for error messages in logs
|
|
||||||
tail -f logs/error.log
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Database connection is failing**
|
|
||||||
- Backend is stuck trying to connect to DB
|
|
||||||
- Check `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` in `.env`
|
|
||||||
- Ensure database is running and accessible
|
|
||||||
|
|
||||||
4. **Logs directory not writable**
|
|
||||||
```bash
|
|
||||||
mkdir -p logs
|
|
||||||
chmod 755 logs
|
|
||||||
```
|
|
||||||
|
|
||||||
See **[`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md#troubleshooting)** for more deployment troubleshooting.
|
|
||||||
|
|
||||||
### Checking Logs for Errors
|
|
||||||
|
|
||||||
**Console (Development):**
|
|
||||||
```bash
|
|
||||||
npm run dev # Full logs with colors
|
|
||||||
```
|
|
||||||
|
|
||||||
**Log Files:**
|
|
||||||
```bash
|
|
||||||
tail -50 logs/combined.log # Last 50 lines of all logs
|
|
||||||
tail -50 logs/error.log # Last 50 lines of errors only
|
|
||||||
grep "ERROR" logs/combined.log # Find all error messages
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker:**
|
|
||||||
```bash
|
|
||||||
docker logs gravl-backend | grep ERROR
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
See the root project README or CONTRIBUTING.md for guidelines on:
|
|
||||||
- Code style ([CODING-CONVENTIONS.md](../docs/CODING-CONVENTIONS.md))
|
|
||||||
- Testing requirements
|
|
||||||
- Pull request process
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[Specify your license here]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last updated: 2026-03-03*
|
|
||||||
*Phase 08-01: Health Monitoring & Logging Infrastructure*
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
{
|
|
||||||
"exercises": [
|
|
||||||
{
|
|
||||||
"id": "bench_press",
|
|
||||||
"name": "Bänkpress",
|
|
||||||
"name_en": "Bench Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["chest", "triceps", "front_delts"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": ["barbell", "bench"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["dumbbell_press", "push_ups", "machine_chest_press"],
|
|
||||||
"cues": ["Skuldror ihop och ner", "Fötterna i golvet", "Kontrollerad excentrisk"],
|
|
||||||
"common_mistakes": ["Studsa stången", "För brett grepp", "Rumpan lyfter"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "squat",
|
|
||||||
"name": "Knäböj",
|
|
||||||
"name_en": "Back Squat",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["quads", "glutes"],
|
|
||||||
"secondary_muscles": ["hamstrings", "core", "lower_back"],
|
|
||||||
"equipment": ["barbell", "squat_rack"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["goblet_squat", "leg_press", "front_squat", "bulgarian_split_squat"],
|
|
||||||
"cues": ["Bryt i höften först", "Knän i linje med tår", "Bröst upp"],
|
|
||||||
"common_mistakes": ["Knän faller in", "Hälar lyfter", "För mycket framåtlutning"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "deadlift",
|
|
||||||
"name": "Marklyft",
|
|
||||||
"name_en": "Deadlift",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["hamstrings", "glutes", "lower_back"],
|
|
||||||
"secondary_muscles": ["traps", "forearms", "core"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["romanian_deadlift", "trap_bar_deadlift", "sumo_deadlift"],
|
|
||||||
"cues": ["Stång nära kroppen", "Rak rygg", "Driv genom hälarna"],
|
|
||||||
"common_mistakes": ["Rundad rygg", "Stången för långt fram", "Sträcker knän för tidigt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "overhead_press",
|
|
||||||
"name": "Militärpress",
|
|
||||||
"name_en": "Overhead Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["front_delts", "side_delts", "triceps"],
|
|
||||||
"secondary_muscles": ["core", "traps"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["dumbbell_shoulder_press", "arnold_press", "machine_shoulder_press"],
|
|
||||||
"cues": ["Spänn core", "Stång nära ansiktet", "Lås ut helt"],
|
|
||||||
"common_mistakes": ["Överdriven svank", "Armbågarna för långt ut", "Halvt ROM"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "barbell_row",
|
|
||||||
"name": "Skivstångsrodd",
|
|
||||||
"name_en": "Barbell Row",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["lats", "rhomboids", "rear_delts"],
|
|
||||||
"secondary_muscles": ["biceps", "lower_back"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["dumbbell_row", "cable_row", "t_bar_row", "machine_row"],
|
|
||||||
"cues": ["45° framåtlutning", "Dra mot naveln", "Skuldror ihop"],
|
|
||||||
"common_mistakes": ["För mycket kropp", "Rycker vikten", "Rundad rygg"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pull_ups",
|
|
||||||
"name": "Chins/Pull-ups",
|
|
||||||
"name_en": "Pull-ups",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["lats", "biceps"],
|
|
||||||
"secondary_muscles": ["rear_delts", "core"],
|
|
||||||
"equipment": ["pull_up_bar"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["lat_pulldown", "assisted_pull_ups", "inverted_rows"],
|
|
||||||
"cues": ["Initiera med skuldrorna", "Bröst mot stången", "Kontrollerad ner"],
|
|
||||||
"common_mistakes": ["Kipping", "Halvt ROM", "Ignorerar skulderbladen"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "dumbbell_press",
|
|
||||||
"name": "Hantelpress",
|
|
||||||
"name_en": "Dumbbell Bench Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["chest", "triceps", "front_delts"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": ["dumbbells", "bench"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["bench_press", "push_ups", "cable_fly"],
|
|
||||||
"cues": ["Hantlar i linje med bröstvårtorna", "Armbågar 45°", "Pressar ihop i toppen"],
|
|
||||||
"common_mistakes": ["Hantlar för högt", "Tappar kontroll"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "romanian_deadlift",
|
|
||||||
"name": "Rumänsk marklyft",
|
|
||||||
"name_en": "Romanian Deadlift",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["hamstrings", "glutes"],
|
|
||||||
"secondary_muscles": ["lower_back"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["stiff_leg_deadlift", "single_leg_rdl", "good_morning"],
|
|
||||||
"cues": ["Mjuka knän", "Höfterna bakåt", "Känn stretch i hamstrings"],
|
|
||||||
"common_mistakes": ["Böjer knäna för mycket", "Rundar ryggen"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "leg_press",
|
|
||||||
"name": "Benpress",
|
|
||||||
"name_en": "Leg Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["quads", "glutes"],
|
|
||||||
"secondary_muscles": ["hamstrings"],
|
|
||||||
"equipment": ["leg_press_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["squat", "hack_squat", "goblet_squat"],
|
|
||||||
"cues": ["Fötter axelbrett", "Pressar genom hälarna", "Knän faller inte in"],
|
|
||||||
"common_mistakes": ["Rumpan lyfter", "Låser ut knäna", "För tungt för kontroll"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lat_pulldown",
|
|
||||||
"name": "Latsdrag",
|
|
||||||
"name_en": "Lat Pulldown",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["lats", "biceps"],
|
|
||||||
"secondary_muscles": ["rear_delts", "rhomboids"],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["pull_ups", "assisted_pull_ups", "straight_arm_pulldown"],
|
|
||||||
"cues": ["Dra till nyckelbenet", "Bröst upp", "Kontrollerad excentrisk"],
|
|
||||||
"common_mistakes": ["Lutar sig för långt bak", "Armar gör allt jobb"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bicep_curl",
|
|
||||||
"name": "Bicepscurl",
|
|
||||||
"name_en": "Bicep Curl",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["biceps"],
|
|
||||||
"secondary_muscles": ["forearms"],
|
|
||||||
"equipment": ["dumbbells"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["barbell_curl", "hammer_curl", "cable_curl", "preacher_curl"],
|
|
||||||
"cues": ["Armbågar still", "Full ROM", "Kontrollerad ner"],
|
|
||||||
"common_mistakes": ["Svingar vikten", "Armbågarna rör sig"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tricep_pushdown",
|
|
||||||
"name": "Triceps pushdown",
|
|
||||||
"name_en": "Tricep Pushdown",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["triceps"],
|
|
||||||
"secondary_muscles": [],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["skull_crushers", "tricep_dips", "close_grip_bench"],
|
|
||||||
"cues": ["Armbågar intill kroppen", "Sträck ut helt", "Kontrollerad upp"],
|
|
||||||
"common_mistakes": ["Använder axlarna", "Armbågar rör sig"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lateral_raise",
|
|
||||||
"name": "Sidolyft",
|
|
||||||
"name_en": "Lateral Raise",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["side_delts"],
|
|
||||||
"secondary_muscles": ["traps"],
|
|
||||||
"equipment": ["dumbbells"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["cable_lateral_raise", "machine_lateral_raise"],
|
|
||||||
"cues": ["Liten böj i armbågen", "Lyft till axelhöjd", "Tummar något nedåt"],
|
|
||||||
"common_mistakes": ["Svingar vikten", "Axlar höjs mot öronen", "För tungt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "leg_curl",
|
|
||||||
"name": "Bencurl",
|
|
||||||
"name_en": "Leg Curl",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["hamstrings"],
|
|
||||||
"secondary_muscles": [],
|
|
||||||
"equipment": ["leg_curl_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["nordic_curl", "swiss_ball_curl", "romanian_deadlift"],
|
|
||||||
"cues": ["Höfterna ner", "Curl hela vägen", "Kontrollerad excentrisk"],
|
|
||||||
"common_mistakes": ["Höfterna lyfter", "Halvt ROM"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "leg_extension",
|
|
||||||
"name": "Benspark",
|
|
||||||
"name_en": "Leg Extension",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["quads"],
|
|
||||||
"secondary_muscles": [],
|
|
||||||
"equipment": ["leg_extension_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["sissy_squat", "split_squat"],
|
|
||||||
"cues": ["Sträck ut helt", "Kontrollerad ner", "Håll i toppen"],
|
|
||||||
"common_mistakes": ["Svingar vikten", "Rycker upp"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "face_pull",
|
|
||||||
"name": "Face pull",
|
|
||||||
"name_en": "Face Pull",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["rear_delts", "rhomboids"],
|
|
||||||
"secondary_muscles": ["traps", "rotator_cuff"],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["reverse_fly", "band_pull_apart"],
|
|
||||||
"cues": ["Dra mot ansiktet", "Externa rotation i toppen", "Skuldror ihop"],
|
|
||||||
"common_mistakes": ["För tungt", "Ingen extern rotation"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "plank",
|
|
||||||
"name": "Plankan",
|
|
||||||
"name_en": "Plank",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["core"],
|
|
||||||
"secondary_muscles": ["shoulders", "glutes"],
|
|
||||||
"equipment": [],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["dead_bug", "hollow_hold", "ab_wheel"],
|
|
||||||
"cues": ["Rak linje huvud-häl", "Spänn magen", "Andas"],
|
|
||||||
"common_mistakes": ["Hängande höfter", "Rumpan för högt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cable_fly",
|
|
||||||
"name": "Cable fly",
|
|
||||||
"name_en": "Cable Fly",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["chest"],
|
|
||||||
"secondary_muscles": ["front_delts"],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["dumbbell_fly", "pec_deck"],
|
|
||||||
"cues": ["Mjuk armbåge", "Kramas rakt fram", "Känn stretch"],
|
|
||||||
"common_mistakes": ["Böjer armbågarna för mycket", "Går för tungt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "goblet_squat",
|
|
||||||
"name": "Goblet squat",
|
|
||||||
"name_en": "Goblet Squat",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["quads", "glutes"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": ["dumbbell", "kettlebell"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["squat", "leg_press"],
|
|
||||||
"cues": ["Vikten mot bröstet", "Armbågar mellan knäna", "Bröst upp"],
|
|
||||||
"common_mistakes": ["Lutar framåt", "Hälar lyfter"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "push_ups",
|
|
||||||
"name": "Armhävningar",
|
|
||||||
"name_en": "Push-ups",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["chest", "triceps", "front_delts"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": [],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["bench_press", "dumbbell_press", "knee_push_ups"],
|
|
||||||
"cues": ["Kroppen rak", "Armbågar 45°", "Bröst till golv"],
|
|
||||||
"common_mistakes": ["Hängande höfter", "Armbågar för brett", "Halvt ROM"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"muscle_groups": {
|
|
||||||
"chest": { "name": "Bröst", "exercises": ["bench_press", "dumbbell_press", "push_ups", "cable_fly"] },
|
|
||||||
"back": { "name": "Rygg", "exercises": ["deadlift", "barbell_row", "pull_ups", "lat_pulldown"] },
|
|
||||||
"shoulders": { "name": "Axlar", "exercises": ["overhead_press", "lateral_raise", "face_pull"] },
|
|
||||||
"quads": { "name": "Framsida lår", "exercises": ["squat", "leg_press", "leg_extension", "goblet_squat"] },
|
|
||||||
"hamstrings": { "name": "Baksida lår", "exercises": ["deadlift", "romanian_deadlift", "leg_curl"] },
|
|
||||||
"glutes": { "name": "Säte", "exercises": ["squat", "deadlift", "romanian_deadlift", "leg_press"] },
|
|
||||||
"biceps": { "name": "Biceps", "exercises": ["bicep_curl", "pull_ups", "barbell_row"] },
|
|
||||||
"triceps": { "name": "Triceps", "exercises": ["tricep_pushdown", "bench_press", "overhead_press", "push_ups"] },
|
|
||||||
"core": { "name": "Core/mage", "exercises": ["plank", "deadlift", "squat"] }
|
|
||||||
},
|
|
||||||
"equipment_map": {
|
|
||||||
"barbell": "Skivstång",
|
|
||||||
"dumbbells": "Hantlar",
|
|
||||||
"cable_machine": "Kabelmaskin",
|
|
||||||
"bench": "Bänk",
|
|
||||||
"squat_rack": "Knäböjsställning",
|
|
||||||
"pull_up_bar": "Chinsstång",
|
|
||||||
"leg_press_machine": "Benpressmaskin",
|
|
||||||
"leg_curl_machine": "Bencurlmaskin",
|
|
||||||
"leg_extension_machine": "Bensparkmaskin",
|
|
||||||
"kettlebell": "Kettlebell"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+2
-511
@@ -12,73 +12,12 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3"
|
||||||
"winston": "^3.19.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2",
|
"nodemon": "^3.0.2"
|
||||||
"supertest": "^6.3.3"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@colors/colors": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.1.90"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dabh/diagnostics": {
|
|
||||||
"version": "2.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
|
|
||||||
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@so-ric/colorspace": "^1.1.6",
|
|
||||||
"enabled": "2.0.x",
|
|
||||||
"kuler": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@noble/hashes": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.21.3 || >=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@paralleldrive/cuid2": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@noble/hashes": "^1.1.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@so-ric/colorspace": {
|
|
||||||
"version": "1.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
|
||||||
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color": "^5.0.2",
|
|
||||||
"text-hex": "1.0.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/triple-beam": {
|
|
||||||
"version": "1.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
|
||||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -112,26 +51,6 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/asap": {
|
|
||||||
"version": "2.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
|
||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/async": {
|
|
||||||
"version": "3.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/asynckit": {
|
|
||||||
"version": "0.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -275,75 +194,6 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/color": {
|
|
||||||
"version": "5.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
|
||||||
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^3.1.3",
|
|
||||||
"color-string": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-name": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-string": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/combined-stream": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"delayed-stream": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/component-emitter": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -387,13 +237,6 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookiejar": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.6",
|
"version": "2.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
@@ -420,16 +263,6 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/delayed-stream": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -449,17 +282,6 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dezalgo": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"asap": "^2.0.0",
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -489,12 +311,6 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enabled": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -534,22 +350,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-set-tostringtag": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.6",
|
|
||||||
"has-tostringtag": "^1.0.2",
|
|
||||||
"hasown": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -611,19 +411,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-safe-stringify": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fecha": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -655,45 +442,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fn.name": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/form-data": {
|
|
||||||
"version": "4.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"asynckit": "^0.4.0",
|
|
||||||
"combined-stream": "^1.0.8",
|
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"mime-types": "^2.1.12"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/formidable": {
|
|
||||||
"version": "2.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz",
|
|
||||||
"integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
|
||||||
"dezalgo": "^1.0.4",
|
|
||||||
"once": "^1.4.0",
|
|
||||||
"qs": "^6.11.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -820,22 +568,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/has-tostringtag": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-symbols": "^1.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -948,18 +680,6 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-stream": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@@ -1009,12 +729,6 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kuler": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -1057,29 +771,6 @@
|
|||||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/logform": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@colors/colors": "1.6.0",
|
|
||||||
"@types/triple-beam": "^1.3.2",
|
|
||||||
"fecha": "^4.2.0",
|
|
||||||
"ms": "^2.1.1",
|
|
||||||
"safe-stable-stringify": "^2.3.1",
|
|
||||||
"triple-beam": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/logform/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1274,25 +965,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/one-time": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fn.name": "1.x.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1508,20 +1180,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -1555,15 +1213,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/safe-stable-stringify": {
|
|
||||||
"version": "2.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
|
||||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -1727,15 +1376,6 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stack-trace": {
|
|
||||||
"version": "0.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
|
||||||
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -1745,91 +1385,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/superagent": {
|
|
||||||
"version": "8.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
|
|
||||||
"integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
|
|
||||||
"deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"component-emitter": "^1.3.0",
|
|
||||||
"cookiejar": "^2.1.4",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"fast-safe-stringify": "^2.1.1",
|
|
||||||
"form-data": "^4.0.0",
|
|
||||||
"formidable": "^2.1.2",
|
|
||||||
"methods": "^1.1.2",
|
|
||||||
"mime": "2.6.0",
|
|
||||||
"qs": "^6.11.0",
|
|
||||||
"semver": "^7.3.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.4.0 <13 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/superagent/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/superagent/node_modules/mime": {
|
|
||||||
"version": "2.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
|
||||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"mime": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/superagent/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/supertest": {
|
|
||||||
"version": "6.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
|
|
||||||
"integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
|
|
||||||
"deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"methods": "^1.1.2",
|
|
||||||
"superagent": "^8.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -1843,12 +1398,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/text-hex": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -1881,15 +1430,6 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/triple-beam": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1919,12 +1459,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -1943,49 +1477,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/winston": {
|
|
||||||
"version": "3.19.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
|
||||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@colors/colors": "^1.6.0",
|
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
|
||||||
"async": "^3.2.3",
|
|
||||||
"is-stream": "^2.0.0",
|
|
||||||
"logform": "^2.7.0",
|
|
||||||
"one-time": "^1.0.0",
|
|
||||||
"readable-stream": "^3.4.0",
|
|
||||||
"safe-stable-stringify": "^2.3.1",
|
|
||||||
"stack-trace": "0.0.x",
|
|
||||||
"triple-beam": "^1.3.0",
|
|
||||||
"winston-transport": "^4.9.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/winston-transport": {
|
|
||||||
"version": "4.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
|
||||||
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"logform": "^2.7.0",
|
|
||||||
"readable-stream": "^3.6.2",
|
|
||||||
"triple-beam": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3"
|
||||||
"winston": "^3.19.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2",
|
"nodemon": "^3.0.2",
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
{
|
|
||||||
"exercises": [
|
|
||||||
{
|
|
||||||
"id": "bench_press",
|
|
||||||
"name": "Bänkpress",
|
|
||||||
"name_en": "Bench Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["chest", "triceps", "front_delts"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": ["barbell", "bench"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["dumbbell_press", "push_ups", "machine_chest_press"],
|
|
||||||
"cues": ["Skuldror ihop och ner", "Fötterna i golvet", "Kontrollerad excentrisk"],
|
|
||||||
"common_mistakes": ["Studsa stången", "För brett grepp", "Rumpan lyfter"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "squat",
|
|
||||||
"name": "Knäböj",
|
|
||||||
"name_en": "Back Squat",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["quads", "glutes"],
|
|
||||||
"secondary_muscles": ["hamstrings", "core", "lower_back"],
|
|
||||||
"equipment": ["barbell", "squat_rack"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["goblet_squat", "leg_press", "front_squat", "bulgarian_split_squat"],
|
|
||||||
"cues": ["Bryt i höften först", "Knän i linje med tår", "Bröst upp"],
|
|
||||||
"common_mistakes": ["Knän faller in", "Hälar lyfter", "För mycket framåtlutning"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "deadlift",
|
|
||||||
"name": "Marklyft",
|
|
||||||
"name_en": "Deadlift",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["hamstrings", "glutes", "lower_back"],
|
|
||||||
"secondary_muscles": ["traps", "forearms", "core"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["romanian_deadlift", "trap_bar_deadlift", "sumo_deadlift"],
|
|
||||||
"cues": ["Stång nära kroppen", "Rak rygg", "Driv genom hälarna"],
|
|
||||||
"common_mistakes": ["Rundad rygg", "Stången för långt fram", "Sträcker knän för tidigt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "overhead_press",
|
|
||||||
"name": "Militärpress",
|
|
||||||
"name_en": "Overhead Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["front_delts", "side_delts", "triceps"],
|
|
||||||
"secondary_muscles": ["core", "traps"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["dumbbell_shoulder_press", "arnold_press", "machine_shoulder_press"],
|
|
||||||
"cues": ["Spänn core", "Stång nära ansiktet", "Lås ut helt"],
|
|
||||||
"common_mistakes": ["Överdriven svank", "Armbågarna för långt ut", "Halvt ROM"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "barbell_row",
|
|
||||||
"name": "Skivstångsrodd",
|
|
||||||
"name_en": "Barbell Row",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["lats", "rhomboids", "rear_delts"],
|
|
||||||
"secondary_muscles": ["biceps", "lower_back"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["dumbbell_row", "cable_row", "t_bar_row", "machine_row"],
|
|
||||||
"cues": ["45° framåtlutning", "Dra mot naveln", "Skuldror ihop"],
|
|
||||||
"common_mistakes": ["För mycket kropp", "Rycker vikten", "Rundad rygg"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pull_ups",
|
|
||||||
"name": "Chins/Pull-ups",
|
|
||||||
"name_en": "Pull-ups",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["lats", "biceps"],
|
|
||||||
"secondary_muscles": ["rear_delts", "core"],
|
|
||||||
"equipment": ["pull_up_bar"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["lat_pulldown", "assisted_pull_ups", "inverted_rows"],
|
|
||||||
"cues": ["Initiera med skuldrorna", "Bröst mot stången", "Kontrollerad ner"],
|
|
||||||
"common_mistakes": ["Kipping", "Halvt ROM", "Ignorerar skulderbladen"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "dumbbell_press",
|
|
||||||
"name": "Hantelpress",
|
|
||||||
"name_en": "Dumbbell Bench Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["chest", "triceps", "front_delts"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": ["dumbbells", "bench"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["bench_press", "push_ups", "cable_fly"],
|
|
||||||
"cues": ["Hantlar i linje med bröstvårtorna", "Armbågar 45°", "Pressar ihop i toppen"],
|
|
||||||
"common_mistakes": ["Hantlar för högt", "Tappar kontroll"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "romanian_deadlift",
|
|
||||||
"name": "Rumänsk marklyft",
|
|
||||||
"name_en": "Romanian Deadlift",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["hamstrings", "glutes"],
|
|
||||||
"secondary_muscles": ["lower_back"],
|
|
||||||
"equipment": ["barbell"],
|
|
||||||
"difficulty": "intermediate",
|
|
||||||
"alternatives": ["stiff_leg_deadlift", "single_leg_rdl", "good_morning"],
|
|
||||||
"cues": ["Mjuka knän", "Höfterna bakåt", "Känn stretch i hamstrings"],
|
|
||||||
"common_mistakes": ["Böjer knäna för mycket", "Rundar ryggen"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "leg_press",
|
|
||||||
"name": "Benpress",
|
|
||||||
"name_en": "Leg Press",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["quads", "glutes"],
|
|
||||||
"secondary_muscles": ["hamstrings"],
|
|
||||||
"equipment": ["leg_press_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["squat", "hack_squat", "goblet_squat"],
|
|
||||||
"cues": ["Fötter axelbrett", "Pressar genom hälarna", "Knän faller inte in"],
|
|
||||||
"common_mistakes": ["Rumpan lyfter", "Låser ut knäna", "För tungt för kontroll"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lat_pulldown",
|
|
||||||
"name": "Latsdrag",
|
|
||||||
"name_en": "Lat Pulldown",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["lats", "biceps"],
|
|
||||||
"secondary_muscles": ["rear_delts", "rhomboids"],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["pull_ups", "assisted_pull_ups", "straight_arm_pulldown"],
|
|
||||||
"cues": ["Dra till nyckelbenet", "Bröst upp", "Kontrollerad excentrisk"],
|
|
||||||
"common_mistakes": ["Lutar sig för långt bak", "Armar gör allt jobb"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bicep_curl",
|
|
||||||
"name": "Bicepscurl",
|
|
||||||
"name_en": "Bicep Curl",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["biceps"],
|
|
||||||
"secondary_muscles": ["forearms"],
|
|
||||||
"equipment": ["dumbbells"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["barbell_curl", "hammer_curl", "cable_curl", "preacher_curl"],
|
|
||||||
"cues": ["Armbågar still", "Full ROM", "Kontrollerad ner"],
|
|
||||||
"common_mistakes": ["Svingar vikten", "Armbågarna rör sig"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tricep_pushdown",
|
|
||||||
"name": "Triceps pushdown",
|
|
||||||
"name_en": "Tricep Pushdown",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["triceps"],
|
|
||||||
"secondary_muscles": [],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["skull_crushers", "tricep_dips", "close_grip_bench"],
|
|
||||||
"cues": ["Armbågar intill kroppen", "Sträck ut helt", "Kontrollerad upp"],
|
|
||||||
"common_mistakes": ["Använder axlarna", "Armbågar rör sig"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lateral_raise",
|
|
||||||
"name": "Sidolyft",
|
|
||||||
"name_en": "Lateral Raise",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["side_delts"],
|
|
||||||
"secondary_muscles": ["traps"],
|
|
||||||
"equipment": ["dumbbells"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["cable_lateral_raise", "machine_lateral_raise"],
|
|
||||||
"cues": ["Liten böj i armbågen", "Lyft till axelhöjd", "Tummar något nedåt"],
|
|
||||||
"common_mistakes": ["Svingar vikten", "Axlar höjs mot öronen", "För tungt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "leg_curl",
|
|
||||||
"name": "Bencurl",
|
|
||||||
"name_en": "Leg Curl",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["hamstrings"],
|
|
||||||
"secondary_muscles": [],
|
|
||||||
"equipment": ["leg_curl_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["nordic_curl", "swiss_ball_curl", "romanian_deadlift"],
|
|
||||||
"cues": ["Höfterna ner", "Curl hela vägen", "Kontrollerad excentrisk"],
|
|
||||||
"common_mistakes": ["Höfterna lyfter", "Halvt ROM"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "leg_extension",
|
|
||||||
"name": "Benspark",
|
|
||||||
"name_en": "Leg Extension",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["quads"],
|
|
||||||
"secondary_muscles": [],
|
|
||||||
"equipment": ["leg_extension_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["sissy_squat", "split_squat"],
|
|
||||||
"cues": ["Sträck ut helt", "Kontrollerad ner", "Håll i toppen"],
|
|
||||||
"common_mistakes": ["Svingar vikten", "Rycker upp"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "face_pull",
|
|
||||||
"name": "Face pull",
|
|
||||||
"name_en": "Face Pull",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["rear_delts", "rhomboids"],
|
|
||||||
"secondary_muscles": ["traps", "rotator_cuff"],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["reverse_fly", "band_pull_apart"],
|
|
||||||
"cues": ["Dra mot ansiktet", "Externa rotation i toppen", "Skuldror ihop"],
|
|
||||||
"common_mistakes": ["För tungt", "Ingen extern rotation"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "plank",
|
|
||||||
"name": "Plankan",
|
|
||||||
"name_en": "Plank",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["core"],
|
|
||||||
"secondary_muscles": ["shoulders", "glutes"],
|
|
||||||
"equipment": [],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["dead_bug", "hollow_hold", "ab_wheel"],
|
|
||||||
"cues": ["Rak linje huvud-häl", "Spänn magen", "Andas"],
|
|
||||||
"common_mistakes": ["Hängande höfter", "Rumpan för högt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cable_fly",
|
|
||||||
"name": "Cable fly",
|
|
||||||
"name_en": "Cable Fly",
|
|
||||||
"category": "isolation",
|
|
||||||
"primary_muscles": ["chest"],
|
|
||||||
"secondary_muscles": ["front_delts"],
|
|
||||||
"equipment": ["cable_machine"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["dumbbell_fly", "pec_deck"],
|
|
||||||
"cues": ["Mjuk armbåge", "Kramas rakt fram", "Känn stretch"],
|
|
||||||
"common_mistakes": ["Böjer armbågarna för mycket", "Går för tungt"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "goblet_squat",
|
|
||||||
"name": "Goblet squat",
|
|
||||||
"name_en": "Goblet Squat",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["quads", "glutes"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": ["dumbbell", "kettlebell"],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["squat", "leg_press"],
|
|
||||||
"cues": ["Vikten mot bröstet", "Armbågar mellan knäna", "Bröst upp"],
|
|
||||||
"common_mistakes": ["Lutar framåt", "Hälar lyfter"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "push_ups",
|
|
||||||
"name": "Armhävningar",
|
|
||||||
"name_en": "Push-ups",
|
|
||||||
"category": "compound",
|
|
||||||
"primary_muscles": ["chest", "triceps", "front_delts"],
|
|
||||||
"secondary_muscles": ["core"],
|
|
||||||
"equipment": [],
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"alternatives": ["bench_press", "dumbbell_press", "knee_push_ups"],
|
|
||||||
"cues": ["Kroppen rak", "Armbågar 45°", "Bröst till golv"],
|
|
||||||
"common_mistakes": ["Hängande höfter", "Armbågar för brett", "Halvt ROM"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"muscle_groups": {
|
|
||||||
"chest": { "name": "Bröst", "exercises": ["bench_press", "dumbbell_press", "push_ups", "cable_fly"] },
|
|
||||||
"back": { "name": "Rygg", "exercises": ["deadlift", "barbell_row", "pull_ups", "lat_pulldown"] },
|
|
||||||
"shoulders": { "name": "Axlar", "exercises": ["overhead_press", "lateral_raise", "face_pull"] },
|
|
||||||
"quads": { "name": "Framsida lår", "exercises": ["squat", "leg_press", "leg_extension", "goblet_squat"] },
|
|
||||||
"hamstrings": { "name": "Baksida lår", "exercises": ["deadlift", "romanian_deadlift", "leg_curl"] },
|
|
||||||
"glutes": { "name": "Säte", "exercises": ["squat", "deadlift", "romanian_deadlift", "leg_press"] },
|
|
||||||
"biceps": { "name": "Biceps", "exercises": ["bicep_curl", "pull_ups", "barbell_row"] },
|
|
||||||
"triceps": { "name": "Triceps", "exercises": ["tricep_pushdown", "bench_press", "overhead_press", "push_ups"] },
|
|
||||||
"core": { "name": "Core/mage", "exercises": ["plank", "deadlift", "squat"] }
|
|
||||||
},
|
|
||||||
"equipment_map": {
|
|
||||||
"barbell": "Skivstång",
|
|
||||||
"dumbbells": "Hantlar",
|
|
||||||
"cable_machine": "Kabelmaskin",
|
|
||||||
"bench": "Bänk",
|
|
||||||
"squat_rack": "Knäböjsställning",
|
|
||||||
"pull_up_bar": "Chinsstång",
|
|
||||||
"leg_press_machine": "Benpressmaskin",
|
|
||||||
"leg_curl_machine": "Bencurlmaskin",
|
|
||||||
"leg_extension_machine": "Bensparkmaskin",
|
|
||||||
"kettlebell": "Kettlebell"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+30
-70
@@ -3,11 +3,7 @@ const cors = require('cors');
|
|||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const logger = require('./utils/logger');
|
|
||||||
const requestLoggerMiddleware = require('./middleware/requestLogger');
|
|
||||||
const { getHealthStatus, getUptime } = require('./utils/health');
|
|
||||||
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
|
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
|
||||||
const { createExerciseRecommendationRouter } = require('./routes/exerciseRecommendations');
|
|
||||||
const { searchExerciseResearch } = require('./services/exaSearch');
|
const { searchExerciseResearch } = require('./services/exaSearch');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -22,13 +18,9 @@ const pool = new Pool({
|
|||||||
database: process.env.DB_NAME || 'gravl'
|
database: process.env.DB_NAME || 'gravl'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware setup
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(requestLoggerMiddleware); // Add request logging middleware
|
|
||||||
|
|
||||||
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
|
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
|
||||||
app.use('/api/exercises', createExerciseRecommendationRouter());
|
|
||||||
|
|
||||||
const authMiddleware = (req, res, next) => {
|
const authMiddleware = (req, res, next) => {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
@@ -39,21 +31,8 @@ const authMiddleware = (req, res, next) => {
|
|||||||
} catch { res.status(401).json({ error: 'Invalid token' }); }
|
} catch { res.status(401).json({ error: 'Invalid token' }); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enhanced health endpoint with uptime and database status
|
app.get('/api/health', (req, res) => {
|
||||||
app.get('/api/health', async (req, res) => {
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
try {
|
|
||||||
const health = await getHealthStatus(pool);
|
|
||||||
const statusCode = health.status === 'healthy' ? 200 : (health.status === 'degraded' ? 200 : 503);
|
|
||||||
res.status(statusCode).json(health);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Health check error', { error: err.message });
|
|
||||||
res.status(503).json({
|
|
||||||
status: 'unhealthy',
|
|
||||||
uptime: getUptime(),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
error: 'Health check failed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/auth/register', async (req, res) => {
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
@@ -66,14 +45,10 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
[email.toLowerCase(), hash]
|
[email.toLowerCase(), hash]
|
||||||
);
|
);
|
||||||
const token = jwt.sign({ id: result.rows[0].id, email: result.rows[0].email }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ id: result.rows[0].id, email: result.rows[0].email }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
logger.info('User registered', { userId: result.rows[0].id, email: result.rows[0].email });
|
|
||||||
res.json({ token, user: result.rows[0] });
|
res.json({ token, user: result.rows[0] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') return res.status(400).json({ error: 'Email already exists' });
|
||||||
logger.warn('Registration failed - email exists', { email: req.body.email });
|
console.error('Register error:', err);
|
||||||
return res.status(400).json({ error: 'Email already exists' });
|
|
||||||
}
|
|
||||||
logger.error('Register error', { error: err.message });
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -82,22 +57,15 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);
|
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email.toLowerCase()]);
|
||||||
if (!result.rows.length) {
|
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
logger.warn('Login failed - user not found', { email });
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
const valid = await bcrypt.compare(password, user.password_hash);
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!valid) {
|
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
logger.warn('Login failed - invalid password', { userId: user.id });
|
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' });
|
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
const { password_hash, ...safeUser } = user;
|
const { password_hash, ...safeUser } = user;
|
||||||
logger.info('User logged in', { userId: user.id, email: user.email });
|
|
||||||
res.json({ token, user: safeUser });
|
res.json({ token, user: safeUser });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Login error', { error: err.message });
|
console.error('Login error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -130,7 +98,7 @@ app.get('/api/user/profile', authMiddleware, async (req, res) => {
|
|||||||
strength: strResult.rows[0] || null
|
strength: strResult.rows[0] || null
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Profile error', { error: err.message, userId: req.user.id });
|
console.error('Profile error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -145,10 +113,9 @@ app.put('/api/user/profile', authMiddleware, async (req, res) => {
|
|||||||
WHERE id=$8 RETURNING id, email, gender, age, height_cm, experience_level, goal, workouts_per_week, onboarding_complete`,
|
WHERE id=$8 RETURNING id, email, gender, age, height_cm, experience_level, goal, workouts_per_week, onboarding_complete`,
|
||||||
[gender, num(age), num(height_cm), experience_level, goal, num(workouts_per_week), onboarding_complete, req.user.id]
|
[gender, num(age), num(height_cm), experience_level, goal, num(workouts_per_week), onboarding_complete, req.user.id]
|
||||||
);
|
);
|
||||||
logger.info('User profile updated', { userId: req.user.id });
|
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Update profile error', { error: err.message, userId: req.user.id });
|
console.error('Update profile error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -164,10 +131,9 @@ app.post('/api/user/measurements', authMiddleware, async (req, res) => {
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
[req.user.id, num(weight), num(neck_cm), num(waist_cm), num(hip_cm), num(body_fat_pct)]
|
[req.user.id, num(weight), num(neck_cm), num(waist_cm), num(hip_cm), num(body_fat_pct)]
|
||||||
);
|
);
|
||||||
logger.info('Measurements added', { userId: req.user.id });
|
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Add measurements error', { error: err.message, userId: req.user.id });
|
console.error('Add measurements error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -181,7 +147,7 @@ app.get('/api/user/measurements', authMiddleware, async (req, res) => {
|
|||||||
);
|
);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Get measurements error', { error: err.message, userId: req.user.id });
|
console.error('Get measurements error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -197,10 +163,9 @@ app.post('/api/user/strength', authMiddleware, async (req, res) => {
|
|||||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
[req.user.id, num(bench_1rm), num(squat_1rm), num(deadlift_1rm)]
|
[req.user.id, num(bench_1rm), num(squat_1rm), num(deadlift_1rm)]
|
||||||
);
|
);
|
||||||
logger.info('Strength record added', { userId: req.user.id });
|
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Add strength error', { error: err.message, userId: req.user.id });
|
console.error('Add strength error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -214,7 +179,7 @@ app.get('/api/user/strength', authMiddleware, async (req, res) => {
|
|||||||
);
|
);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Get strength error', { error: err.message, userId: req.user.id });
|
console.error('Get strength error:', err);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -225,7 +190,7 @@ app.get('/api/programs', async (req, res) => {
|
|||||||
const result = await pool.query('SELECT * FROM programs ORDER BY id');
|
const result = await pool.query('SELECT * FROM programs ORDER BY id');
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching programs', { error: err.message });
|
console.error('Error fetching programs:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -263,7 +228,7 @@ app.get('/api/programs/:id', async (req, res) => {
|
|||||||
days: days.rows
|
days: days.rows
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching program', { error: err.message, programId: req.params.id });
|
console.error('Error fetching program:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -281,7 +246,7 @@ app.get('/api/days/:dayId/exercises', async (req, res) => {
|
|||||||
`, [req.params.dayId]);
|
`, [req.params.dayId]);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching exercises', { error: err.message, dayId: req.params.dayId });
|
console.error('Error fetching exercises:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -309,7 +274,7 @@ app.get('/api/exercises/:id/alternatives', async (req, res) => {
|
|||||||
|
|
||||||
res.json(alternatives.rows);
|
res.json(alternatives.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching alternatives', { error: err.message, exerciseId: req.params.id });
|
console.error('Error fetching alternatives:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -336,7 +301,7 @@ app.get('/api/exercises/:id/last-workout', async (req, res) => {
|
|||||||
`, [req.params.id, user_id || 1]);
|
`, [req.params.id, user_id || 1]);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching last workout for exercise', { error: err.message, exerciseId: req.params.id });
|
console.error('Error fetching last workout for exercise:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -390,7 +355,7 @@ app.get('/api/progression/:programExerciseId', async (req, res) => {
|
|||||||
reason: 'Keep same weight until you hit max reps on all sets'
|
reason: 'Keep same weight until you hit max reps on all sets'
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error calculating progression', { error: err.message, programExerciseId: req.params.programExerciseId });
|
console.error('Error calculating progression:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -427,14 +392,14 @@ app.get('/api/today/:programId', async (req, res) => {
|
|||||||
days: days.rows
|
days: days.rows
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching today workout', { error: err.message, programId: req.params.programId });
|
console.error('Error fetching today workout:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
logger.info(`Gravl API started`, { port: PORT, environment: process.env.NODE_ENV || 'development' });
|
console.log(`Gravl API running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +415,7 @@ app.get('/api/exercises', async (req, res) => {
|
|||||||
);
|
);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching exercises', { error: err.message });
|
console.error('Error fetching exercises:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -497,7 +462,6 @@ app.post('/api/custom-workouts', authMiddleware, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
logger.info('Custom workout created', { userId: user_id, workoutId: customWorkout.id });
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...customWorkout,
|
...customWorkout,
|
||||||
@@ -505,7 +469,7 @@ app.post('/api/custom-workouts', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
logger.error('Error creating custom workout', { error: err.message, userId: req.user.id });
|
console.error('Error creating custom workout:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -527,7 +491,7 @@ app.get('/api/custom-workouts', authMiddleware, async (req, res) => {
|
|||||||
);
|
);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching custom workouts', { error: err.message, userId: req.user.id });
|
console.error('Error fetching custom workouts:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -570,7 +534,7 @@ app.get('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|||||||
exercises: exercisesResult.rows
|
exercises: exercisesResult.rows
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
|
console.error('Error fetching custom workout:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -630,7 +594,6 @@ app.put('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
logger.info('Custom workout updated', { userId: user_id, workoutId: workout_id });
|
|
||||||
|
|
||||||
// Fetch and return updated workout
|
// Fetch and return updated workout
|
||||||
const updatedResult = await pool.query(
|
const updatedResult = await pool.query(
|
||||||
@@ -657,7 +620,7 @@ app.put('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
logger.error('Error updating custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
|
console.error('Error updating custom workout:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -679,10 +642,9 @@ app.delete('/api/custom-workouts/:id', authMiddleware, async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Custom workout not found' });
|
return res.status(404).json({ error: 'Custom workout not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Custom workout deleted', { userId: user_id, workoutId: workout_id });
|
|
||||||
res.json({ deleted: result.rows[0].id });
|
res.json({ deleted: result.rows[0].id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error deleting custom workout', { error: err.message, userId: req.user.id, workoutId: req.params.id });
|
console.error('Error deleting custom workout:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -720,7 +682,7 @@ app.get('/api/logs', async (req, res) => {
|
|||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error fetching logs', { error: err.message });
|
console.error('Error fetching logs:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -769,10 +731,9 @@ app.post('/api/logs', async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Workout set logged', { userId: user_id, exerciseId: exerciseRef, weight, reps });
|
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error logging set', { error: err.message });
|
console.error('Error logging set:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -801,10 +762,9 @@ app.delete('/api/logs', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Log not found' });
|
return res.status(404).json({ error: 'Log not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Workout log deleted', { userId: user_id, date, setNumber: set_number });
|
|
||||||
res.json({ deleted: result.rows[0].id });
|
res.json({ deleted: result.rows[0].id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error deleting log', { error: err.message });
|
console.error('Error deleting log:', err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request Logging Middleware
|
|
||||||
* Logs HTTP method, path, status code, and request duration
|
|
||||||
*/
|
|
||||||
function requestLoggerMiddleware(req, res, next) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const originalSend = res.send;
|
|
||||||
|
|
||||||
// Override send method to capture response
|
|
||||||
res.send = function (data) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
const statusCode = res.statusCode;
|
|
||||||
|
|
||||||
// Log request details
|
|
||||||
logger.info('HTTP Request', {
|
|
||||||
method: req.method,
|
|
||||||
path: req.path,
|
|
||||||
statusCode: statusCode,
|
|
||||||
duration: `${duration}ms`,
|
|
||||||
ip: req.ip,
|
|
||||||
userAgent: req.get('user-agent')
|
|
||||||
});
|
|
||||||
|
|
||||||
// Call original send method
|
|
||||||
return originalSend.call(this, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = requestLoggerMiddleware;
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
|
|
||||||
const exercisesData = require('../data/exercises.json');
|
|
||||||
|
|
||||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'deepseek-v3.2:cloud';
|
|
||||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY;
|
|
||||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
||||||
const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
|
||||||
|
|
||||||
const VALID_FITNESS_LEVELS = ['beginner', 'intermediate', 'advanced'];
|
|
||||||
const VALID_GOALS = ['strength', 'hypertrophy', 'fat_loss', 'endurance', 'mobility', 'general_fitness'];
|
|
||||||
|
|
||||||
const difficultyRank = {
|
|
||||||
beginner: 1,
|
|
||||||
intermediate: 2,
|
|
||||||
advanced: 3
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeGoals = (goals) => {
|
|
||||||
if (!goals) return [];
|
|
||||||
if (Array.isArray(goals)) {
|
|
||||||
return goals.map((goal) => String(goal).trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (typeof goals === 'string') {
|
|
||||||
return goals.split(',').map((goal) => goal.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeList = (value) => {
|
|
||||||
if (!value) return [];
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePayload = (payload) => {
|
|
||||||
const errors = [];
|
|
||||||
const fitnessLevel = payload?.fitness_level;
|
|
||||||
const goals = normalizeGoals(payload?.goals);
|
|
||||||
const availableTime = Number(payload?.available_time);
|
|
||||||
|
|
||||||
if (!fitnessLevel || typeof fitnessLevel !== 'string' || !VALID_FITNESS_LEVELS.includes(fitnessLevel)) {
|
|
||||||
errors.push('fitness_level is required and must be beginner, intermediate, or advanced');
|
|
||||||
}
|
|
||||||
if (!goals.length) {
|
|
||||||
errors.push('goals is required and must be a non-empty array or comma-separated string');
|
|
||||||
} else {
|
|
||||||
const invalidGoals = goals.filter((goal) => !VALID_GOALS.includes(goal));
|
|
||||||
if (invalidGoals.length) {
|
|
||||||
errors.push(`goals contains invalid values: ${invalidGoals.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(availableTime) || availableTime <= 0) {
|
|
||||||
errors.push('available_time is required and must be a positive number (minutes)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { errors, goals, availableTime };
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPrompt = ({ fitnessLevel, goals, availableTime, equipment, focusMuscles, limit, exercises }) => {
|
|
||||||
const coachPersona = `Du är Coach, en erfaren styrke- och konditionscoach (15+ års erfarenhet).\n` +
|
|
||||||
`- Direkt och tydlig, inga fluff.\n- Anpassar språk efter nivå.\n- Prioritera säkerhet.\n- Ge alltid alternativ.\n` +
|
|
||||||
`Svara på svenska.`;
|
|
||||||
|
|
||||||
const requestContext = {
|
|
||||||
fitness_level: fitnessLevel,
|
|
||||||
goals,
|
|
||||||
available_time_minutes: availableTime,
|
|
||||||
equipment,
|
|
||||||
focus_muscles: focusMuscles,
|
|
||||||
limit
|
|
||||||
};
|
|
||||||
|
|
||||||
const exerciseCatalog = exercises.map((exercise) => ({
|
|
||||||
id: exercise.id,
|
|
||||||
name: exercise.name,
|
|
||||||
name_en: exercise.name_en,
|
|
||||||
category: exercise.category,
|
|
||||||
primary_muscles: exercise.primary_muscles,
|
|
||||||
secondary_muscles: exercise.secondary_muscles,
|
|
||||||
equipment: exercise.equipment,
|
|
||||||
difficulty: exercise.difficulty,
|
|
||||||
alternatives: exercise.alternatives
|
|
||||||
}));
|
|
||||||
|
|
||||||
return `${coachPersona}\n\n` +
|
|
||||||
`Uppgift: Rekommendera övningar för användaren baserat på kontexten nedan.\n` +
|
|
||||||
`- Välj endast från katalogen.\n- Anpassa set/reps/rest till mål och nivå.\n- Motivera kort varför varje övning passar.\n- Svara med exakt JSON enligt schema.\n\n` +
|
|
||||||
`KONTEKST:\n${JSON.stringify(requestContext)}\n\n` +
|
|
||||||
`KATALOG:\n${JSON.stringify(exerciseCatalog)}\n\n` +
|
|
||||||
`SCHEMA:\n` +
|
|
||||||
`{"recommendations":[{"id":"","sets":0,"reps":"","rest_seconds":0,"reason":"","alternatives":[]}],"notes":""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractJsonPayload = (text) => {
|
|
||||||
if (!text || typeof text !== 'string') {
|
|
||||||
throw new Error('No response text to parse');
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = text.indexOf('{');
|
|
||||||
const end = text.lastIndexOf('}');
|
|
||||||
if (start === -1 || end === -1 || end <= start) {
|
|
||||||
throw new Error('No JSON object found in response');
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonString = text.slice(start, end + 1);
|
|
||||||
return JSON.parse(jsonString);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseRecommendations = (payload, exerciseMap) => {
|
|
||||||
if (!payload || !Array.isArray(payload.recommendations)) {
|
|
||||||
throw new Error('Invalid recommendations payload');
|
|
||||||
}
|
|
||||||
|
|
||||||
const recommendations = payload.recommendations
|
|
||||||
.map((rec) => {
|
|
||||||
const exercise = exerciseMap.get(rec.id);
|
|
||||||
if (!exercise) return null;
|
|
||||||
return {
|
|
||||||
id: exercise.id,
|
|
||||||
name: exercise.name,
|
|
||||||
name_en: exercise.name_en,
|
|
||||||
sets: Number(rec.sets) || 3,
|
|
||||||
reps: rec.reps || '8-12',
|
|
||||||
rest_seconds: Number(rec.rest_seconds) || 90,
|
|
||||||
reason: rec.reason || 'Bra match för ditt mål och din nivå.',
|
|
||||||
alternatives: Array.isArray(rec.alternatives) && rec.alternatives.length
|
|
||||||
? rec.alternatives
|
|
||||||
: exercise.alternatives || []
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (!recommendations.length) {
|
|
||||||
throw new Error('No valid recommendations after parsing');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
recommendations,
|
|
||||||
notes: payload.notes || ''
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildHeuristicRecommendations = ({ fitnessLevel, goals, availableTime, equipment, focusMuscles, limit }) => {
|
|
||||||
const maxDifficulty = difficultyRank[fitnessLevel] || 2;
|
|
||||||
const equipmentSet = new Set((equipment || []).map((item) => item.toLowerCase()));
|
|
||||||
const focusSet = new Set((focusMuscles || []).map((item) => item.toLowerCase()));
|
|
||||||
|
|
||||||
const goalWeights = {
|
|
||||||
strength: { compound: 3, isolation: 1 },
|
|
||||||
hypertrophy: { compound: 2, isolation: 2 },
|
|
||||||
fat_loss: { compound: 2, isolation: 1 },
|
|
||||||
endurance: { compound: 1, isolation: 2 },
|
|
||||||
mobility: { compound: 1, isolation: 2 },
|
|
||||||
general_fitness: { compound: 2, isolation: 1 }
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredExercises = exercisesData.exercises.filter((exercise) => {
|
|
||||||
const diffOk = (difficultyRank[exercise.difficulty] || 2) <= maxDifficulty;
|
|
||||||
if (!diffOk) return false;
|
|
||||||
|
|
||||||
if (equipmentSet.size === 0) return true;
|
|
||||||
|
|
||||||
if (!exercise.equipment || exercise.equipment.length === 0) return true;
|
|
||||||
return exercise.equipment.some((item) => equipmentSet.has(item.toLowerCase()));
|
|
||||||
});
|
|
||||||
|
|
||||||
const exercises = filteredExercises.length ? filteredExercises : exercisesData.exercises;
|
|
||||||
|
|
||||||
const scored = exercises.map((exercise) => {
|
|
||||||
let score = 0;
|
|
||||||
goals.forEach((goal) => {
|
|
||||||
const weights = goalWeights[goal] || goalWeights.general_fitness;
|
|
||||||
score += weights[exercise.category] || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (focusSet.size) {
|
|
||||||
if (exercise.primary_muscles?.some((muscle) => focusSet.has(muscle.toLowerCase()))) {
|
|
||||||
score += 3;
|
|
||||||
} else if (exercise.secondary_muscles?.some((muscle) => focusSet.has(muscle.toLowerCase()))) {
|
|
||||||
score += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exercise.equipment || exercise.equipment.length === 0) {
|
|
||||||
score += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { exercise, score };
|
|
||||||
});
|
|
||||||
|
|
||||||
scored.sort((a, b) => b.score - a.score);
|
|
||||||
|
|
||||||
const timeBasedLimit = availableTime <= 20
|
|
||||||
? 3
|
|
||||||
: availableTime <= 35
|
|
||||||
? 4
|
|
||||||
: availableTime <= 50
|
|
||||||
? 6
|
|
||||||
: 8;
|
|
||||||
|
|
||||||
const finalLimit = Math.min(limit || timeBasedLimit, 10);
|
|
||||||
const selected = scored.slice(0, finalLimit);
|
|
||||||
|
|
||||||
return selected.map(({ exercise }) => ({
|
|
||||||
id: exercise.id,
|
|
||||||
name: exercise.name,
|
|
||||||
name_en: exercise.name_en,
|
|
||||||
sets: exercise.category === 'compound' ? 4 : 3,
|
|
||||||
reps: goals.includes('strength') ? '4-6' : '8-12',
|
|
||||||
rest_seconds: exercise.category === 'compound' ? 120 : 60,
|
|
||||||
reason: `Passar ${goals.join(', ')} med fokus på ${exercise.primary_muscles.join(', ')}.`,
|
|
||||||
alternatives: exercise.alternatives || []
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractProviderText = (provider, data) => {
|
|
||||||
if (provider === 'ollama') {
|
|
||||||
return data?.response || '';
|
|
||||||
}
|
|
||||||
if (provider === 'gemini') {
|
|
||||||
return data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
||||||
}
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
return data?.choices?.[0]?.message?.content || '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateRecommendationsWithFallback = async ({ prompt }) => {
|
|
||||||
if (typeof fetch !== 'function') {
|
|
||||||
throw new Error('Fetch API not available in this runtime');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 1: Ollama
|
|
||||||
try {
|
|
||||||
console.log(`📍 [Recommend] Tier 1: Ollama (${OLLAMA_MODEL})`);
|
|
||||||
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
prompt,
|
|
||||||
stream: false,
|
|
||||||
temperature: 0.6
|
|
||||||
}),
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ [Recommend] Ollama success');
|
|
||||||
return { provider: 'ollama', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`⚠️ [Recommend] Ollama error: ${response.status}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ [Recommend] Ollama failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 2: Gemini
|
|
||||||
if (GEMINI_API_KEY) {
|
|
||||||
try {
|
|
||||||
console.log('📍 [Recommend] Tier 2: Gemini');
|
|
||||||
const response = await fetch(
|
|
||||||
`https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key=${GEMINI_API_KEY}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{ parts: [{ text: prompt }] }],
|
|
||||||
generationConfig: { temperature: 0.6 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ [Recommend] Gemini success');
|
|
||||||
return { provider: 'gemini', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 429 || response.status === 403) {
|
|
||||||
console.warn('⚠️ [Recommend] Gemini quota exceeded');
|
|
||||||
} else {
|
|
||||||
console.warn(`⚠️ [Recommend] Gemini error: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ [Recommend] Gemini failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 3: OpenRouter
|
|
||||||
if (OPENROUTER_API_KEY) {
|
|
||||||
try {
|
|
||||||
console.log('📍 [Recommend] Tier 3: OpenRouter');
|
|
||||||
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': 'https://gravl.app'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'openai/gpt-4',
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
temperature: 0.6,
|
|
||||||
max_tokens: 1200
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ [Recommend] OpenRouter success');
|
|
||||||
return { provider: 'openrouter', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`⚠️ [Recommend] OpenRouter error: ${response.status}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ [Recommend] OpenRouter failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('All recommendation providers failed (Ollama → Gemini → OpenRouter)');
|
|
||||||
};
|
|
||||||
|
|
||||||
const createExerciseRecommendationRouter = () => {
|
|
||||||
const router = express.Router();
|
|
||||||
const exerciseMap = new Map(exercisesData.exercises.map((exercise) => [exercise.id, exercise]));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/exercises/recommend
|
|
||||||
* Request body:
|
|
||||||
* {
|
|
||||||
* "fitness_level": "beginner" | "intermediate" | "advanced",
|
|
||||||
* "goals": ["strength" | "hypertrophy" | "fat_loss" | "endurance" | "mobility" | "general_fitness"],
|
|
||||||
* "available_time": 30,
|
|
||||||
* "equipment": ["barbell", "dumbbells"],
|
|
||||||
* "focus_muscles": ["chest", "back"],
|
|
||||||
* "limit": 6
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.post('/recommend', async (req, res) => {
|
|
||||||
const { errors, goals, availableTime } = validatePayload(req.body);
|
|
||||||
if (errors.length) {
|
|
||||||
return res.status(400).json({ error: 'Validation failed', details: errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fitnessLevel = req.body.fitness_level;
|
|
||||||
const equipment = normalizeList(req.body.equipment);
|
|
||||||
const focusMuscles = normalizeList(req.body.focus_muscles);
|
|
||||||
const limit = Number.isFinite(Number(req.body.limit)) ? Math.min(Number(req.body.limit), 10) : null;
|
|
||||||
|
|
||||||
const prompt = buildPrompt({
|
|
||||||
fitnessLevel,
|
|
||||||
goals,
|
|
||||||
availableTime,
|
|
||||||
equipment,
|
|
||||||
focusMuscles,
|
|
||||||
limit,
|
|
||||||
exercises: exercisesData.exercises
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { provider, data } = await generateRecommendationsWithFallback({ prompt });
|
|
||||||
const text = extractProviderText(provider, data);
|
|
||||||
const parsedPayload = extractJsonPayload(text);
|
|
||||||
const aiRecommendations = parseRecommendations(parsedPayload, exerciseMap);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
recommendations: aiRecommendations.recommendations,
|
|
||||||
notes: aiRecommendations.notes,
|
|
||||||
provider,
|
|
||||||
status: 'success'
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ [Recommend] Falling back to heuristic recommendations: ${err.message}`);
|
|
||||||
const fallbackRecommendations = buildHeuristicRecommendations({
|
|
||||||
fitnessLevel,
|
|
||||||
goals,
|
|
||||||
availableTime,
|
|
||||||
equipment,
|
|
||||||
focusMuscles,
|
|
||||||
limit
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
recommendations: fallbackRecommendations,
|
|
||||||
notes: 'Fallback recommendations generated without AI provider.',
|
|
||||||
provider: 'fallback',
|
|
||||||
status: 'degraded'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createExerciseRecommendationRouter
|
|
||||||
};
|
|
||||||
@@ -45,8 +45,7 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
|||||||
? Math.min(requestedResults, 10)
|
? Math.min(requestedResults, 10)
|
||||||
: 5;
|
: 5;
|
||||||
|
|
||||||
// Fetch research with fallback support
|
const { summary, results } = await exaSearch({ query, numResults });
|
||||||
const { summary, results, provider, status } = await exaSearch({ query, numResults });
|
|
||||||
|
|
||||||
let researchRecord = null;
|
let researchRecord = null;
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +53,7 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
|||||||
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
|
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, created_at`,
|
RETURNING id, created_at`,
|
||||||
[exerciseId, query, summary, JSON.stringify(results), provider || 'exa']
|
[exerciseId, query, summary, JSON.stringify(results), 'exa']
|
||||||
);
|
);
|
||||||
researchRecord = insertResult.rows[0] || null;
|
researchRecord = insertResult.rows[0] || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -66,16 +65,11 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
|||||||
query,
|
query,
|
||||||
summary,
|
summary,
|
||||||
results,
|
results,
|
||||||
stored: researchRecord,
|
stored: researchRecord
|
||||||
provider: provider || 'exa',
|
|
||||||
status: status || 'success'
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error running exercise research:', err);
|
console.error('Error running exercise research:', err);
|
||||||
res.status(500).json({
|
res.status(500).json({ error: 'Failed to fetch research' });
|
||||||
error: 'Failed to fetch research',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,115 +20,56 @@ const buildSummary = (results) => {
|
|||||||
return snippets.slice(0, 3).join(' ');
|
return snippets.slice(0, 3).join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Create synthetic results for fallback scenarios
|
|
||||||
* Generates plausible web search results when primary API is unavailable
|
|
||||||
*/
|
|
||||||
const createFallbackResults = (query, numResults = 5) => {
|
|
||||||
const sources = [
|
|
||||||
{ domain: 'wikipedia.org', title: `${query} - Wikipedia` },
|
|
||||||
{ domain: 'youtube.com', title: `${query} Tutorial | How to Perform Correctly` },
|
|
||||||
{ domain: 'fitnessforum.com', title: `Best Practices for ${query} Form and Technique` },
|
|
||||||
{ domain: 'acefitness.org', title: `Exercise Guide: ${query}` },
|
|
||||||
{ domain: 'stronglifts.com', title: `${query} Guide: Everything You Need to Know` },
|
|
||||||
{ domain: 'bodybuilding.com', title: `${query} Exercise - Benefits and Variations` },
|
|
||||||
{ domain: 'nhs.uk', title: `${query}: Health Benefits and Safety` },
|
|
||||||
{ domain: 'healthline.com', title: `${query}: Technique, Benefits & Common Mistakes` }
|
|
||||||
];
|
|
||||||
|
|
||||||
return sources.slice(0, numResults).map((source, index) => ({
|
|
||||||
id: `fallback-${index}`,
|
|
||||||
title: source.title,
|
|
||||||
url: `https://${source.domain}/search?q=${encodeURIComponent(query)}`,
|
|
||||||
snippet: `Learn about proper ${query} technique, benefits, and safety precautions.`,
|
|
||||||
publishedDate: new Date().toISOString(),
|
|
||||||
score: 0.8 - (index * 0.05),
|
|
||||||
isFallback: true,
|
|
||||||
provider: 'fallback'
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main research search function with Exa API + fallback support
|
|
||||||
* Tier 1: Exa API (primary)
|
|
||||||
* Tier 2: Fallback to synthetic results with suggested sources
|
|
||||||
*/
|
|
||||||
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
throw new Error('Query must be a non-empty string');
|
throw new Error('Query must be a non-empty string');
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.EXA_API_KEY;
|
const apiKey = process.env.EXA_API_KEY;
|
||||||
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
|
if (!apiKey) {
|
||||||
|
throw new Error('EXA_API_KEY is not configured');
|
||||||
// Tier 1: Try Exa API (primary)
|
|
||||||
if (apiKey) {
|
|
||||||
try {
|
|
||||||
console.log(`📍 [Research] Attempting Exa API for: "${query}"`);
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'x-api-key': apiKey
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query,
|
|
||||||
numResults,
|
|
||||||
type: 'neural',
|
|
||||||
useAutoprompt: true
|
|
||||||
}),
|
|
||||||
timeout: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
console.warn(`⚠️ [Research] Exa API error: ${response.status}`);
|
|
||||||
throw new Error(`Exa search failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const results = (data.results || []).map((result) => ({
|
|
||||||
id: result.id,
|
|
||||||
title: result.title,
|
|
||||||
url: result.url,
|
|
||||||
snippet: Array.isArray(result.highlights) && result.highlights.length > 0
|
|
||||||
? result.highlights[0]
|
|
||||||
: result.snippet,
|
|
||||||
highlight: result.highlight,
|
|
||||||
publishedDate: result.publishedDate,
|
|
||||||
score: result.score,
|
|
||||||
provider: 'exa'
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`✅ [Research] Exa API success - ${results.length} results`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary: buildSummary(results),
|
|
||||||
results,
|
|
||||||
provider: 'exa',
|
|
||||||
status: 'success'
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ [Research] Exa API failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ [Research] EXA_API_KEY not configured, using fallback');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 2: Fallback to synthetic results with suggested sources
|
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
|
||||||
console.log(`📍 [Research] Using fallback results for: "${query}"`);
|
|
||||||
const fallbackResults = createFallbackResults(query, numResults);
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
numResults,
|
||||||
|
type: 'neural',
|
||||||
|
useAutoprompt: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Exa search failed: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const results = (data.results || []).map((result) => ({
|
||||||
|
id: result.id,
|
||||||
|
title: result.title,
|
||||||
|
url: result.url,
|
||||||
|
snippet: Array.isArray(result.highlights) && result.highlights.length > 0
|
||||||
|
? result.highlights[0]
|
||||||
|
: result.snippet,
|
||||||
|
highlight: result.highlight,
|
||||||
|
publishedDate: result.publishedDate,
|
||||||
|
score: result.score
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: `Research sources for "${query}". Click links below to learn more about this exercise.`,
|
summary: buildSummary(results),
|
||||||
results: fallbackResults,
|
results
|
||||||
provider: 'fallback',
|
|
||||||
status: 'degraded'
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
searchExerciseResearch,
|
searchExerciseResearch
|
||||||
createFallbackResults
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI API Fallback System
|
|
||||||
* Tries: Ollama (local) → Gemini → OpenRouter → OpenCode
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
|
|
||||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'deepseek-v3.2:cloud';
|
|
||||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY;
|
|
||||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
||||||
const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
|
||||||
const OPENCODE_API_KEY = process.env.OPENCODE_API_KEY;
|
|
||||||
const OPENCODE_BASE_URL = process.env.OPENCODE_BASE_URL || 'https://api.opencode.com/v1';
|
|
||||||
|
|
||||||
async function generateWithFallback(prompt, options = {}) {
|
|
||||||
console.log('🤖 Generating content...');
|
|
||||||
|
|
||||||
// Tier 1: Try Ollama (local, free)
|
|
||||||
try {
|
|
||||||
console.log(`📍 Tier 1: Attempting Ollama (${OLLAMA_MODEL})...`);
|
|
||||||
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
timeout: 30000,
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
prompt: prompt,
|
|
||||||
stream: false,
|
|
||||||
temperature: options.temperature || 0.7
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ Ollama success');
|
|
||||||
return { success: true, provider: 'ollama', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`⚠️ Ollama error: ${response.status}, trying next...`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Ollama failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 2: Try Gemini
|
|
||||||
if (GEMINI_API_KEY) {
|
|
||||||
try {
|
|
||||||
console.log('📍 Tier 2: Attempting Gemini API...');
|
|
||||||
const response = await fetch(
|
|
||||||
`https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key=${GEMINI_API_KEY}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{ parts: [{ text: prompt }] }],
|
|
||||||
generationConfig: options.config || {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ Gemini API success');
|
|
||||||
return { success: true, provider: 'gemini', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 429 || response.status === 403) {
|
|
||||||
console.warn('⚠️ Gemini quota exceeded, trying next...');
|
|
||||||
} else {
|
|
||||||
throw new Error(`Gemini error: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Gemini failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 3: Fallback to OpenRouter
|
|
||||||
if (OPENROUTER_API_KEY) {
|
|
||||||
try {
|
|
||||||
console.log('📍 Tier 3: Attempting OpenRouter API...');
|
|
||||||
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': 'https://gravl.app'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: options.model || 'openai/gpt-4',
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
temperature: options.temperature || 0.7,
|
|
||||||
max_tokens: options.maxTokens || 2048
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ OpenRouter API success');
|
|
||||||
return { success: true, provider: 'openrouter', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`OpenRouter error: ${response.status}, trying next...`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`OpenRouter failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 4: Final fallback to OpenCode
|
|
||||||
if (OPENCODE_API_KEY) {
|
|
||||||
try {
|
|
||||||
console.log('📍 Tier 4: Attempting OpenCode API...');
|
|
||||||
const response = await fetch(`${OPENCODE_BASE_URL}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${OPENCODE_API_KEY}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: options.model || 'gpt-4',
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
temperature: options.temperature || 0.7,
|
|
||||||
max_tokens: options.maxTokens || 2048
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ OpenCode API success');
|
|
||||||
return { success: true, provider: 'opencode', data };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`OpenCode error: ${response.status}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`OpenCode failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('All generation APIs failed (Ollama → Gemini → OpenRouter → OpenCode)');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
generateWithFallback,
|
|
||||||
getAvailableProviders: () => ({
|
|
||||||
ollama: true, // Always available locally
|
|
||||||
gemini: !!GEMINI_API_KEY,
|
|
||||||
openrouter: !!OPENROUTER_API_KEY,
|
|
||||||
opencode: !!OPENCODE_API_KEY
|
|
||||||
})
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
const { Pool } = require('pg');
|
|
||||||
const logger = require('./logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health Monitoring Module
|
|
||||||
* Tracks application health metrics including uptime and database connectivity
|
|
||||||
*/
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get application health status
|
|
||||||
* @returns {Object} Health status object with status, uptime, and timestamp
|
|
||||||
*/
|
|
||||||
async function getHealthStatus(pool) {
|
|
||||||
try {
|
|
||||||
// Check database connectivity
|
|
||||||
const dbHealthStart = Date.now();
|
|
||||||
const dbResult = await pool.query('SELECT NOW()');
|
|
||||||
const dbHealthDuration = Date.now() - dbHealthStart;
|
|
||||||
|
|
||||||
const dbHealthy = dbResult.rows.length > 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: dbHealthy ? 'healthy' : 'degraded',
|
|
||||||
uptime: Math.floor((Date.now() - startTime) / 1000), // uptime in seconds
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
database: {
|
|
||||||
connected: dbHealthy,
|
|
||||||
responseTime: `${dbHealthDuration}ms`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Health check failed', { error: err.message });
|
|
||||||
return {
|
|
||||||
status: 'unhealthy',
|
|
||||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
database: {
|
|
||||||
connected: false,
|
|
||||||
error: err.message
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get uptime in seconds since application start
|
|
||||||
* @returns {number} Uptime in seconds
|
|
||||||
*/
|
|
||||||
function getUptime() {
|
|
||||||
return Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getHealthStatus,
|
|
||||||
getUptime
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
const winston = require('winston');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Winston Logger Configuration
|
|
||||||
* Structured logging for Gravl backend with console and file outputs
|
|
||||||
*/
|
|
||||||
|
|
||||||
const logDir = path.join(__dirname, '../../logs');
|
|
||||||
const env = process.env.NODE_ENV || 'development';
|
|
||||||
const isDev = env === 'development';
|
|
||||||
|
|
||||||
// Custom format for readable console output
|
|
||||||
const consoleFormat = winston.format.combine(
|
|
||||||
winston.format.colorize({ all: true }),
|
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
||||||
winston.format.printf(info => {
|
|
||||||
const { timestamp, level, message, ...meta } = info;
|
|
||||||
const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
|
|
||||||
return `${timestamp} [${level}] ${message} ${metaStr}`;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// JSON format for file logging
|
|
||||||
const fileFormat = winston.format.combine(
|
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
|
|
||||||
winston.format.json()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Logger configuration
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: isDev ? 'debug' : 'info',
|
|
||||||
format: fileFormat,
|
|
||||||
defaultMeta: { service: 'gravl-backend' },
|
|
||||||
transports: [
|
|
||||||
// Console transport with readable format
|
|
||||||
new winston.transports.Console({
|
|
||||||
format: consoleFormat
|
|
||||||
}),
|
|
||||||
// All logs to combined file
|
|
||||||
new winston.transports.File({
|
|
||||||
filename: path.join(logDir, 'combined.log'),
|
|
||||||
maxsize: 5242880, // 5MB
|
|
||||||
maxFiles: 5
|
|
||||||
}),
|
|
||||||
// Error logs only
|
|
||||||
new winston.transports.File({
|
|
||||||
filename: path.join(logDir, 'error.log'),
|
|
||||||
level: 'error',
|
|
||||||
maxsize: 5242880, // 5MB
|
|
||||||
maxFiles: 5
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle uncaught exceptions
|
|
||||||
process.on('uncaughtException', (err) => {
|
|
||||||
logger.error('Uncaught Exception', { error: err.message, stack: err.stack });
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle unhandled promise rejections
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
logger.error('Unhandled Rejection at:', { promise, reason });
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = logger;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert');
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
|
|
||||||
// Mock logger
|
|
||||||
const mockLogger = {
|
|
||||||
info: () => {},
|
|
||||||
error: () => {},
|
|
||||||
warn: () => {},
|
|
||||||
debug: () => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
test('Health endpoint returns status and uptime', async () => {
|
|
||||||
const mockPool = {
|
|
||||||
query: async () => ({ rows: [{ now: new Date() }] })
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getHealthStatus, getUptime } = require('../src/utils/health');
|
|
||||||
|
|
||||||
// Test getUptime function
|
|
||||||
const uptime = getUptime();
|
|
||||||
assert(typeof uptime === 'number', 'Uptime should be a number');
|
|
||||||
assert(uptime >= 0, 'Uptime should be non-negative');
|
|
||||||
|
|
||||||
// Test getHealthStatus function with mock pool
|
|
||||||
const health = await getHealthStatus(mockPool);
|
|
||||||
assert(health.status, 'Health should have status');
|
|
||||||
assert(['healthy', 'degraded', 'unhealthy'].includes(health.status), 'Status should be valid');
|
|
||||||
assert(typeof health.uptime === 'number', 'Uptime should be a number');
|
|
||||||
assert(health.timestamp, 'Health should have timestamp');
|
|
||||||
assert(health.database, 'Health should have database info');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Health endpoint handles database errors gracefully', async () => {
|
|
||||||
const mockPoolError = {
|
|
||||||
query: async () => {
|
|
||||||
throw new Error('Database connection failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { getHealthStatus } = require('../src/utils/health');
|
|
||||||
|
|
||||||
const health = await getHealthStatus(mockPoolError);
|
|
||||||
assert.equal(health.status, 'unhealthy', 'Status should be unhealthy on DB error');
|
|
||||||
assert.equal(health.database.connected, false, 'Database should show disconnected');
|
|
||||||
assert(health.database.error, 'Should include error message');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Request logging middleware logs HTTP requests', () => {
|
|
||||||
const { default: requestLogger } = require('../src/middleware/requestLogger');
|
|
||||||
|
|
||||||
// Mock request and response objects
|
|
||||||
const mockReq = {
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/health',
|
|
||||||
ip: '127.0.0.1',
|
|
||||||
get: () => 'test-agent'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRes = {
|
|
||||||
statusCode: 200,
|
|
||||||
send: function(data) { return data; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockNext = () => {};
|
|
||||||
|
|
||||||
// The middleware should not throw
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
requestLogger(mockReq, mockRes, mockNext);
|
|
||||||
}, 'Middleware should not throw on valid request');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✓ Health monitoring and logging tests passed');
|
|
||||||
@@ -4,9 +4,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
|
||||||
BUILD_DATE: ${BUILD_DATE:-unknown}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
@@ -19,18 +16,12 @@ services:
|
|||||||
- homelab
|
- homelab
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- "3001"
|
||||||
labels:
|
|
||||||
- "org.opencontainers.image.revision=${GIT_COMMIT:-unknown}"
|
|
||||||
- "org.opencontainers.image.created=${BUILD_DATE:-unknown}"
|
|
||||||
|
|
||||||
gravl-frontend:
|
gravl-frontend:
|
||||||
container_name: gravl-frontend
|
container_name: gravl-frontend
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
GIT_COMMIT: ${GIT_COMMIT:-unknown}
|
|
||||||
BUILD_DATE: ${BUILD_DATE:-unknown}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- gravl-backend
|
- gravl-backend
|
||||||
@@ -46,8 +37,6 @@ services:
|
|||||||
- "traefik.http.routers.gravl-secure.tls=true"
|
- "traefik.http.routers.gravl-secure.tls=true"
|
||||||
- "traefik.http.routers.gravl-secure.service=gravl"
|
- "traefik.http.routers.gravl-secure.service=gravl"
|
||||||
- "traefik.http.services.gravl.loadbalancer.server.port=80"
|
- "traefik.http.services.gravl.loadbalancer.server.port=80"
|
||||||
- "org.opencontainers.image.revision=${GIT_COMMIT:-unknown}"
|
|
||||||
- "org.opencontainers.image.created=${BUILD_DATE:-unknown}"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
# Gravl Deployment Guide
|
|
||||||
|
|
||||||
This guide covers how to deploy Gravl's backend and frontend services using automated scripts, verify deployment status, and handle troubleshooting and recovery scenarios.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Gravl uses Docker and Docker Compose for containerization. Two automated scripts manage the deployment lifecycle:
|
|
||||||
|
|
||||||
- **`scripts/deploy.sh`**: Pulls latest code, builds fresh images (with `--no-cache` to prevent stale assets), and starts containers with health checks
|
|
||||||
- **`scripts/build-check.sh`**: Verifies that running containers match the current git HEAD (detects stale deployments)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Before deploying, ensure you have:
|
|
||||||
|
|
||||||
1. **Docker & Docker Compose** installed and running
|
|
||||||
```bash
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Git** configured with push/pull access to the repository
|
|
||||||
```bash
|
|
||||||
git remote -v
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Network access** to required ports:
|
|
||||||
- Backend: `localhost:3001` (health check at `http://localhost:3001/api/health`)
|
|
||||||
- Frontend: `localhost:3000` (or configured in `docker-compose.yml`)
|
|
||||||
|
|
||||||
4. **Sufficient disk space** for Docker images and volumes
|
|
||||||
```bash
|
|
||||||
docker system df
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **No conflicting services** using ports 3000-3001
|
|
||||||
```bash
|
|
||||||
lsof -i :3000 -i :3001 # (macOS/Linux only)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Run `deploy.sh`
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /workspace/gravl
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### What It Does
|
|
||||||
|
|
||||||
1. **Git Pull**: Fetches and merges latest code from remote
|
|
||||||
- Exits if merge conflicts occur (manual resolution required)
|
|
||||||
|
|
||||||
2. **Captures Metadata**:
|
|
||||||
- Current git commit hash
|
|
||||||
- Build timestamp
|
|
||||||
- These are stored as Docker image labels for later verification
|
|
||||||
|
|
||||||
3. **Builds Docker Images** (`--no-cache`):
|
|
||||||
- Rebuilds all layers (no caching) to prevent stale assets
|
|
||||||
- Applies git commit and build timestamp as labels
|
|
||||||
|
|
||||||
4. **Starts Containers**:
|
|
||||||
- Uses `docker compose up -d --force-recreate` to ensure clean start
|
|
||||||
- Both backend and frontend containers are started
|
|
||||||
|
|
||||||
5. **Health Check**:
|
|
||||||
- Waits up to 60 seconds for backend to respond on `/api/health`
|
|
||||||
- Retries every 5 seconds (12 attempts max)
|
|
||||||
- Fails with exit code 1 if health check times out
|
|
||||||
|
|
||||||
### Exit Codes
|
|
||||||
|
|
||||||
| Code | Meaning | Next Steps |
|
|
||||||
|------|---------|-----------|
|
|
||||||
| 0 | Success | Deployment complete; containers healthy |
|
|
||||||
| 1 | Failure | See troubleshooting below |
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
All deploy activity is logged to `logs/deploy.log`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tail -50 logs/deploy.log # Last 50 lines
|
|
||||||
grep ERROR logs/deploy.log # Find errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Optional env vars can be set before running `deploy.sh`:
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `GIT_COMMIT` | auto-detected | Override git commit label (not recommended) |
|
|
||||||
| `BUILD_DATE` | auto-detected | Override build timestamp (not recommended) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Check Build Status (`build-check.sh`)
|
|
||||||
|
|
||||||
Run this command anytime to verify deployed containers match your local code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/build-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Output Example
|
|
||||||
|
|
||||||
**Healthy deployment:**
|
|
||||||
```
|
|
||||||
Local HEAD: abc1234 (abc1234567890abcdef1234567890abcdef123456)
|
|
||||||
|
|
||||||
[gravl-backend] Built: abc1234 on 2026-03-03T18:21:00Z
|
|
||||||
[gravl-backend] OK: up to date
|
|
||||||
[gravl-frontend] Built: abc1234 on 2026-03-03T18:21:00Z
|
|
||||||
[gravl-frontend] OK: up to date
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stale containers (code updated, not redeployed):**
|
|
||||||
```
|
|
||||||
Local HEAD: xyz5678 (xyz5678...)
|
|
||||||
|
|
||||||
[gravl-backend] Built: abc1234 on 2026-03-03T18:21:00Z
|
|
||||||
[gravl-backend] STALE: container is behind local code — run scripts/deploy.sh
|
|
||||||
[gravl-frontend] Built: abc1234 on 2026-03-03T18:21:00Z
|
|
||||||
[gravl-frontend] STALE: container is behind local code — run scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Missing labels (container built manually, not via deploy.sh):**
|
|
||||||
```
|
|
||||||
Local HEAD: abc1234
|
|
||||||
|
|
||||||
[gravl-backend] WARNING: no build label found — redeploy with scripts/deploy.sh to add tracking
|
|
||||||
[gravl-frontend] Not running
|
|
||||||
```
|
|
||||||
|
|
||||||
### Exit Codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| 0 | All checks completed (warnings don't fail; see output for status) |
|
|
||||||
| (no error exit) | Missing containers are noted but don't cause failure |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Health Check Failures
|
|
||||||
|
|
||||||
**Symptom:** `ERROR: Health check failed after 60s`
|
|
||||||
|
|
||||||
**Causes & Solutions:**
|
|
||||||
|
|
||||||
1. **Backend service didn't start**
|
|
||||||
```bash
|
|
||||||
docker logs gravl-backend | tail -20
|
|
||||||
# Look for:
|
|
||||||
# - Port conflicts (ERR_EADDRINUSE)
|
|
||||||
# - Missing dependencies (module not found)
|
|
||||||
# - Database connection errors
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Port 3001 is already in use**
|
|
||||||
```bash
|
|
||||||
lsof -i :3001 # Find what's using it
|
|
||||||
docker port gravl-backend # Check exposed port
|
|
||||||
kill -9 <PID> # Kill conflicting process (if safe)
|
|
||||||
scripts/deploy.sh # Retry
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Network issue between host and container**
|
|
||||||
```bash
|
|
||||||
docker inspect gravl-backend --format '{{.NetworkSettings.IPAddress}}'
|
|
||||||
curl -sf http://<container-ip>:3001/api/health # Test directly
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Backend code has syntax error**
|
|
||||||
```bash
|
|
||||||
docker logs gravl-backend 2>&1 | grep -i "syntax\|error\|exception"
|
|
||||||
# Check backend/src/index.js for obvious errors
|
|
||||||
# Revert recent changes: git log --oneline -5 && git checkout <good-commit>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Quick recovery:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Stop everything
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# 2. Check backend logs
|
|
||||||
docker compose up -d gravl-backend
|
|
||||||
sleep 5
|
|
||||||
docker logs gravl-backend | tail -50
|
|
||||||
|
|
||||||
# 3. If logs show errors, fix code and retry
|
|
||||||
git diff HEAD~1..HEAD backend/src/
|
|
||||||
# ... fix issues ...
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Stale Containers
|
|
||||||
|
|
||||||
**Symptom:** `build-check.sh` shows `STALE: container is behind local code`
|
|
||||||
|
|
||||||
**Causes:**
|
|
||||||
|
|
||||||
- Code was updated (`git pull`) but `deploy.sh` hasn't been run
|
|
||||||
- Deployment failed partway through
|
|
||||||
- Manual restart without redeploy
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/deploy.sh
|
|
||||||
scripts/build-check.sh # Verify update
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Missing Build Labels
|
|
||||||
|
|
||||||
**Symptom:** `WARNING: no build label found — redeploy with scripts/deploy.sh`
|
|
||||||
|
|
||||||
**Causes:**
|
|
||||||
|
|
||||||
- Container was built with `docker compose build` directly (not via `deploy.sh`)
|
|
||||||
- Container predates the labeling system
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Re-deploy to add labels
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Container Won't Start (CrashLoopBackOff / Exited)
|
|
||||||
|
|
||||||
**Symptom:** `docker compose ps` shows container in "Exited" state
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
|
|
||||||
1. **Check container logs**
|
|
||||||
```bash
|
|
||||||
docker logs gravl-backend --tail 50
|
|
||||||
docker logs gravl-frontend --tail 50
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check docker-compose.yml for typos**
|
|
||||||
```bash
|
|
||||||
docker compose config # Validates syntax
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Inspect health check endpoint**
|
|
||||||
```bash
|
|
||||||
curl -v http://localhost:3001/api/health
|
|
||||||
# Should see HTTP 200, not 404 or 500
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **If all else fails, clean rebuild**
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
docker rmi gravl-backend gravl-frontend
|
|
||||||
docker system prune -f
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Database Connection Issues
|
|
||||||
|
|
||||||
**Symptom:** Backend logs show `Connection refused` or `ECONNREFUSED`
|
|
||||||
|
|
||||||
**Causes:**
|
|
||||||
- Database service not running
|
|
||||||
- Wrong host/port in `.env` or backend code
|
|
||||||
- Network issue between containers
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check database service status** (if applicable)
|
|
||||||
```bash
|
|
||||||
docker compose ps # All services running?
|
|
||||||
docker network ls # Check gravl network exists
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify connection string in `.env`**
|
|
||||||
```bash
|
|
||||||
cat .env | grep -i database
|
|
||||||
# Should match docker-compose.yml service name (e.g., gravl-db:5432)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test connection from backend container**
|
|
||||||
```bash
|
|
||||||
docker exec gravl-backend ping gravl-db
|
|
||||||
docker exec gravl-backend curl http://gravl-db:5432 # If HTTP, adjust port
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Disk Space Issues
|
|
||||||
|
|
||||||
**Symptom:** `no space left on device` during build
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check disk usage
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
# Clean up unused images/containers
|
|
||||||
docker system prune -a --volumes
|
|
||||||
|
|
||||||
# Then retry deploy
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recovery Procedures
|
|
||||||
|
|
||||||
### Manual Rollback to Previous Commit
|
|
||||||
|
|
||||||
Use this when the deployed code is broken and you need to quickly revert.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Find the last good commit
|
|
||||||
git log --oneline -10 # Review recent commits
|
|
||||||
|
|
||||||
# 2. Check out the known-good commit
|
|
||||||
git checkout <commit-hash>
|
|
||||||
|
|
||||||
# 3. Redeploy
|
|
||||||
scripts/deploy.sh
|
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
scripts/build-check.sh
|
|
||||||
curl -sf http://localhost:3001/api/health
|
|
||||||
|
|
||||||
# 5. Document the incident
|
|
||||||
echo "Rolled back to <commit-hash> due to <reason>" >> logs/rollback.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Emergency Container Cleanup
|
|
||||||
|
|
||||||
Use this when containers are hung, corrupted, or in an unknown state.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Stop all services
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# 2. Remove images (forces fresh rebuild)
|
|
||||||
docker rmi gravl-backend gravl-frontend
|
|
||||||
|
|
||||||
# 3. Clear unused volumes (optional; use with caution!)
|
|
||||||
# docker volume prune
|
|
||||||
|
|
||||||
# 4. Rebuild from scratch
|
|
||||||
scripts/deploy.sh
|
|
||||||
|
|
||||||
# 5. Verify all containers running and healthy
|
|
||||||
docker compose ps
|
|
||||||
scripts/build-check.sh
|
|
||||||
curl -sf http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
**Safety Check:** If your data is in Docker volumes, `docker volume prune` will destroy them. Skip this step unless you're sure you don't need the data.
|
|
||||||
|
|
||||||
### Staged Rollback (Zero-Downtime)
|
|
||||||
|
|
||||||
If you're running a blue-green deployment setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Deploy to green environment
|
|
||||||
cd /path/to/green
|
|
||||||
git pull && docker compose build --no-cache && docker compose up -d
|
|
||||||
|
|
||||||
# 2. Test green (health check, smoke tests)
|
|
||||||
curl -sf http://green-backend:3001/api/health
|
|
||||||
|
|
||||||
# 3. Switch traffic to green (via load balancer or DNS)
|
|
||||||
# (Implementation depends on your infrastructure)
|
|
||||||
|
|
||||||
# 4. If green has issues, revert traffic to blue immediately
|
|
||||||
# (Blue kept serving; no downtime)
|
|
||||||
|
|
||||||
# 5. Debug green offline
|
|
||||||
docker logs gravl-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring After Deployment
|
|
||||||
|
|
||||||
### Immediate Checks (after `deploy.sh` completes)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Containers are running
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# Backend is healthy
|
|
||||||
curl -sf http://localhost:3001/api/health | jq .
|
|
||||||
|
|
||||||
# Containers match local code
|
|
||||||
scripts/build-check.sh
|
|
||||||
|
|
||||||
# Logs have no errors
|
|
||||||
docker logs gravl-backend 2>&1 | grep -i error | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ongoing Checks (periodically)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run build-check regularly (cron every 30 min, or manual)
|
|
||||||
scripts/build-check.sh
|
|
||||||
|
|
||||||
# Monitor resource usage
|
|
||||||
docker stats gravl-backend gravl-frontend
|
|
||||||
|
|
||||||
# Audit logs for issues
|
|
||||||
docker logs gravl-backend --since 1h --until now | grep ERROR
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Monitoring Script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# Save as scripts/health-monitor.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HEALTHY=true
|
|
||||||
|
|
||||||
# Check containers running
|
|
||||||
docker compose ps | grep -q "Up" || HEALTHY=false
|
|
||||||
|
|
||||||
# Check health endpoint
|
|
||||||
curl -sf http://localhost:3001/api/health || HEALTHY=false
|
|
||||||
|
|
||||||
# Check for stale containers
|
|
||||||
scripts/build-check.sh | grep -q "STALE" && HEALTHY=false
|
|
||||||
|
|
||||||
if [ "$HEALTHY" = "true" ]; then
|
|
||||||
echo "[$(date)] Gravl is healthy ✓"
|
|
||||||
else
|
|
||||||
echo "[$(date)] Gravl has issues! See above." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Always run `build-check.sh` before deploying changes**
|
|
||||||
- Ensures you know current state
|
|
||||||
- Catches stale containers early
|
|
||||||
|
|
||||||
2. **Review changes before deploying**
|
|
||||||
```bash
|
|
||||||
git log --oneline -5 # Recent commits
|
|
||||||
git diff origin/main..HEAD # What will be deployed
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test in staging first**
|
|
||||||
- Separate staging environment for pre-production testing
|
|
||||||
- Deploy to staging, verify, then deploy to production
|
|
||||||
|
|
||||||
4. **Keep logs rotated**
|
|
||||||
- `logs/deploy.log` can grow large
|
|
||||||
- Use `logrotate` or manual cleanup: `tail -1000 logs/deploy.log > logs/deploy.log.1 && > logs/deploy.log`
|
|
||||||
|
|
||||||
5. **Automate regular checks**
|
|
||||||
- Cron job to run `build-check.sh` every 30 minutes
|
|
||||||
- Send alerts if "STALE" or "WARNING" found
|
|
||||||
|
|
||||||
6. **Document rollbacks**
|
|
||||||
- Always log why you rolled back
|
|
||||||
- Review patterns (e.g., "rolled back 3 times this week" = code review process failing)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- **Testing**: [DEPLOYMENT_TEST_PLAN.md](./DEPLOYMENT_TEST_PLAN.md) — comprehensive test scenarios
|
|
||||||
- **Code style**: [CODING-CONVENTIONS.md](./CODING-CONVENTIONS.md)
|
|
||||||
- **Architecture**: Backend README or architecture docs (if available)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last updated: 2026-03-03 | Maintained by: Gravl Development Team*
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
# Gravl Deployment Testing Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines unit, integration, and rollback testing procedures for the Gravl deployment automation scripts:
|
|
||||||
- `scripts/deploy.sh`: Pulls code, builds fresh images (--no-cache), starts containers
|
|
||||||
- `scripts/build-check.sh`: Verifies deployed containers match local git HEAD
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part A: Unit Tests
|
|
||||||
|
|
||||||
### Unit Test Suite for `deploy.sh`
|
|
||||||
|
|
||||||
#### UT-D1: Git Pull Functionality
|
|
||||||
**Objective:** Verify that `git pull` successfully fetches and merges latest code.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Create a test branch with at least one commit ahead of current HEAD
|
|
||||||
- Have a clean working tree
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Note current git HEAD: `GIT_BEFORE=$(git rev-parse HEAD)`
|
|
||||||
2. Manually push a new commit to remote
|
|
||||||
3. Run `scripts/deploy.sh`
|
|
||||||
4. Verify commit was pulled: `git rev-parse HEAD` should differ from `GIT_BEFORE`
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- `git pull` completes without merge conflicts
|
|
||||||
- Script continues to build step
|
|
||||||
- New commit is reflected in logs: `git log --oneline -1`
|
|
||||||
|
|
||||||
**Failure Handling:**
|
|
||||||
- If merge conflict occurs, script exits with `set -e`
|
|
||||||
- Manual resolution required before retry
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-D2: Docker Build with --no-cache
|
|
||||||
**Objective:** Verify that `docker compose build --no-cache` forces fresh image builds.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Clear Docker build cache: `docker builder prune -af`
|
|
||||||
- Have a recent layer in backend/Dockerfile that changes behavior
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Build images normally: `docker compose build`
|
|
||||||
2. Note build output time
|
|
||||||
3. Immediately run `scripts/deploy.sh`
|
|
||||||
4. Capture build output: `docker compose build --no-cache 2>&1 | tee /tmp/build-output.txt`
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- No layers are cached (all FROM statements rebuild)
|
|
||||||
- Build completes successfully
|
|
||||||
- Final images have new `org.opencontainers.image.revision` label set to current `GIT_COMMIT`
|
|
||||||
|
|
||||||
**Failure Handling:**
|
|
||||||
- If a layer fails to rebuild, check Dockerfile syntax and dependencies
|
|
||||||
- Clear `node_modules` and rebuild if necessary
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-D3: Health Check Success Path
|
|
||||||
**Objective:** Verify backend service responds to health endpoint within timeout.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Backend service responds quickly on `/api/health`
|
|
||||||
- Network connectivity is stable
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Run `scripts/deploy.sh`
|
|
||||||
2. Observe health check loop in logs
|
|
||||||
3. Verify backend responds: `curl -sf http://localhost:3001/api/health`
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Health check completes on first or second attempt (within 10s)
|
|
||||||
- Log shows: `[...] Backend healthy`
|
|
||||||
- Script exits with code 0
|
|
||||||
|
|
||||||
**Failure Handling:**
|
|
||||||
- See health check timeout scenario (UT-D4)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-D4: Health Check Timeout (Negative Test)
|
|
||||||
**Objective:** Verify script fails gracefully when backend doesn't respond.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Stop backend service before health check loop
|
|
||||||
- Health endpoint returns 500 or times out
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Run `scripts/deploy.sh`
|
|
||||||
2. Observe health check loop iterate 12 times (60 seconds total)
|
|
||||||
3. Verify script exits with error code 1
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Loop runs all 12 iterations (5-second intervals)
|
|
||||||
- Final log shows: `ERROR: Health check failed after 60s`
|
|
||||||
- Process exits non-zero
|
|
||||||
- Containers remain running (so you can debug manually)
|
|
||||||
|
|
||||||
**Failure Handling:**
|
|
||||||
- Check backend logs: `docker logs gravl-backend`
|
|
||||||
- Verify port 3001 is exposed: `docker port gravl-backend`
|
|
||||||
- Test endpoint manually: `curl -v http://localhost:3001/api/health`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-D5: Metadata Labeling
|
|
||||||
**Objective:** Verify build metadata is correctly stored in container labels.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- After a successful deploy, query container labels
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Run `scripts/deploy.sh`
|
|
||||||
2. Inspect backend container: `docker inspect gravl-backend --format '{{json .Config.Labels}}'`
|
|
||||||
3. Verify labels contain:
|
|
||||||
- `org.opencontainers.image.revision`: matches `git rev-parse HEAD`
|
|
||||||
- `org.opencontainers.image.created`: matches build timestamp
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Both labels are present and non-empty
|
|
||||||
- Revision matches current HEAD
|
|
||||||
- Created timestamp is recent (within 1 minute of deploy time)
|
|
||||||
|
|
||||||
**Failure Handling:**
|
|
||||||
- Check docker-compose.yml build args are being passed
|
|
||||||
- Verify Dockerfile includes label copy from build args
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Unit Test Suite for `build-check.sh`
|
|
||||||
|
|
||||||
#### UT-B1: Label Detection - Matching Commit
|
|
||||||
**Objective:** Verify build-check correctly identifies up-to-date containers.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Deploy using `scripts/deploy.sh` (creates proper labels)
|
|
||||||
- Run build-check immediately after deploy
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Execute: `scripts/build-check.sh`
|
|
||||||
2. Observe output for gravl-backend and gravl-frontend
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Output shows: `[gravl-backend] OK: up to date`
|
|
||||||
- Output shows: `[gravl-frontend] OK: up to date`
|
|
||||||
- No STALE or WARNING messages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-B2: Label Detection - Missing Labels (Negative)
|
|
||||||
**Objective:** Verify build-check warns when containers lack revision labels.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Manually build and run container without deploy.sh
|
|
||||||
- Container has no `org.opencontainers.image.revision` label
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Build without labels: `docker build -t gravl-backend:test .`
|
|
||||||
2. Run container manually
|
|
||||||
3. Execute: `scripts/build-check.sh`
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Output shows: `WARNING: no build label found — redeploy with scripts/deploy.sh to add tracking`
|
|
||||||
- No crash or error exit code
|
|
||||||
- Script provides remediation guidance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-B3: Stale Detection - Behind HEAD
|
|
||||||
**Objective:** Verify build-check detects containers built from old commits.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Deploy at commit A
|
|
||||||
- Push new commit B to remote
|
|
||||||
- `git pull` locally (so local HEAD = B, but container is at A)
|
|
||||||
- Don't redeploy
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Note current HEAD: `BEFORE=$(git rev-parse HEAD)`
|
|
||||||
2. Create a dummy commit and push: `echo "test" >> test.txt && git add test.txt && git commit -m "test" && git push`
|
|
||||||
3. In test environment, pull but don't deploy: `git pull`
|
|
||||||
4. Run: `scripts/build-check.sh`
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Output shows: `[gravl-backend] STALE: container is behind local code — run scripts/deploy.sh`
|
|
||||||
- Commit hash differs between "Built:" and "Local HEAD:"
|
|
||||||
- Exit code is 0 (warning only, not error)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-B4: Container Not Running
|
|
||||||
**Objective:** Verify build-check handles missing containers gracefully.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Stop one of the containers (e.g., frontend)
|
|
||||||
- Run build-check
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Stop frontend: `docker stop gravl-frontend`
|
|
||||||
2. Run: `scripts/build-check.sh`
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Output shows: `[gravl-frontend] Not running`
|
|
||||||
- Output for backend is normal
|
|
||||||
- No error; script completes with exit code 0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### UT-B5: Commit Comparison Logic
|
|
||||||
**Objective:** Verify build-check correctly compares local HEAD against container labels.
|
|
||||||
|
|
||||||
**Setup:**
|
|
||||||
- Deploy at commit with known hash (e.g., abc1234)
|
|
||||||
- Verify container label has exact match
|
|
||||||
- Then create new commit without redeploying
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
1. Get deployed commit: `docker inspect gravl-backend --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'`
|
|
||||||
2. Verify it matches current HEAD: `git rev-parse HEAD`
|
|
||||||
3. Create and commit new code: `git commit -am "test"`
|
|
||||||
4. Run build-check again
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Before new commit: "OK: up to date"
|
|
||||||
- After new commit: "STALE: container is behind local code"
|
|
||||||
- Commit hashes are extracted and compared correctly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part B: Integration Tests
|
|
||||||
|
|
||||||
### Integration Test Suite
|
|
||||||
|
|
||||||
#### IT-1: Full Deploy Cycle in Staging
|
|
||||||
**Objective:** Verify entire deployment workflow from code to running containers.
|
|
||||||
|
|
||||||
**Preconditions:**
|
|
||||||
- Staging environment isolated from production
|
|
||||||
- Docker daemon running
|
|
||||||
- Git remotes configured
|
|
||||||
- Backend health endpoint functional
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
|
|
||||||
1. **Baseline:** Document initial state
|
|
||||||
```bash
|
|
||||||
git rev-parse HEAD > /tmp/baseline-commit.txt
|
|
||||||
scripts/build-check.sh | tee /tmp/baseline-check.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Commit code:** Push a non-breaking change
|
|
||||||
```bash
|
|
||||||
git checkout -b test/it-1-$$
|
|
||||||
echo "// test change" >> backend/src/index.js
|
|
||||||
git add backend/src/index.js
|
|
||||||
git commit -m "test: IT-1 change"
|
|
||||||
git push origin test/it-1-$$
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Deploy:** Run the full deployment
|
|
||||||
```bash
|
|
||||||
scripts/deploy.sh | tee /tmp/deploy-log.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Verify:** Check health and container state
|
|
||||||
```bash
|
|
||||||
scripts/build-check.sh | tee /tmp/postdeploy-check.txt
|
|
||||||
docker compose ps
|
|
||||||
curl -sf http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Cleanup:** Revert test branch
|
|
||||||
```bash
|
|
||||||
git checkout -
|
|
||||||
git branch -D test/it-1-$$
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- `scripts/deploy.sh` completes with exit code 0
|
|
||||||
- Health check passes within 60s
|
|
||||||
- `build-check.sh` shows "OK: up to date" for both containers
|
|
||||||
- Containers remain running after deploy completes
|
|
||||||
- Logs show proper git pull, build, and health check steps
|
|
||||||
|
|
||||||
**Rollback Path (if failure occurs during IT-1):**
|
|
||||||
- See rollback procedures below
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### IT-2: Deploy with Health Check Failure Recovery
|
|
||||||
**Objective:** Verify deployment handles intermittent health check failures and recovers.
|
|
||||||
|
|
||||||
**Preconditions:**
|
|
||||||
- Backend can be temporarily paused/resumed
|
|
||||||
- System has `docker pause`/`docker unpause` available
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
|
|
||||||
1. **Pre-deploy:** Baseline state
|
|
||||||
```bash
|
|
||||||
scripts/build-check.sh > /tmp/it2-baseline.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Deploy start:** Trigger deployment (background)
|
|
||||||
```bash
|
|
||||||
scripts/deploy.sh > /tmp/it2-deploy.log 2>&1 &
|
|
||||||
DEPLOY_PID=$!
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Introduce pause:** After 3 seconds, pause backend (simulates slow startup)
|
|
||||||
```bash
|
|
||||||
sleep 3
|
|
||||||
docker pause gravl-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Allow recovery:** Unpause before timeout
|
|
||||||
```bash
|
|
||||||
sleep 15
|
|
||||||
docker unpause gravl-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Verify completion:**
|
|
||||||
```bash
|
|
||||||
wait $DEPLOY_PID
|
|
||||||
RESULT=$?
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Deploy script retries health check multiple times
|
|
||||||
- When backend recovers, health check passes
|
|
||||||
- Script completes with exit code 0
|
|
||||||
- Containers transition to healthy state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### IT-3: Multi-Service Coordination
|
|
||||||
**Objective:** Verify frontend and backend both restart and sync properly.
|
|
||||||
|
|
||||||
**Preconditions:**
|
|
||||||
- Both services configured in docker-compose.yml
|
|
||||||
- Frontend depends on backend being healthy
|
|
||||||
|
|
||||||
**Test Steps:**
|
|
||||||
|
|
||||||
1. **Deploy:**
|
|
||||||
```bash
|
|
||||||
scripts/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check startup order:**
|
|
||||||
- Grep logs for `[gravl-backend]` and `[gravl-frontend]` timestamps
|
|
||||||
- Verify backend logs appear before frontend health check
|
|
||||||
|
|
||||||
3. **Verify networking:**
|
|
||||||
```bash
|
|
||||||
docker exec gravl-frontend curl -sf http://gravl-backend:3001/api/health
|
|
||||||
docker exec gravl-backend curl -sf http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Verify labels on both:**
|
|
||||||
```bash
|
|
||||||
docker inspect gravl-backend gravl-frontend --format '{{.Name}} => {{index .Config.Labels "org.opencontainers.image.revision"}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- Both containers start successfully
|
|
||||||
- Both containers have matching revision labels (same commit)
|
|
||||||
- Frontend can reach backend via container hostname
|
|
||||||
- Build-check shows "OK: up to date" for both
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part C: Rollback Procedures & Safety Checks
|
|
||||||
|
|
||||||
### RB-1: Manual Rollback to Previous Commit
|
|
||||||
|
|
||||||
**When to use:** Deployed code is broken and breaks production.
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- Know the last good commit hash
|
|
||||||
- Database migrations (if any) are reversible
|
|
||||||
- Users can be impacted for <5 min
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Document current state
|
|
||||||
git rev-parse HEAD > /tmp/rollback-from.txt
|
|
||||||
|
|
||||||
# 2. Check out previous good commit
|
|
||||||
git checkout <good-commit-hash>
|
|
||||||
|
|
||||||
# 3. Redeploy (pulls and rebuilds)
|
|
||||||
scripts/deploy.sh
|
|
||||||
|
|
||||||
# 4. Verify recovery
|
|
||||||
scripts/build-check.sh
|
|
||||||
curl -sf http://localhost:3001/api/health
|
|
||||||
|
|
||||||
# 5. Log the incident
|
|
||||||
echo "Rolled back from $(cat /tmp/rollback-from.txt) to $good-commit-hash" >> logs/rollback.log
|
|
||||||
```
|
|
||||||
|
|
||||||
**Safety Checks:**
|
|
||||||
- ✅ Always verify health endpoint responds after rollback
|
|
||||||
- ✅ Check logs for errors: `docker logs gravl-backend | tail -50`
|
|
||||||
- ✅ Check database state if applicable (query active sessions, etc.)
|
|
||||||
- ✅ Notify team of rollback and reason
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### RB-2: Emergency Container Cleanup & Restart
|
|
||||||
|
|
||||||
**When to use:** Containers are hung, corrupted, or in unknown state.
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- OK to restart services temporarily
|
|
||||||
- Data is persistent in volumes
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Stop all containers
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# 2. Remove images (to force fresh rebuild on next deploy)
|
|
||||||
docker rmi gravl-backend gravl-frontend
|
|
||||||
|
|
||||||
# 3. Redeploy fresh
|
|
||||||
scripts/deploy.sh
|
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
docker compose ps
|
|
||||||
scripts/build-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Safety Checks:**
|
|
||||||
- ✅ Confirm volumes are not removed: `docker volume ls | grep gravl`
|
|
||||||
- ✅ Verify all containers start: `docker compose ps` shows all "Up"
|
|
||||||
- ✅ Health check passes within 60s
|
|
||||||
- ✅ No data loss from persistent stores
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### RB-3: Staged Rollback (Blue-Green Alternative)
|
|
||||||
|
|
||||||
**When to use:** Can't tolerate any downtime.
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- Two separate services running (blue = prod, green = staging)
|
|
||||||
- Load balancer or router can switch traffic
|
|
||||||
- Synchronized database
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Deploy to green environment
|
|
||||||
cd /path/to/green/environment
|
|
||||||
git pull
|
|
||||||
docker compose build --no-cache
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 2. Health check green
|
|
||||||
curl -sf http://green-backend:3001/api/health
|
|
||||||
|
|
||||||
# 3. Route traffic to green (via load balancer/DNS)
|
|
||||||
# (This step is environment-specific)
|
|
||||||
|
|
||||||
# 4. If issues, revert traffic to blue immediately
|
|
||||||
# (No containers to roll back on blue; it kept serving)
|
|
||||||
|
|
||||||
# 5. Debug green offline
|
|
||||||
# (No downtime for users)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Safety Checks Summary
|
|
||||||
|
|
||||||
| Check | When | Command | Pass Criteria |
|
|
||||||
|-------|------|---------|---------------|
|
|
||||||
| Health | After deploy | `curl -sf http://localhost:3001/api/health` | HTTP 200 within 60s |
|
|
||||||
| Labels | After deploy | `docker inspect gravl-backend --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'` | Non-empty, matches `git rev-parse HEAD` |
|
|
||||||
| Build status | Before deploy | `scripts/build-check.sh` | No STALE warnings |
|
|
||||||
| Container state | After deploy | `docker compose ps` | All containers "Up" |
|
|
||||||
| Logs | After deploy | `docker logs gravl-backend \| tail -20` | No ERROR or CRITICAL lines |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running Tests Locally
|
|
||||||
|
|
||||||
### Quick Test (5 min)
|
|
||||||
```bash
|
|
||||||
cd /workspace/gravl
|
|
||||||
|
|
||||||
# UT-D1: Git pull
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# UT-D2: Build with no-cache
|
|
||||||
docker compose build --no-cache
|
|
||||||
|
|
||||||
# UT-D3: Health check
|
|
||||||
curl -sf http://localhost:3001/api/health
|
|
||||||
|
|
||||||
# UT-B1: Build-check
|
|
||||||
scripts/build-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full Suite (30 min)
|
|
||||||
```bash
|
|
||||||
# Clone test repo in /tmp
|
|
||||||
mkdir -p /tmp/gravl-test
|
|
||||||
cd /tmp/gravl-test
|
|
||||||
git clone /workspace/gravl .
|
|
||||||
git remote set-url origin /workspace/gravl
|
|
||||||
|
|
||||||
# Run all UTs and IT-1
|
|
||||||
# (See individual test steps above)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Metrics to Monitor
|
|
||||||
|
|
||||||
After each test, log these metrics to `logs/test-results.json`:
|
|
||||||
- Deploy time (seconds)
|
|
||||||
- Health check time (seconds)
|
|
||||||
- Build cache hit rate (% of layers reused)
|
|
||||||
- Container restart count
|
|
||||||
- Error count in logs
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2026-03-03T18:21:00Z",
|
|
||||||
"test_name": "IT-1",
|
|
||||||
"deploy_time_sec": 45,
|
|
||||||
"health_check_time_sec": 8,
|
|
||||||
"result": "pass"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last updated: 2026-03-03 | Next review: After phase 07-04 completion*
|
|
||||||
@@ -10,11 +10,6 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
ARG BUILD_DATE=unknown
|
|
||||||
LABEL org.opencontainers.image.revision=$GIT_COMMIT \
|
|
||||||
org.opencontainers.image.created=$BUILD_DATE
|
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
# Gravl E2E Testing Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This project uses Playwright for E2E and API testing.
|
|
||||||
|
|
||||||
## Test Suites
|
|
||||||
|
|
||||||
### 1. API Tests (`tests/gravl.api.spec.js`)
|
|
||||||
✅ **Working** - Uses Playwright's API context (no browser required)
|
|
||||||
|
|
||||||
Tests HTTP endpoints without launching a browser:
|
|
||||||
- Homepage accessibility check
|
|
||||||
- Login page accessibility
|
|
||||||
- API connectivity validation
|
|
||||||
|
|
||||||
**Run API tests:**
|
|
||||||
```bash
|
|
||||||
npx playwright test tests/gravl.api.spec.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. UI Tests (`tests/gravl.spec.js`)
|
|
||||||
⚠️ **Requires System Setup** - Needs graphics libraries
|
|
||||||
|
|
||||||
Tests interactive UI elements using browser automation:
|
|
||||||
- Login form visibility
|
|
||||||
- Logo detection
|
|
||||||
- Dashboard title validation
|
|
||||||
|
|
||||||
**System Requirements:**
|
|
||||||
- libXcomposite.so.1
|
|
||||||
- libX11 and related X11 libraries
|
|
||||||
- libwayland (for Wayland support)
|
|
||||||
- Other graphics/media libraries
|
|
||||||
|
|
||||||
**Install on Ubuntu/Debian:**
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
libxcomposite1 libxdamage1 libxrandr2 libxinerama1 \
|
|
||||||
libxcursor1 libxtst6 libxss1 libx11-6 libatk1.0-0 \
|
|
||||||
libatk-bridge2.0-0 libpango-1.0-0 libcairo2 libgdk-pixbuf2.0-0 \
|
|
||||||
libgtk-3-0 libnss3 libnspr4 libdbus-1-3 libxext6 libxfixes3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** For CI/CD environments without X11, use API tests or containerized setup.
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### All tests (API only in this environment):
|
|
||||||
```bash
|
|
||||||
npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
### With JSON report:
|
|
||||||
```bash
|
|
||||||
npx playwright test --reporter=json > test-results.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Headless browser (requires system libraries):
|
|
||||||
```bash
|
|
||||||
STAGING_URL=http://localhost:3000 npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Watch mode:
|
|
||||||
```bash
|
|
||||||
npx playwright test --watch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
**File:** `playwright.config.js`
|
|
||||||
|
|
||||||
- **testDir:** `./tests`
|
|
||||||
- **baseURL:** `http://localhost:5173` (dev) or `$STAGING_URL`
|
|
||||||
- **Projects:** API context (no browser)
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
See `/test-results/` directory for latest run reports.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Executable doesn't exist" / Missing browsers
|
|
||||||
Run: `npx playwright install`
|
|
||||||
|
|
||||||
### "cannot open shared object file: libXcomposite.so.1"
|
|
||||||
Browser engine missing system dependencies. Use API tests instead.
|
|
||||||
|
|
||||||
### Tests timeout
|
|
||||||
Check if application is running on baseURL (e.g., http://localhost:5173)
|
|
||||||
|
|
||||||
## Phase 06-04 Status
|
|
||||||
|
|
||||||
✅ **API tests working** - 3/3 passing
|
|
||||||
⚠️ **UI tests blocked** - Requires system graphics libraries (not available in this environment)
|
|
||||||
|
|
||||||
Workaround implemented: Use API tests for regression testing. Full E2E testing requires browser environment.
|
|
||||||
+67
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<title>Gravl - Träning</title>
|
<title>Gravl - Träning</title>
|
||||||
<script type="module" crossorigin src="/assets/index-aU0r4U2I.js"></script>
|
<script type="module" crossorigin src="/assets/index-hhKetRGz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-KaIXgP3q.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-my_lGtI5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
export default {
|
module.exports = {
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.STAGING_URL || "http://localhost:5173",
|
baseURL: process.env.STAGING_URL || "https://gravl.homelab.local",
|
||||||
|
headless: true,
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
},
|
},
|
||||||
// Remove webServer config for now since it's already running
|
projects: [{
|
||||||
projects: [
|
name: "chromium",
|
||||||
{
|
use: { browserName: "chromium" }
|
||||||
name: "api",
|
}]
|
||||||
use: {
|
|
||||||
// API context - no browser required
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3168,302 +3168,3 @@
|
|||||||
.modal-btn.confirm:active:not(:disabled) {
|
.modal-btn.confirm:active:not(:disabled) {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
RESEARCH DISPLAY COMPONENT
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.research-panel {
|
|
||||||
margin: var(--space-4) 0;
|
|
||||||
padding: var(--space-4);
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-panel-title {
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading State */
|
|
||||||
.rd-loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-6);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-loading-text {
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-loading-text em {
|
|
||||||
color: var(--accent);
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error State */
|
|
||||||
.rd-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: rgba(255, 107, 74, 0.1);
|
|
||||||
border: 1px solid rgba(255, 107, 74, 0.3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: #ff6b4a;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-error-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-error-message {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-dismiss {
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
padding: 0;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-dismiss:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Results Container */
|
|
||||||
.rd-results {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-header-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Summary Section */
|
|
||||||
.rd-summary {
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-section-title {
|
|
||||||
font-size: var(--font-md);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin: 0 0 var(--space-2) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-section-icon {
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-count {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-summary-text {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sources List */
|
|
||||||
.rd-sources {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-sources-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-item {
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-item:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: color var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-link:hover {
|
|
||||||
color: #ff8066;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-index {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-title {
|
|
||||||
flex: 1;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-arrow {
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-snippet {
|
|
||||||
margin: var(--space-2) 0 0 0;
|
|
||||||
padding: 0 0 0 32px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: rgba(255, 107, 74, 0.15);
|
|
||||||
color: var(--accent);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.rd-empty {
|
|
||||||
padding: var(--space-4);
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Provider Badge */
|
|
||||||
.rd-provider-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-provider-primary {
|
|
||||||
background: rgba(100, 200, 255, 0.15);
|
|
||||||
color: #64c8ff;
|
|
||||||
border: 1px solid rgba(100, 200, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-provider-secondary {
|
|
||||||
background: rgba(200, 150, 255, 0.15);
|
|
||||||
color: #c896ff;
|
|
||||||
border: 1px solid rgba(200, 150, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-provider-accent {
|
|
||||||
background: rgba(255, 107, 74, 0.15);
|
|
||||||
color: var(--accent);
|
|
||||||
border: 1px solid rgba(255, 107, 74, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-provider-degraded {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-provider-status {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-provider-label {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import ResearchDisplay from './ResearchDisplay'
|
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
@@ -17,23 +16,13 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse response regardless of status
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.error || data.message || 'Failed to fetch research')
|
const data = await res.json()
|
||||||
|
throw new Error(data.error || 'Failed to fetch research')
|
||||||
}
|
}
|
||||||
|
const data = await res.json()
|
||||||
// Include provider and status info from response
|
setResearch(data)
|
||||||
setResearch({
|
|
||||||
summary: data.summary,
|
|
||||||
results: data.results,
|
|
||||||
provider: data.provider,
|
|
||||||
status: data.status
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Research fetch error:', err);
|
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -44,23 +33,73 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
|||||||
<div className="research-panel">
|
<div className="research-panel">
|
||||||
<div className="research-panel-header">
|
<div className="research-panel-header">
|
||||||
<h3 className="research-panel-title">Research</h3>
|
<h3 className="research-panel-title">Research</h3>
|
||||||
<button
|
{!research && (
|
||||||
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
<button
|
||||||
onClick={fetchResearch}
|
className="btn btn-primary research-btn"
|
||||||
disabled={loading}
|
onClick={fetchResearch}
|
||||||
title={research ? 'Refresh research results' : 'Fetch research for this exercise'}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Fetching…' : research ? 'Refresh' : 'Get Research'}
|
{loading ? 'Fetching...' : 'Get Research'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{research && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary research-btn"
|
||||||
|
onClick={fetchResearch}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Fetching...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResearchDisplay
|
{loading && (
|
||||||
loading={loading}
|
<div className="research-loading">
|
||||||
error={error}
|
<div className="research-spinner"></div>
|
||||||
data={research}
|
<span>Searching for information on {exerciseName}...</span>
|
||||||
name={exerciseName}
|
</div>
|
||||||
onDismiss={() => setError(null)}
|
)}
|
||||||
/>
|
|
||||||
|
{error && (
|
||||||
|
<div className="research-error">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button className="btn-close" onClick={() => setError(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{research && !loading && (
|
||||||
|
<div className="research-results">
|
||||||
|
{research.summary && (
|
||||||
|
<div className="research-summary">
|
||||||
|
<h4>Summary</h4>
|
||||||
|
<p>{research.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{research.results && research.results.length > 0 && (
|
||||||
|
<div className="research-sources">
|
||||||
|
<h4>Sources</h4>
|
||||||
|
<ul className="research-sources-list">
|
||||||
|
{research.results.map((result, i) => (
|
||||||
|
<li key={i} className="research-source-item">
|
||||||
|
<a
|
||||||
|
href={result.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="research-source-link"
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</a>
|
||||||
|
{result.snippet && (
|
||||||
|
<p className="research-source-snippet">{result.snippet}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
function ResearchLoadingSkeleton({ exerciseName }) {
|
|
||||||
return (
|
|
||||||
<div className="rd-loading">
|
|
||||||
<div className="rd-spinner" aria-hidden="true" />
|
|
||||||
<span className="rd-loading-text">
|
|
||||||
Searching for information on <em>{exerciseName}</em>…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResearchError({ message, onDismiss }) {
|
|
||||||
return (
|
|
||||||
<div className="rd-error" role="alert">
|
|
||||||
<span className="rd-error-icon" aria-hidden="true">⚠</span>
|
|
||||||
<span className="rd-error-message">{message}</span>
|
|
||||||
{onDismiss && (
|
|
||||||
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResearchSourceCard({ result, index }) {
|
|
||||||
return (
|
|
||||||
<li className="rd-source-item">
|
|
||||||
<a
|
|
||||||
href={result.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rd-source-link"
|
|
||||||
>
|
|
||||||
<span className="rd-source-index">{index + 1}</span>
|
|
||||||
<span className="rd-source-title">{result.title}</span>
|
|
||||||
<span className="rd-source-arrow" aria-hidden="true">↗</span>
|
|
||||||
</a>
|
|
||||||
{result.snippet && (
|
|
||||||
<p className="rd-source-snippet">{result.snippet}</p>
|
|
||||||
)}
|
|
||||||
{result.isFallback && (
|
|
||||||
<span className="rd-source-badge">Suggested</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResearchProviderBadge({ provider, status }) {
|
|
||||||
if (!provider) return null;
|
|
||||||
|
|
||||||
const badgeConfig = {
|
|
||||||
exa: { emoji: '🔍', label: 'Exa Search', color: 'primary' },
|
|
||||||
fallback: { emoji: '🔗', label: 'Web Sources', color: 'secondary' },
|
|
||||||
gemini: { emoji: '✨', label: 'AI Summary', color: 'accent' },
|
|
||||||
openrouter: { emoji: '🤖', label: 'AI Powered', color: 'accent' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = badgeConfig[provider] || { emoji: '📊', label: provider, color: 'secondary' };
|
|
||||||
const isDegraded = status === 'degraded';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rd-provider-badge rd-provider-${config.color} ${isDegraded ? 'rd-provider-degraded' : ''}`}>
|
|
||||||
<span aria-hidden="true">{config.emoji}</span>
|
|
||||||
<span className="rd-provider-label">{config.label}</span>
|
|
||||||
{isDegraded && (
|
|
||||||
<span className="rd-provider-status" title="Fallback source - primary API unavailable">
|
|
||||||
(Fallback)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ResearchDisplay — pure presentational component.
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* loading {boolean} Show loading skeleton
|
|
||||||
* error {string} Error message to display
|
|
||||||
* data {object} Research data: { summary, results, provider, status }
|
|
||||||
* name {string} Exercise name (shown during loading)
|
|
||||||
* onDismiss {function} Clear error callback
|
|
||||||
*/
|
|
||||||
function ResearchDisplay({ loading, error, data, name, onDismiss }) {
|
|
||||||
if (loading) {
|
|
||||||
return <ResearchLoadingSkeleton exerciseName={name} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <ResearchError message={error} onDismiss={onDismiss} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return null
|
|
||||||
|
|
||||||
const hasSummary = Boolean(data.summary)
|
|
||||||
const hasSources = Array.isArray(data.results) && data.results.length > 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rd-results">
|
|
||||||
<div className="rd-header">
|
|
||||||
<div className="rd-header-content">
|
|
||||||
{hasSummary && (
|
|
||||||
<div className="rd-summary">
|
|
||||||
<h4 className="rd-section-title">
|
|
||||||
<span className="rd-section-icon" aria-hidden="true">📋</span>
|
|
||||||
Summary
|
|
||||||
</h4>
|
|
||||||
<p className="rd-summary-text">{data.summary}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{data.provider && (
|
|
||||||
<ResearchProviderBadge provider={data.provider} status={data.status} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSources && (
|
|
||||||
<div className="rd-sources">
|
|
||||||
<h4 className="rd-section-title">
|
|
||||||
<span className="rd-section-icon" aria-hidden="true">🔗</span>
|
|
||||||
Sources
|
|
||||||
<span className="rd-count">{data.results.length}</span>
|
|
||||||
</h4>
|
|
||||||
<ul className="rd-sources-list" aria-label="Research sources">
|
|
||||||
{data.results.map((result, i) => (
|
|
||||||
<ResearchSourceCard key={i} result={result} index={i} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasSummary && !hasSources && (
|
|
||||||
<p className="rd-empty">No research data found for this exercise.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ResearchDisplay
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import './exerciseRecommendations.css'
|
|
||||||
|
|
||||||
const difficultyTokens = {
|
|
||||||
easy: { label: 'Easy', className: 'difficulty-easy' },
|
|
||||||
medium: { label: 'Medium', className: 'difficulty-medium' },
|
|
||||||
med: { label: 'Medium', className: 'difficulty-medium' },
|
|
||||||
hard: { label: 'Hard', className: 'difficulty-hard' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeDifficulty = (difficulty) => {
|
|
||||||
if (!difficulty) return null
|
|
||||||
const key = String(difficulty).trim().toLowerCase()
|
|
||||||
return difficultyTokens[key] || { label: difficulty, className: 'difficulty-custom' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (exercise) => {
|
|
||||||
const value = exercise?.duration ?? exercise?.duration_min ?? exercise?.durationMinutes
|
|
||||||
if (!value) return null
|
|
||||||
return `${value} min`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatReps = (exercise) => {
|
|
||||||
const { reps, reps_min, reps_max, repsMin, repsMax } = exercise || {}
|
|
||||||
if (reps) return `${reps} reps`
|
|
||||||
const min = reps_min ?? repsMin
|
|
||||||
const max = reps_max ?? repsMax
|
|
||||||
if (min && max) return `${min}-${max} reps`
|
|
||||||
if (min) return `${min}+ reps`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExerciseCard({
|
|
||||||
exercise,
|
|
||||||
onSelect,
|
|
||||||
className = '',
|
|
||||||
compact = false,
|
|
||||||
showMeta = true
|
|
||||||
}) {
|
|
||||||
if (!exercise) return null
|
|
||||||
|
|
||||||
const difficulty = normalizeDifficulty(exercise.difficulty)
|
|
||||||
const duration = formatDuration(exercise)
|
|
||||||
const reps = formatReps(exercise)
|
|
||||||
const imageSrc = exercise.image_url || exercise.image || exercise.imageUrl
|
|
||||||
const Element = onSelect ? 'button' : 'article'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Element
|
|
||||||
type={onSelect ? 'button' : undefined}
|
|
||||||
className={`exercise-recommendation-card ${compact ? 'is-compact' : ''} ${className}`}
|
|
||||||
onClick={onSelect ? () => onSelect(exercise) : undefined}
|
|
||||||
>
|
|
||||||
<div className="exercise-card-media">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt={exercise.name} loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<div className="exercise-card-placeholder" aria-hidden="true">
|
|
||||||
<span>{exercise.name?.slice(0, 1) || 'E'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="exercise-card-content">
|
|
||||||
<div className="exercise-card-header">
|
|
||||||
<h3>{exercise.name}</h3>
|
|
||||||
{difficulty && (
|
|
||||||
<span className={`difficulty-badge ${difficulty.className}`}>
|
|
||||||
{difficulty.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{exercise.description && !compact && (
|
|
||||||
<p className="exercise-card-description">{exercise.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showMeta && (duration || reps) && (
|
|
||||||
<div className="exercise-card-meta">
|
|
||||||
{duration && <span className="exercise-meta-pill">{duration}</span>}
|
|
||||||
{reps && <span className="exercise-meta-pill">{reps}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Element>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExerciseCard
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import './exerciseRecommendations.css'
|
|
||||||
|
|
||||||
const resolveStatus = (level, index, activeIndex) => {
|
|
||||||
if (level.status) return level.status
|
|
||||||
if (activeIndex == null) return 'available'
|
|
||||||
if (index < activeIndex) return 'completed'
|
|
||||||
if (index === activeIndex) return 'current'
|
|
||||||
return 'locked'
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressionTracker({
|
|
||||||
title = 'Progression Path',
|
|
||||||
levels = [],
|
|
||||||
activeLevelId,
|
|
||||||
activeIndex,
|
|
||||||
onSelect,
|
|
||||||
className = ''
|
|
||||||
}) {
|
|
||||||
const resolvedActiveIndex = activeIndex != null
|
|
||||||
? activeIndex
|
|
||||||
: levels.findIndex(level => level.id === activeLevelId)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`progression-tracker ${className}`}>
|
|
||||||
<header className="progression-tracker-header">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="progression-track">
|
|
||||||
{levels.map((level, index) => {
|
|
||||||
const status = resolveStatus(level, index, resolvedActiveIndex)
|
|
||||||
const levelClass = `progression-level is-${status}`
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<div className="progression-node" aria-hidden="true">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="progression-info">
|
|
||||||
<h3>{level.label}</h3>
|
|
||||||
{level.description && <p>{level.description}</p>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={level.id || level.label}
|
|
||||||
className={levelClass}
|
|
||||||
aria-current={status === 'current' ? 'step' : undefined}
|
|
||||||
>
|
|
||||||
{onSelect ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="progression-level-button"
|
|
||||||
onClick={() => onSelect(level, index)}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
content
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProgressionTracker
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import ExerciseCard from './ExerciseCard'
|
|
||||||
import './exerciseRecommendations.css'
|
|
||||||
|
|
||||||
const normalizeGroupLabel = (item) => {
|
|
||||||
return item.group || item.category || item.level || item.progression_level || 'Recommended'
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupRecommendations = (items) => {
|
|
||||||
if (!Array.isArray(items)) return []
|
|
||||||
const groups = items.reduce((acc, item) => {
|
|
||||||
const label = normalizeGroupLabel(item)
|
|
||||||
if (!acc[label]) acc[label] = []
|
|
||||||
acc[label].push(item)
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
return Object.entries(groups).map(([title, recommendations]) => ({
|
|
||||||
id: title,
|
|
||||||
title,
|
|
||||||
recommendations
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecommendationPanel({
|
|
||||||
title = 'Recommended Exercises',
|
|
||||||
subtitle,
|
|
||||||
recommendations = [],
|
|
||||||
groups,
|
|
||||||
layout = 'grid',
|
|
||||||
onSelect,
|
|
||||||
emptyMessage = 'No recommendations available yet.',
|
|
||||||
className = ''
|
|
||||||
}) {
|
|
||||||
const resolvedGroups = Array.isArray(groups) && groups.length > 0
|
|
||||||
? groups
|
|
||||||
: groupRecommendations(recommendations)
|
|
||||||
|
|
||||||
const hasContent = resolvedGroups.some(group => group.recommendations?.length)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`recommendation-panel ${className}`}>
|
|
||||||
<div className="recommendation-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>{title}</h2>
|
|
||||||
{subtitle && <p>{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hasContent && (
|
|
||||||
<div className="recommendation-empty">{emptyMessage}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasContent && (
|
|
||||||
<div className="recommendation-panel-body">
|
|
||||||
{resolvedGroups.map(group => (
|
|
||||||
<div key={group.id || group.title} className="recommendation-group">
|
|
||||||
<div className="recommendation-group-header">
|
|
||||||
<h3>{group.title}</h3>
|
|
||||||
{group.description && <span>{group.description}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={`recommendation-list recommendation-list--${layout}`}>
|
|
||||||
{(group.recommendations || group.items || []).map(item => (
|
|
||||||
<ExerciseCard
|
|
||||||
key={item.id || `${group.title}-${item.name}`}
|
|
||||||
exercise={item}
|
|
||||||
onSelect={onSelect}
|
|
||||||
compact={layout === 'list'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RecommendationPanel
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
.recommendation-panel {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-5);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-panel-header h2 {
|
|
||||||
font-size: var(--font-xl);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-panel-header p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-panel-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-empty {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-group-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-group-header h3 {
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-group-header span {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-list {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-list--grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.recommendation-list--list {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-recommendation-card {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: stretch;
|
|
||||||
padding: var(--space-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-align: left;
|
|
||||||
transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-recommendation-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-recommendation-card.is-compact {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-media {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-media img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
background: linear-gradient(135deg, var(--bg-card), var(--bg-secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-header h3 {
|
|
||||||
font-size: var(--font-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-meta-pill {
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.difficulty-badge {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.difficulty-easy {
|
|
||||||
background: var(--success-subtle);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.difficulty-medium {
|
|
||||||
background: var(--warning-subtle);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.difficulty-hard {
|
|
||||||
background: var(--error-subtle);
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.difficulty-custom {
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-tracker {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: var(--space-5);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-tracker-header {
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-tracker-header h2 {
|
|
||||||
font-size: var(--font-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-track {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-node {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-node::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 34px;
|
|
||||||
left: 50%;
|
|
||||||
width: 2px;
|
|
||||||
height: calc(100% + var(--space-3));
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level:last-child .progression-node::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level.is-completed .progression-node,
|
|
||||||
.progression-level.is-current .progression-node {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level.is-completed .progression-node {
|
|
||||||
color: var(--success);
|
|
||||||
border-color: var(--success);
|
|
||||||
background: var(--success-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level.is-locked .progression-node {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-info h3 {
|
|
||||||
font-size: var(--font-base);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-info p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level.is-current .progression-info h3 {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level.is-completed .progression-info h3 {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level-button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
text-align: left;
|
|
||||||
color: inherit;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
|
||||||
.progression-track {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-node::after {
|
|
||||||
top: 50%;
|
|
||||||
left: 36px;
|
|
||||||
width: calc(100% + var(--space-3));
|
|
||||||
height: 2px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level:last-child .progression-node::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progression-level,
|
|
||||||
.progression-level-button {
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
/* ============================================
|
|
||||||
EXERCISE ENCYCLOPEDIA — Dark Theme
|
|
||||||
Uses CSS variables from index.css
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Page shell */
|
|
||||||
.encyclopedia-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.encyclopedia-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-header h1 {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-back-btn {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
min-height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-back-btn:hover {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spacer keeps header balanced */
|
|
||||||
.encyclopedia-header-spacer {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main scrollable area */
|
|
||||||
.encyclopedia-main {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--space-4) var(--space-4) var(--space-8);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
max-width: 720px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search bar */
|
|
||||||
.encyclopedia-search-wrap {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-search {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 16px;
|
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-search::placeholder {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-search:hover {
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* State messages */
|
|
||||||
.encyclopedia-state {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-6) var(--space-4);
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-error {
|
|
||||||
background: var(--error-subtle);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-4);
|
|
||||||
color: var(--error);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Exercise list */
|
|
||||||
.encyclopedia-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Exercise card */
|
|
||||||
.exercise-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card:hover {
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card.exercise-card--open {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 1px var(--accent-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
cursor: pointer;
|
|
||||||
gap: var(--space-3);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-header:hover .exercise-chevron {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-info h3 {
|
|
||||||
margin: 0 0 var(--space-2);
|
|
||||||
font-size: var(--font-base);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-tag {
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-tag.exercise-tag--difficulty {
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-chevron {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-base);
|
|
||||||
transition: transform var(--transition-fast), color var(--transition-fast);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-chevron--open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded detail area */
|
|
||||||
.exercise-detail {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-instructions h4 {
|
|
||||||
margin: 0 0 var(--space-2);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-instructions p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
RESEARCH PANEL — Dark Theme
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.research-panel {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-panel-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.research-btn {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
min-height: 36px;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary.research-btn {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary.research-btn:hover:not(:disabled) {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary.research-btn {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary.research-btn:hover:not(:disabled) {
|
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary.research-btn:disabled,
|
|
||||||
.btn-secondary.research-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
RESEARCH DISPLAY — rd- prefix
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Loading state */
|
|
||||||
.rd-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3) 0;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-spinner {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: rd-spin 0.8s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rd-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-loading-text em {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error state */
|
|
||||||
.rd-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
background: var(--error-subtle);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-error-icon {
|
|
||||||
font-size: var(--font-base);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-error-message {
|
|
||||||
flex: 1;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--error);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-dismiss {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--error);
|
|
||||||
font-size: var(--font-xl);
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity var(--transition-fast);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-dismiss:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Results */
|
|
||||||
.rd-results {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-section-title {
|
|
||||||
margin: 0 0 var(--space-3);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-section-icon {
|
|
||||||
font-size: var(--font-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-count {
|
|
||||||
margin-left: auto;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: 1px var(--space-2);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Summary */
|
|
||||||
.rd-summary {
|
|
||||||
padding: var(--space-4);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-summary-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sources */
|
|
||||||
.rd-sources-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-item {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-item:hover {
|
|
||||||
border-color: var(--border-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--accent);
|
|
||||||
transition: background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-link:hover {
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-index {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-arrow {
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
opacity: 0.6;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-snippet {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 var(--space-4) var(--space-3);
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.rd-empty {
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--space-4);
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.encyclopedia-header {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-main {
|
|
||||||
padding: var(--space-3) var(--space-3) var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-card-header {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exercise-detail {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-link {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-source-snippet {
|
|
||||||
padding: var(--space-2) var(--space-3) var(--space-3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PROVIDER BADGE — AI fallback indicator
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.research-panel-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider-local { border-color: rgba(34, 197, 94, 0.4); color: #4ade80; }
|
|
||||||
.provider-gemini { border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
|
|
||||||
.provider-openrouter { border-color: rgba(234, 179, 8, 0.4); color: #facc15; }
|
|
||||||
.provider-opencode { border-color: rgba(251, 146, 60, 0.4); color: #fb923c; }
|
|
||||||
.provider-exa { border-color: rgba(56, 189, 248, 0.4); color: #38bdf8; }
|
|
||||||
.provider-unknown { border-color: var(--border); color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* Error actions row */
|
|
||||||
.rd-error-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-retry {
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
min-height: 28px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import ExerciseResearchPanel from '../components/ExerciseResearchPanel'
|
import ExerciseResearchPanel from '../components/ExerciseResearchPanel'
|
||||||
import './ExerciseEncyclopediaPage.css'
|
import './WorkoutEditPage.css'
|
||||||
|
|
||||||
const API_URL = '/api'
|
const API_URL = '/api'
|
||||||
|
|
||||||
@@ -31,100 +31,96 @@ function ExerciseEncyclopediaPage({ onBack }) {
|
|||||||
ex.name.toLowerCase().includes(search.toLowerCase())
|
ex.name.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = (exercise) =>
|
|
||||||
setSelected(prev => (prev?.id === exercise.id ? null : exercise))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="encyclopedia-page">
|
<div className="edit-page">
|
||||||
<header className="encyclopedia-header">
|
<div className="page-header">
|
||||||
<button className="encyclopedia-back-btn" onClick={onBack}>
|
<button className="back-btn" onClick={onBack}>
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
<h1>Exercise Encyclopedia</h1>
|
<h1>Exercise Encyclopedia</h1>
|
||||||
<div className="encyclopedia-header-spacer" />
|
<div style={{ width: 70 }} />
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<main className="encyclopedia-main">
|
<div className="edit-main">
|
||||||
<div className="encyclopedia-search-wrap">
|
<div className="workout-meta-card">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search exercises…"
|
placeholder="Search exercises..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="encyclopedia-search"
|
className="encyclopedia-search"
|
||||||
aria-label="Search exercises"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="encyclopedia-state">Loading exercises…</div>
|
<div className="workout-meta-card">
|
||||||
|
<p>Loading exercises...</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="encyclopedia-error" role="alert">{error}</div>
|
<div className="error-banner">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<div className="encyclopedia-list">
|
<div className="edit-exercises-list">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="encyclopedia-state">No exercises found.</div>
|
<div className="workout-meta-card">
|
||||||
|
<p>No exercises found.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map(exercise => {
|
{filtered.map(exercise => (
|
||||||
const isOpen = selected?.id === exercise.id
|
<div
|
||||||
return (
|
key={exercise.id}
|
||||||
|
className={`edit-exercise-card${selected?.id === exercise.id ? ' exercise-selected' : ''}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
key={exercise.id}
|
className="edit-card-header"
|
||||||
className={`exercise-card${isOpen ? ' exercise-card--open' : ''}`}
|
onClick={() => setSelected(selected?.id === exercise.id ? null : exercise)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<div
|
<div className="edit-card-info">
|
||||||
className="exercise-card-header"
|
<h3>{exercise.name}</h3>
|
||||||
onClick={() => toggle(exercise)}
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
role="button"
|
{exercise.difficulty && (
|
||||||
aria-expanded={isOpen}
|
<span className="muscle-group">{exercise.difficulty}</span>
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && toggle(exercise)}
|
|
||||||
>
|
|
||||||
<div className="exercise-card-info">
|
|
||||||
<h3>{exercise.name}</h3>
|
|
||||||
<div className="exercise-card-tags">
|
|
||||||
{exercise.difficulty && (
|
|
||||||
<span className="exercise-tag exercise-tag--difficulty">
|
|
||||||
{exercise.difficulty}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(exercise.muscle_groups || []).map(mg => (
|
|
||||||
<span key={mg} className="exercise-tag">{mg}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{exercise.description && (
|
|
||||||
<p className="exercise-card-description">{exercise.description}</p>
|
|
||||||
)}
|
)}
|
||||||
|
{(exercise.muscle_groups || []).map(mg => (
|
||||||
|
<span key={mg} className="muscle-group">{mg}</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className={`exercise-chevron${isOpen ? ' exercise-chevron--open' : ''}`}>
|
{exercise.description && (
|
||||||
▼
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.875rem', color: '#666' }}>
|
||||||
</span>
|
{exercise.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span style={{ color: '#999', fontSize: '1.25rem' }}>
|
||||||
{isOpen && (
|
{selected?.id === exercise.id ? '▲' : '▼'}
|
||||||
<div className="exercise-detail">
|
</span>
|
||||||
{exercise.instructions && (
|
|
||||||
<div className="exercise-instructions">
|
|
||||||
<h4>Instructions</h4>
|
|
||||||
<p>{exercise.instructions}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ExerciseResearchPanel
|
|
||||||
exerciseId={exercise.id}
|
|
||||||
exerciseName={exercise.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})}
|
{selected?.id === exercise.id && (
|
||||||
|
<div className="exercise-detail-expanded">
|
||||||
|
{exercise.instructions && (
|
||||||
|
<div className="exercise-instructions">
|
||||||
|
<h4>Instructions</h4>
|
||||||
|
<p>{exercise.instructions}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ExerciseResearchPanel
|
||||||
|
exerciseId={exercise.id}
|
||||||
|
exerciseName={exercise.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
export type Difficulty = 'Easy' | 'Medium' | 'Hard' | 'Beginner' | 'Intermediate' | 'Advanced'
|
|
||||||
|
|
||||||
export interface ExerciseRecommendation {
|
|
||||||
id?: string | number
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
difficulty?: Difficulty | string
|
|
||||||
duration?: number
|
|
||||||
duration_min?: number
|
|
||||||
durationMinutes?: number
|
|
||||||
reps?: string | number
|
|
||||||
reps_min?: number
|
|
||||||
reps_max?: number
|
|
||||||
repsMin?: number
|
|
||||||
repsMax?: number
|
|
||||||
image_url?: string
|
|
||||||
image?: string
|
|
||||||
imageUrl?: string
|
|
||||||
group?: string
|
|
||||||
category?: string
|
|
||||||
level?: string
|
|
||||||
progression_level?: string
|
|
||||||
equipment?: string[]
|
|
||||||
tags?: string[]
|
|
||||||
rationale?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecommendationGroup {
|
|
||||||
id?: string
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
recommendations?: ExerciseRecommendation[]
|
|
||||||
items?: ExerciseRecommendation[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProgressionStatus = 'completed' | 'current' | 'available' | 'locked'
|
|
||||||
|
|
||||||
export interface ProgressionLevel {
|
|
||||||
id?: string
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
status?: ProgressionStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExerciseRecommendationResponse {
|
|
||||||
recommendations: ExerciseRecommendation[]
|
|
||||||
groups?: RecommendationGroup[]
|
|
||||||
progression?: ProgressionLevel[]
|
|
||||||
meta?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"failedTests": [
|
|
||||||
"1cff6d33be29939b74bb-c25666845faaea0ae7fc",
|
|
||||||
"1cff6d33be29939b74bb-e9e8328cd1d970cad6ea",
|
|
||||||
"1cff6d33be29939b74bb-2248a6b3e98521a34137",
|
|
||||||
"1cff6d33be29939b74bb-7e76fffa3f30b98b96d5",
|
|
||||||
"1cff6d33be29939b74bb-045200a3114dcdff62ad",
|
|
||||||
"1cff6d33be29939b74bb-0ad6600c1c575c583335",
|
|
||||||
"1cff6d33be29939b74bb-95bbf51cc82f216f4a28",
|
|
||||||
"1cff6d33be29939b74bb-9dcf66b8b04cf8e4cad7",
|
|
||||||
"1cff6d33be29939b74bb-532abd6ac85eb6b633b8",
|
|
||||||
"1cff6d33be29939b74bb-2bb550a7880ccd26e0d7",
|
|
||||||
"1cff6d33be29939b74bb-9538d4b31282bda8fd5f",
|
|
||||||
"1cff6d33be29939b74bb-9b22c2a972679a47a470",
|
|
||||||
"1cff6d33be29939b74bb-ae7da4d4df1250697906",
|
|
||||||
"1cff6d33be29939b74bb-2eb19f1ae434fcc0b422",
|
|
||||||
"1cff6d33be29939b74bb-015b195164adb3714032",
|
|
||||||
"1cff6d33be29939b74bb-3156b92984b449d99fdd",
|
|
||||||
"1cff6d33be29939b74bb-38c0c6f62e80517ce0dc",
|
|
||||||
"c39c7dd450cd069ede52-4036a12ed607ba60ad4c",
|
|
||||||
"c39c7dd450cd069ede52-61b24ae6caaeb46ff912",
|
|
||||||
"c39c7dd450cd069ede52-344299ef4ebecfc6ca07"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Gravl API Tests", () => {
|
|
||||||
const BASE_URL = process.env.STAGING_URL || "http://localhost:5173";
|
|
||||||
const API_URL = process.env.API_URL || "http://localhost:5173/api";
|
|
||||||
|
|
||||||
// ========== ORIGINAL TESTS (06-04) ==========
|
|
||||||
test("homepage loads successfully", async ({ request }) => {
|
|
||||||
const response = await request.get(`${BASE_URL}/`);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const html = await response.text();
|
|
||||||
expect(html).toContain("Gravl");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("login page is accessible", async ({ request }) => {
|
|
||||||
const response = await request.get(`${BASE_URL}/login`);
|
|
||||||
expect([200, 301, 302]).toContain(response.status());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("API connectivity check", async ({ request }) => {
|
|
||||||
// Check if backend API is accessible
|
|
||||||
const response = await request.get(`${BASE_URL}/`);
|
|
||||||
expect(response.status()).toBeLessThan(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== NEW TESTS: EXERCISE API ENDPOINTS (06-05) ==========
|
|
||||||
|
|
||||||
// Test 4: GET /api/exercises - Fetch all exercises
|
|
||||||
test("GET /api/exercises returns exercises list", async ({ request }) => {
|
|
||||||
const response = await request.get(`${API_URL}/exercises`);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(Array.isArray(data)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 5: GET /api/exercises with pagination
|
|
||||||
test("GET /api/exercises with limit and offset parameters", async ({ request }) => {
|
|
||||||
const response = await request.get(`${API_URL}/exercises?limit=5&offset=0`);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(Array.isArray(data)).toBeTruthy();
|
|
||||||
expect(data.length).toBeLessThanOrEqual(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 6: GET /api/exercises - Search functionality
|
|
||||||
test("GET /api/exercises with search query", async ({ request }) => {
|
|
||||||
const response = await request.get(`${API_URL}/exercises?search=squat`);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(Array.isArray(data)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 7: GET /api/exercises - Filter by difficulty
|
|
||||||
test("GET /api/exercises with difficulty filter", async ({ request }) => {
|
|
||||||
const response = await request.get(`${API_URL}/exercises?difficulty=beginner`);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(Array.isArray(data)).toBeTruthy();
|
|
||||||
if (data.length > 0) {
|
|
||||||
data.forEach((exercise) => {
|
|
||||||
expect(["beginner", "intermediate", "advanced"]).toContain(exercise.difficulty);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 8: GET /api/exercises/:id - Get non-existent exercise (404 error handling)
|
|
||||||
test("GET /api/exercises/:id returns 404 for non-existent ID", async ({ request }) => {
|
|
||||||
const response = await request.get(`${API_URL}/exercises/99999`);
|
|
||||||
expect(response.status()).toBe(404);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== NEW TESTS: DATA VALIDATION ==========
|
|
||||||
|
|
||||||
// Test 9: POST /api/exercises - Invalid payload (missing required fields)
|
|
||||||
test("POST /api/exercises rejects invalid data - missing name", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises`, {
|
|
||||||
data: {
|
|
||||||
description: "A test exercise",
|
|
||||||
difficulty: "intermediate"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
expect(data.details).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 10: POST /api/exercises - Invalid difficulty value
|
|
||||||
test("POST /api/exercises rejects invalid difficulty", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises`, {
|
|
||||||
data: {
|
|
||||||
name: "Test Exercise",
|
|
||||||
difficulty: "invalid_level",
|
|
||||||
muscle_groups: ["chest"],
|
|
||||||
equipment_needed: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 11: POST /api/exercises - Invalid array fields
|
|
||||||
test("POST /api/exercises rejects non-array muscle_groups", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises`, {
|
|
||||||
data: {
|
|
||||||
name: "Test Exercise",
|
|
||||||
difficulty: "beginner",
|
|
||||||
muscle_groups: "not_an_array",
|
|
||||||
equipment_needed: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== NEW TESTS: EXERCISE RECOMMENDATIONS API ==========
|
|
||||||
|
|
||||||
// Test 12: POST /api/exercises/recommend - Valid recommendation request
|
|
||||||
test("POST /api/exercises/recommend returns recommendations", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises/recommend`, {
|
|
||||||
data: {
|
|
||||||
fitness_level: "beginner",
|
|
||||||
goals: ["strength", "hypertrophy"],
|
|
||||||
available_time: 30
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect([200, 400]).toContain(response.status());
|
|
||||||
if (response.status() === 200) {
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.recommendations).toBeDefined();
|
|
||||||
expect(Array.isArray(data.recommendations)).toBeTruthy();
|
|
||||||
expect(data.status).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 13: POST /api/exercises/recommend - Invalid fitness_level
|
|
||||||
test("POST /api/exercises/recommend rejects invalid fitness_level", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises/recommend`, {
|
|
||||||
data: {
|
|
||||||
fitness_level: "invalid_level",
|
|
||||||
goals: ["strength"],
|
|
||||||
available_time: 30
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 14: POST /api/exercises/recommend - Missing goals
|
|
||||||
test("POST /api/exercises/recommend rejects missing goals", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises/recommend`, {
|
|
||||||
data: {
|
|
||||||
fitness_level: "intermediate",
|
|
||||||
goals: [],
|
|
||||||
available_time: 30
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 15: POST /api/exercises/recommend - Invalid available_time
|
|
||||||
test("POST /api/exercises/recommend rejects invalid available_time", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises/recommend`, {
|
|
||||||
data: {
|
|
||||||
fitness_level: "advanced",
|
|
||||||
goals: ["fat_loss"],
|
|
||||||
available_time: -10
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== NEW TESTS: FRONTEND INTEGRATION ==========
|
|
||||||
|
|
||||||
// Test 16: Multiple API calls - Simulating user flow
|
|
||||||
test("Frontend integration flow - exercises then recommendations", async ({ request }) => {
|
|
||||||
const exercisesResponse = await request.get(`${API_URL}/exercises?limit=3`);
|
|
||||||
expect(exercisesResponse.status()).toBe(200);
|
|
||||||
const exercises = await exercisesResponse.json();
|
|
||||||
|
|
||||||
const recommendResponse = await request.post(`${API_URL}/exercises/recommend`, {
|
|
||||||
data: {
|
|
||||||
fitness_level: "intermediate",
|
|
||||||
goals: ["strength"],
|
|
||||||
available_time: 45
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect([200, 400]).toContain(recommendResponse.status());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 17: Error handling - HTTP status codes
|
|
||||||
test("API returns appropriate HTTP status codes", async ({ request }) => {
|
|
||||||
const endpoints = [
|
|
||||||
{ method: "get", url: `${API_URL}/exercises`, expectedStatus: 200 },
|
|
||||||
{
|
|
||||||
method: "post",
|
|
||||||
url: `${API_URL}/exercises`,
|
|
||||||
expectedStatus: 400,
|
|
||||||
data: { description: "missing name" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "get",
|
|
||||||
url: `${API_URL}/exercises/nonexistent`,
|
|
||||||
expectedStatus: 404
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
let response;
|
|
||||||
if (endpoint.method === "get") {
|
|
||||||
response = await request.get(endpoint.url);
|
|
||||||
} else {
|
|
||||||
response = await request.post(endpoint.url, { data: endpoint.data });
|
|
||||||
}
|
|
||||||
expect(response.status()).toBe(endpoint.expectedStatus);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 18: Response content-type validation
|
|
||||||
test("API responses have correct content-type", async ({ request }) => {
|
|
||||||
const response = await request.get(`${API_URL}/exercises`);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const contentType = response.headers()["content-type"];
|
|
||||||
expect(contentType).toContain("application/json");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 19: POST with comma-separated goals
|
|
||||||
test("POST /api/exercises/recommend with comma-separated goals", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises/recommend`, {
|
|
||||||
data: {
|
|
||||||
fitness_level: "advanced",
|
|
||||||
goals: "strength,hypertrophy",
|
|
||||||
available_time: 60
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect([200, 400]).toContain(response.status());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test 20: Data validation - empty string handling
|
|
||||||
test("POST /api/exercises rejects empty name string", async ({ request }) => {
|
|
||||||
const response = await request.post(`${API_URL}/exercises`, {
|
|
||||||
data: {
|
|
||||||
name: " ",
|
|
||||||
difficulty: "beginner",
|
|
||||||
muscle_groups: [],
|
|
||||||
equipment_needed: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.error).toContain("Validation failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +1,17 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
const { test, expect } = require("@playwright/test");
|
||||||
|
|
||||||
test.describe("Gravl UI Tests (Browser-based)", () => {
|
test("login page loads", async ({ page }) => {
|
||||||
// NOTE: These tests require system graphics libraries (libXcomposite, libX11, etc.)
|
await page.goto("/login");
|
||||||
// which are not available in the current environment.
|
await expect(page.locator("form")).toBeVisible();
|
||||||
// See: TESTING.md for browser setup instructions
|
});
|
||||||
|
|
||||||
test("login page loads", async ({ page }) => {
|
test("logo exists", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.locator("form")).toBeVisible();
|
const logo = await page.locator("svg, img[class*=logo], .logo").first();
|
||||||
});
|
await expect(logo).toBeVisible();
|
||||||
|
});
|
||||||
test("logo exists", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
test("dashboard loads", async ({ page }) => {
|
||||||
const logo = await page.locator("svg, img[class*=logo], .logo").first();
|
await page.goto("/");
|
||||||
await expect(logo).toBeVisible();
|
await expect(page).toHaveTitle(/Gravl/);
|
||||||
});
|
|
||||||
|
|
||||||
test("dashboard loads", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page).toHaveTitle(/Gravl/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Gravl Build Status Checker
|
|
||||||
#
|
|
||||||
# Purpose:
|
|
||||||
# Verifies that deployed containers match the current git HEAD.
|
|
||||||
# Warns if containers are stale (built from older commits).
|
|
||||||
# Helps you catch situations where code was updated but not redeployed.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Gets current local git commit (HEAD)
|
|
||||||
# 2. Queries each container's build labels
|
|
||||||
# 3. Compares container label commit vs local HEAD
|
|
||||||
# 4. Reports status: "OK", "STALE", or "WARNING"
|
|
||||||
#
|
|
||||||
# Exit codes:
|
|
||||||
# 0 = All checks completed (see output for individual status)
|
|
||||||
# (Warnings don't cause non-zero exit)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/build-check.sh
|
|
||||||
#
|
|
||||||
# Example output:
|
|
||||||
# Local HEAD: abc1234 (abc1234567890abcdef...)
|
|
||||||
#
|
|
||||||
# [gravl-backend] Built: abc1234 on 2026-03-03T18:21:00Z
|
|
||||||
# [gravl-backend] OK: up to date
|
|
||||||
# [gravl-frontend] Built: abc1234 on 2026-03-03T18:21:00Z
|
|
||||||
# [gravl-frontend] OK: up to date
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
|
|
||||||
# Get the current local git commit (what's checked out locally)
|
|
||||||
LOCAL_COMMIT=$(git rev-parse HEAD)
|
|
||||||
echo "Local HEAD: $(git rev-parse --short HEAD) ($LOCAL_COMMIT)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# check() helper function
|
|
||||||
# ============================================================================
|
|
||||||
# Queries a container's build labels and compares against local HEAD.
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# $1 = Container name (e.g., "gravl-backend")
|
|
||||||
#
|
|
||||||
# Label fields used:
|
|
||||||
# org.opencontainers.image.revision = commit hash when image was built
|
|
||||||
# Format: 40-character SHA (same as git rev-parse HEAD)
|
|
||||||
# Set by: scripts/deploy.sh -> docker compose build args
|
|
||||||
#
|
|
||||||
# org.opencontainers.image.created = RFC3339 timestamp when image was built
|
|
||||||
# Format: 2026-03-03T18:21:00Z
|
|
||||||
# Set by: scripts/deploy.sh -> docker compose build args
|
|
||||||
# Purpose: Shows humans when the image was built (for diagnostics)
|
|
||||||
#
|
|
||||||
# Status outcomes:
|
|
||||||
# - "Not running": Container doesn't exist or isn't running
|
|
||||||
# - "WARNING": Container exists but has no revision label
|
|
||||||
# Fix: Re-deploy with scripts/deploy.sh
|
|
||||||
# - "OK": Container label commit = local HEAD (up to date)
|
|
||||||
# - "STALE": Container label commit != local HEAD
|
|
||||||
# Fix: Run scripts/deploy.sh to update container
|
|
||||||
check() {
|
|
||||||
local name="$1"
|
|
||||||
|
|
||||||
# Check if container exists and is running
|
|
||||||
if ! docker inspect "$name" &>/dev/null; then
|
|
||||||
echo "[$name] Not running"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract build labels from container config
|
|
||||||
# These labels are set in the docker-compose.yml build args,
|
|
||||||
# and the Dockerfile COPYs them into image labels.
|
|
||||||
local commit date
|
|
||||||
commit=$(docker inspect "$name" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null)
|
|
||||||
date=$(docker inspect "$name" --format '{{index .Config.Labels "org.opencontainers.image.created"}}' 2>/dev/null)
|
|
||||||
|
|
||||||
# Check if revision label exists
|
|
||||||
if [ -z "$commit" ] || [ "$commit" = "unknown" ]; then
|
|
||||||
echo "[$name] WARNING: no build label found — redeploy with scripts/deploy.sh to add tracking"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Display when this container's image was built
|
|
||||||
echo "[$name] Built: ${commit:0:7} on ${date:-unknown}"
|
|
||||||
|
|
||||||
# Compare container's commit against local HEAD
|
|
||||||
# If they match, container is up to date.
|
|
||||||
# If they differ, code has changed locally but container hasn't been redeployed.
|
|
||||||
if [ "$commit" = "$LOCAL_COMMIT" ]; then
|
|
||||||
echo "[$name] ✓ OK: up to date"
|
|
||||||
else
|
|
||||||
echo "[$name] ⚠ STALE: container is behind local code — run scripts/deploy.sh"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Check Each Service
|
|
||||||
# ============================================================================
|
|
||||||
# These are the service names defined in docker-compose.yml.
|
|
||||||
# Adjust if you rename services.
|
|
||||||
check "gravl-backend"
|
|
||||||
check "gravl-frontend"
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Gravl Deployment Script
|
|
||||||
#
|
|
||||||
# Purpose:
|
|
||||||
# Automates the deployment of Gravl services to production/staging.
|
|
||||||
# Ensures fresh builds and verifies service health after startup.
|
|
||||||
#
|
|
||||||
# Prevents stale containers by always building fresh with --no-cache:
|
|
||||||
# The --no-cache flag rebuilds all Docker layers from scratch.
|
|
||||||
# This prevents stale application code, assets, or dependencies
|
|
||||||
# from being cached and deployed. Essential for reliable deployments.
|
|
||||||
#
|
|
||||||
# Workflow:
|
|
||||||
# 1. Pull latest code from git
|
|
||||||
# 2. Capture build metadata (commit hash, timestamp)
|
|
||||||
# 3. Build Docker images (--no-cache for freshness)
|
|
||||||
# 4. Start containers with new images
|
|
||||||
# 5. Health check: wait for backend to respond
|
|
||||||
#
|
|
||||||
# Exit codes:
|
|
||||||
# 0 = Success (deployment complete, services healthy)
|
|
||||||
# 1 = Failure (see error message in logs)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/deploy.sh
|
|
||||||
#
|
|
||||||
# Logs:
|
|
||||||
# All output saved to logs/deploy.log (see tail to follow)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
LOG_FILE="$REPO_DIR/logs/deploy.log"
|
|
||||||
BACKEND_HEALTH="http://localhost:3001/api/health"
|
|
||||||
|
|
||||||
# Logging helper: prints timestamp + message to both stdout and log file
|
|
||||||
log() {
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure logs directory exists
|
|
||||||
mkdir -p "$REPO_DIR/logs"
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
|
|
||||||
log "=== Deploy started ==="
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# STEP 1: Git Pull
|
|
||||||
# ============================================================================
|
|
||||||
# Fetches latest code from remote and merges into current branch.
|
|
||||||
# Fails if there are merge conflicts (manual intervention required).
|
|
||||||
log "Pulling latest code..."
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# STEP 2: Capture Build Metadata
|
|
||||||
# ============================================================================
|
|
||||||
# Build labels are attached to Docker images and stored in container labels.
|
|
||||||
# These are used by build-check.sh to verify deployed containers match local HEAD.
|
|
||||||
#
|
|
||||||
# Labels:
|
|
||||||
# org.opencontainers.image.revision = git commit hash (40-char SHA)
|
|
||||||
# Purpose: Track which commit the image was built from
|
|
||||||
# Example: abc1234567890abcdef1234567890abcdef123456
|
|
||||||
#
|
|
||||||
# org.opencontainers.image.created = RFC3339 timestamp
|
|
||||||
# Purpose: Track when the image was built
|
|
||||||
# Example: 2026-03-03T18:21:00Z
|
|
||||||
GIT_COMMIT=$(git rev-parse HEAD)
|
|
||||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
log "Commit: $(git rev-parse --short HEAD) | Date: $BUILD_DATE"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# STEP 3: Build Docker Images (--no-cache)
|
|
||||||
# ============================================================================
|
|
||||||
# Why --no-cache?
|
|
||||||
# Docker layer caching can hide stale assets (CSS, JS bundles, dependencies).
|
|
||||||
# Example: If package.json changes but npm install is cached, old dependencies are used.
|
|
||||||
# --no-cache forces full rebuild of all layers every time.
|
|
||||||
#
|
|
||||||
# Build args are passed to Dockerfile via export, allowing them to be used
|
|
||||||
# in RUN instructions or referenced in labels (see docker-compose.yml).
|
|
||||||
log "Building images (--no-cache to prevent stale assets)..."
|
|
||||||
export GIT_COMMIT BUILD_DATE
|
|
||||||
docker compose build --no-cache
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# STEP 4: Start Containers with New Images
|
|
||||||
# ============================================================================
|
|
||||||
# docker compose up -d --force-recreate:
|
|
||||||
# -d = Run in background (detached mode)
|
|
||||||
# --force-recreate = Stop and remove existing containers, start fresh
|
|
||||||
# Ensures old containers with old images are not reused.
|
|
||||||
#
|
|
||||||
# This step also networks containers (creates/reuses docker network).
|
|
||||||
log "Starting containers..."
|
|
||||||
docker compose up -d --force-recreate
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# STEP 5: Health Check
|
|
||||||
# ============================================================================
|
|
||||||
# Waits for backend to respond on /api/health endpoint.
|
|
||||||
# This proves the service started correctly and is ready for traffic.
|
|
||||||
#
|
|
||||||
# Timeout configuration:
|
|
||||||
# Loop: 12 iterations
|
|
||||||
# Interval: 5 seconds per iteration
|
|
||||||
# Total: 60 seconds max wait time
|
|
||||||
#
|
|
||||||
# Why 60 seconds?
|
|
||||||
# - Docker startup: ~5-10 seconds
|
|
||||||
# - Node.js app initialization: ~5 seconds
|
|
||||||
# - Database connection: ~5-10 seconds
|
|
||||||
# - Buffer for system load: ~30 seconds
|
|
||||||
#
|
|
||||||
# If this timeout is too short, you may see false negatives (healthy app fails check).
|
|
||||||
# If too long, deployment takes unnecessarily long to fail.
|
|
||||||
#
|
|
||||||
# Endpoint details:
|
|
||||||
# URL: http://localhost:3001/api/health
|
|
||||||
# Method: GET
|
|
||||||
# Expected status: 200
|
|
||||||
# Should complete in <1 second
|
|
||||||
log "Health check: waiting for backend (60s timeout)..."
|
|
||||||
for i in $(seq 1 12); do
|
|
||||||
if curl -sf "$BACKEND_HEALTH" >/dev/null 2>&1; then
|
|
||||||
log "✓ Backend healthy"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ "$i" -eq 12 ]; then
|
|
||||||
log "✗ ERROR: Health check failed after 60s"
|
|
||||||
log " Try: docker logs gravl-backend | tail -20"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
log " Waiting... ($i/12 attempts, 5s intervals)"
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
log "=== Deploy complete: ${GIT_COMMIT:0:7} ==="
|
|
||||||
Reference in New Issue
Block a user