Compare commits
10 Commits
6a8d55c0b9
...
9c5b8466f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c5b8466f6 | |||
| 714194f2d3 | |||
| 00d902a396 | |||
| 267d45f98a | |||
| 39198fdf8c | |||
| b2915bca9c | |||
| 5a0cdf7450 | |||
| 5ec3aba8b3 | |||
| 2ffee63a59 | |||
| 6c3dd34412 |
@@ -4,25 +4,34 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
|
|
||||||
## Current State Summary
|
## Current State Summary
|
||||||
|
|
||||||
|
### Overall Status: 749 tests passing across 42 test files
|
||||||
|
|
||||||
### Library Implementation
|
### Library Implementation
|
||||||
| File | Status | Gap Analysis |
|
| File | Status | Gap Analysis |
|
||||||
|------|--------|--------------|
|
|------|--------|--------------|
|
||||||
| `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready |
|
| `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready |
|
||||||
| `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance |
|
| `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance |
|
||||||
| `email.ts` | **COMPLETE** | 14 tests covering sendDailyEmail, sendPeriodConfirmationEmail, email formatting, subject lines |
|
| `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines |
|
||||||
| `ics.ts` | **COMPLETE** | 23 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling |
|
| `ics.ts` | **COMPLETE** | 23 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling |
|
||||||
| `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation |
|
| `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation |
|
||||||
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
|
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
|
||||||
| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation |
|
| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation |
|
||||||
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
|
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
|
||||||
| `auth-middleware.ts` | **COMPLETE** | 6 tests covering `withAuth()` wrapper for API route protection |
|
| `auth-middleware.ts` | **COMPLETE** | 9 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures |
|
||||||
| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login |
|
| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login |
|
||||||
|
| `logger.ts` | **COMPLETE** | 16 tests covering JSON output, log levels, error stack traces, child loggers |
|
||||||
|
| `metrics.ts` | **COMPLETE** | 33 tests covering metrics collection, counters, gauges, histograms, Prometheus format |
|
||||||
|
|
||||||
### Missing Infrastructure Files (CONFIRMED NOT EXIST)
|
### Infrastructure Gaps (from specs/ - pending implementation)
|
||||||
- ~~`src/lib/auth-middleware.ts`~~ - **CREATED** in P0.2
|
| Gap | Spec Reference | Task | Priority |
|
||||||
- ~~`src/middleware.ts`~~ - **CREATED** in P0.2
|
|-----|----------------|------|----------|
|
||||||
|
| Health Check Endpoint | specs/observability.md | P2.15 | **COMPLETE** |
|
||||||
|
| Prometheus Metrics | specs/observability.md | P2.16 | **COMPLETE** |
|
||||||
|
| Structured Logging (pino) | specs/observability.md | P2.17 | **COMPLETE** |
|
||||||
|
| OIDC Authentication | specs/authentication.md | P2.18 | **COMPLETE** |
|
||||||
|
| Token Expiration Warnings | specs/email.md | P3.9 | **COMPLETE** |
|
||||||
|
|
||||||
### API Routes (15 total)
|
### API Routes (17 total)
|
||||||
| Route | Status | Notes |
|
| Route | Status | Notes |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
|
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
|
||||||
@@ -37,9 +46,11 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) |
|
| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) |
|
||||||
| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) |
|
| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) |
|
||||||
| POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) |
|
| POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) |
|
||||||
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) |
|
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs, sends token expiration warnings (32 tests) |
|
||||||
| POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) |
|
| POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) |
|
||||||
| GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) |
|
| GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) |
|
||||||
|
| GET /api/health | **COMPLETE** | Health check for deployment monitoring (14 tests) |
|
||||||
|
| GET /metrics | **COMPLETE** | 33 tests (18 lib + 15 route) |
|
||||||
|
|
||||||
### Pages (7 total)
|
### Pages (7 total)
|
||||||
| Page | Status | Notes |
|
| Page | Status | Notes |
|
||||||
@@ -50,7 +61,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| Settings/Garmin (`/settings/garmin`) | **COMPLETE** | Token management UI, connection status, disconnect functionality, 27 tests |
|
| Settings/Garmin (`/settings/garmin`) | **COMPLETE** | Token management UI, connection status, disconnect functionality, 27 tests |
|
||||||
| Calendar (`/calendar`) | **COMPLETE** | MonthView with navigation, ICS subscription section, token regeneration, 23 tests |
|
| Calendar (`/calendar`) | **COMPLETE** | MonthView with navigation, ICS subscription section, token regeneration, 23 tests |
|
||||||
| History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests |
|
| History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests |
|
||||||
| Plan (`/plan`) | Placeholder | Needs phase details display |
|
| Plan (`/plan`) | **COMPLETE** | Phase overview, training guidelines, rebounding techniques, 16 tests |
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
@@ -60,7 +71,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `NutritionPanel` | **COMPLETE** | Shows seeds, carbs, keto guidance |
|
| `NutritionPanel` | **COMPLETE** | Shows seeds, carbs, keto guidance |
|
||||||
| `OverrideToggles` | **COMPLETE** | Toggle buttons with callbacks |
|
| `OverrideToggles` | **COMPLETE** | Toggle buttons with callbacks |
|
||||||
| `DayCell` | **COMPLETE** | Phase-colored day with click handler |
|
| `DayCell` | **COMPLETE** | Phase-colored day with click handler |
|
||||||
| `MiniCalendar` | **Partial (~30%)** | Has header only, **MISSING: calendar grid** |
|
| `MiniCalendar` | **COMPLETE** | Compact calendar widget with phase colors, navigation, legend (23 tests) |
|
||||||
| `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend |
|
| `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend |
|
||||||
|
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
@@ -69,7 +80,9 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/lib/cycle.test.ts` | **EXISTS** - 9 tests |
|
| `src/lib/cycle.test.ts` | **EXISTS** - 9 tests |
|
||||||
| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) |
|
| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) |
|
||||||
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
|
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
|
||||||
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 6 tests (withAuth wrapper, error handling) |
|
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 9 tests (withAuth wrapper, error handling, structured logging) |
|
||||||
|
| `src/lib/logger.test.ts` | **EXISTS** - 16 tests (JSON format, log levels, error serialization, child loggers) |
|
||||||
|
| `src/lib/metrics.test.ts` | **EXISTS** - 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) |
|
||||||
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
|
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
|
||||||
| `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) |
|
| `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) |
|
||||||
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
|
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
|
||||||
@@ -79,32 +92,43 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/app/login/page.test.tsx` | **EXISTS** - 14 tests (form rendering, auth flow, error handling, validation) |
|
| `src/app/login/page.test.tsx` | **EXISTS** - 14 tests (form rendering, auth flow, error handling, validation) |
|
||||||
| `src/app/page.test.tsx` | **EXISTS** - 23 tests (data fetching, component rendering, override toggles, error handling) |
|
| `src/app/page.test.tsx` | **EXISTS** - 23 tests (data fetching, component rendering, override toggles, error handling) |
|
||||||
| `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) |
|
| `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) |
|
||||||
| `src/lib/email.test.ts` | **EXISTS** - 14 tests (email content, subject lines, formatting) |
|
| `src/lib/email.test.ts` | **EXISTS** - 24 tests (email content, subject lines, formatting, token expiration warnings) |
|
||||||
| `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) |
|
| `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) |
|
||||||
| `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) |
|
| `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) |
|
||||||
| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) |
|
| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) |
|
||||||
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
|
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
|
||||||
| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) |
|
| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) |
|
||||||
| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) |
|
| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 32 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling) |
|
||||||
| `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) |
|
| `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) |
|
||||||
| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) |
|
| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) |
|
||||||
| `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) |
|
| `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) |
|
||||||
| `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) |
|
| `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) |
|
||||||
|
| `src/app/api/health/route.test.ts` | **EXISTS** - 14 tests (healthy/unhealthy states, PocketBase connectivity, error handling) |
|
||||||
| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
|
| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
|
||||||
|
| `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) |
|
||||||
| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 21 tests (calendar grid, phase colors, navigation, legend) |
|
| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 21 tests (calendar grid, phase colors, navigation, legend) |
|
||||||
| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) |
|
| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) |
|
||||||
| E2E tests | **NONE** |
|
| `src/app/settings/page.test.tsx` | **EXISTS** - 24+ tests (form rendering, validation, submission) |
|
||||||
|
| `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) |
|
||||||
|
| `src/components/dashboard/decision-card.test.tsx` | **EXISTS** - 11 tests (rendering, status icons, styling) |
|
||||||
|
| `src/components/dashboard/data-panel.test.tsx` | **EXISTS** - 18 tests (biometrics display, null handling, styling) |
|
||||||
|
| `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 12 tests (seeds, carbs, keto guidance) |
|
||||||
|
| `src/components/dashboard/override-toggles.test.tsx` | **EXISTS** - 18 tests (toggle states, callbacks, styling) |
|
||||||
|
| `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) |
|
||||||
|
| `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 23 tests (phase coloring, today highlighting, click handling) |
|
||||||
|
| `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) |
|
||||||
|
| E2E tests | **AUTHORIZED SKIP** - Per specs/testing.md |
|
||||||
|
|
||||||
### Critical Business Rules (from Spec)
|
### Critical Business Rules (from Spec)
|
||||||
1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order)
|
1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order)
|
||||||
2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable)
|
2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable)
|
||||||
3. **Phase Limits:** Strictly enforced per phase configuration
|
3. **Phase Limits:** Strictly enforced per phase configuration
|
||||||
4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry
|
4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry (IMPLEMENTED - P3.9 COMPLETE)
|
||||||
5. **ICS Feed:** Generates 90 days of phase events for calendar subscription
|
5. **ICS Feed:** Generates 90 days of phase events for calendar subscription
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P0: Critical Blockers
|
## P0: Critical Blockers ✅ ALL COMPLETE
|
||||||
|
|
||||||
These must be completed first - nothing else works without them.
|
These must be completed first - nothing else works without them.
|
||||||
|
|
||||||
@@ -158,7 +182,7 @@ These must be completed first - nothing else works without them.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P1: Core Functionality
|
## P1: Core Functionality ✅ ALL COMPLETE
|
||||||
|
|
||||||
Minimum viable product - app can be used for daily decisions.
|
Minimum viable product - app can be used for daily decisions.
|
||||||
|
|
||||||
@@ -295,7 +319,7 @@ Full feature set for production use.
|
|||||||
- `connected` - Boolean indicating if tokens exist
|
- `connected` - Boolean indicating if tokens exist
|
||||||
- `daysUntilExpiry` - Days until token expires (null if not connected)
|
- `daysUntilExpiry` - Days until token expires (null if not connected)
|
||||||
- `expired` - Boolean indicating if tokens have expired
|
- `expired` - Boolean indicating if tokens have expired
|
||||||
- `warningLevel` - "critical" (≤7 days), "warning" (8-14 days), or null
|
- `warningLevel` - "critical" (<=7 days), "warning" (8-14 days), or null
|
||||||
- **Why:** Users need visibility into their Garmin connection
|
- **Why:** Users need visibility into their Garmin connection
|
||||||
- **Depends On:** P0.1, P0.2, P2.1
|
- **Depends On:** P0.1, P0.2, P2.1
|
||||||
|
|
||||||
@@ -304,7 +328,7 @@ Full feature set for production use.
|
|||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog
|
- `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/app/api/cron/garmin-sync/route.test.ts` - 22 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling
|
- `src/app/api/cron/garmin-sync/route.test.ts` - 32 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling
|
||||||
- **Features Implemented:**
|
- **Features Implemented:**
|
||||||
- Fetches all users with garminConnected=true
|
- Fetches all users with garminConnected=true
|
||||||
- Skips users with expired tokens
|
- Skips users with expired tokens
|
||||||
@@ -312,6 +336,7 @@ Full feature set for production use.
|
|||||||
- Calculates cycle day, phase, phase limit, remaining minutes
|
- Calculates cycle day, phase, phase limit, remaining minutes
|
||||||
- Computes training decision using decision engine
|
- Computes training decision using decision engine
|
||||||
- Creates DailyLog entries for each user
|
- Creates DailyLog entries for each user
|
||||||
|
- Sends token expiration warning emails at 14 and 7 days before expiry
|
||||||
- Returns sync summary (usersProcessed, errors, skippedExpired, timestamp)
|
- Returns sync summary (usersProcessed, errors, skippedExpired, timestamp)
|
||||||
- **Why:** Automated data sync is required for morning notifications
|
- **Why:** Automated data sync is required for morning notifications
|
||||||
- **Depends On:** P2.1, P2.2
|
- **Depends On:** P2.1, P2.2
|
||||||
@@ -423,23 +448,109 @@ Full feature set for production use.
|
|||||||
- **Why:** Users want to review their training history
|
- **Why:** Users want to review their training history
|
||||||
- **Depends On:** P2.8
|
- **Depends On:** P2.8
|
||||||
|
|
||||||
### P2.13: Plan Page Implementation
|
### P2.13: Plan Page Implementation ✅ COMPLETE
|
||||||
- [ ] Phase-specific training plan view
|
- [x] Phase-specific training plan view
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/plan/page.tsx` - Current phase details, upcoming phases, limits
|
- `src/app/plan/page.tsx` - Phase overview, training guidelines, exercise reference, rebounding techniques
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- E2E test: correct phase info displayed
|
- `src/app/plan/page.test.tsx` - 16 tests covering loading states, error handling, phase display, exercise reference, rebounding techniques
|
||||||
|
- **Features Implemented:**
|
||||||
|
- Current phase status display (day, phase name, training type, weekly limit)
|
||||||
|
- Phase overview cards for all 5 phases with weekly intensity minute limits
|
||||||
|
- Strength training exercises reference with descriptions
|
||||||
|
- Rebounding techniques organized by phase (follicular and luteal)
|
||||||
|
- Weekly guidelines for each phase with training goals
|
||||||
- **Why:** Users want detailed training guidance
|
- **Why:** Users want detailed training guidance
|
||||||
- **Depends On:** P0.4, P1.3
|
- **Depends On:** P0.4, P1.3
|
||||||
|
|
||||||
### P2.14: Mini Calendar Component
|
### P2.14: Mini Calendar Component ✅ COMPLETE
|
||||||
- [ ] Dashboard overview calendar
|
- [x] Dashboard overview calendar
|
||||||
|
- **Current State:** COMPLETE - Compact calendar grid with phase colors, navigation buttons, today highlighting, phase legend
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/components/dashboard/mini-calendar.tsx` - **Complete calendar grid with phase colors**
|
- `src/components/dashboard/mini-calendar.tsx` - Complete calendar grid with DayCell integration
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- Component test: renders current month, highlights today
|
- `src/components/dashboard/mini-calendar.test.tsx` - 23 tests (calendar grid, phase colors, navigation, legend)
|
||||||
|
- **Features Implemented:**
|
||||||
|
- Calendar grid using DayCell component
|
||||||
|
- Current week/month view
|
||||||
|
- Phase color coding
|
||||||
|
- Today highlight
|
||||||
|
- Navigation buttons (prev/next month)
|
||||||
|
- Phase legend
|
||||||
- **Why:** Quick visual reference on dashboard
|
- **Why:** Quick visual reference on dashboard
|
||||||
- **Note:** Component exists with header only, needs calendar grid (~70% remaining)
|
|
||||||
|
### P2.15: Health Check Endpoint ✅ COMPLETE
|
||||||
|
- [x] GET /api/health for deployment monitoring
|
||||||
|
- **Current State:** Fully implemented with PocketBase connectivity checks
|
||||||
|
- **Files:**
|
||||||
|
- `src/app/api/health/route.ts` - Returns health status with PocketBase connectivity check
|
||||||
|
- **Tests:**
|
||||||
|
- `src/app/api/health/route.test.ts` - 14 tests for healthy (200) and unhealthy (503) states
|
||||||
|
- **Response Shape:**
|
||||||
|
- `status` - "ok" or "unhealthy"
|
||||||
|
- `timestamp` - ISO 8601 timestamp
|
||||||
|
- `version` - Application version
|
||||||
|
- **Checks Performed:**
|
||||||
|
- PocketBase connectivity
|
||||||
|
- Basic app startup complete
|
||||||
|
- **Why:** Required for Nomad health checks, load balancer probes, and uptime monitoring (per specs/observability.md)
|
||||||
|
|
||||||
|
### P2.16: Prometheus Metrics Endpoint ✅ COMPLETE
|
||||||
|
- [x] GET /metrics for monitoring
|
||||||
|
- **Current State:** Fully implemented with prom-client
|
||||||
|
- **Files:**
|
||||||
|
- `src/app/api/metrics/route.ts` - Returns Prometheus-format metrics (15 tests)
|
||||||
|
- `src/lib/metrics.ts` - Metrics collection with prom-client (18 tests)
|
||||||
|
- **Tests:**
|
||||||
|
- `src/lib/metrics.test.ts` - 18 tests covering metrics collection, counters, gauges, histograms, Prometheus format
|
||||||
|
- `src/app/api/metrics/route.test.ts` - 15 tests for Prometheus format output, metric types, route handling
|
||||||
|
- **Metrics Implemented:**
|
||||||
|
- Custom counters: `phaseflow_garmin_sync_total`, `phaseflow_email_sent_total`, `phaseflow_decision_engine_calls_total`
|
||||||
|
- Custom gauge: `phaseflow_active_users`
|
||||||
|
- Custom histogram: `phaseflow_garmin_sync_duration_seconds`
|
||||||
|
- **Integrations:**
|
||||||
|
- garmin-sync route: garminSyncTotal, garminSyncDuration, activeUsersGauge
|
||||||
|
- email.ts: emailSentTotal (daily and warning types)
|
||||||
|
- decision-engine.ts: decisionEngineCallsTotal
|
||||||
|
- **Why:** Required for Prometheus scraping and production monitoring (per specs/observability.md)
|
||||||
|
### P2.17: Structured Logging with Pino ✅ COMPLETE
|
||||||
|
- [x] Create pino-based logger with JSON output
|
||||||
|
- **Files:**
|
||||||
|
- `src/lib/logger.ts` - Pino logger configuration with LOG_LEVEL env var support
|
||||||
|
- **Tests:**
|
||||||
|
- `src/lib/logger.test.ts` - 16 tests covering JSON format, log levels, error stack traces, child loggers
|
||||||
|
- **Features Implemented:**
|
||||||
|
- JSON output to stdout for log aggregators (Loki, ELK)
|
||||||
|
- Log levels: error, warn, info, debug
|
||||||
|
- LOG_LEVEL environment variable configuration (defaults to "info")
|
||||||
|
- Error objects serialized with type, message, and stack trace
|
||||||
|
- Child logger support for bound context
|
||||||
|
- ISO 8601 timestamps
|
||||||
|
- **Why:** Required for log aggregators and production debugging (per specs/observability.md)
|
||||||
|
- **Next Step:** Integrate logger into API routes (can be done incrementally)
|
||||||
|
|
||||||
|
### P2.18: OIDC Authentication ✅ COMPLETE
|
||||||
|
- [x] Replace email/password login with OIDC (Pocket-ID)
|
||||||
|
- **Files:**
|
||||||
|
- `src/app/login/page.tsx` - OIDC button with email/password fallback
|
||||||
|
- **Tests:**
|
||||||
|
- `src/app/login/page.test.tsx` - 24 tests (10 new OIDC tests)
|
||||||
|
- **Features Implemented:**
|
||||||
|
- Auto-detection of OIDC provider via `listAuthMethods()` API
|
||||||
|
- "Sign In with Pocket-ID" button when OIDC provider is configured
|
||||||
|
- Email/password form fallback when OIDC is not available
|
||||||
|
- PocketBase `authWithOAuth2()` popup-based OAuth2 flow
|
||||||
|
- Loading states during authentication
|
||||||
|
- Error handling with user-friendly messages
|
||||||
|
- **Flow:**
|
||||||
|
1. Page checks for available auth methods on mount
|
||||||
|
2. If OIDC provider configured, shows "Sign In with Pocket-ID" button
|
||||||
|
3. User clicks button, PocketBase handles OAuth2 popup flow
|
||||||
|
4. On success, user redirected to dashboard
|
||||||
|
5. Falls back to email/password when OIDC not available
|
||||||
|
- **Environment Variables (configured in PocketBase Admin):**
|
||||||
|
- Client ID, Client Secret, Issuer URL configured in PocketBase
|
||||||
|
- **Why:** Required per specs/authentication.md for secure identity management
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -514,28 +625,54 @@ Testing, error handling, and refinements.
|
|||||||
- Response parsing for biometric data structures
|
- Response parsing for biometric data structures
|
||||||
- **Why:** External API integration robustness is now fully tested
|
- **Why:** External API integration robustness is now fully tested
|
||||||
|
|
||||||
### P3.7: Error Handling Improvements
|
### P3.7: Error Handling Improvements ✅ COMPLETE
|
||||||
- [ ] Add consistent error responses across all API routes
|
- [x] Add consistent error responses across all API routes
|
||||||
|
- [x] Replace console.error with structured pino logger
|
||||||
|
- [x] Add logging for key events per observability spec
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- All route files - Standardize error format, add logging
|
- `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging
|
||||||
- **Why:** Better debugging and user experience
|
- `src/app/api/cycle/period/route.ts` - Added "Period logged" event logging, structured error logging
|
||||||
|
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Replaced console.error with structured logger
|
||||||
### P3.8: Loading States
|
- `src/app/api/overrides/route.ts` - Added "Override toggled" event logging
|
||||||
- [ ] Add loading indicators to all pages
|
- `src/app/api/today/route.ts` - Added "Decision calculated" event logging
|
||||||
- **Files:**
|
|
||||||
- All page files - Add loading.tsx or Suspense boundaries
|
|
||||||
- **Why:** Better perceived performance
|
|
||||||
|
|
||||||
### P3.9: Token Expiration Warnings
|
|
||||||
- [ ] Email warnings at 14 and 7 days before Garmin token expiry
|
|
||||||
- **Files:**
|
|
||||||
- `src/lib/email.ts` - Add `sendTokenExpirationWarning()`
|
|
||||||
- `src/app/api/cron/garmin-sync/route.ts` - Check expiry, trigger warnings
|
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- Test warning triggers at exactly 14 days and 7 days
|
- `src/lib/auth-middleware.test.ts` - Added 3 tests for structured logging (9 total)
|
||||||
- **Why:** Users need time to refresh tokens (per spec requirement)
|
- **Events Logged (per observability spec):**
|
||||||
|
- Auth failure (warn): reason
|
||||||
|
- Period logged (info): userId, date
|
||||||
|
- Override toggled (info): userId, override, enabled
|
||||||
|
- Decision calculated (info): userId, decision, reason
|
||||||
|
- Error events (error): err object with stack trace
|
||||||
|
- **Why:** Better debugging and user experience with structured JSON logs
|
||||||
|
|
||||||
### P3.10: E2E Test Suite
|
### P3.8: Loading States ✅ COMPLETE
|
||||||
|
- [x] Add loading indicators to all pages
|
||||||
|
- **Files:**
|
||||||
|
- `src/components/dashboard/skeletons.tsx` - Skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton)
|
||||||
|
- `src/components/dashboard/skeletons.test.tsx` - 29 tests
|
||||||
|
- `src/app/loading.tsx` - Dashboard route loading state
|
||||||
|
- `src/app/calendar/loading.tsx` - Calendar route loading state
|
||||||
|
- `src/app/history/loading.tsx` - History route loading state
|
||||||
|
- `src/app/plan/loading.tsx` - Plan route loading state
|
||||||
|
- `src/app/settings/loading.tsx` - Settings route loading state
|
||||||
|
- **Features:** Skeleton placeholders with shimmer animations matching spec requirements, updated dashboard page to use skeleton components
|
||||||
|
- **Why:** Better perceived performance
|
||||||
|
### P3.9: Token Expiration Warnings ✅ COMPLETE
|
||||||
|
- [x] Email warnings at 14 and 7 days before Garmin token expiry
|
||||||
|
- **Files:**
|
||||||
|
- `src/lib/email.ts` - Added `sendTokenExpirationWarning()` function
|
||||||
|
- `src/app/api/cron/garmin-sync/route.ts` - Added token expiry checking and warning logic
|
||||||
|
- **Tests:**
|
||||||
|
- `src/lib/email.test.ts` - 10 new tests for warning email function (24 total)
|
||||||
|
- `src/app/api/cron/garmin-sync/route.test.ts` - 10 new tests for warning integration (32 total)
|
||||||
|
- **Features Implemented:**
|
||||||
|
- Sends warning email at exactly 14 days before token expiry
|
||||||
|
- Sends warning email at exactly 7 days before token expiry
|
||||||
|
- Warning logic integrated into garmin-sync cron job
|
||||||
|
- Email includes days until expiry and instructions for refreshing tokens
|
||||||
|
- **Why:** Users need time to refresh tokens (per spec requirement in specs/email.md)
|
||||||
|
|
||||||
|
### P3.10: E2E Test Suite (AUTHORIZED SKIP)
|
||||||
- [ ] Comprehensive end-to-end tests
|
- [ ] Comprehensive end-to-end tests
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `tests/e2e/*.spec.ts` - Full user flows
|
- `tests/e2e/*.spec.ts` - Full user flows
|
||||||
@@ -547,6 +684,101 @@ Testing, error handling, and refinements.
|
|||||||
- Garmin connection flow
|
- Garmin connection flow
|
||||||
- Calendar subscription
|
- Calendar subscription
|
||||||
- **Why:** Confidence in production deployment
|
- **Why:** Confidence in production deployment
|
||||||
|
- **Status:** Per specs/testing.md: "End-to-end tests are not required for MVP (authorized skip)"
|
||||||
|
|
||||||
|
### P3.11: Missing Component Tests ✅ COMPLETE
|
||||||
|
- [x] Add unit tests for untested components
|
||||||
|
- **Components Tested (5 total):**
|
||||||
|
- `src/components/dashboard/decision-card.tsx` - 11 tests for rendering decision status, icon, reason, styling
|
||||||
|
- `src/components/dashboard/data-panel.tsx` - 18 tests for biometrics display (BB, HRV, intensity), null handling, styling
|
||||||
|
- `src/components/dashboard/nutrition-panel.tsx` - 12 tests for seeds, carbs, keto guidance display
|
||||||
|
- `src/components/dashboard/override-toggles.tsx` - 18 tests for toggle states, callbacks, styling
|
||||||
|
- `src/components/calendar/day-cell.tsx` - 23 tests for phase coloring, today highlighting, click handling
|
||||||
|
- **Test Files Created:**
|
||||||
|
- `src/components/dashboard/decision-card.test.tsx` - 11 tests
|
||||||
|
- `src/components/dashboard/data-panel.test.tsx` - 18 tests
|
||||||
|
- `src/components/dashboard/nutrition-panel.test.tsx` - 12 tests
|
||||||
|
- `src/components/dashboard/override-toggles.test.tsx` - 18 tests
|
||||||
|
- `src/components/calendar/day-cell.test.tsx` - 23 tests
|
||||||
|
- **Total Tests Added:** 82 tests across 5 files
|
||||||
|
- **Why:** Component isolation ensures UI correctness and prevents regressions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P4: UX Polish and Accessibility
|
||||||
|
|
||||||
|
Enhancements from spec requirements that improve user experience.
|
||||||
|
|
||||||
|
### P4.1: Dashboard Onboarding Banners
|
||||||
|
- [ ] Show setup prompts for missing configuration
|
||||||
|
- **Spec Reference:** specs/dashboard.md mentions onboarding banners
|
||||||
|
- **Features Needed:**
|
||||||
|
- Banner when Garmin not connected
|
||||||
|
- Banner when period date not set
|
||||||
|
- Banner when notification time not configured
|
||||||
|
- Dismissible after user completes setup
|
||||||
|
- **Files:**
|
||||||
|
- `src/app/page.tsx` - Add conditional banner rendering
|
||||||
|
- `src/components/dashboard/onboarding-banner.tsx` - New component
|
||||||
|
- **Why:** Helps new users complete setup for full functionality
|
||||||
|
|
||||||
|
### P4.2: Accessibility Improvements
|
||||||
|
- [ ] Keyboard navigation and focus indicators
|
||||||
|
- **Spec Reference:** specs/dashboard.md accessibility requirements
|
||||||
|
- **Requirements:**
|
||||||
|
- Keyboard navigation for all interactive elements
|
||||||
|
- Visible focus indicators (focus:ring styles)
|
||||||
|
- 4.5:1 minimum contrast ratio
|
||||||
|
- Screen reader labels where needed
|
||||||
|
- **Files:**
|
||||||
|
- All component files - Add focus:ring classes, aria-labels
|
||||||
|
- **Why:** Required for accessibility compliance
|
||||||
|
|
||||||
|
### P4.3: Dark Mode Configuration
|
||||||
|
- [ ] Complete dark mode support
|
||||||
|
- **Current State:** Partial Tailwind support via dark: classes exists in some components
|
||||||
|
- **Needs:**
|
||||||
|
- Configure prefers-color-scheme detection in tailwind.config.js
|
||||||
|
- Theme toggle in settings (optional)
|
||||||
|
- Ensure all components have dark: variants
|
||||||
|
- Test contrast ratios in dark mode
|
||||||
|
- **Files:**
|
||||||
|
- `tailwind.config.js` - Add darkMode configuration
|
||||||
|
- Component files - Verify dark: class coverage
|
||||||
|
- **Why:** User preference for dark mode
|
||||||
|
|
||||||
|
### P4.4: Loading Performance
|
||||||
|
- [ ] Loading states within 100ms target
|
||||||
|
- **Spec Reference:** specs/dashboard.md performance requirements
|
||||||
|
- **Features:**
|
||||||
|
- Skeleton loading states
|
||||||
|
- Optimistic UI updates (partially done with overrides)
|
||||||
|
- Suspense boundaries for code splitting
|
||||||
|
- **Files:**
|
||||||
|
- Page files - Add loading.tsx skeletons
|
||||||
|
- **Why:** Perceived performance improvement
|
||||||
|
|
||||||
|
### P4.5: Period Prediction Accuracy Feedback
|
||||||
|
- [ ] Mark predicted vs confirmed period dates
|
||||||
|
- **Spec Reference:** specs/calendar.md mentions predictions marked with "Predicted" suffix
|
||||||
|
- **Features:**
|
||||||
|
- Visual distinction between logged and predicted periods
|
||||||
|
- Calendar events show "Predicted" label for future periods
|
||||||
|
- **Files:**
|
||||||
|
- `src/lib/ics.ts` - Add "Predicted" suffix to future phase events
|
||||||
|
- `src/components/calendar/day-cell.tsx` - Visual indicator for predictions
|
||||||
|
- **Why:** Helps users understand prediction accuracy
|
||||||
|
|
||||||
|
### P4.6: Rate Limiting
|
||||||
|
- [ ] Login attempt rate limiting
|
||||||
|
- **Spec Reference:** specs/email.md mentions 5 login attempts per minute
|
||||||
|
- **Features:**
|
||||||
|
- Rate limit login attempts by IP/email
|
||||||
|
- Show remaining attempts on error
|
||||||
|
- Temporary lockout after exceeding limit
|
||||||
|
- **Files:**
|
||||||
|
- `src/app/api/auth/route.ts` or PocketBase config - Rate limiting logic
|
||||||
|
- **Why:** Security requirement from spec
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -560,7 +792,7 @@ P0.3 Override Logic ───┴──> P1.4 GET /api/today ──> P1.7 Dashboa
|
|||||||
P1.1 PATCH /api/user ────> P2.9 Settings Page
|
P1.1 PATCH /api/user ────> P2.9 Settings Page
|
||||||
P1.2 POST period ────────> P1.3 GET current ────> P1.7 Dashboard
|
P1.2 POST period ────────> P1.3 GET current ────> P1.7 Dashboard
|
||||||
P1.5 Overrides API ──────> P1.7 Dashboard
|
P1.5 Overrides API ──────> P1.7 Dashboard
|
||||||
P1.6 Login Page
|
P1.6 Login Page ─────────> P2.18 OIDC Auth (upgrade)
|
||||||
|
|
||||||
P2.1 Garmin fetchers ──> P2.2 Garmin tokens ──> P2.4 Cron sync ──> P2.5 Notifications
|
P2.1 Garmin fetchers ──> P2.2 Garmin tokens ──> P2.4 Cron sync ──> P2.5 Notifications
|
||||||
│
|
│
|
||||||
@@ -573,8 +805,21 @@ P2.7 Regen token
|
|||||||
P2.8 History API ──────> P2.12 History page
|
P2.8 History API ──────> P2.12 History page
|
||||||
P2.13 Plan page
|
P2.13 Plan page
|
||||||
P2.14 Mini calendar
|
P2.14 Mini calendar
|
||||||
|
|
||||||
|
P2.15 Health endpoint (independent - HIGH PRIORITY for deployment)
|
||||||
|
P2.16 Metrics endpoint (independent)
|
||||||
|
P2.17 Structured logging (independent, but should be done before other items for proper logging)
|
||||||
|
|
||||||
|
P3.11 Component tests ─> Can be done in parallel with other work
|
||||||
|
P4.* UX Polish ────────> After core functionality complete
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Remaining Work Priority
|
||||||
|
|
||||||
|
| Priority | Task | Effort | Notes |
|
||||||
|
|----------|------|--------|-------|
|
||||||
|
| Low | P4.* UX Polish | Various | After core complete |
|
||||||
|
|
||||||
### Dependency Summary
|
### Dependency Summary
|
||||||
|
|
||||||
| Task | Blocked By | Blocks |
|
| Task | Blocked By | Blocks |
|
||||||
@@ -583,6 +828,7 @@ P2.14 Mini calendar
|
|||||||
| P0.2 | P0.1 | P0.4, P1.1-P1.5, P2.2-P2.3, P2.7-P2.8 |
|
| P0.2 | P0.1 | P0.4, P1.1-P1.5, P2.2-P2.3, P2.7-P2.8 |
|
||||||
| P0.3 | - | P1.4, P1.5 |
|
| P0.3 | - | P1.4, P1.5 |
|
||||||
| P0.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 |
|
| P0.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 |
|
||||||
|
| P3.9 | P2.4 | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -593,10 +839,14 @@ P2.14 Mini calendar
|
|||||||
- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
|
- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
|
||||||
- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`)
|
- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`)
|
||||||
- [x] **nutrition.ts** - Complete with 17 tests (`getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific carb ranges, keto guidance) (P3.2)
|
- [x] **nutrition.ts** - Complete with 17 tests (`getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific carb ranges, keto guidance) (P3.2)
|
||||||
- [x] **email.ts** - Complete with 14 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, email formatting) (P3.3)
|
- [x] **email.ts** - Complete with 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9)
|
||||||
- [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4)
|
- [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4)
|
||||||
- [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5)
|
- [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5)
|
||||||
- [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6)
|
- [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6)
|
||||||
|
- [x] **auth-middleware.ts** - Complete with 6 tests (`withAuth()` wrapper)
|
||||||
|
- [x] **middleware.ts** - Complete with 12 tests (Next.js page protection)
|
||||||
|
- [x] **logger.ts** - Complete with 16 tests (JSON output, log levels, error serialization, child loggers) (P2.17)
|
||||||
|
- [x] **metrics.ts** - Complete with 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) (P2.16)
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
- [x] **DecisionCard** - Displays decision status, icon, and reason
|
- [x] **DecisionCard** - Displays decision status, icon, and reason
|
||||||
@@ -605,8 +855,9 @@ P2.14 Mini calendar
|
|||||||
- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms
|
- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms
|
||||||
- [x] **DayCell** - Phase-colored calendar day cell with click handler
|
- [x] **DayCell** - Phase-colored calendar day cell with click handler
|
||||||
- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend, 21 tests
|
- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend, 21 tests
|
||||||
|
- [x] **MiniCalendar** - Compact calendar widget with phase colors, navigation, legend, 23 tests (P2.14)
|
||||||
|
|
||||||
### API Routes
|
### API Routes (17 complete)
|
||||||
- [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4)
|
- [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4)
|
||||||
- [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1)
|
- [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1)
|
||||||
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
|
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
|
||||||
@@ -617,23 +868,38 @@ P2.14 Mini calendar
|
|||||||
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
|
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
|
||||||
- [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2)
|
- [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2)
|
||||||
- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3)
|
- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3)
|
||||||
- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4)
|
- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, sends token expiration warnings, 32 tests (P2.4, P3.9)
|
||||||
- [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5)
|
- [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5)
|
||||||
- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6)
|
- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6)
|
||||||
- [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7)
|
- [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7)
|
||||||
- [x] **GET /api/history** - Paginated historical daily logs with date filtering, validation, 19 tests (P2.8)
|
- [x] **GET /api/history** - Paginated historical daily logs with date filtering, validation, 19 tests (P2.8)
|
||||||
|
- [x] **GET /api/health** - Health check endpoint with PocketBase connectivity check, 14 tests (P2.15)
|
||||||
|
- [x] **GET /metrics** - Prometheus metrics endpoint with counters, gauges, histograms, 33 tests (18 lib + 15 route) (P2.16)
|
||||||
|
|
||||||
### Pages
|
### Pages (7 complete)
|
||||||
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
- [x] **Login Page** - OIDC (Pocket-ID) with email/password fallback, error handling, loading states, redirect, 24 tests (P1.6, P2.18)
|
||||||
- [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7)
|
- [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7)
|
||||||
- [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9)
|
- [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9)
|
||||||
- [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10)
|
- [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10)
|
||||||
- [x] **Calendar Page** - MonthView with navigation controls, ICS subscription section with URL display, copy button, token regeneration, 23 tests (P2.11)
|
- [x] **Calendar Page** - MonthView with navigation controls, ICS subscription section with URL display, copy button, token regeneration, 23 tests (P2.11)
|
||||||
- [x] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12)
|
- [x] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12)
|
||||||
|
- [x] **Plan Page** - Phase overview, training guidance, exercise reference, rebounding techniques, 16 tests (P2.13)
|
||||||
|
|
||||||
### Test Infrastructure
|
### Test Infrastructure
|
||||||
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
|
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
|
||||||
|
|
||||||
|
### P3: Quality and Testing
|
||||||
|
- [x] **P3.1: Decision Engine Tests** - Complete with 24 tests covering all 8 priority rules and override combinations
|
||||||
|
- [x] **P3.2: Nutrition Tests** - Complete with 17 tests covering seed cycling, carb ranges, keto guidance by phase
|
||||||
|
- [x] **P3.3: Email Tests** - Complete with 24 tests covering daily emails, period confirmation, token expiration warnings
|
||||||
|
- [x] **P3.4: ICS Tests** - Complete with 23 tests covering ICS format validation, 90-day event generation, timezone handling
|
||||||
|
- [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation
|
||||||
|
- [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling
|
||||||
|
- [x] **P3.7: Error Handling Improvements** - Replaced console.error with structured pino logger across API routes, added key event logging (Period logged, Override toggled, Decision calculated, Auth failure), 3 new tests in auth-middleware.test.ts
|
||||||
|
- [x] **P3.8: Loading States** - Complete with skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton), 29 tests in skeletons.test.tsx; loading.tsx files for all routes (dashboard, calendar, history, plan, settings); shimmer animations matching spec requirements
|
||||||
|
- [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry
|
||||||
|
- [x] **P3.11: Missing Component Tests** - Complete with 82 tests across 5 component test files (DecisionCard: 11, DataPanel: 18, NutritionPanel: 12, OverrideToggles: 18, DayCell: 23)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Discovered Issues
|
## Discovered Issues
|
||||||
@@ -655,7 +921,14 @@ P2.14 Mini calendar
|
|||||||
3. **Incremental Delivery:** P1 completion = usable app without Garmin (manual data entry fallback)
|
3. **Incremental Delivery:** P1 completion = usable app without Garmin (manual data entry fallback)
|
||||||
4. **P2 Completion:** Full feature set with automation
|
4. **P2 Completion:** Full feature set with automation
|
||||||
5. **P3:** Quality and polish for production confidence
|
5. **P3:** Quality and polish for production confidence
|
||||||
6. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7
|
6. **P4:** UX polish and accessibility improvements from spec requirements
|
||||||
7. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles
|
7. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7
|
||||||
8. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms
|
8. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles
|
||||||
9. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry
|
9. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms
|
||||||
|
10. **Token Warnings:** Per spec, warnings are sent at exactly 14 days and 7 days before expiry (P3.9 COMPLETE)
|
||||||
|
11. **Health Check Priority:** P2.15 (GET /api/health) should be implemented early - it's required for deployment monitoring and load balancer health probes
|
||||||
|
12. **Structured Logging:** P2.17 (pino logger) is COMPLETE - new code should use `import { logger } from "@/lib/logger"` for all logging
|
||||||
|
13. **OIDC Authentication:** P2.18 COMPLETE - Login page auto-detects OIDC via `listAuthMethods()` and shows "Sign In with Pocket-ID" button when configured. Falls back to email/password when OIDC not available. Configure OIDC provider in PocketBase Admin under Settings → Auth providers → OpenID Connect
|
||||||
|
14. **E2E Tests:** Authorized skip per specs/testing.md - unit and integration tests are sufficient for MVP
|
||||||
|
15. **Dark Mode:** Partial Tailwind support exists via dark: classes but may need prefers-color-scheme configuration in tailwind.config.js (see P4.3)
|
||||||
|
16. **Component Tests:** P3.11 COMPLETE - All 5 dashboard and calendar components now have comprehensive unit tests (82 tests total)
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"pino": "^10.1.1",
|
||||||
"pocketbase": "^0.26.5",
|
"pocketbase": "^0.26.5",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"resend": "^6.7.0",
|
"resend": "^6.7.0",
|
||||||
|
|||||||
139
pnpm-lock.yaml
generated
139
pnpm-lock.yaml
generated
@@ -16,7 +16,7 @@ importers:
|
|||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.1
|
specifier: ^0.45.1
|
||||||
version: 0.45.1
|
version: 0.45.1(@opentelemetry/api@1.9.0)
|
||||||
ics:
|
ics:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
@@ -25,13 +25,19 @@ importers:
|
|||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
next:
|
next:
|
||||||
specifier: 16.1.1
|
specifier: 16.1.1
|
||||||
version: 16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
pino:
|
||||||
|
specifier: ^10.1.1
|
||||||
|
version: 10.1.1
|
||||||
pocketbase:
|
pocketbase:
|
||||||
specifier: ^0.26.5
|
specifier: ^0.26.5
|
||||||
version: 0.26.5
|
version: 0.26.5
|
||||||
|
prom-client:
|
||||||
|
specifier: ^15.1.3
|
||||||
|
version: 15.1.3
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
@@ -95,7 +101,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.16
|
specifier: ^4.0.16
|
||||||
version: 4.0.16(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)
|
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -961,6 +967,13 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.0':
|
||||||
|
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0':
|
||||||
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.53':
|
'@rolldown/pluginutils@1.0.0-beta.53':
|
||||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||||
|
|
||||||
@@ -1301,6 +1314,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0:
|
||||||
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.14:
|
baseline-browser-mapping@2.9.14:
|
||||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1308,6 +1325,9 @@ packages:
|
|||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
|
bintrees@1.0.2:
|
||||||
|
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
|
||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
@@ -1731,6 +1751,10 @@ packages:
|
|||||||
obug@2.1.1:
|
obug@2.1.1:
|
||||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2:
|
||||||
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.0:
|
||||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||||
|
|
||||||
@@ -1744,6 +1768,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||||
|
|
||||||
|
pino-std-serializers@7.0.0:
|
||||||
|
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||||
|
|
||||||
|
pino@10.1.1:
|
||||||
|
resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pocketbase@0.26.5:
|
pocketbase@0.26.5:
|
||||||
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
|
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
|
||||||
|
|
||||||
@@ -1759,6 +1793,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
process-warning@5.0.0:
|
||||||
|
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||||
|
|
||||||
|
prom-client@15.1.3:
|
||||||
|
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
|
||||||
|
engines: {node: ^16 || ^18 || >=20}
|
||||||
|
|
||||||
property-expr@2.0.6:
|
property-expr@2.0.6:
|
||||||
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
||||||
|
|
||||||
@@ -1766,6 +1807,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4:
|
||||||
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
react-dom@19.2.3:
|
react-dom@19.2.3:
|
||||||
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
|
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1782,6 +1826,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
real-require@0.2.0:
|
||||||
|
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||||
|
engines: {node: '>= 12.13.0'}
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1810,6 +1858,10 @@ packages:
|
|||||||
runes2@1.1.4:
|
runes2@1.1.4:
|
||||||
resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==}
|
resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==}
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0:
|
||||||
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
@@ -1833,6 +1885,9 @@ packages:
|
|||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
|
sonic-boom@4.2.0:
|
||||||
|
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1844,6 +1899,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
@@ -1886,6 +1945,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tdigest@0.1.2:
|
||||||
|
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
||||||
|
|
||||||
|
thread-stream@4.0.0:
|
||||||
|
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
tiny-case@1.0.3:
|
tiny-case@1.0.3:
|
||||||
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
|
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
|
||||||
|
|
||||||
@@ -2653,6 +2719,10 @@ snapshots:
|
|||||||
'@next/swc-win32-x64-msvc@16.1.1':
|
'@next/swc-win32-x64-msvc@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.0': {}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||||
@@ -2946,12 +3016,16 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.14: {}
|
baseline-browser-mapping@2.9.14: {}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
bintrees@1.0.2: {}
|
||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.9.14
|
baseline-browser-mapping: 2.9.14
|
||||||
@@ -3020,7 +3094,9 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.45.1: {}
|
drizzle-orm@0.45.1(@opentelemetry/api@1.9.0):
|
||||||
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
@@ -3287,7 +3363,7 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.1.1
|
'@next/env': 16.1.1
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -3306,6 +3382,7 @@ snapshots:
|
|||||||
'@next/swc-linux-x64-musl': 16.1.1
|
'@next/swc-linux-x64-musl': 16.1.1
|
||||||
'@next/swc-win32-arm64-msvc': 16.1.1
|
'@next/swc-win32-arm64-msvc': 16.1.1
|
||||||
'@next/swc-win32-x64-msvc': 16.1.1
|
'@next/swc-win32-x64-msvc': 16.1.1
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -3317,6 +3394,8 @@ snapshots:
|
|||||||
|
|
||||||
obug@2.1.1: {}
|
obug@2.1.1: {}
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 6.0.1
|
entities: 6.0.1
|
||||||
@@ -3327,6 +3406,26 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
|
pino-std-serializers@7.0.0: {}
|
||||||
|
|
||||||
|
pino@10.1.1:
|
||||||
|
dependencies:
|
||||||
|
'@pinojs/redact': 0.4.0
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
on-exit-leak-free: 2.1.2
|
||||||
|
pino-abstract-transport: 3.0.0
|
||||||
|
pino-std-serializers: 7.0.0
|
||||||
|
process-warning: 5.0.0
|
||||||
|
quick-format-unescaped: 4.0.4
|
||||||
|
real-require: 0.2.0
|
||||||
|
safe-stable-stringify: 2.5.0
|
||||||
|
sonic-boom: 4.2.0
|
||||||
|
thread-stream: 4.0.0
|
||||||
|
|
||||||
pocketbase@0.26.5: {}
|
pocketbase@0.26.5: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
@@ -3347,10 +3446,19 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
process-warning@5.0.0: {}
|
||||||
|
|
||||||
|
prom-client@15.1.3:
|
||||||
|
dependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
|
tdigest: 0.1.2
|
||||||
|
|
||||||
property-expr@2.0.6: {}
|
property-expr@2.0.6: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|
||||||
react-dom@19.2.3(react@19.2.3):
|
react-dom@19.2.3(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
@@ -3362,6 +3470,8 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.3: {}
|
react@19.2.3: {}
|
||||||
|
|
||||||
|
real-require@0.2.0: {}
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
indent-string: 4.0.0
|
indent-string: 4.0.0
|
||||||
@@ -3408,6 +3518,8 @@ snapshots:
|
|||||||
|
|
||||||
runes2@1.1.4: {}
|
runes2@1.1.4: {}
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
@@ -3453,6 +3565,10 @@ snapshots:
|
|||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
sonic-boom@4.2.0:
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -3462,6 +3578,8 @@ snapshots:
|
|||||||
|
|
||||||
source-map@0.6.1: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
standardwebhooks@1.0.0:
|
standardwebhooks@1.0.0:
|
||||||
@@ -3495,6 +3613,14 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
tdigest@0.1.2:
|
||||||
|
dependencies:
|
||||||
|
bintrees: 1.0.2
|
||||||
|
|
||||||
|
thread-stream@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
real-require: 0.2.0
|
||||||
|
|
||||||
tiny-case@1.0.3: {}
|
tiny-case@1.0.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
@@ -3556,7 +3682,7 @@ snapshots:
|
|||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
|
|
||||||
vitest@4.0.16(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2):
|
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.0.16
|
'@vitest/expect': 4.0.16
|
||||||
'@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2))
|
'@vitest/mocker': 4.0.16(vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||||
@@ -3579,6 +3705,7 @@ snapshots:
|
|||||||
vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)
|
vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/node': 20.19.27
|
'@types/node': 20.19.27
|
||||||
jsdom: 27.4.0
|
jsdom: 27.4.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import { generateIcsFeed } from "@/lib/ics";
|
import { generateIcsFeed } from "@/lib/ics";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -61,8 +62,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
|||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-throw unexpected errors
|
logger.error({ err: error, userId }, "Calendar feed error");
|
||||||
console.error("Calendar feed error:", error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const mockFetchBodyBattery = vi
|
|||||||
.mockResolvedValue({ current: 85, yesterdayLow: 45 });
|
.mockResolvedValue({ current: 85, yesterdayLow: 45 });
|
||||||
const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60);
|
const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60);
|
||||||
const mockIsTokenExpired = vi.fn().mockReturnValue(false);
|
const mockIsTokenExpired = vi.fn().mockReturnValue(false);
|
||||||
|
const mockDaysUntilExpiry = vi.fn().mockReturnValue(30);
|
||||||
|
|
||||||
vi.mock("@/lib/garmin", () => ({
|
vi.mock("@/lib/garmin", () => ({
|
||||||
fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args),
|
fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args),
|
||||||
@@ -51,6 +52,15 @@ vi.mock("@/lib/garmin", () => ({
|
|||||||
fetchIntensityMinutes: (...args: unknown[]) =>
|
fetchIntensityMinutes: (...args: unknown[]) =>
|
||||||
mockFetchIntensityMinutes(...args),
|
mockFetchIntensityMinutes(...args),
|
||||||
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
|
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
|
||||||
|
daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock email sending
|
||||||
|
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mock("@/lib/email", () => ({
|
||||||
|
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||||
|
mockSendTokenExpirationWarning(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
@@ -93,6 +103,7 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUsers = [];
|
mockUsers = [];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
||||||
process.env.CRON_SECRET = validSecret;
|
process.env.CRON_SECRET = validSecret;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,5 +392,128 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.timestamp).toBeDefined();
|
expect(body.timestamp).toBeDefined();
|
||||||
expect(new Date(body.timestamp)).toBeInstanceOf(Date);
|
expect(new Date(body.timestamp)).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes warningsSent in response", async () => {
|
||||||
|
mockUsers = [];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.warningsSent).toBeDefined();
|
||||||
|
expect(body.warningsSent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Token expiration warnings", () => {
|
||||||
|
it("sends warning email when token expires in exactly 14 days", async () => {
|
||||||
|
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(14);
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
||||||
|
"user@example.com",
|
||||||
|
14,
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.warningsSent).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends warning email when token expires in exactly 7 days", async () => {
|
||||||
|
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(7);
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
||||||
|
"user@example.com",
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.warningsSent).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send warning when token expires in 30 days", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(30);
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send warning when token expires in 15 days", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(15);
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send warning when token expires in 8 days", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(8);
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send warning when token expires in 6 days", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(6);
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends warnings for multiple users on different thresholds", async () => {
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({ id: "user1", email: "user1@example.com" }),
|
||||||
|
createMockUser({ id: "user2", email: "user2@example.com" }),
|
||||||
|
];
|
||||||
|
// First user at 14 days, second user at 7 days
|
||||||
|
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
||||||
|
"user1@example.com",
|
||||||
|
14,
|
||||||
|
);
|
||||||
|
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
|
||||||
|
"user2@example.com",
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.warningsSent).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues processing sync even if warning email fails", async () => {
|
||||||
|
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(14);
|
||||||
|
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
||||||
|
new Error("Email failed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.usersProcessed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send warning for expired tokens", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockIsTokenExpired.mockReturnValue(true);
|
||||||
|
mockDaysUntilExpiry.mockReturnValue(-1);
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
||||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||||
|
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||||
import { decrypt } from "@/lib/encryption";
|
import { decrypt } from "@/lib/encryption";
|
||||||
import {
|
import {
|
||||||
|
daysUntilExpiry,
|
||||||
fetchBodyBattery,
|
fetchBodyBattery,
|
||||||
fetchHrvStatus,
|
fetchHrvStatus,
|
||||||
fetchIntensityMinutes,
|
fetchIntensityMinutes,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
} from "@/lib/garmin";
|
} from "@/lib/garmin";
|
||||||
|
import {
|
||||||
|
activeUsersGauge,
|
||||||
|
garminSyncDuration,
|
||||||
|
garminSyncTotal,
|
||||||
|
} from "@/lib/metrics";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
import type { GarminTokens, User } from "@/types";
|
import type { GarminTokens, User } from "@/types";
|
||||||
|
|
||||||
@@ -19,6 +26,7 @@ interface SyncResult {
|
|||||||
usersProcessed: number;
|
usersProcessed: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
skippedExpired: number;
|
skippedExpired: number;
|
||||||
|
warningsSent: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +39,14 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncStartTime = Date.now();
|
||||||
|
|
||||||
const result: SyncResult = {
|
const result: SyncResult = {
|
||||||
success: true,
|
success: true,
|
||||||
usersProcessed: 0,
|
usersProcessed: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
skippedExpired: 0,
|
skippedExpired: 0,
|
||||||
|
warningsSent: 0,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,6 +72,17 @@ export async function POST(request: Request) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for token expiration warnings (exactly 14 or 7 days)
|
||||||
|
const daysRemaining = daysUntilExpiry(tokens);
|
||||||
|
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||||
|
try {
|
||||||
|
await sendTokenExpirationWarning(user.email, daysRemaining);
|
||||||
|
result.warningsSent++;
|
||||||
|
} catch {
|
||||||
|
// Continue processing even if warning email fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt OAuth2 token
|
// Decrypt OAuth2 token
|
||||||
const oauth2Json = decrypt(user.garminOauth2Token);
|
const oauth2Json = decrypt(user.garminOauth2Token);
|
||||||
const oauth2Data = JSON.parse(oauth2Json);
|
const oauth2Data = JSON.parse(oauth2Json);
|
||||||
@@ -114,10 +136,17 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
result.usersProcessed++;
|
result.usersProcessed++;
|
||||||
|
garminSyncTotal.inc({ status: "success" });
|
||||||
} catch {
|
} catch {
|
||||||
result.errors++;
|
result.errors++;
|
||||||
|
garminSyncTotal.inc({ status: "failure" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record sync duration and active users
|
||||||
|
const syncDurationSeconds = (Date.now() - syncStartTime) / 1000;
|
||||||
|
garminSyncDuration.observe(syncDurationSeconds);
|
||||||
|
activeUsersGauge.set(result.usersProcessed);
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { getCycleDay, getPhase } from "@/lib/cycle";
|
import { getCycleDay, getPhase } from "@/lib/cycle";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
|
|
||||||
interface PeriodLogRequest {
|
interface PeriodLogRequest {
|
||||||
@@ -80,6 +81,9 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
|||||||
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
|
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
|
||||||
const phase = getPhase(cycleDay);
|
const phase = getPhase(cycleDay);
|
||||||
|
|
||||||
|
// Log successful period logging per observability spec
|
||||||
|
logger.info({ userId: user.id, date: body.startDate }, "Period logged");
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Period start date logged successfully",
|
message: "Period start date logged successfully",
|
||||||
lastPeriodDate: body.startDate,
|
lastPeriodDate: body.startDate,
|
||||||
@@ -87,7 +91,7 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
|||||||
phase,
|
phase,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Period logging error:", error);
|
logger.error({ err: error, userId: user.id }, "Period logging error");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to update period date" },
|
{ error: "Failed to update period date" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
197
src/app/api/health/route.test.ts
Normal file
197
src/app/api/health/route.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// ABOUTME: Tests for health check endpoint used by deployment monitoring and load balancers.
|
||||||
|
// ABOUTME: Covers healthy (200) and unhealthy (503) states based on PocketBase connectivity.
|
||||||
|
|
||||||
|
import type Client from "pocketbase";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock PocketBase before importing the route
|
||||||
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
|
createPocketBaseClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
|
import { GET } from "./route";
|
||||||
|
|
||||||
|
const mockCreatePocketBaseClient = vi.mocked(createPocketBaseClient);
|
||||||
|
|
||||||
|
function mockPocketBaseWithHealth(checkFn: ReturnType<typeof vi.fn>): void {
|
||||||
|
const mockPb = {
|
||||||
|
health: { check: checkFn },
|
||||||
|
} as unknown as Client;
|
||||||
|
mockCreatePocketBaseClient.mockReturnValue(mockPb);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/health", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("healthy state", () => {
|
||||||
|
it("returns 200 when PocketBase is reachable", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns status ok when healthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes ISO 8601 timestamp when healthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.timestamp).toBeDefined();
|
||||||
|
// Verify it's a valid ISO 8601 date
|
||||||
|
const date = new Date(body.timestamp);
|
||||||
|
expect(date.toISOString()).toBe(body.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes version when healthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.version).toBeDefined();
|
||||||
|
expect(typeof body.version).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include error field when healthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockResolvedValue({ code: 200, message: "OK" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unhealthy state", () => {
|
||||||
|
it("returns 503 when PocketBase is unreachable", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns status unhealthy when PocketBase fails", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.status).toBe("unhealthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes ISO 8601 timestamp when unhealthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.timestamp).toBeDefined();
|
||||||
|
const date = new Date(body.timestamp);
|
||||||
|
expect(date.toISOString()).toBe(body.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes error message when unhealthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.error).toBeDefined();
|
||||||
|
expect(typeof body.error).toBe("string");
|
||||||
|
expect(body.error.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes PocketBase failure in error message", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.error).toContain("PocketBase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include version field when unhealthy", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("Connection refused")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
// Per spec, version is only in healthy response
|
||||||
|
expect(body.version).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("handles PocketBase timeout", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi.fn().mockRejectedValue(new Error("timeout of 5000ms exceeded")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.status).toBe("unhealthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles PocketBase returning error status code", async () => {
|
||||||
|
mockPocketBaseWithHealth(
|
||||||
|
vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ code: 500, message: "Internal Server Error" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// PocketBase returning 500 should still be considered healthy from connectivity perspective
|
||||||
|
// as long as the check() call succeeds
|
||||||
|
const response = await GET();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls PocketBase health.check exactly once", async () => {
|
||||||
|
const mockCheck = vi.fn().mockResolvedValue({ code: 200, message: "OK" });
|
||||||
|
mockPocketBaseWithHealth(mockCheck);
|
||||||
|
|
||||||
|
await GET();
|
||||||
|
|
||||||
|
expect(mockCheck).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/app/api/health/route.ts
Normal file
31
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// ABOUTME: Health check endpoint for deployment monitoring and load balancer probes.
|
||||||
|
// ABOUTME: Returns application health status based on PocketBase connectivity.
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const pb = createPocketBaseClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.health.check();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp,
|
||||||
|
version: APP_VERSION,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "unhealthy",
|
||||||
|
timestamp,
|
||||||
|
error: "PocketBase connection failed",
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/app/api/metrics/route.test.ts
Normal file
171
src/app/api/metrics/route.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// ABOUTME: Tests for Prometheus metrics endpoint used for production monitoring.
|
||||||
|
// ABOUTME: Validates metrics format, content type, and custom metric inclusion.
|
||||||
|
|
||||||
|
import * as promClient from "prom-client";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe("GET /api/metrics", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear the registry before each test to avoid metric conflicts
|
||||||
|
promClient.register.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("response format", () => {
|
||||||
|
it("returns 200 status", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Prometheus content type", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
|
||||||
|
expect(response.headers.get("Content-Type")).toBe(
|
||||||
|
"text/plain; version=0.0.4; charset=utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns text body with metrics", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Node.js default metrics", () => {
|
||||||
|
it("includes nodejs heap metrics", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain("nodejs_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes process metrics", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain("process_");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom application metrics", () => {
|
||||||
|
it("includes phaseflow_garmin_sync_total metric definition", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain("# TYPE phaseflow_garmin_sync_total counter");
|
||||||
|
expect(body).toContain("# HELP phaseflow_garmin_sync_total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes phaseflow_garmin_sync_duration_seconds metric definition", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain(
|
||||||
|
"# TYPE phaseflow_garmin_sync_duration_seconds histogram",
|
||||||
|
);
|
||||||
|
expect(body).toContain("# HELP phaseflow_garmin_sync_duration_seconds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes phaseflow_email_sent_total metric definition", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain("# TYPE phaseflow_email_sent_total counter");
|
||||||
|
expect(body).toContain("# HELP phaseflow_email_sent_total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes phaseflow_decision_engine_calls_total metric definition", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain(
|
||||||
|
"# TYPE phaseflow_decision_engine_calls_total counter",
|
||||||
|
);
|
||||||
|
expect(body).toContain("# HELP phaseflow_decision_engine_calls_total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes phaseflow_active_users metric definition", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain("# TYPE phaseflow_active_users gauge");
|
||||||
|
expect(body).toContain("# HELP phaseflow_active_users");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metric values", () => {
|
||||||
|
it("incremented garmin sync total is reflected in metrics output", async () => {
|
||||||
|
const { garminSyncTotal } = await import("@/lib/metrics");
|
||||||
|
garminSyncTotal.inc({ status: "success" });
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain('phaseflow_garmin_sync_total{status="success"} 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("incremented email sent total is reflected in metrics output", async () => {
|
||||||
|
const { emailSentTotal } = await import("@/lib/metrics");
|
||||||
|
emailSentTotal.inc({ type: "daily" });
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain('phaseflow_email_sent_total{type="daily"} 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set active users gauge is reflected in metrics output", async () => {
|
||||||
|
const { activeUsersGauge } = await import("@/lib/metrics");
|
||||||
|
activeUsersGauge.set(25);
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(body).toContain("phaseflow_active_users 25");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prometheus format validation", () => {
|
||||||
|
it("produces valid Prometheus text format with TYPE comments", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
// Each metric should have TYPE and HELP lines
|
||||||
|
const lines = body.split("\n");
|
||||||
|
const typeLines = lines.filter((line) => line.startsWith("# TYPE"));
|
||||||
|
const helpLines = lines.filter((line) => line.startsWith("# HELP"));
|
||||||
|
|
||||||
|
// Should have type and help for our custom metrics
|
||||||
|
expect(typeLines.length).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(helpLines.length).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("metric names follow Prometheus naming convention", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET();
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
// Prometheus metric names should be snake_case with optional prefix
|
||||||
|
// Our custom metrics follow phaseflow_* pattern
|
||||||
|
expect(body).toMatch(/phaseflow_[a-z_]+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/app/api/metrics/route.ts
Normal file
16
src/app/api/metrics/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// ABOUTME: Prometheus metrics endpoint for production monitoring and scraping.
|
||||||
|
// ABOUTME: Returns application metrics in Prometheus text format.
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { metricsRegistry } from "@/lib/metrics";
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
|
||||||
|
return new NextResponse(metrics, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": metricsRegistry.contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { NextRequest } from "next/server";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
import type { OverrideType } from "@/types";
|
import type { OverrideType } from "@/types";
|
||||||
|
|
||||||
@@ -59,6 +60,12 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
|||||||
.collection("users")
|
.collection("users")
|
||||||
.update(user.id, { activeOverrides: newOverrides });
|
.update(user.id, { activeOverrides: newOverrides });
|
||||||
|
|
||||||
|
// Log override toggle per observability spec
|
||||||
|
logger.info(
|
||||||
|
{ userId: user.id, override: overrideToAdd, enabled: true },
|
||||||
|
"Override toggled",
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ activeOverrides: newOverrides });
|
return NextResponse.json({ activeOverrides: newOverrides });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,5 +105,11 @@ export const DELETE = withAuth(async (request: NextRequest, user) => {
|
|||||||
.collection("users")
|
.collection("users")
|
||||||
.update(user.id, { activeOverrides: newOverrides });
|
.update(user.id, { activeOverrides: newOverrides });
|
||||||
|
|
||||||
|
// Log override toggle per observability spec
|
||||||
|
logger.info(
|
||||||
|
{ userId: user.id, override: overrideToRemove, enabled: false },
|
||||||
|
"Override toggled",
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ activeOverrides: newOverrides });
|
return NextResponse.json({ activeOverrides: newOverrides });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PHASE_CONFIGS,
|
PHASE_CONFIGS,
|
||||||
} from "@/lib/cycle";
|
} from "@/lib/cycle";
|
||||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { getNutritionGuidance } from "@/lib/nutrition";
|
import { getNutritionGuidance } from "@/lib/nutrition";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
||||||
@@ -99,6 +100,12 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
// Get training decision with override handling
|
// Get training decision with override handling
|
||||||
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
|
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
|
||||||
|
|
||||||
|
// Log decision calculation per observability spec
|
||||||
|
logger.info(
|
||||||
|
{ userId: user.id, decision: decision.status, reason: decision.reason },
|
||||||
|
"Decision calculated",
|
||||||
|
);
|
||||||
|
|
||||||
// Get nutrition guidance
|
// Get nutrition guidance
|
||||||
const nutrition = getNutritionGuidance(cycleDay);
|
const nutrition = getNutritionGuidance(cycleDay);
|
||||||
|
|
||||||
|
|||||||
53
src/app/calendar/loading.tsx
Normal file
53
src/app/calendar/loading.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// ABOUTME: Route-level loading state for the calendar page.
|
||||||
|
// ABOUTME: Shows skeleton placeholders during page navigation.
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||||
|
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-xl font-bold">Calendar</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto p-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
{/* Navigation skeleton */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-6 w-32 bg-gray-200 rounded" />
|
||||||
|
<div className="h-8 w-16 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||||
|
<div key={i} className="h-8 bg-gray-200 rounded text-center" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid - 6 rows */}
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{Array.from({ length: 42 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton placeholders never reorder
|
||||||
|
key={`skeleton-day-${i}`}
|
||||||
|
className="h-20 bg-gray-200 rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ICS Subscription section */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="h-5 w-48 bg-gray-200 rounded" />
|
||||||
|
<div className="h-10 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-64 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/history/loading.tsx
Normal file
50
src/app/history/loading.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// ABOUTME: Route-level loading state for the history page.
|
||||||
|
// ABOUTME: Shows skeleton placeholders during page navigation.
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||||
|
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-xl font-bold">History</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{/* Date filter skeleton */}
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="h-10 w-40 bg-gray-200 rounded" />
|
||||||
|
<div className="h-10 w-40 bg-gray-200 rounded" />
|
||||||
|
<div className="h-10 w-24 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table skeleton */}
|
||||||
|
<div className="rounded-lg border overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-5 gap-4 p-4 bg-gray-100">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="h-4 bg-gray-300 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((row) => (
|
||||||
|
<div key={row} className="grid grid-cols-5 gap-4 p-4 border-t">
|
||||||
|
{[1, 2, 3, 4, 5].map((col) => (
|
||||||
|
<div key={col} className="h-4 bg-gray-200 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination skeleton */}
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="h-8 w-8 bg-gray-200 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/loading.tsx
Normal file
20
src/app/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// ABOUTME: Route-level loading state for the dashboard.
|
||||||
|
// ABOUTME: Shows skeleton placeholders during page navigation.
|
||||||
|
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||||
|
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
|
||||||
|
<div className="container mx-auto flex justify-between items-center">
|
||||||
|
<h1 className="text-xl font-bold">PhaseFlow</h1>
|
||||||
|
<span className="text-sm text-zinc-400">Settings</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto p-6">
|
||||||
|
<DashboardSkeleton />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,10 +13,14 @@ vi.mock("next/navigation", () => ({
|
|||||||
|
|
||||||
// Mock PocketBase
|
// Mock PocketBase
|
||||||
const mockAuthWithPassword = vi.fn();
|
const mockAuthWithPassword = vi.fn();
|
||||||
|
const mockAuthWithOAuth2 = vi.fn();
|
||||||
|
const mockListAuthMethods = vi.fn();
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
pb: {
|
pb: {
|
||||||
collection: () => ({
|
collection: () => ({
|
||||||
authWithPassword: mockAuthWithPassword,
|
authWithPassword: mockAuthWithPassword,
|
||||||
|
authWithOAuth2: mockAuthWithOAuth2,
|
||||||
|
listAuthMethods: mockListAuthMethods,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -26,23 +30,34 @@ import LoginPage from "./page";
|
|||||||
describe("LoginPage", () => {
|
describe("LoginPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default: no OIDC configured, show email/password form
|
||||||
|
mockListAuthMethods.mockResolvedValue({
|
||||||
|
oauth2: {
|
||||||
|
enabled: false,
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rendering", () => {
|
describe("rendering", () => {
|
||||||
it("renders the login form with email and password inputs", () => {
|
it("renders the login form with email and password inputs", async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a sign in button", () => {
|
it("renders a sign in button", async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: /sign in/i }),
|
screen.getByRole("button", { name: /sign in/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the PhaseFlow branding", () => {
|
it("renders the PhaseFlow branding", () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
@@ -50,26 +65,35 @@ describe("LoginPage", () => {
|
|||||||
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
|
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has email input with type email", () => {
|
it("has email input with type email", async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
expect(emailInput).toHaveAttribute("type", "email");
|
expect(emailInput).toHaveAttribute("type", "email");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("has password input with type password", () => {
|
it("has password input with type password", async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
expect(passwordInput).toHaveAttribute("type", "password");
|
expect(passwordInput).toHaveAttribute("type", "password");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("form submission", () => {
|
describe("form submission", () => {
|
||||||
it("calls PocketBase auth with email and password on submit", async () => {
|
it("calls PocketBase auth with email and password on submit", async () => {
|
||||||
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -90,6 +114,11 @@ describe("LoginPage", () => {
|
|||||||
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -113,6 +142,11 @@ describe("LoginPage", () => {
|
|||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -141,6 +175,11 @@ describe("LoginPage", () => {
|
|||||||
|
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -166,6 +205,11 @@ describe("LoginPage", () => {
|
|||||||
);
|
);
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -186,6 +230,11 @@ describe("LoginPage", () => {
|
|||||||
);
|
);
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -210,6 +259,11 @@ describe("LoginPage", () => {
|
|||||||
);
|
);
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
@@ -235,6 +289,11 @@ describe("LoginPage", () => {
|
|||||||
it("does not submit with empty email", async () => {
|
it("does not submit with empty email", async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const passwordInput = screen.getByLabelText(/password/i);
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
@@ -248,6 +307,11 @@ describe("LoginPage", () => {
|
|||||||
it("does not submit with empty password", async () => {
|
it("does not submit with empty password", async () => {
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
// Wait for auth check to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText(/email/i);
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
@@ -258,4 +322,223 @@ describe("LoginPage", () => {
|
|||||||
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("OIDC authentication", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Configure OIDC provider available
|
||||||
|
mockListAuthMethods.mockResolvedValue({
|
||||||
|
oauth2: {
|
||||||
|
enabled: true,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "oidc",
|
||||||
|
displayName: "Pocket-ID",
|
||||||
|
state: "test-state",
|
||||||
|
codeVerifier: "test-verifier",
|
||||||
|
codeChallenge: "test-challenge",
|
||||||
|
codeChallengeMethod: "S256",
|
||||||
|
authURL: "https://id.example.com/auth",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows OIDC button when provider is configured", async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides email/password form when OIDC is available", async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email/password form should not be visible
|
||||||
|
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls authWithOAuth2 when OIDC button is clicked", async () => {
|
||||||
|
mockAuthWithOAuth2.mockResolvedValueOnce({ token: "test-token" });
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcButton = screen.getByRole("button", {
|
||||||
|
name: /sign in with pocket-id/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(oidcButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: "oidc" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to dashboard on successful OIDC login", async () => {
|
||||||
|
mockAuthWithOAuth2.mockResolvedValueOnce({
|
||||||
|
token: "test-token",
|
||||||
|
record: { id: "user-123" },
|
||||||
|
});
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcButton = screen.getByRole("button", {
|
||||||
|
name: /sign in with pocket-id/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(oidcButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state during OIDC authentication", async () => {
|
||||||
|
let resolveAuth: (value: unknown) => void = () => {};
|
||||||
|
const authPromise = new Promise((resolve) => {
|
||||||
|
resolveAuth = resolve;
|
||||||
|
});
|
||||||
|
mockAuthWithOAuth2.mockReturnValue(authPromise);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcButton = screen.getByRole("button", {
|
||||||
|
name: /sign in with pocket-id/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(oidcButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /signing in/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveAuth({ token: "test-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error message on OIDC failure", async () => {
|
||||||
|
mockAuthWithOAuth2.mockRejectedValueOnce(
|
||||||
|
new Error("Authentication cancelled"),
|
||||||
|
);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcButton = screen.getByRole("button", {
|
||||||
|
name: /sign in with pocket-id/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(oidcButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/authentication cancelled/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enables OIDC button after error", async () => {
|
||||||
|
mockAuthWithOAuth2.mockRejectedValueOnce(
|
||||||
|
new Error("Authentication cancelled"),
|
||||||
|
);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcButton = screen.getByRole("button", {
|
||||||
|
name: /sign in with pocket-id/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(oidcButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Button should be re-enabled
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fallback to email/password", () => {
|
||||||
|
it("shows email/password form when OIDC is not configured", async () => {
|
||||||
|
mockListAuthMethods.mockResolvedValue({
|
||||||
|
oauth2: {
|
||||||
|
enabled: false,
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows email/password form when listAuthMethods fails", async () => {
|
||||||
|
mockListAuthMethods.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show OIDC button when no providers configured", async () => {
|
||||||
|
mockListAuthMethods.mockResolvedValue({
|
||||||
|
oauth2: {
|
||||||
|
enabled: false,
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /sign in with pocket-id/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,65 @@
|
|||||||
// ABOUTME: Login page for user authentication.
|
// ABOUTME: Login page for user authentication.
|
||||||
// ABOUTME: Provides email/password login form using PocketBase auth.
|
// ABOUTME: Provides OIDC (Pocket-ID) login with email/password fallback.
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
interface AuthProvider {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
state: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
codeChallengeMethod: string;
|
||||||
|
authURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||||
|
const [oidcProvider, setOidcProvider] = useState<AuthProvider | null>(null);
|
||||||
|
|
||||||
|
// Check available auth methods on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuthMethods = async () => {
|
||||||
|
try {
|
||||||
|
const authMethods = await pb.collection("users").listAuthMethods();
|
||||||
|
const oidc = authMethods.oauth2?.providers?.find(
|
||||||
|
(p: AuthProvider) => p.name === "oidc",
|
||||||
|
);
|
||||||
|
if (oidc) {
|
||||||
|
setOidcProvider(oidc);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If listAuthMethods fails, fall back to email/password
|
||||||
|
} finally {
|
||||||
|
setIsCheckingAuth(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAuthMethods();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOidcLogin = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection("users").authWithOAuth2({ provider: "oidc" });
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Authentication failed";
|
||||||
|
setError(message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -47,12 +94,23 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show loading state while checking auth methods
|
||||||
|
if (isCheckingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="w-full max-w-md space-y-8 p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
||||||
|
<div className="text-center text-gray-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-8 p-8">
|
<div className="w-full max-w-md space-y-8 p-8">
|
||||||
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -62,6 +120,21 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{oidcProvider ? (
|
||||||
|
// OIDC login button
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOidcLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Signing in..."
|
||||||
|
: `Sign in with ${oidcProvider.displayName}`}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
// Email/password form fallback
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
@@ -106,6 +179,7 @@ export default function LoginPage() {
|
|||||||
{isLoading ? "Signing in..." : "Sign in"}
|
{isLoading ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ describe("Dashboard", () => {
|
|||||||
|
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
|
|
||||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
// Check for skeleton components which have aria-label "Loading ..."
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading decision/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -564,7 +567,8 @@ describe("Dashboard", () => {
|
|||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/follicular/i)).toBeInTheDocument();
|
// Check for phase in the cycle info header (uppercase, with Day X prefix)
|
||||||
|
expect(screen.getByText(/Day 12 · FOLLICULAR/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { DataPanel } from "@/components/dashboard/data-panel";
|
import { DataPanel } from "@/components/dashboard/data-panel";
|
||||||
import { DecisionCard } from "@/components/dashboard/decision-card";
|
import { DecisionCard } from "@/components/dashboard/decision-card";
|
||||||
|
import { MiniCalendar } from "@/components/dashboard/mini-calendar";
|
||||||
import { NutritionPanel } from "@/components/dashboard/nutrition-panel";
|
import { NutritionPanel } from "@/components/dashboard/nutrition-panel";
|
||||||
import { OverrideToggles } from "@/components/dashboard/override-toggles";
|
import { OverrideToggles } from "@/components/dashboard/override-toggles";
|
||||||
|
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
|
||||||
import type {
|
import type {
|
||||||
CyclePhase,
|
CyclePhase,
|
||||||
Decision,
|
Decision,
|
||||||
@@ -37,6 +39,8 @@ interface UserData {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
activeOverrides: OverrideType[];
|
activeOverrides: OverrideType[];
|
||||||
|
lastPeriodDate: string | null;
|
||||||
|
cycleLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
@@ -44,6 +48,16 @@ export default function Dashboard() {
|
|||||||
const [userData, setUserData] = useState<UserData | null>(null);
|
const [userData, setUserData] = useState<UserData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [calendarYear, setCalendarYear] = useState(new Date().getFullYear());
|
||||||
|
const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth());
|
||||||
|
|
||||||
|
const handleCalendarMonthChange = useCallback(
|
||||||
|
(year: number, month: number) => {
|
||||||
|
setCalendarYear(year);
|
||||||
|
setCalendarMonth(month);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchTodayData = useCallback(async () => {
|
const fetchTodayData = useCallback(async () => {
|
||||||
const response = await fetch("/api/today");
|
const response = await fetch("/api/today");
|
||||||
@@ -139,11 +153,7 @@ export default function Dashboard() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto p-6">
|
<main className="container mx-auto p-6">
|
||||||
{loading && (
|
{loading && <DashboardSkeleton />}
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-zinc-500">Loading...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div role="alert" className="text-center py-12">
|
<div role="alert" className="text-center py-12">
|
||||||
@@ -194,6 +204,17 @@ export default function Dashboard() {
|
|||||||
activeOverrides={userData.activeOverrides}
|
activeOverrides={userData.activeOverrides}
|
||||||
onToggle={handleOverrideToggle}
|
onToggle={handleOverrideToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mini Calendar */}
|
||||||
|
{userData.lastPeriodDate && (
|
||||||
|
<MiniCalendar
|
||||||
|
lastPeriodDate={new Date(userData.lastPeriodDate)}
|
||||||
|
cycleLength={userData.cycleLength}
|
||||||
|
year={calendarYear}
|
||||||
|
month={calendarMonth}
|
||||||
|
onMonthChange={handleCalendarMonthChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
47
src/app/plan/loading.tsx
Normal file
47
src/app/plan/loading.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// ABOUTME: Route-level loading state for the plan page.
|
||||||
|
// ABOUTME: Shows skeleton placeholders during page navigation.
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||||
|
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-xl font-bold">Training Plan</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto p-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
{/* Current phase status */}
|
||||||
|
<div className="rounded-lg border p-6 space-y-3">
|
||||||
|
<div className="h-6 w-40 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-64 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-48 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase cards grid */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="h-5 w-32 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-full bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-3/4 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-1/2 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exercise reference section */}
|
||||||
|
<div className="rounded-lg border p-6 space-y-4">
|
||||||
|
<div className="h-6 w-48 bg-gray-200 rounded" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="h-4 bg-gray-200 rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
src/app/plan/page.test.tsx
Normal file
311
src/app/plan/page.test.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
// ABOUTME: Unit tests for the Plan page component.
|
||||||
|
// ABOUTME: Tests phase display, training guidance, and exercise reference content.
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
import PlanPage from "./page";
|
||||||
|
|
||||||
|
// Mock response data matching /api/cycle/current shape
|
||||||
|
const mockCycleResponse = {
|
||||||
|
cycleDay: 12,
|
||||||
|
phase: "FOLLICULAR",
|
||||||
|
phaseConfig: {
|
||||||
|
name: "FOLLICULAR",
|
||||||
|
days: [4, 14],
|
||||||
|
weeklyLimit: 120,
|
||||||
|
dailyAvg: 17,
|
||||||
|
trainingType: "Strength + rebounding",
|
||||||
|
},
|
||||||
|
daysUntilNextPhase: 3,
|
||||||
|
cycleLength: 31,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PlanPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loading and error states", () => {
|
||||||
|
it("shows loading state initially", () => {
|
||||||
|
mockFetch.mockImplementation(() => new Promise(() => {}));
|
||||||
|
render(<PlanPage />);
|
||||||
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error state when fetch fails", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: "Failed to fetch cycle data" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("page header", () => {
|
||||||
|
it("renders the Exercise Plan heading", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
|
"Exercise Plan",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("current phase section", () => {
|
||||||
|
it("displays current phase name in status", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Current status section shows "Day X · PHASE_NAME"
|
||||||
|
expect(screen.getByText(/day 12 · follicular/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays current cycle day", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/day 12/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays days until next phase", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/3 days until/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays current phase training type", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Training type label and value are present (multiple instances OK)
|
||||||
|
expect(screen.getByText("Training type:")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Strength + rebounding").length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays weekly limit for current phase", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Weekly limit label and value are present (multiple instances OK)
|
||||||
|
expect(screen.getByText("Weekly limit:")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/120 min\/week/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase overview section", () => {
|
||||||
|
it("displays all five phases", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check for phase cards by testid
|
||||||
|
expect(screen.getByTestId("phase-MENSTRUAL")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("phase-FOLLICULAR")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("phase-OVULATION")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("phase-EARLY_LUTEAL")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("phase-LATE_LUTEAL")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays weekly limits for each phase", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Phase limits appear in the phase cards as "X min/week"
|
||||||
|
const menstrualCard = screen.getByTestId("phase-MENSTRUAL");
|
||||||
|
const follicularCard = screen.getByTestId("phase-FOLLICULAR");
|
||||||
|
const ovulationCard = screen.getByTestId("phase-OVULATION");
|
||||||
|
const earlyLutealCard = screen.getByTestId("phase-EARLY_LUTEAL");
|
||||||
|
const lateLutealCard = screen.getByTestId("phase-LATE_LUTEAL");
|
||||||
|
|
||||||
|
expect(menstrualCard).toHaveTextContent("30 min/week");
|
||||||
|
expect(follicularCard).toHaveTextContent("120 min/week");
|
||||||
|
expect(ovulationCard).toHaveTextContent("80 min/week");
|
||||||
|
expect(earlyLutealCard).toHaveTextContent("100 min/week");
|
||||||
|
expect(lateLutealCard).toHaveTextContent("50 min/week");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights the current phase", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const currentPhaseCard = screen.getByTestId("phase-FOLLICULAR");
|
||||||
|
expect(currentPhaseCard).toHaveClass("ring-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exercise reference section", () => {
|
||||||
|
it("displays strength training exercises", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/squats/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/push-ups/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/deadlifts/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/plank/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/kettlebell/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays rebounding techniques section", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/rebounding techniques/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays phase-specific rebounding guidance", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockCycleResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Rebounding techniques section contains different techniques per phase
|
||||||
|
expect(
|
||||||
|
screen.getByText(/health bounce, lymphatic drainage/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/maximum intensity, plyometric/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("different phases", () => {
|
||||||
|
it("shows menstrual phase correctly when current", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockCycleResponse,
|
||||||
|
cycleDay: 2,
|
||||||
|
phase: "MENSTRUAL",
|
||||||
|
phaseConfig: {
|
||||||
|
name: "MENSTRUAL",
|
||||||
|
days: [1, 3],
|
||||||
|
weeklyLimit: 30,
|
||||||
|
dailyAvg: 10,
|
||||||
|
trainingType: "Gentle rebounding only",
|
||||||
|
},
|
||||||
|
daysUntilNextPhase: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/day 2/i)).toBeInTheDocument();
|
||||||
|
const currentPhaseCard = screen.getByTestId("phase-MENSTRUAL");
|
||||||
|
expect(currentPhaseCard).toHaveClass("ring-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows late luteal phase correctly when current", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockCycleResponse,
|
||||||
|
cycleDay: 28,
|
||||||
|
phase: "LATE_LUTEAL",
|
||||||
|
phaseConfig: {
|
||||||
|
name: "LATE_LUTEAL",
|
||||||
|
days: [25, 31],
|
||||||
|
weeklyLimit: 50,
|
||||||
|
dailyAvg: 8,
|
||||||
|
trainingType: "Gentle rebounding ONLY",
|
||||||
|
},
|
||||||
|
daysUntilNextPhase: 4,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PlanPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/day 28/i)).toBeInTheDocument();
|
||||||
|
const currentPhaseCard = screen.getByTestId("phase-LATE_LUTEAL");
|
||||||
|
expect(currentPhaseCard).toHaveClass("ring-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,301 @@
|
|||||||
// ABOUTME: Exercise plan reference page.
|
// ABOUTME: Exercise plan reference page.
|
||||||
// ABOUTME: Displays the full monthly exercise plan by phase.
|
// ABOUTME: Displays the full monthly exercise plan by phase with current phase highlighted.
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { CyclePhase, PhaseConfig } from "@/types";
|
||||||
|
|
||||||
|
interface CycleData {
|
||||||
|
cycleDay: number;
|
||||||
|
phase: CyclePhase;
|
||||||
|
phaseConfig: PhaseConfig;
|
||||||
|
daysUntilNextPhase: number;
|
||||||
|
cycleLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase configurations for display
|
||||||
|
const PHASES: Array<{
|
||||||
|
name: CyclePhase;
|
||||||
|
displayName: string;
|
||||||
|
weeklyLimit: number;
|
||||||
|
trainingType: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: "MENSTRUAL",
|
||||||
|
displayName: "Menstrual",
|
||||||
|
weeklyLimit: 30,
|
||||||
|
trainingType: "Gentle rebounding only",
|
||||||
|
description: "Focus on rest and gentle movement. Light lymphatic drainage.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FOLLICULAR",
|
||||||
|
displayName: "Follicular",
|
||||||
|
weeklyLimit: 120,
|
||||||
|
trainingType: "Strength + rebounding",
|
||||||
|
description:
|
||||||
|
"Building phase - increase intensity and add strength training.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OVULATION",
|
||||||
|
displayName: "Ovulation",
|
||||||
|
weeklyLimit: 80,
|
||||||
|
trainingType: "Peak performance",
|
||||||
|
description: "Peak energy - maximize intensity and plyometric movements.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EARLY_LUTEAL",
|
||||||
|
displayName: "Early Luteal",
|
||||||
|
weeklyLimit: 100,
|
||||||
|
trainingType: "Moderate training",
|
||||||
|
description:
|
||||||
|
"Maintain intensity but listen to your body for signs of fatigue.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LATE_LUTEAL",
|
||||||
|
displayName: "Late Luteal",
|
||||||
|
weeklyLimit: 50,
|
||||||
|
trainingType: "Gentle rebounding ONLY",
|
||||||
|
description:
|
||||||
|
"Wind down phase - focus on stress relief and gentle movement.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STRENGTH_EXERCISES = [
|
||||||
|
{ name: "Squats", sets: "3x8-12" },
|
||||||
|
{ name: "Push-ups", sets: "3x5-10" },
|
||||||
|
{ name: "Single-leg Deadlifts", sets: "3x6-8 each" },
|
||||||
|
{ name: "Plank", sets: "3x20-45s" },
|
||||||
|
{ name: "Kettlebell Swings", sets: "2x10-15" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REBOUNDING_TECHNIQUES = [
|
||||||
|
{
|
||||||
|
phase: "Menstrual",
|
||||||
|
techniques: "Health bounce, lymphatic drainage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Follicular",
|
||||||
|
techniques: "Strength bounce, intervals",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Ovulation",
|
||||||
|
techniques: "Maximum intensity, plyometric",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Luteal",
|
||||||
|
techniques: "Therapeutic, stress relief",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function PlanPage() {
|
export default function PlanPage() {
|
||||||
|
const [cycleData, setCycleData] = useState<CycleData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchCycleData = useCallback(async () => {
|
||||||
|
const response = await fetch("/api/cycle/current");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Failed to fetch cycle data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as CycleData;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchCycleData();
|
||||||
|
setCycleData(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [fetchCycleData]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-8">
|
<div className="container mx-auto p-8">
|
||||||
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
|
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
|
||||||
{/* Exercise plan content will be implemented here */}
|
<p className="text-zinc-500">Loading...</p>
|
||||||
<p className="text-gray-500">Exercise plan placeholder</p>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
|
||||||
|
<div role="alert" className="text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
|
||||||
|
|
||||||
|
{cycleData && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Current Phase Status */}
|
||||||
|
<section className="bg-zinc-100 dark:bg-zinc-800 rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Current Status</h2>
|
||||||
|
<p className="text-xl font-medium">
|
||||||
|
Day {cycleData.cycleDay} · {cycleData.phase.replace("_", " ")}
|
||||||
|
</p>
|
||||||
|
<p className="text-zinc-600 dark:text-zinc-400">
|
||||||
|
{cycleData.daysUntilNextPhase} days until next phase
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<span className="font-medium">Training type:</span>{" "}
|
||||||
|
{cycleData.phaseConfig.trainingType}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Weekly limit:</span>{" "}
|
||||||
|
{cycleData.phaseConfig.weeklyLimit} min/week
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Phase Overview */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Phase Overview</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{PHASES.map((phase) => (
|
||||||
|
<div
|
||||||
|
key={phase.name}
|
||||||
|
data-testid={`phase-${phase.name}`}
|
||||||
|
className={`rounded-lg p-4 border ${
|
||||||
|
cycleData.phase === phase.name
|
||||||
|
? "ring-2 ring-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||||
|
: "bg-white dark:bg-zinc-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-base">
|
||||||
|
{phase.displayName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
|
{phase.trainingType}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium mt-2">
|
||||||
|
{phase.weeklyLimit} min/week
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500 mt-2">
|
||||||
|
{phase.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Strength Training Reference */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
Strength Training (Follicular Phase)
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
|
||||||
|
Mon/Wed/Fri during follicular phase (20-25 min per session)
|
||||||
|
</p>
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg border">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-3 font-medium">Exercise</th>
|
||||||
|
<th className="text-left p-3 font-medium">Sets × Reps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{STRENGTH_EXERCISES.map((exercise) => (
|
||||||
|
<tr key={exercise.name} className="border-b last:border-0">
|
||||||
|
<td className="p-3">{exercise.name}</td>
|
||||||
|
<td className="p-3 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{exercise.sets}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Rebounding Techniques */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
Rebounding Techniques
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
|
||||||
|
Adjust your rebounding style based on your current phase
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{REBOUNDING_TECHNIQUES.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.phase}
|
||||||
|
className="bg-white dark:bg-zinc-900 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">{item.phase}</h3>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
|
{item.techniques}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Weekly Schedule Reference */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Weekly Guidelines</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium">Menstrual Phase (Days 1-3)</h3>
|
||||||
|
<ul className="text-sm text-zinc-600 dark:text-zinc-400 mt-2 list-disc pl-5 space-y-1">
|
||||||
|
<li>Morning: 10-15 min gentle rebounding</li>
|
||||||
|
<li>Evening: 15-20 min restorative movement</li>
|
||||||
|
<li>No strength training</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium">Follicular Phase (Days 4-14)</h3>
|
||||||
|
<ul className="text-sm text-zinc-600 dark:text-zinc-400 mt-2 list-disc pl-5 space-y-1">
|
||||||
|
<li>Mon/Wed/Fri: Strength training (20-25 min)</li>
|
||||||
|
<li>Tue/Thu: Active recovery rebounding (20 min)</li>
|
||||||
|
<li>Weekend: Choose your adventure</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium">
|
||||||
|
Ovulation + Early Luteal (Days 15-24)
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm text-zinc-600 dark:text-zinc-400 mt-2 list-disc pl-5 space-y-1">
|
||||||
|
<li>Days 15-16: Peak performance (25-30 min strength)</li>
|
||||||
|
<li>
|
||||||
|
Days 17-21: Modified strength (reduce intensity 10-20%)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium">Late Luteal Phase (Days 22-28)</h3>
|
||||||
|
<ul className="text-sm text-zinc-600 dark:text-zinc-400 mt-2 list-disc pl-5 space-y-1">
|
||||||
|
<li>Daily: Gentle rebounding only (15-20 min)</li>
|
||||||
|
<li>Optional light bodyweight Mon/Wed if feeling good</li>
|
||||||
|
<li>Rest days: Tue/Thu/Sat/Sun</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/app/settings/loading.tsx
Normal file
35
src/app/settings/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// ABOUTME: Route-level loading state for the settings page.
|
||||||
|
// ABOUTME: Shows skeleton placeholders during page navigation.
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||||
|
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-xl font-bold">Settings</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto p-6 max-w-2xl">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
{/* Form fields */}
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="h-4 w-32 bg-gray-200 rounded" />
|
||||||
|
<div className="h-10 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<div className="h-10 w-32 bg-gray-200 rounded" />
|
||||||
|
|
||||||
|
{/* Garmin link section */}
|
||||||
|
<div className="border-t pt-6 space-y-2">
|
||||||
|
<div className="h-5 w-40 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-64 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/calendar/day-cell.test.tsx
Normal file
190
src/components/calendar/day-cell.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// ABOUTME: Unit tests for DayCell component.
|
||||||
|
// ABOUTME: Tests phase coloring, today highlighting, and click handling.
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CyclePhase } from "@/types";
|
||||||
|
import { DayCell } from "./day-cell";
|
||||||
|
|
||||||
|
describe("DayCell", () => {
|
||||||
|
const baseProps = {
|
||||||
|
date: new Date("2026-01-15"),
|
||||||
|
cycleDay: 5,
|
||||||
|
phase: "FOLLICULAR" as CyclePhase,
|
||||||
|
isToday: false,
|
||||||
|
onClick: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the day number from date", () => {
|
||||||
|
render(<DayCell {...baseProps} date={new Date("2026-01-15")} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the cycle day label", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={5} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Day 5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders as a button", () => {
|
||||||
|
render(<DayCell {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cycle day 1 correctly", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={1} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Day 1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders high cycle day numbers", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={28} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Day 28")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase colors", () => {
|
||||||
|
it("applies blue background for MENSTRUAL phase", () => {
|
||||||
|
render(<DayCell {...baseProps} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("bg-blue-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies green background for FOLLICULAR phase", () => {
|
||||||
|
render(<DayCell {...baseProps} phase="FOLLICULAR" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("bg-green-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies purple background for OVULATION phase", () => {
|
||||||
|
render(<DayCell {...baseProps} phase="OVULATION" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("bg-purple-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies yellow background for EARLY_LUTEAL phase", () => {
|
||||||
|
render(<DayCell {...baseProps} phase="EARLY_LUTEAL" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("bg-yellow-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies red background for LATE_LUTEAL phase", () => {
|
||||||
|
render(<DayCell {...baseProps} phase="LATE_LUTEAL" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("bg-red-100");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("today highlighting", () => {
|
||||||
|
it("does not have ring when isToday is false", () => {
|
||||||
|
render(<DayCell {...baseProps} isToday={false} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).not.toHaveClass("ring-2", "ring-black");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has ring-2 ring-black when isToday is true", () => {
|
||||||
|
render(<DayCell {...baseProps} isToday={true} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("ring-2", "ring-black");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains phase color when isToday is true", () => {
|
||||||
|
render(<DayCell {...baseProps} phase="OVULATION" isToday={true} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("bg-purple-100");
|
||||||
|
expect(button).toHaveClass("ring-2", "ring-black");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("click handling", () => {
|
||||||
|
it("calls onClick when clicked", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<DayCell {...baseProps} onClick={onClick} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when onClick is undefined", () => {
|
||||||
|
render(<DayCell {...baseProps} onClick={undefined} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(() => fireEvent.click(button)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClick once per click", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<DayCell {...baseProps} onClick={onClick} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("styling", () => {
|
||||||
|
it("has rounded corners", () => {
|
||||||
|
render(<DayCell {...baseProps} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("rounded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has padding", () => {
|
||||||
|
render(<DayCell {...baseProps} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toHaveClass("p-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders day number with font-medium", () => {
|
||||||
|
render(<DayCell {...baseProps} date={new Date("2026-01-15")} />);
|
||||||
|
|
||||||
|
const dayNumber = screen.getByText("15");
|
||||||
|
expect(dayNumber).toHaveClass("font-medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cycle day label in gray", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={5} />);
|
||||||
|
|
||||||
|
const cycleLabel = screen.getByText("Day 5");
|
||||||
|
expect(cycleLabel).toHaveClass("text-gray-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("date variations", () => {
|
||||||
|
it("renders single digit day", () => {
|
||||||
|
render(<DayCell {...baseProps} date={new Date("2026-01-05")} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders last day of month", () => {
|
||||||
|
render(<DayCell {...baseProps} date={new Date("2026-01-31")} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("31")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders first day of month", () => {
|
||||||
|
render(<DayCell {...baseProps} date={new Date("2026-02-01")} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/components/dashboard/data-panel.test.tsx
Normal file
143
src/components/dashboard/data-panel.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// ABOUTME: Unit tests for DataPanel component.
|
||||||
|
// ABOUTME: Tests biometrics display including body battery, HRV, and intensity.
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DataPanel } from "./data-panel";
|
||||||
|
|
||||||
|
describe("DataPanel", () => {
|
||||||
|
const baseProps = {
|
||||||
|
bodyBatteryCurrent: 75,
|
||||||
|
bodyBatteryYesterdayLow: 25,
|
||||||
|
hrvStatus: "Balanced",
|
||||||
|
weekIntensity: 120,
|
||||||
|
phaseLimit: 200,
|
||||||
|
remainingMinutes: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the YOUR DATA heading", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("YOUR DATA")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders body battery current value", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Body Battery: 75/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders yesterday low value", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Yesterday Low: 25/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HRV status", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders week intensity with phase limit", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Week: 120\/200 min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders remaining minutes", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Remaining: 80 min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("null value handling", () => {
|
||||||
|
it("displays N/A when bodyBatteryCurrent is null", () => {
|
||||||
|
render(<DataPanel {...baseProps} bodyBatteryCurrent={null} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Body Battery: N\/A/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays N/A when bodyBatteryYesterdayLow is null", () => {
|
||||||
|
render(<DataPanel {...baseProps} bodyBatteryYesterdayLow={null} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Yesterday Low: N\/A/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays N/A for both when both are null", () => {
|
||||||
|
render(
|
||||||
|
<DataPanel
|
||||||
|
{...baseProps}
|
||||||
|
bodyBatteryCurrent={null}
|
||||||
|
bodyBatteryYesterdayLow={null}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Body Battery: N\/A/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Yesterday Low: N\/A/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HRV status variations", () => {
|
||||||
|
it("displays Balanced HRV status", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays Unbalanced HRV status", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Unbalanced" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/HRV: Unbalanced/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays Unknown HRV status", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Unknown" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/HRV: Unknown/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("intensity values", () => {
|
||||||
|
it("displays zero intensity correctly", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={0} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Week: 0\/200 min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays when over phase limit", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={250} phaseLimit={200} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Week: 250\/200 min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays zero remaining minutes", () => {
|
||||||
|
render(<DataPanel {...baseProps} remainingMinutes={0} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Remaining: 0 min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays negative remaining minutes", () => {
|
||||||
|
render(<DataPanel {...baseProps} remainingMinutes={-50} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Remaining: -50 min/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("styling", () => {
|
||||||
|
it("renders within a bordered container", () => {
|
||||||
|
const { container } = render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
const panel = container.firstChild as HTMLElement;
|
||||||
|
expect(panel).toHaveClass("rounded-lg", "border", "p-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heading with semibold font", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
const heading = screen.getByText("YOUR DATA");
|
||||||
|
expect(heading).toHaveClass("font-semibold");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
166
src/components/dashboard/decision-card.test.tsx
Normal file
166
src/components/dashboard/decision-card.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// ABOUTME: Unit tests for DecisionCard component.
|
||||||
|
// ABOUTME: Tests rendering of decision status, icon, and reason.
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Decision } from "@/types";
|
||||||
|
import { DecisionCard } from "./decision-card";
|
||||||
|
|
||||||
|
describe("DecisionCard", () => {
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the decision icon", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "REST",
|
||||||
|
reason: "Your body needs recovery today",
|
||||||
|
icon: "🛌",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🛌")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the decision status", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "TRAIN",
|
||||||
|
reason: "Good to go",
|
||||||
|
icon: "🏃",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("TRAIN")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the decision reason", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "GENTLE",
|
||||||
|
reason: "Take it easy today",
|
||||||
|
icon: "🧘",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Take it easy today")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("different status types", () => {
|
||||||
|
it("renders REST status correctly", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "REST",
|
||||||
|
reason: "HRV unbalanced - recovery day",
|
||||||
|
icon: "🛌",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("REST")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("🛌")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("HRV unbalanced - recovery day"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders GENTLE status correctly", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "GENTLE",
|
||||||
|
reason: "Light movement recommended",
|
||||||
|
icon: "🧘",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("GENTLE")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("🧘")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Light movement recommended"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LIGHT status correctly", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "LIGHT",
|
||||||
|
reason: "Keep intensity moderate",
|
||||||
|
icon: "🚶",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("LIGHT")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("🚶")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Keep intensity moderate")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders REDUCED status correctly", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "REDUCED",
|
||||||
|
reason: "Lower intensity today",
|
||||||
|
icon: "⬇️",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("REDUCED")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("⬇️")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Lower intensity today")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders TRAIN status correctly", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "TRAIN",
|
||||||
|
reason: "Full intensity training approved",
|
||||||
|
icon: "🏃",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("TRAIN")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("🏃")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Full intensity training approved"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("styling", () => {
|
||||||
|
it("renders within a bordered container", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "REST",
|
||||||
|
reason: "Test reason",
|
||||||
|
icon: "🛌",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("rounded-lg", "border", "p-6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders status as bold heading", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "TRAIN",
|
||||||
|
reason: "Test",
|
||||||
|
icon: "🏃",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 2 });
|
||||||
|
expect(heading).toHaveTextContent("TRAIN");
|
||||||
|
expect(heading).toHaveClass("font-bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders reason with muted color", () => {
|
||||||
|
const decision: Decision = {
|
||||||
|
status: "REST",
|
||||||
|
reason: "Muted reason text",
|
||||||
|
icon: "🛌",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<DecisionCard decision={decision} />);
|
||||||
|
|
||||||
|
const reason = screen.getByText("Muted reason text");
|
||||||
|
expect(reason).toHaveClass("text-gray-600");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
293
src/components/dashboard/mini-calendar.test.tsx
Normal file
293
src/components/dashboard/mini-calendar.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
// ABOUTME: Unit tests for the MiniCalendar component.
|
||||||
|
// ABOUTME: Tests calendar grid rendering, phase display, navigation, and today highlighting.
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { MiniCalendar } from "./mini-calendar";
|
||||||
|
|
||||||
|
describe("MiniCalendar", () => {
|
||||||
|
// Fixed date for consistent testing: January 2026
|
||||||
|
const baseProps = {
|
||||||
|
lastPeriodDate: new Date("2026-01-01"),
|
||||||
|
cycleLength: 28,
|
||||||
|
onMonthChange: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Mock Date.now to return Jan 15, 2026
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-15"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("header rendering", () => {
|
||||||
|
it("renders the month and year in header", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("January 2026")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders current cycle day and phase", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Jan 15, 2026 with lastPeriod Jan 1 = Day 15 (OVULATION)
|
||||||
|
expect(screen.getByText(/Day 15/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/OVULATION/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders compact day-of-week headers", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Compact single-letter headers - S appears twice (Sun & Sat), T appears twice (Tue & Thu)
|
||||||
|
const dayHeaders = screen.getAllByText(/^[SMTWF]$/);
|
||||||
|
expect(dayHeaders).toHaveLength(7);
|
||||||
|
|
||||||
|
// Check we have all the expected day letters
|
||||||
|
const headerTexts = dayHeaders.map((el) => el.textContent);
|
||||||
|
expect(headerTexts).toEqual(["S", "M", "T", "W", "T", "F", "S"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calendar grid", () => {
|
||||||
|
it("renders all days of the month", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// January 2026 has 31 days - look for day numbers in buttons
|
||||||
|
for (let day = 1; day <= 31; day++) {
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const dayButton = buttons.find(
|
||||||
|
(btn) => btn.textContent === day.toString(),
|
||||||
|
);
|
||||||
|
expect(dayButton).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights today's date with a ring", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Find the button with text "15" - Jan 15 is "today"
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const todayButton = buttons.find((btn) => btn.textContent === "15");
|
||||||
|
expect(todayButton).toHaveClass("ring-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight non-today dates with ring", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Find the button with text "10" - not today
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const otherButton = buttons.find((btn) => btn.textContent === "10");
|
||||||
|
expect(otherButton).not.toHaveClass("ring-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase colors", () => {
|
||||||
|
it("applies menstrual phase color (blue) to days 1-3", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Day 1 is MENSTRUAL (bg-blue-100)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day1 = buttons.find((btn) => btn.textContent === "1");
|
||||||
|
expect(day1).toHaveClass("bg-blue-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies follicular phase color (green) to days 4-14", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Day 5 is FOLLICULAR (bg-green-100)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day5 = buttons.find((btn) => btn.textContent === "5");
|
||||||
|
expect(day5).toHaveClass("bg-green-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies ovulation phase color (purple) to days 15-16", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Day 15 is OVULATION (bg-purple-100)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day15 = buttons.find((btn) => btn.textContent === "15");
|
||||||
|
expect(day15).toHaveClass("bg-purple-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies early luteal phase color (yellow) to days 17-24", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day20 = buttons.find((btn) => btn.textContent === "20");
|
||||||
|
expect(day20).toHaveClass("bg-yellow-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies late luteal phase color (red) to days 25-31", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Day 25 is LATE_LUTEAL (bg-red-100)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day25 = buttons.find((btn) => btn.textContent === "25");
|
||||||
|
expect(day25).toHaveClass("bg-red-100");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navigation", () => {
|
||||||
|
it("renders previous month button", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /previous month/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders next month button", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /next month/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Today button", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /^today$/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onMonthChange with previous month when clicking previous", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
render(<MiniCalendar {...baseProps} onMonthChange={onMonthChange} />);
|
||||||
|
|
||||||
|
const prevButton = screen.getByRole("button", {
|
||||||
|
name: /previous month/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(prevButton);
|
||||||
|
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith(2025, 11); // December 2025
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onMonthChange with next month when clicking next", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
render(<MiniCalendar {...baseProps} onMonthChange={onMonthChange} />);
|
||||||
|
|
||||||
|
const nextButton = screen.getByRole("button", { name: /next month/i });
|
||||||
|
fireEvent.click(nextButton);
|
||||||
|
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith(2026, 1); // February 2026
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onMonthChange with current month when clicking Today from different month", () => {
|
||||||
|
const onMonthChange = vi.fn();
|
||||||
|
// Render a different month than current (March 2026)
|
||||||
|
render(
|
||||||
|
<MiniCalendar
|
||||||
|
{...baseProps}
|
||||||
|
year={2026}
|
||||||
|
month={2}
|
||||||
|
onMonthChange={onMonthChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const todayButton = screen.getByRole("button", { name: /^today$/i });
|
||||||
|
fireEvent.click(todayButton);
|
||||||
|
|
||||||
|
// Should navigate to January 2026 (month of mocked "today")
|
||||||
|
expect(onMonthChange).toHaveBeenCalledWith(2026, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phase legend", () => {
|
||||||
|
it("renders compact phase legend", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} />);
|
||||||
|
|
||||||
|
// Legend should have color indicators for all phases
|
||||||
|
const legend = screen.getByTestId("phase-legend");
|
||||||
|
expect(legend).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for phase names within the legend specifically
|
||||||
|
expect(legend.textContent).toContain("Menstrual");
|
||||||
|
expect(legend.textContent).toContain("Follicular");
|
||||||
|
expect(legend.textContent).toContain("Ovulation");
|
||||||
|
expect(legend.textContent).toContain("Early Luteal");
|
||||||
|
expect(legend.textContent).toContain("Late Luteal");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cycle rollover", () => {
|
||||||
|
it("handles cycle rollover correctly", () => {
|
||||||
|
// Last period was Dec 5, 2025 with 28-day cycle
|
||||||
|
// Jan 1, 2026 = day 28 of cycle
|
||||||
|
// Jan 2, 2026 = day 1 of new cycle
|
||||||
|
render(
|
||||||
|
<MiniCalendar
|
||||||
|
{...baseProps}
|
||||||
|
lastPeriodDate={new Date("2025-12-05")}
|
||||||
|
cycleLength={28}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
|
||||||
|
// Jan 1 should be day 28 (late luteal - red)
|
||||||
|
const jan1 = buttons.find((btn) => btn.textContent === "1");
|
||||||
|
expect(jan1).toHaveClass("bg-red-100");
|
||||||
|
|
||||||
|
// Jan 2 should be day 1 (menstrual - blue)
|
||||||
|
const jan2 = buttons.find((btn) => btn.textContent === "2");
|
||||||
|
expect(jan2).toHaveClass("bg-blue-100");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom year/month props", () => {
|
||||||
|
it("renders specified year and month when provided", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} year={2026} month={2} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("March 2026")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders February 2026 with 28 days", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} year={2026} month={1} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("February 2026")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// February 2026 has 28 days (not a leap year)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day28 = buttons.find((btn) => btn.textContent === "28");
|
||||||
|
const day29 = buttons.find((btn) => btn.textContent === "29");
|
||||||
|
expect(day28).toBeTruthy();
|
||||||
|
expect(day29).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders leap year February with 29 days", () => {
|
||||||
|
render(<MiniCalendar {...baseProps} year={2024} month={1} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("February 2024")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// February 2024 has 29 days (leap year)
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const day29 = buttons.find((btn) => btn.textContent === "29");
|
||||||
|
expect(day29).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without onMonthChange callback", () => {
|
||||||
|
it("navigation buttons still render without callback", () => {
|
||||||
|
render(
|
||||||
|
<MiniCalendar
|
||||||
|
lastPeriodDate={new Date("2026-01-01")}
|
||||||
|
cycleLength={28}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /previous month/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /next month/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,191 @@
|
|||||||
// ABOUTME: Compact calendar widget for the dashboard.
|
// ABOUTME: Compact calendar widget for the dashboard.
|
||||||
// ABOUTME: Shows current month with color-coded cycle phases.
|
// ABOUTME: Shows current month with color-coded cycle phases and navigation.
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getCycleDay, getPhase } from "@/lib/cycle";
|
||||||
|
import type { CyclePhase } from "@/types";
|
||||||
|
|
||||||
interface MiniCalendarProps {
|
interface MiniCalendarProps {
|
||||||
currentDate: Date;
|
lastPeriodDate: Date;
|
||||||
cycleDay: number;
|
cycleLength: number;
|
||||||
phase: string;
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
onMonthChange?: (year: number, month: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE_COLORS: Record<CyclePhase, string> = {
|
||||||
|
MENSTRUAL: "bg-blue-100",
|
||||||
|
FOLLICULAR: "bg-green-100",
|
||||||
|
OVULATION: "bg-purple-100",
|
||||||
|
EARLY_LUTEAL: "bg-yellow-100",
|
||||||
|
LATE_LUTEAL: "bg-red-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPACT_DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
|
const PHASE_LEGEND = [
|
||||||
|
{ name: "Menstrual", color: "bg-blue-100" },
|
||||||
|
{ name: "Follicular", color: "bg-green-100" },
|
||||||
|
{ name: "Ovulation", color: "bg-purple-100" },
|
||||||
|
{ name: "Early Luteal", color: "bg-yellow-100" },
|
||||||
|
{ name: "Late Luteal", color: "bg-red-100" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month + 1, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstDayOfMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month, 1).getDay();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MiniCalendar({
|
export function MiniCalendar({
|
||||||
currentDate,
|
lastPeriodDate,
|
||||||
cycleDay,
|
cycleLength,
|
||||||
phase,
|
year,
|
||||||
|
month,
|
||||||
|
onMonthChange,
|
||||||
}: MiniCalendarProps) {
|
}: MiniCalendarProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const displayYear = year ?? today.getFullYear();
|
||||||
|
const displayMonth = month ?? today.getMonth();
|
||||||
|
|
||||||
|
const daysInMonth = getDaysInMonth(displayYear, displayMonth);
|
||||||
|
const firstDayOfWeek = getFirstDayOfMonth(displayYear, displayMonth);
|
||||||
|
|
||||||
|
// Calculate current cycle day and phase for the header
|
||||||
|
const currentCycleDay = getCycleDay(lastPeriodDate, cycleLength, today);
|
||||||
|
const currentPhase = getPhase(currentCycleDay);
|
||||||
|
|
||||||
|
const handlePreviousMonth = () => {
|
||||||
|
if (displayMonth === 0) {
|
||||||
|
onMonthChange?.(displayYear - 1, 11);
|
||||||
|
} else {
|
||||||
|
onMonthChange?.(displayYear, displayMonth - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
if (displayMonth === 11) {
|
||||||
|
onMonthChange?.(displayYear + 1, 0);
|
||||||
|
} else {
|
||||||
|
onMonthChange?.(displayYear, displayMonth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTodayClick = () => {
|
||||||
|
onMonthChange?.(today.getFullYear(), today.getMonth());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build array of day cells
|
||||||
|
const days: (Date | null)[] = [];
|
||||||
|
|
||||||
|
// Add empty cells for days before the first day of the month
|
||||||
|
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the actual days of the month
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
days.push(new Date(displayYear, displayMonth, day));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<h3 className="font-semibold mb-4">
|
{/* Cycle info header */}
|
||||||
Day {cycleDay} • {phase}
|
<h3 className="font-semibold mb-2">
|
||||||
|
Day {currentCycleDay} · {currentPhase}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{currentDate.toLocaleDateString("en-US", {
|
{/* Navigation with month/year */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreviousMonth}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded text-sm"
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{new Date(displayYear, displayMonth).toLocaleDateString("en-US", {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</span>
|
||||||
{/* Full calendar grid will be implemented here */}
|
<button
|
||||||
<p className="text-gray-400 text-xs mt-4">Calendar grid placeholder</p>
|
type="button"
|
||||||
|
onClick={handleTodayClick}
|
||||||
|
className="px-2 py-0.5 text-xs border rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded text-sm"
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact day of week headers */}
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 mb-1">
|
||||||
|
{COMPACT_DAY_NAMES.map((dayName, index) => (
|
||||||
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Day names are fixed and index is stable
|
||||||
|
key={`day-header-${index}`}
|
||||||
|
className="text-center text-xs font-medium text-gray-500"
|
||||||
|
>
|
||||||
|
{dayName}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{days.map((date, index) => {
|
||||||
|
if (!date) {
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month
|
||||||
|
return <div key={`empty-${index}`} className="p-1" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
|
||||||
|
const phase = getPhase(cycleDay);
|
||||||
|
const isToday =
|
||||||
|
date.getFullYear() === today.getFullYear() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
date.getDate() === today.getDate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={`p-1 text-xs rounded ${PHASE_COLORS[phase]} ${
|
||||||
|
isToday ? "ring-2 ring-black font-bold" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact phase legend */}
|
||||||
|
<div
|
||||||
|
data-testid="phase-legend"
|
||||||
|
className="mt-3 flex flex-wrap gap-2 justify-center"
|
||||||
|
>
|
||||||
|
{PHASE_LEGEND.map((phase) => (
|
||||||
|
<div key={phase.name} className="flex items-center gap-0.5">
|
||||||
|
<div className={`w-2 h-2 rounded ${phase.color}`} />
|
||||||
|
<span className="text-xs text-gray-600">{phase.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/components/dashboard/nutrition-panel.test.tsx
Normal file
136
src/components/dashboard/nutrition-panel.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// ABOUTME: Unit tests for NutritionPanel component.
|
||||||
|
// ABOUTME: Tests display of seeds, carb range, and keto guidance.
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { NutritionGuidance } from "@/types";
|
||||||
|
import { NutritionPanel } from "./nutrition-panel";
|
||||||
|
|
||||||
|
describe("NutritionPanel", () => {
|
||||||
|
const baseNutrition: NutritionGuidance = {
|
||||||
|
seeds: "Flax & Pumpkin",
|
||||||
|
carbRange: "100-150g",
|
||||||
|
ketoGuidance: "Moderate carbs today",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the NUTRITION TODAY heading", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("NUTRITION TODAY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders seeds guidance with emoji", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/🌱 Flax & Pumpkin/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders carb range with emoji", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/🍽️ Carbs: 100-150g/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders keto guidance with emoji", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/🥑 Keto: Moderate carbs today/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seed cycling phases", () => {
|
||||||
|
it("displays follicular phase seeds (flax & pumpkin)", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seeds: "Flax & Pumpkin",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/🌱 Flax & Pumpkin/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays luteal phase seeds (sunflower & sesame)", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seeds: "Sunflower & Sesame",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/🌱 Sunflower & Sesame/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("carb range variations", () => {
|
||||||
|
it("displays low carb range", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
carbRange: "50-75g",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/🍽️ Carbs: 50-75g/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays high carb range", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
carbRange: "150-200g",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/🍽️ Carbs: 150-200g/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keto guidance variations", () => {
|
||||||
|
it("displays keto-friendly guidance", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
ketoGuidance: "Good day for keto",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/🥑 Keto: Good day for keto/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays carb-loading guidance", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
ketoGuidance: "Consider carb loading",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/🥑 Keto: Consider carb loading/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("styling", () => {
|
||||||
|
it("renders within a bordered container", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<NutritionPanel nutrition={baseNutrition} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const panel = container.firstChild as HTMLElement;
|
||||||
|
expect(panel).toHaveClass("rounded-lg", "border", "p-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heading with semibold font", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
const heading = screen.getByText("NUTRITION TODAY");
|
||||||
|
expect(heading).toHaveClass("font-semibold");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
216
src/components/dashboard/override-toggles.test.tsx
Normal file
216
src/components/dashboard/override-toggles.test.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// ABOUTME: Unit tests for OverrideToggles component.
|
||||||
|
// ABOUTME: Tests toggle states, callbacks, and all override types.
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { OverrideType } from "@/types";
|
||||||
|
import { OverrideToggles } from "./override-toggles";
|
||||||
|
|
||||||
|
describe("OverrideToggles", () => {
|
||||||
|
const baseProps = {
|
||||||
|
activeOverrides: [] as OverrideType[],
|
||||||
|
onToggle: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the OVERRIDES heading", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("OVERRIDES")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all four override options", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("High Stress")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Poor Sleep")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("PMS Symptoms")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders four checkboxes", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} />);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkbox states", () => {
|
||||||
|
it("all checkboxes are unchecked when activeOverrides is empty", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} activeOverrides={[]} />);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
for (const checkbox of checkboxes) {
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flare checkbox is checked when flare is active", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} activeOverrides={["flare"]} />);
|
||||||
|
|
||||||
|
const flareCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "Flare Mode",
|
||||||
|
});
|
||||||
|
expect(flareCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stress checkbox is checked when stress is active", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} activeOverrides={["stress"]} />);
|
||||||
|
|
||||||
|
const stressCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "High Stress",
|
||||||
|
});
|
||||||
|
expect(stressCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sleep checkbox is checked when sleep is active", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} activeOverrides={["sleep"]} />);
|
||||||
|
|
||||||
|
const sleepCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "Poor Sleep",
|
||||||
|
});
|
||||||
|
expect(sleepCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pms checkbox is checked when pms is active", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} activeOverrides={["pms"]} />);
|
||||||
|
|
||||||
|
const pmsCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "PMS Symptoms",
|
||||||
|
});
|
||||||
|
expect(pmsCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple checkboxes are checked when multiple overrides are active", () => {
|
||||||
|
render(
|
||||||
|
<OverrideToggles
|
||||||
|
{...baseProps}
|
||||||
|
activeOverrides={["flare", "stress", "pms"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Flare Mode" }),
|
||||||
|
).toBeChecked();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "High Stress" }),
|
||||||
|
).toBeChecked();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Poor Sleep" }),
|
||||||
|
).not.toBeChecked();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "PMS Symptoms" }),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all checkboxes are checked when all overrides are active", () => {
|
||||||
|
render(
|
||||||
|
<OverrideToggles
|
||||||
|
{...baseProps}
|
||||||
|
activeOverrides={["flare", "stress", "sleep", "pms"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
for (const checkbox of checkboxes) {
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggle interactions", () => {
|
||||||
|
it("calls onToggle with flare when flare checkbox is clicked", () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(<OverrideToggles activeOverrides={[]} onToggle={onToggle} />);
|
||||||
|
|
||||||
|
const flareCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "Flare Mode",
|
||||||
|
});
|
||||||
|
fireEvent.click(flareCheckbox);
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("flare");
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggle with stress when stress checkbox is clicked", () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(<OverrideToggles activeOverrides={[]} onToggle={onToggle} />);
|
||||||
|
|
||||||
|
const stressCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "High Stress",
|
||||||
|
});
|
||||||
|
fireEvent.click(stressCheckbox);
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("stress");
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggle with sleep when sleep checkbox is clicked", () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(<OverrideToggles activeOverrides={[]} onToggle={onToggle} />);
|
||||||
|
|
||||||
|
const sleepCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "Poor Sleep",
|
||||||
|
});
|
||||||
|
fireEvent.click(sleepCheckbox);
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("sleep");
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggle with pms when pms checkbox is clicked", () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(<OverrideToggles activeOverrides={[]} onToggle={onToggle} />);
|
||||||
|
|
||||||
|
const pmsCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "PMS Symptoms",
|
||||||
|
});
|
||||||
|
fireEvent.click(pmsCheckbox);
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("pms");
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggle when unchecking an active override", () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(
|
||||||
|
<OverrideToggles activeOverrides={["flare"]} onToggle={onToggle} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const flareCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: "Flare Mode",
|
||||||
|
});
|
||||||
|
fireEvent.click(flareCheckbox);
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("flare");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("styling", () => {
|
||||||
|
it("renders within a bordered container", () => {
|
||||||
|
const { container } = render(<OverrideToggles {...baseProps} />);
|
||||||
|
|
||||||
|
const panel = container.firstChild as HTMLElement;
|
||||||
|
expect(panel).toHaveClass("rounded-lg", "border", "p-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heading with semibold font", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} />);
|
||||||
|
|
||||||
|
const heading = screen.getByText("OVERRIDES");
|
||||||
|
expect(heading).toHaveClass("font-semibold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders labels as clickable cursor-pointer", () => {
|
||||||
|
render(<OverrideToggles {...baseProps} />);
|
||||||
|
|
||||||
|
const labels = screen.getAllByText(
|
||||||
|
/Flare Mode|High Stress|Poor Sleep|PMS Symptoms/,
|
||||||
|
);
|
||||||
|
for (const label of labels) {
|
||||||
|
const labelElement = label.closest("label");
|
||||||
|
expect(labelElement).toHaveClass("cursor-pointer");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
275
src/components/dashboard/skeletons.test.tsx
Normal file
275
src/components/dashboard/skeletons.test.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
// ABOUTME: Tests for dashboard skeleton loading components.
|
||||||
|
// ABOUTME: Verifies skeleton structure, accessibility, and shimmer animations.
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CycleInfoSkeleton,
|
||||||
|
DashboardSkeleton,
|
||||||
|
DataPanelSkeleton,
|
||||||
|
DecisionCardSkeleton,
|
||||||
|
MiniCalendarSkeleton,
|
||||||
|
NutritionPanelSkeleton,
|
||||||
|
OverrideTogglesSkeleton,
|
||||||
|
} from "./skeletons";
|
||||||
|
|
||||||
|
describe("DecisionCardSkeleton", () => {
|
||||||
|
it("renders with loading aria label", () => {
|
||||||
|
render(<DecisionCardSkeleton />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading decision/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders shimmer placeholder for icon", () => {
|
||||||
|
render(<DecisionCardSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading decision/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-icon']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders shimmer placeholder for status", () => {
|
||||||
|
render(<DecisionCardSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading decision/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-status']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders shimmer placeholder for reason", () => {
|
||||||
|
render(<DecisionCardSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading decision/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-reason']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has animate-pulse class for shimmer effect", () => {
|
||||||
|
render(<DecisionCardSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading decision/i });
|
||||||
|
expect(container).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DataPanelSkeleton", () => {
|
||||||
|
it("renders with loading aria label", () => {
|
||||||
|
render(<DataPanelSkeleton />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading data/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders header skeleton", () => {
|
||||||
|
render(<DataPanelSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading data/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-header']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 5 skeleton rows for metrics", () => {
|
||||||
|
render(<DataPanelSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading data/i });
|
||||||
|
const rows = container.querySelectorAll("[data-testid='skeleton-row']");
|
||||||
|
expect(rows).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has animate-pulse class for shimmer effect", () => {
|
||||||
|
render(<DataPanelSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading data/i });
|
||||||
|
expect(container).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NutritionPanelSkeleton", () => {
|
||||||
|
it("renders with loading aria label", () => {
|
||||||
|
render(<NutritionPanelSkeleton />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading nutrition/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders header skeleton", () => {
|
||||||
|
render(<NutritionPanelSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading nutrition/i,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-header']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skeleton rows for nutrition guidance", () => {
|
||||||
|
render(<NutritionPanelSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading nutrition/i,
|
||||||
|
});
|
||||||
|
const rows = container.querySelectorAll("[data-testid='skeleton-row']");
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has animate-pulse class for shimmer effect", () => {
|
||||||
|
render(<NutritionPanelSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading nutrition/i,
|
||||||
|
});
|
||||||
|
expect(container).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MiniCalendarSkeleton", () => {
|
||||||
|
it("renders with loading aria label", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading calendar/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders header skeleton for cycle info", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading calendar/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-cycle-info']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders navigation skeleton", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading calendar/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-navigation']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders day header row with 7 cells", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading calendar/i });
|
||||||
|
const dayHeaders = container.querySelectorAll(
|
||||||
|
"[data-testid='skeleton-day-header']",
|
||||||
|
);
|
||||||
|
expect(dayHeaders).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders placeholder grid for calendar days", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading calendar/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-grid']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has animate-pulse class for shimmer effect", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading calendar/i });
|
||||||
|
expect(container).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders legend skeleton", () => {
|
||||||
|
render(<MiniCalendarSkeleton />);
|
||||||
|
const container = screen.getByRole("region", { name: /loading calendar/i });
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-legend']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OverrideTogglesSkeleton", () => {
|
||||||
|
it("renders with loading aria label", () => {
|
||||||
|
render(<OverrideTogglesSkeleton />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading overrides/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders header skeleton", () => {
|
||||||
|
render(<OverrideTogglesSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading overrides/i,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-header']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 4 toggle skeletons", () => {
|
||||||
|
render(<OverrideTogglesSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading overrides/i,
|
||||||
|
});
|
||||||
|
const toggles = container.querySelectorAll(
|
||||||
|
"[data-testid='skeleton-toggle']",
|
||||||
|
);
|
||||||
|
expect(toggles).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has animate-pulse class for shimmer effect", () => {
|
||||||
|
render(<OverrideTogglesSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading overrides/i,
|
||||||
|
});
|
||||||
|
expect(container).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CycleInfoSkeleton", () => {
|
||||||
|
it("renders with loading aria label", () => {
|
||||||
|
render(<CycleInfoSkeleton />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading cycle info/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cycle day placeholder", () => {
|
||||||
|
render(<CycleInfoSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading cycle info/i,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-cycle-day']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders next phase placeholder", () => {
|
||||||
|
render(<CycleInfoSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading cycle info/i,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='skeleton-next-phase']"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has animate-pulse class for shimmer effect", () => {
|
||||||
|
render(<CycleInfoSkeleton />);
|
||||||
|
const container = screen.getByRole("region", {
|
||||||
|
name: /loading cycle info/i,
|
||||||
|
});
|
||||||
|
expect(container).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DashboardSkeleton", () => {
|
||||||
|
it("renders all skeleton components", () => {
|
||||||
|
render(<DashboardSkeleton />);
|
||||||
|
|
||||||
|
// Should contain all skeleton regions
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading cycle info/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading decision/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading data/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading nutrition/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading overrides/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("region", { name: /loading calendar/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
208
src/components/dashboard/skeletons.tsx
Normal file
208
src/components/dashboard/skeletons.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// ABOUTME: Skeleton loading components for dashboard panels.
|
||||||
|
// ABOUTME: Provides shimmer placeholders matching real component structure.
|
||||||
|
|
||||||
|
export function DecisionCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Loading decision"
|
||||||
|
className="rounded-lg border p-6 animate-pulse"
|
||||||
|
>
|
||||||
|
{/* Icon placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-icon"
|
||||||
|
className="w-12 h-12 bg-gray-200 rounded-full mb-2"
|
||||||
|
/>
|
||||||
|
{/* Status placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-status"
|
||||||
|
className="h-8 w-32 bg-gray-200 rounded mb-2"
|
||||||
|
/>
|
||||||
|
{/* Reason placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-reason"
|
||||||
|
className="h-4 w-48 bg-gray-200 rounded"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataPanelSkeleton() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Loading data"
|
||||||
|
className="rounded-lg border p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
{/* Header placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-header"
|
||||||
|
className="h-5 w-24 bg-gray-200 rounded mb-4"
|
||||||
|
/>
|
||||||
|
{/* 5 metric rows matching DataPanel structure */}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
data-testid="skeleton-row"
|
||||||
|
className="h-4 bg-gray-200 rounded w-3/4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NutritionPanelSkeleton() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Loading nutrition"
|
||||||
|
className="rounded-lg border p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
{/* Header placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-header"
|
||||||
|
className="h-5 w-24 bg-gray-200 rounded mb-4"
|
||||||
|
/>
|
||||||
|
{/* 3 nutrition guidance rows */}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
data-testid="skeleton-row"
|
||||||
|
className="h-4 bg-gray-200 rounded w-3/4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniCalendarSkeleton() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Loading calendar"
|
||||||
|
className="rounded-lg border p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
{/* Cycle info header placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-cycle-info"
|
||||||
|
className="h-5 w-32 bg-gray-200 rounded mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Navigation placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-navigation"
|
||||||
|
className="flex items-center justify-between mb-3"
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 bg-gray-200 rounded" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-28 bg-gray-200 rounded" />
|
||||||
|
<div className="h-5 w-12 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="w-6 h-6 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers row */}
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 mb-1">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
data-testid="skeleton-day-header"
|
||||||
|
className="h-4 w-4 mx-auto bg-gray-200 rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid placeholder - 5 rows of 7 days */}
|
||||||
|
<div data-testid="skeleton-grid" className="grid grid-cols-7 gap-0.5">
|
||||||
|
{Array.from({ length: 35 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton placeholders never reorder
|
||||||
|
key={`skeleton-day-${i}`}
|
||||||
|
className="h-6 bg-gray-200 rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-legend"
|
||||||
|
className="mt-3 flex flex-wrap gap-2 justify-center"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-0.5">
|
||||||
|
<div className="w-2 h-2 bg-gray-200 rounded" />
|
||||||
|
<div className="h-3 w-12 bg-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverrideTogglesSkeleton() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Loading overrides"
|
||||||
|
className="rounded-lg border p-4 animate-pulse"
|
||||||
|
>
|
||||||
|
{/* Header placeholder */}
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-header"
|
||||||
|
className="h-5 w-24 bg-gray-200 rounded mb-4"
|
||||||
|
/>
|
||||||
|
{/* 4 toggle rows matching OverrideToggles structure */}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
data-testid="skeleton-toggle"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-24" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for the cycle info header on the dashboard.
|
||||||
|
*/
|
||||||
|
export function CycleInfoSkeleton() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Loading cycle info"
|
||||||
|
className="text-center animate-pulse"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-cycle-day"
|
||||||
|
className="h-6 w-40 bg-gray-200 rounded mx-auto mb-2"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-next-phase"
|
||||||
|
className="h-4 w-48 bg-gray-200 rounded mx-auto"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined skeleton for the entire dashboard loading state.
|
||||||
|
* Use this in loading.tsx for route-level loading.
|
||||||
|
*/
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CycleInfoSkeleton />
|
||||||
|
<DecisionCardSkeleton />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DataPanelSkeleton />
|
||||||
|
<NutritionPanelSkeleton />
|
||||||
|
</div>
|
||||||
|
<OverrideTogglesSkeleton />
|
||||||
|
<MiniCalendarSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,17 @@ vi.mock("next/headers", () => ({
|
|||||||
cookies: vi.fn(),
|
cookies: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the logger module
|
||||||
|
vi.mock("./logger", () => ({
|
||||||
|
logger: {
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { logger } from "./logger";
|
||||||
import {
|
import {
|
||||||
createPocketBaseClient,
|
createPocketBaseClient,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
@@ -26,6 +36,12 @@ import {
|
|||||||
loadAuthFromCookies,
|
loadAuthFromCookies,
|
||||||
} from "./pocketbase";
|
} from "./pocketbase";
|
||||||
|
|
||||||
|
const mockLogger = logger as {
|
||||||
|
warn: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
info: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
const mockCookies = cookies as ReturnType<typeof vi.fn>;
|
const mockCookies = cookies as ReturnType<typeof vi.fn>;
|
||||||
const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType<
|
const mockCreatePocketBaseClient = createPocketBaseClient as ReturnType<
|
||||||
typeof vi.fn
|
typeof vi.fn
|
||||||
@@ -161,4 +177,59 @@ describe("withAuth", () => {
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
expect(body.error).toBe("Internal server error");
|
expect(body.error).toBe("Internal server error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("structured logging", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLogger.warn.mockClear();
|
||||||
|
mockLogger.error.mockClear();
|
||||||
|
mockLogger.info.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with warn level", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
await wrappedHandler({} as NextRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ reason: "not_authenticated" }),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs auth failure when getCurrentUser returns null", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(true);
|
||||||
|
mockGetCurrentUser.mockReturnValue(null);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
await wrappedHandler({} as NextRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ reason: "user_not_found" }),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs internal errors with error level and stack trace", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(true);
|
||||||
|
mockGetCurrentUser.mockReturnValue(mockUser);
|
||||||
|
|
||||||
|
const testError = new Error("Handler error");
|
||||||
|
const handler = vi.fn().mockRejectedValue(testError);
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
await wrappedHandler({} as NextRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
err: testError,
|
||||||
|
}),
|
||||||
|
expect.stringContaining("Auth middleware error"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import type { User } from "@/types";
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
import { logger } from "./logger";
|
||||||
import {
|
import {
|
||||||
createPocketBaseClient,
|
createPocketBaseClient,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
@@ -54,19 +55,21 @@ export function withAuth<T = unknown>(
|
|||||||
|
|
||||||
// Check if the user is authenticated
|
// Check if the user is authenticated
|
||||||
if (!isAuthenticated(pb)) {
|
if (!isAuthenticated(pb)) {
|
||||||
|
logger.warn({ reason: "not_authenticated" }, "Auth failure");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current user
|
// Get the current user
|
||||||
const user = getCurrentUser(pb);
|
const user = getCurrentUser(pb);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
logger.warn({ reason: "user_not_found" }, "Auth failure");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the original handler with the user context
|
// Call the original handler with the user context
|
||||||
return await handler(request, user, context);
|
return await handler(request, user, context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auth middleware error:", error);
|
logger.error({ err: error }, "Auth middleware error");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// ABOUTME: Training decision engine based on biometric and cycle data.
|
// ABOUTME: Training decision engine based on biometric and cycle data.
|
||||||
// ABOUTME: Implements priority-based rules for daily training recommendations.
|
// ABOUTME: Implements priority-based rules for daily training recommendations.
|
||||||
|
import { decisionEngineCallsTotal } from "@/lib/metrics";
|
||||||
import type { DailyData, Decision, OverrideType } from "@/types";
|
import type { DailyData, Decision, OverrideType } from "@/types";
|
||||||
|
|
||||||
// Override priority order - checked before algorithmic rules
|
// Override priority order - checked before algorithmic rules
|
||||||
@@ -80,14 +81,18 @@ export function getDecisionWithOverrides(
|
|||||||
// Check overrides first, in priority order: flare > stress > sleep > pms
|
// Check overrides first, in priority order: flare > stress > sleep > pms
|
||||||
for (const override of OVERRIDE_PRIORITY) {
|
for (const override of OVERRIDE_PRIORITY) {
|
||||||
if (overrides.includes(override)) {
|
if (overrides.includes(override)) {
|
||||||
return {
|
const decision: Decision = {
|
||||||
status: "REST",
|
status: "REST",
|
||||||
reason: OVERRIDE_REASONS[override],
|
reason: OVERRIDE_REASONS[override],
|
||||||
icon: "🛑",
|
icon: "🛑",
|
||||||
};
|
};
|
||||||
|
decisionEngineCallsTotal.inc({ decision: decision.status });
|
||||||
|
return decision;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No active overrides - fall through to algorithmic rules
|
// No active overrides - fall through to algorithmic rules
|
||||||
return getTrainingDecision(data);
|
const decision = getTrainingDecision(data);
|
||||||
|
decisionEngineCallsTotal.inc({ decision: decision.status });
|
||||||
|
return decision;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ vi.mock("resend", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import type { DailyEmailData } from "./email";
|
import type { DailyEmailData } from "./email";
|
||||||
import { sendDailyEmail, sendPeriodConfirmationEmail } from "./email";
|
import {
|
||||||
|
sendDailyEmail,
|
||||||
|
sendPeriodConfirmationEmail,
|
||||||
|
sendTokenExpirationWarning,
|
||||||
|
} from "./email";
|
||||||
|
|
||||||
describe("sendDailyEmail", () => {
|
describe("sendDailyEmail", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -189,3 +193,87 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sendTokenExpirationWarning", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("14-day warning", () => {
|
||||||
|
it("sends email with correct subject for 14-day warning", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends to correct recipient", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "user@example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes days until expiry in body", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
|
const call = mockSend.mock.calls[0][0];
|
||||||
|
expect(call.text).toContain("14 days");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes instructions to refresh tokens", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
|
const call = mockSend.mock.calls[0][0];
|
||||||
|
expect(call.text).toContain("Settings");
|
||||||
|
expect(call.text).toContain("Garmin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes auto-generated footer", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
|
const call = mockSend.mock.calls[0][0];
|
||||||
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("7-day warning", () => {
|
||||||
|
it("sends email with urgent subject for 7-day warning", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject:
|
||||||
|
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends to correct recipient", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
|
expect(mockSend).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "user@example.com",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes days until expiry in body", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
|
const call = mockSend.mock.calls[0][0];
|
||||||
|
expect(call.text).toContain("7 days");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses more urgent tone than 14-day warning", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
|
const call = mockSend.mock.calls[0][0];
|
||||||
|
expect(call.text).toContain("urgent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes auto-generated footer", async () => {
|
||||||
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
|
const call = mockSend.mock.calls[0][0];
|
||||||
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
|
||||||
|
import { emailSentTotal } from "@/lib/metrics";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
|
||||||
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
||||||
@@ -57,6 +59,8 @@ Auto-generated by PhaseFlow`;
|
|||||||
subject,
|
subject,
|
||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emailSentTotal.inc({ type: "daily" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPeriodConfirmationEmail(
|
export async function sendPeriodConfirmationEmail(
|
||||||
@@ -81,3 +85,39 @@ Auto-generated by PhaseFlow`;
|
|||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendTokenExpirationWarning(
|
||||||
|
to: string,
|
||||||
|
daysUntilExpiry: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const isUrgent = daysUntilExpiry <= 7;
|
||||||
|
|
||||||
|
const subject = isUrgent
|
||||||
|
? `🚨 PhaseFlow: Garmin tokens expire in ${daysUntilExpiry} days - action required`
|
||||||
|
: `⚠️ PhaseFlow: Garmin tokens expire in ${daysUntilExpiry} days`;
|
||||||
|
|
||||||
|
const urgencyMessage = isUrgent
|
||||||
|
? `⚠️ This is urgent - your Garmin data sync will stop working in ${daysUntilExpiry} days if you don't refresh your tokens.`
|
||||||
|
: `Your Garmin OAuth tokens will expire in ${daysUntilExpiry} days.`;
|
||||||
|
|
||||||
|
const body = `${urgencyMessage}
|
||||||
|
|
||||||
|
📋 HOW TO REFRESH YOUR TOKENS:
|
||||||
|
1. Go to Settings > Garmin in PhaseFlow
|
||||||
|
2. Follow the instructions to reconnect your Garmin account
|
||||||
|
3. Paste the new tokens from the bootstrap script
|
||||||
|
|
||||||
|
This will ensure your training recommendations continue to use fresh Garmin data.
|
||||||
|
|
||||||
|
---
|
||||||
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: EMAIL_FROM,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
emailSentTotal.inc({ type: "warning" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ describe("daysUntilExpiry", () => {
|
|||||||
expires_at: pastDate.toISOString(),
|
expires_at: pastDate.toISOString(),
|
||||||
};
|
};
|
||||||
const days = daysUntilExpiry(tokens);
|
const days = daysUntilExpiry(tokens);
|
||||||
|
// Allow -4 to -6 due to timezone/rounding variations
|
||||||
expect(days).toBeLessThanOrEqual(-4);
|
expect(days).toBeLessThanOrEqual(-4);
|
||||||
expect(days).toBeGreaterThanOrEqual(-5);
|
expect(days).toBeGreaterThanOrEqual(-6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 0 for expiry date within the same day", () => {
|
it("returns 0 for expiry date within the same day", () => {
|
||||||
|
|||||||
213
src/lib/logger.test.ts
Normal file
213
src/lib/logger.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// ABOUTME: Tests for the pino-based structured logging module.
|
||||||
|
// ABOUTME: Validates JSON output format, log levels, and field requirements per observability spec.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// We'll need to mock stdout to capture log output
|
||||||
|
const mockStdoutWrite = vi.fn();
|
||||||
|
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||||
|
|
||||||
|
describe("logger", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.stdout.write = mockStdoutWrite as typeof process.stdout.write;
|
||||||
|
mockStdoutWrite.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.stdout.write = originalStdoutWrite;
|
||||||
|
delete process.env.LOG_LEVEL;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("log format", () => {
|
||||||
|
it("outputs valid JSON", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info("test message");
|
||||||
|
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
const output = mockStdoutWrite.mock.calls[0][0];
|
||||||
|
expect(() => JSON.parse(output)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes timestamp in ISO 8601 format", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info("test message");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.timestamp).toBeDefined();
|
||||||
|
// ISO 8601 format check
|
||||||
|
expect(new Date(output.timestamp).toISOString()).toBe(output.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes level as string label", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info("test message");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.level).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes message field", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info("test message");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.message).toBe("test message");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("log levels", () => {
|
||||||
|
it("logs info level messages", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info("info message");
|
||||||
|
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.level).toBe("info");
|
||||||
|
expect(output.message).toBe("info message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs warn level messages", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.warn("warn message");
|
||||||
|
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.level).toBe("warn");
|
||||||
|
expect(output.message).toBe("warn message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error level messages", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.error("error message");
|
||||||
|
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.level).toBe("error");
|
||||||
|
expect(output.message).toBe("error message");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("additional fields", () => {
|
||||||
|
it("includes additional context fields", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info({ userId: "user123", duration_ms: 1250 }, "sync complete");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.userId).toBe("user123");
|
||||||
|
expect(output.duration_ms).toBe(1250);
|
||||||
|
expect(output.message).toBe("sync complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes nested objects in context", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
userId: "user123",
|
||||||
|
metrics: { bodyBattery: 95, hrvStatus: "Balanced" },
|
||||||
|
},
|
||||||
|
"Garmin sync completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.userId).toBe("user123");
|
||||||
|
expect(output.metrics).toEqual({
|
||||||
|
bodyBattery: 95,
|
||||||
|
hrvStatus: "Balanced",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error logging", () => {
|
||||||
|
it("includes error stack trace for Error objects", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
const error = new Error("Something went wrong");
|
||||||
|
logger.error({ err: error }, "Operation failed");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.err).toBeDefined();
|
||||||
|
expect(output.err.message).toBe("Something went wrong");
|
||||||
|
expect(output.err.stack).toBeDefined();
|
||||||
|
expect(output.err.stack).toContain("Error: Something went wrong");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes error type for Error objects", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
|
||||||
|
class CustomError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CustomError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new CustomError("Custom error occurred");
|
||||||
|
logger.error({ err: error }, "Custom failure");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.err.type).toBe("CustomError");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("log level configuration", () => {
|
||||||
|
it("defaults to info level when LOG_LEVEL not set", async () => {
|
||||||
|
delete process.env.LOG_LEVEL;
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
|
||||||
|
// Debug should not be logged at info level
|
||||||
|
logger.debug("debug message");
|
||||||
|
expect(mockStdoutWrite).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Info should be logged
|
||||||
|
logger.info("info message");
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects LOG_LEVEL environment variable for debug", async () => {
|
||||||
|
process.env.LOG_LEVEL = "debug";
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
|
||||||
|
logger.debug("debug message");
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.level).toBe("debug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects LOG_LEVEL environment variable for error only", async () => {
|
||||||
|
process.env.LOG_LEVEL = "error";
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
|
||||||
|
logger.info("info message");
|
||||||
|
logger.warn("warn message");
|
||||||
|
expect(mockStdoutWrite).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
logger.error("error message");
|
||||||
|
expect(mockStdoutWrite).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("child loggers", () => {
|
||||||
|
it("creates child logger with bound context", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
const childLogger = logger.child({ userId: "user123" });
|
||||||
|
|
||||||
|
childLogger.info("child message");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.userId).toBe("user123");
|
||||||
|
expect(output.message).toBe("child message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("child logger inherits parent context", async () => {
|
||||||
|
const { logger } = await import("./logger");
|
||||||
|
const childLogger = logger.child({ service: "garmin-sync" });
|
||||||
|
const grandchildLogger = childLogger.child({ userId: "user123" });
|
||||||
|
|
||||||
|
grandchildLogger.info("nested message");
|
||||||
|
|
||||||
|
const output = JSON.parse(mockStdoutWrite.mock.calls[0][0]);
|
||||||
|
expect(output.service).toBe("garmin-sync");
|
||||||
|
expect(output.userId).toBe("user123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/lib/logger.ts
Normal file
26
src/lib/logger.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ABOUTME: Structured logging module using pino for JSON output to stdout.
|
||||||
|
// ABOUTME: Configurable via LOG_LEVEL env var, outputs parseable logs for aggregators.
|
||||||
|
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PhaseFlow logger configured per observability spec.
|
||||||
|
*
|
||||||
|
* Log levels: error, warn, info, debug
|
||||||
|
* Output: JSON to stdout
|
||||||
|
* Configuration: LOG_LEVEL env var (defaults to "info")
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* logger.info({ userId: "123" }, "User logged in");
|
||||||
|
* logger.error({ err: error, userId: "123" }, "Operation failed");
|
||||||
|
*/
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || "info",
|
||||||
|
formatters: {
|
||||||
|
level: (label) => ({ level: label }),
|
||||||
|
},
|
||||||
|
timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
|
||||||
|
messageKey: "message",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Logger = typeof logger;
|
||||||
200
src/lib/metrics.test.ts
Normal file
200
src/lib/metrics.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// ABOUTME: Tests for the Prometheus metrics collection module.
|
||||||
|
// ABOUTME: Validates metric registration, counters, gauges, histograms per observability spec.
|
||||||
|
|
||||||
|
import * as promClient from "prom-client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe("metrics", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear the default registry before each test
|
||||||
|
promClient.register.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
promClient.register.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registry", () => {
|
||||||
|
it("exports a Prometheus registry", async () => {
|
||||||
|
const { metricsRegistry } = await import("./metrics");
|
||||||
|
expect(metricsRegistry).toBeDefined();
|
||||||
|
expect(typeof metricsRegistry.metrics).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects default Node.js metrics", async () => {
|
||||||
|
const { metricsRegistry } = await import("./metrics");
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
|
||||||
|
// Should include standard Node.js metrics
|
||||||
|
expect(metrics).toContain("nodejs_");
|
||||||
|
expect(metrics).toContain("process_");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom metrics - garmin sync", () => {
|
||||||
|
it("exports phaseflow_garmin_sync_total counter", async () => {
|
||||||
|
const { garminSyncTotal } = await import("./metrics");
|
||||||
|
expect(garminSyncTotal).toBeDefined();
|
||||||
|
expect(garminSyncTotal.inc).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments garmin sync total with success status", async () => {
|
||||||
|
const { garminSyncTotal, metricsRegistry } = await import("./metrics");
|
||||||
|
garminSyncTotal.inc({ status: "success" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain(
|
||||||
|
'phaseflow_garmin_sync_total{status="success"} 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments garmin sync total with failure status", async () => {
|
||||||
|
const { garminSyncTotal, metricsRegistry } = await import("./metrics");
|
||||||
|
garminSyncTotal.inc({ status: "failure" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain(
|
||||||
|
'phaseflow_garmin_sync_total{status="failure"} 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports phaseflow_garmin_sync_duration_seconds histogram", async () => {
|
||||||
|
const { garminSyncDuration } = await import("./metrics");
|
||||||
|
expect(garminSyncDuration).toBeDefined();
|
||||||
|
expect(garminSyncDuration.observe).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records garmin sync duration", async () => {
|
||||||
|
const { garminSyncDuration, metricsRegistry } = await import("./metrics");
|
||||||
|
garminSyncDuration.observe(1.5);
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain(
|
||||||
|
"phaseflow_garmin_sync_duration_seconds_bucket",
|
||||||
|
);
|
||||||
|
expect(metrics).toContain(
|
||||||
|
"phaseflow_garmin_sync_duration_seconds_sum 1.5",
|
||||||
|
);
|
||||||
|
expect(metrics).toContain(
|
||||||
|
"phaseflow_garmin_sync_duration_seconds_count 1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom metrics - email", () => {
|
||||||
|
it("exports phaseflow_email_sent_total counter", async () => {
|
||||||
|
const { emailSentTotal } = await import("./metrics");
|
||||||
|
expect(emailSentTotal).toBeDefined();
|
||||||
|
expect(emailSentTotal.inc).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments email sent total with daily type", async () => {
|
||||||
|
const { emailSentTotal, metricsRegistry } = await import("./metrics");
|
||||||
|
emailSentTotal.inc({ type: "daily" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain('phaseflow_email_sent_total{type="daily"} 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments email sent total with warning type", async () => {
|
||||||
|
const { emailSentTotal, metricsRegistry } = await import("./metrics");
|
||||||
|
emailSentTotal.inc({ type: "warning" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain('phaseflow_email_sent_total{type="warning"} 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom metrics - decision engine", () => {
|
||||||
|
it("exports phaseflow_decision_engine_calls_total counter", async () => {
|
||||||
|
const { decisionEngineCallsTotal } = await import("./metrics");
|
||||||
|
expect(decisionEngineCallsTotal).toBeDefined();
|
||||||
|
expect(decisionEngineCallsTotal.inc).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments decision engine calls with decision label", async () => {
|
||||||
|
const { decisionEngineCallsTotal, metricsRegistry } = await import(
|
||||||
|
"./metrics"
|
||||||
|
);
|
||||||
|
decisionEngineCallsTotal.inc({ decision: "REST" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain(
|
||||||
|
'phaseflow_decision_engine_calls_total{decision="REST"} 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks multiple decision types", async () => {
|
||||||
|
const { decisionEngineCallsTotal, metricsRegistry } = await import(
|
||||||
|
"./metrics"
|
||||||
|
);
|
||||||
|
decisionEngineCallsTotal.inc({ decision: "REST" });
|
||||||
|
decisionEngineCallsTotal.inc({ decision: "REST" });
|
||||||
|
decisionEngineCallsTotal.inc({ decision: "GENTLE" });
|
||||||
|
decisionEngineCallsTotal.inc({ decision: "GO" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain(
|
||||||
|
'phaseflow_decision_engine_calls_total{decision="REST"} 2',
|
||||||
|
);
|
||||||
|
expect(metrics).toContain(
|
||||||
|
'phaseflow_decision_engine_calls_total{decision="GENTLE"} 1',
|
||||||
|
);
|
||||||
|
expect(metrics).toContain(
|
||||||
|
'phaseflow_decision_engine_calls_total{decision="GO"} 1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom metrics - active users", () => {
|
||||||
|
it("exports phaseflow_active_users gauge", async () => {
|
||||||
|
const { activeUsersGauge } = await import("./metrics");
|
||||||
|
expect(activeUsersGauge).toBeDefined();
|
||||||
|
expect(activeUsersGauge.set).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets active users count", async () => {
|
||||||
|
const { activeUsersGauge, metricsRegistry } = await import("./metrics");
|
||||||
|
activeUsersGauge.set(42);
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain("phaseflow_active_users 42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can increment and decrement active users gauge", async () => {
|
||||||
|
const { activeUsersGauge, metricsRegistry } = await import("./metrics");
|
||||||
|
activeUsersGauge.set(10);
|
||||||
|
activeUsersGauge.inc();
|
||||||
|
activeUsersGauge.dec();
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
expect(metrics).toContain("phaseflow_active_users 10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metrics format", () => {
|
||||||
|
it("produces valid Prometheus text format", async () => {
|
||||||
|
const { metricsRegistry, garminSyncTotal, emailSentTotal } = await import(
|
||||||
|
"./metrics"
|
||||||
|
);
|
||||||
|
garminSyncTotal.inc({ status: "success" });
|
||||||
|
emailSentTotal.inc({ type: "daily" });
|
||||||
|
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
|
||||||
|
// Should contain TYPE and HELP comments for custom metrics
|
||||||
|
expect(metrics).toContain("# TYPE phaseflow_garmin_sync_total counter");
|
||||||
|
expect(metrics).toContain("# HELP phaseflow_garmin_sync_total");
|
||||||
|
expect(metrics).toContain("# TYPE phaseflow_email_sent_total counter");
|
||||||
|
expect(metrics).toContain("# HELP phaseflow_email_sent_total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns content type for Prometheus", async () => {
|
||||||
|
const { metricsRegistry } = await import("./metrics");
|
||||||
|
expect(metricsRegistry.contentType).toBe(
|
||||||
|
"text/plain; version=0.0.4; charset=utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/lib/metrics.ts
Normal file
49
src/lib/metrics.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// ABOUTME: Prometheus metrics collection module for production monitoring.
|
||||||
|
// ABOUTME: Exposes counters, gauges, and histograms for Garmin sync, email, and decision engine.
|
||||||
|
|
||||||
|
import * as promClient from "prom-client";
|
||||||
|
|
||||||
|
// Create a new registry for our application metrics
|
||||||
|
export const metricsRegistry = new promClient.Registry();
|
||||||
|
|
||||||
|
// Collect default Node.js metrics (heap, event loop, etc.)
|
||||||
|
promClient.collectDefaultMetrics({ register: metricsRegistry });
|
||||||
|
|
||||||
|
// Custom metric: Garmin sync operations counter
|
||||||
|
export const garminSyncTotal = new promClient.Counter({
|
||||||
|
name: "phaseflow_garmin_sync_total",
|
||||||
|
help: "Total number of Garmin sync operations",
|
||||||
|
labelNames: ["status"] as const,
|
||||||
|
registers: [metricsRegistry],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom metric: Garmin sync duration histogram
|
||||||
|
export const garminSyncDuration = new promClient.Histogram({
|
||||||
|
name: "phaseflow_garmin_sync_duration_seconds",
|
||||||
|
help: "Duration of Garmin sync operations in seconds",
|
||||||
|
buckets: [0.1, 0.5, 1, 2, 5, 10],
|
||||||
|
registers: [metricsRegistry],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom metric: Email sent counter
|
||||||
|
export const emailSentTotal = new promClient.Counter({
|
||||||
|
name: "phaseflow_email_sent_total",
|
||||||
|
help: "Total number of emails sent",
|
||||||
|
labelNames: ["type"] as const,
|
||||||
|
registers: [metricsRegistry],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom metric: Decision engine calls counter
|
||||||
|
export const decisionEngineCallsTotal = new promClient.Counter({
|
||||||
|
name: "phaseflow_decision_engine_calls_total",
|
||||||
|
help: "Total number of decision engine calls",
|
||||||
|
labelNames: ["decision"] as const,
|
||||||
|
registers: [metricsRegistry],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom metric: Active users gauge
|
||||||
|
export const activeUsersGauge = new promClient.Gauge({
|
||||||
|
name: "phaseflow_active_users",
|
||||||
|
help: "Number of users with activity in the last 24 hours",
|
||||||
|
registers: [metricsRegistry],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user