Compare commits

..

108 Commits

Author SHA1 Message Date
ec3d341e51 Document production URL in CLAUDE.md
All checks were successful
Deploy / deploy (push) Successful in 1m43s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:59:42 +00:00
1a9a327a30 Increase Garmin sync frequency to every 3 hours
Some checks failed
Deploy / deploy (push) Has been cancelled
Change cron schedule from 4x daily (0,6,12,18 UTC) to 8x daily
(every 3 hours) to keep body battery and other Garmin data fresher.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:57:59 +00:00
d4b04a17be Fix email delivery and body battery null handling
All checks were successful
Deploy / deploy (push) Successful in 2m34s
- Add PocketBase admin auth to notifications endpoint (was returning 0 users)
- Store null instead of 100 for body battery when Garmin returns no data
- Update decision engine to skip body battery rules when values are null
- Dashboard and email already display "N/A" for null values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 08:50:30 +00:00
092d8bb3dd Fix email timing and show fallback data when Garmin sync pending
All checks were successful
Deploy / deploy (push) Successful in 2m31s
- Add 15-minute notification granularity (*/15 cron) so users get emails
  at their configured time instead of rounding to the nearest hour
- Add DailyLog fallback to most recent when today's log doesn't exist,
  preventing 100/100/Unknown default values before morning sync
- Show "Last synced" indicator when displaying stale data
- Change Garmin sync to 6-hour intervals (0,6,12,18 UTC) to ensure
  data is available before European morning notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:56:41 +00:00
0d5785aaaa Add MAILGUN_URL for EU region support
All checks were successful
Deploy / deploy (push) Successful in 1m41s
Mailgun EU accounts require api.eu.mailgun.net endpoint.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:28:50 +00:00
9c4fcd8b52 Add test endpoint for verifying email configuration
All checks were successful
Deploy / deploy (push) Successful in 2m46s
POST /api/test/email with {"to": "email@example.com"} to send a test email.
Protected by CRON_SECRET.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:22:19 +00:00
140de56450 Reduce Garmin sync frequency to 3 times daily
All checks were successful
Deploy / deploy (push) Successful in 2m38s
Sync at 08:00, 14:00, and 22:00 UTC instead of every hour.
Garmin data updates once daily so hourly was excessive.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:17:46 +00:00
ccbc86016d Switch email provider from Resend to Mailgun and add cron scheduler
Some checks failed
Deploy / deploy (push) Failing after 1m43s
- Replace resend package with mailgun.js and form-data
- Update email.ts to use Mailgun API with lazy client initialization
- Add instrumentation.ts to schedule cron jobs (notifications :00, Garmin :30)
- Update tests for Mailgun mock structure
- Update .env.example with MAILGUN_API_KEY and MAILGUN_DOMAIN

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:37:24 +00:00
6543c79a04 Show "Goal exceeded" instead of negative remaining minutes
All checks were successful
Deploy / deploy (push) Successful in 1m38s
When weekly intensity exceeds the phase goal, display "Goal exceeded by X min"
instead of the confusing "Remaining: -X min".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:27:32 +00:00
ed14aea0ea Fix intensity goal defaults for PocketBase 0 values
All checks were successful
Deploy / deploy (push) Successful in 2m39s
PocketBase number fields default to 0, not null. Using ?? (nullish
coalescing) caused 0 to be preserved instead of using the default value.
Changed to || so 0 is treated as falsy and falls back to defaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:14:39 +00:00
8956e04eca Fix garmin-sync upsert and add Settings UI for intensity goals
All checks were successful
Deploy / deploy (push) Successful in 1m39s
- Fix dailyLog upsert to use range query (matches today route pattern)
- Properly distinguish 404 errors from other failures in upsert logic
- Add logging for dailyLog create/update operations
- Add Settings UI section for weekly intensity goals per phase
- Add unit tests for upsert behavior and intensity goals UI
- Add E2E tests for intensity goals settings flow

This fixes the issue where Garmin sync was creating new dailyLog
records instead of updating existing ones (322 vs 222 intensity
minutes bug, Unknown HRV bug).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:53:43 +00:00
6cd0c06396 Fix Garmin intensity minutes and add user-configurable phase goals
All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Apply 2x multiplier for vigorous intensity minutes (matches Garmin)
- Use calendar week (Mon-Sun) instead of trailing 7 days for intensity
- Add HRV yesterday fallback when today's data returns empty
- Add user-configurable phase intensity goals with new defaults:
  - Menstrual: 75, Follicular: 150, Ovulation: 100
  - Early Luteal: 120, Late Luteal: 50
- Update garmin-sync and today routes to use user-specific phase limits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:18:20 +00:00
a1495ff23f Fix garmin-sync to upsert dailyLogs instead of always creating
All checks were successful
Deploy / deploy (push) Successful in 1m39s
The sync was creating a new record every time it ran, causing duplicate
records for the same day. Combined with PocketBase's inability to sort
by the 'created' field, this caused the dashboard to display stale data.

Now checks for an existing record for the user+date before creating,
and updates the existing record if found.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:06:03 +00:00
4dad370e66 PB credentials. 2026-01-15 22:02:09 +00:00
a184909957 Fix PocketBase query error by sorting by date instead of created
All checks were successful
Deploy / deploy (push) Successful in 2m28s
The sort=-created parameter was causing PocketBase to return a 400 error
when querying dailyLogs. This is likely a compatibility issue with how
PocketBase handles the auto-generated 'created' field in certain query
combinations. Changing to sort by -date resolves the issue and makes
more semantic sense for dailyLogs which have one record per day.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:55:41 +00:00
3e2d9047fb Fix PocketBase date query - use range operators not contains
All checks were successful
Deploy / deploy (push) Successful in 1m39s
The ~ contains operator doesn't work with PocketBase date fields.
Use >= and < operators with YYYY-MM-DD format instead, matching
the working /api/history pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:24:00 +00:00
55a3505b55 Document CI/CD auto-deployment on git push
All checks were successful
Deploy / deploy (push) Successful in 1m39s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:17:44 +00:00
14bd0407f9 Fix PocketBase date format - use YYYY-MM-DD instead of ISO
Some checks failed
Deploy / deploy (push) Has been cancelled
PocketBase filters don't accept ISO format with T separator (causes 400).
Changed both garmin-sync storage and today route query to use simple
YYYY-MM-DD format, matching the working /api/history pattern.

TDD approach: wrote failing tests first, then implemented the fix.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:16:07 +00:00
1f7c804a4b Add production log access instructions to AGENTS.md
All checks were successful
Deploy / deploy (push) Successful in 2m30s
Document how to use Nomad CLI to view production logs for debugging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:00:51 +00:00
5f8e913555 Fix dailyLog date query to use proper date comparison
All checks were successful
Deploy / deploy (push) Successful in 1m40s
- Change /api/today query from string contains (~) to date range (>=, <)
- Store dates in full ISO format in garmin-sync for consistent comparison
- PocketBase date fields need proper date operators, not string contains

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:37:53 +00:00
f923e1ce48 Add debug logging for dailyLog query
All checks were successful
Deploy / deploy (push) Successful in 2m39s
2026-01-15 14:28:47 +00:00
599a66bbb5 Sort dailyLogs by created DESC to get most recent record
All checks were successful
Deploy / deploy (push) Successful in 1m38s
When multiple dailyLog records exist for the same date (from multiple
syncs), getFirstListItem was returning the oldest one with stale data.
Now sorts by -created to return the most recent record.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:25:16 +00:00
923b5fdb01 Fix body battery array parsing - use index 1 not 2
All checks were successful
Deploy / deploy (push) Successful in 2m39s
The Garmin API returns bodyBatteryValuesArray as [timestamp, level]
tuples (2 elements), not [timestamp, status, level, quality] (4 elements).
Was accessing index 2 which doesn't exist, now correctly using index 1.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:19:27 +00:00
c080e7054d Add debug logging for body battery API response
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Need to inspect the raw response structure to debug why current
and yesterdayLow values are missing despite hasCurrentData=true.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:14:42 +00:00
83fd29b6c6 Fix intensity minutes URL to use path parameters
All checks were successful
Deploy / deploy (push) Successful in 1m38s
The Garmin API uses path parameters, not query parameters:
- Correct: /usersummary-service/stats/im/weekly/{start}/{end}
- Wrong: /usersummary-service/stats/im/weekly?start=...&end=...

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:09:46 +00:00
cf89675b92 Fix body battery and intensity minutes Garmin API endpoints
All checks were successful
Deploy / deploy (push) Successful in 2m27s
Body Battery:
- Change endpoint from /usersummary-service/stats/bodyBattery/dates/
  to /wellness-service/wellness/bodyBattery/reports/daily
- Parse new response format: array with bodyBatteryValuesArray time series
- Current value = last entry's level (index 2)
- YesterdayLow = min level from yesterday's data

Intensity Minutes:
- Change endpoint from /fitnessstats-service/activity
  to /usersummary-service/stats/im/weekly
- Add date parameter to function signature
- Parse new response format: array with moderateValue/vigorousValue

Endpoints verified against python-garminconnect source code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:58:04 +00:00
59d70ee414 Use connectapi.garmin.com directly instead of web proxy
All checks were successful
Deploy / deploy (push) Successful in 1m38s
The connect.garmin.com/modern/proxy URL returns HTML (website) instead
of JSON API responses. Garth library uses connectapi.garmin.com subdomain
directly, which is the actual API endpoint.

- Change base URL from connect.garmin.com/modern/proxy to connectapi.garmin.com
- Update User-Agent to match garth library: GCM-iOS-5.19.1.2
- Factor out headers into getGarminHeaders() to avoid duplication
- Remove NK header (not needed when using connectapi subdomain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:38:55 +00:00
51f4c8eb80 Add User-Agent header to Garmin API requests
All checks were successful
Deploy / deploy (push) Successful in 2m29s
Garmin now requires a mobile app User-Agent header (GCM-iOS-5.7.2.1)
for API access. Without it, they serve the website HTML instead of
JSON API responses.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:29:10 +00:00
98293f5ab5 Log raw response when Garmin returns non-JSON
All checks were successful
Deploy / deploy (push) Successful in 1m40s
Garmin is returning HTML error pages instead of JSON data. This
change reads the response as text first, checks if it starts with
{ or [, and logs the first 1000 chars of the response body if not.

This will help diagnose what page Garmin is returning (login, captcha,
rate limit, etc).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:20:28 +00:00
85b535f04a Add build info metric and diagnostic logging for Garmin sync
All checks were successful
Deploy / deploy (push) Successful in 1m44s
- Add phaseflow_build_info metric with version and commit labels
- Inject GIT_COMMIT env var at build time via next.config.ts
- Add logging to all Garmin fetch functions (HRV, body battery, intensity)
- Log API response status codes, actual data values, and errors

This enables verifying which build is deployed and diagnosing
silent failures where Garmin API returns errors but sync reports success.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:09:29 +00:00
7ed827f82c Fix body battery showing zeros on dashboard after Garmin sync
All checks were successful
Deploy / deploy (push) Successful in 2m29s
PocketBase coerces null number fields to 0 when reading. When Garmin
API returned no data (null), we stored null, which became 0 on
retrieval. The nullish coalescing (?? 100) in the API route didn't
catch this because 0 is not nullish.

Now store default value 100 when Garmin returns null, matching the
existing pattern used for decision engine calculations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 12:47:12 +00:00
3a06bff4d4 Fix Garmin sync to handle PocketBase date strings
All checks were successful
Deploy / deploy (push) Successful in 2m38s
PocketBase returns date fields as ISO strings, not Date objects.
The sync was failing with "e.getTime is not a function" because
the code expected Date objects.

- Export mapRecordToUser from pocketbase.ts
- Use mapRecordToUser in cron route to properly parse dates
- Add test for handling date fields as ISO strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:38:37 +00:00
4ba9f44cef Add PocketBase admin auth to garmin-sync cron job
All checks were successful
Deploy / deploy (push) Successful in 2m28s
The cron job needs to list all users, but the users collection
doesn't have a public listRule (for security). Added admin
authentication so the job can access user records.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:13:18 +00:00
0579ca2534 Add API rules setup to database initialization
All checks were successful
Deploy / deploy (push) Successful in 2m29s
The period_logs collection was returning 403 errors because API rules
were only configured in the e2e test harness, not in the production
setup script. This consolidates the setup logic so both prod and test
use the same setupApiRules() function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:49:42 +00:00
4a874476c3 Enable 5 previously skipped e2e tests
All checks were successful
Deploy / deploy (push) Successful in 1m37s
- Fix OIDC tests with route interception for auth-methods API
- Add data-testid to DecisionCard for reliable test selection
- Fix /api/today to fetch fresh user data instead of stale cookie data
- Fix period logging test timing with proper API wait patterns
- Fix decision engine test with waitForResponse instead of timeout
- Simplify mobile viewport test locator

All 206 e2e tests now pass with 0 skipped.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:30:51 +00:00
ff3d8fad2c Add Playwright fixtures with 5 test user types for e2e tests
Creates test infrastructure to enable previously skipped e2e tests:
- Onboarding user (no period data) for setup flow tests
- Established user (period 14 days ago) for normal usage tests
- Calendar user (with calendarToken) for ICS feed tests
- Garmin user (valid tokens) for connected state tests
- Garmin expired user (expired tokens) for expiry warning tests

Also fixes ICS feed route to strip .ics suffix from Next.js dynamic
route param, adds calendarToken to /api/user response, and sets
viewRule on users collection for unauthenticated ICS access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 05:54:49 +00:00
b221acee40 Implement automatic Garmin token refresh and fix expiry tracking
- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint
- Track refresh token expiry (~30 days) instead of access token expiry (~21 hours)
- Auto-refresh access tokens in cron sync before they expire
- Update Python script to output refresh_token_expires_at
- Add garminRefreshTokenExpiresAt field to User type and database schema
- Fix token input UX: show when warning active, not just when disconnected
- Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data
- Add oauth-1.0a package for OAuth1 signature generation

The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token,
so users only need to re-run the Python auth script every ~30 days (when refresh
token expires) instead of every ~21 hours (when access token expires).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:33:10 +00:00
6df145d916 Fix Garmin token storage and flaky e2e test
1. Increase garminOauth1Token and garminOauth2Token max length from
   5000 to 20000 characters to accommodate encrypted OAuth tokens.
   Add logic to update existing field constraints in addUserFields().

2. Fix flaky pocketbase-harness e2e test by adding retry logic with
   exponential backoff to createAdminUser() and createTestUser().
   Handles SQLite database lock during PocketBase startup migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:52:01 +00:00
00b84d0b22 Fix E2E test reliability issues and stale data bugs
- Fix race conditions: Set workers: 1 since all tests share test user state
- Fix stale data: GET /api/user and /api/cycle/current now fetch fresh data
  from database instead of returning stale PocketBase auth store cache
- Fix timing: Replace waitForTimeout with retry-based Playwright assertions
- Fix mobile test: Use exact heading match to avoid strict mode violation
- Add test user setup: Include notificationTime and update rule for users

All 1014 unit tests and 190 E2E tests pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:23:32 +00:00
7dd08ab5ce Remove redundant notifications.spec.ts from enhancement list
All checks were successful
Deploy / deploy (push) Successful in 1m47s
The suggested notifications.spec.ts E2E tests for notification preferences
are already thoroughly covered by existing settings.spec.ts tests which
validate notification time and timezone input, validation, and persistence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 19:08:35 +00:00
9709cf27ab Add 2 E2E tests for dark mode system preference detection
All checks were successful
Deploy / deploy (push) Successful in 2m36s
Verifies that the app correctly applies light/dark mode styling based on
the user's system preference (prefers-color-scheme). Tests cover both
light and dark modes using Playwright's emulateMedia API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 19:03:11 +00:00
04a532bb01 Add 4 Garmin E2E tests for network error recovery
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Add tests to verify error handling when network requests fail:
- Error toast when token save fails (500 response)
- Error toast when disconnect fails (500 response)
- Error state display when status fetch fails
- Retry succeeds after network failure

These tests improve resilience coverage for the Garmin connection flow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:54:58 +00:00
b6f139883f Add 8 new E2E tests for accessibility and error recovery
All checks were successful
Deploy / deploy (push) Successful in 1m40s
- calendar.spec.ts: +4 accessibility tests (ARIA role, aria-labels, keyboard navigation, accessible nav buttons)
- settings.spec.ts: +1 error recovery test (retry after failed save)
- mobile.spec.ts: +3 calendar mobile tests (rendering, touch targets, navigation)

Total E2E tests: 190 → 198

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:48:58 +00:00
f3d7f8bd35 Add 4 new E2E tests for mobile viewport behavior
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Tests mobile responsiveness including:
- Login page renders correctly on mobile viewport
- Dashboard displays correctly on mobile viewport
- Dashboard uses single-column layout on mobile (< 768px)
- Navigation elements are interactive on mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:42:29 +00:00
e2a600700d Add 6 new E2E tests for OIDC flow and session persistence
All checks were successful
Deploy / deploy (push) Successful in 1m39s
New auth.spec.ts tests:
- OIDC button shows provider name when configured
- OIDC button shows loading state during authentication
- OIDC button is disabled when rate limited
- Session persists after page refresh
- Session persists when navigating between pages
- Logout clears session and redirects to login

E2E test count: 180 → 186 (auth.spec.ts: 14 → 20)
Total tests: 1194 → 1200

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:36:15 +00:00
c4d56f23e2 Add 5 new E2E tests for settings persistence
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Tests added:
- notification time changes persist after page reload
- timezone changes persist after page reload
- multiple settings changes persist after page reload
- cycle length persistence verifies exact saved value
- settings form shows correct values after save without reload

Updated IMPLEMENTATION_PLAN.md with accurate E2E test counts (now 180 E2E tests).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:31:39 +00:00
79414b813a Add 5 new E2E tests for period logging modal and edit/delete flows
All checks were successful
Deploy / deploy (push) Successful in 2m38s
New tests cover:
- Period date modal opens from dashboard onboarding banner
- Period date input restricts future dates via max attribute
- Logging period from modal updates dashboard cycle info
- Edit period modal flow changes date successfully
- Delete period confirmation flow removes entry

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:26:03 +00:00
5bfe51d630 Add 5 new Garmin E2E tests for expiry warnings and lifecycle
All checks were successful
Deploy / deploy (push) Successful in 1m39s
New tests:
- Yellow warning banner when token expires in 10 days (warning level)
- Red critical banner when token expires in 5 days (critical level)
- Expired token state shows token input for re-entry
- Connection persists after page reload
- Can reconnect after disconnecting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:20:42 +00:00
b3c711b9af Condense IMPLEMENTATION_PLAN.md after feature completion
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Reduced the implementation plan from 1514 lines to 156 lines (90% reduction)
by consolidating completed items into compact summary tables. The project is
feature complete with 1014 unit tests and 165 E2E tests passing.

Key changes:
- Removed detailed task descriptions for completed P0-P5 items
- Consolidated library, API, page, component, and E2E test summaries
- Preserved critical business rules and architecture notes
- Kept E2E enhancement opportunities for future reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:14:35 +00:00
f6b05a0765 Add 14 new E2E tests for ICS content validation and settings form
All checks were successful
Deploy / deploy (push) Successful in 2m27s
Calendar ICS content validation tests (7):
- VCALENDAR structure validation
- VEVENT entries verification
- Phase events with emojis (🩸🌱🌸🌙🌑)
- CATEGORIES for calendar color coding
- 90-day span coverage
- Warning events inclusion
- Content-type header validation

Settings form validation tests (7):
- Notification time HH:MM format acceptance
- Cycle length minimum (21) boundary validation
- Cycle length maximum (45) boundary validation
- Timezone field editability
- Current cycle length value display
- Settings persistence after page reload
- Save button loading state

Total E2E test count: 165 tests across 12 files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:09:37 +00:00
78c658822e Add 16 new dashboard E2E tests for comprehensive UI coverage
All checks were successful
Deploy / deploy (push) Successful in 1m57s
- Decision Card tests: GENTLE/LIGHT/REDUCED status display, icon rendering
- Override behavior tests: stress forces REST, PMS forces GENTLE, persistence after refresh
- Mini Calendar tests: current month display, today highlight, phase colors, navigation
- Onboarding Banner tests: setup prompts, Garmin link, period date prompt
- Loading state tests: skeleton loaders, performance validation

Total dashboard E2E coverage now 42 tests. Overall E2E count: 129 tests across 12 files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:00:41 +00:00
2558930507 Add 13 new E2E tests for period logging flow and calendar display
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Period logging tests (5 new):
- Future date validation
- Cycle length display between periods
- Prediction accuracy display
- Delete period log from history
- Edit period log from history

Calendar tests (8 new):
- Today highlight in calendar view
- Phase colors in calendar days
- Phase legend display
- Today button for quick navigation
- Multi-month navigation with return to today
- Calendar URL generation
- URL format validation
- Copy to clipboard functionality

Total E2E tests: 113 (was 100)
Total unit tests: 1014 (51 test files)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:51:23 +00:00
f4a3f7d9fd Add 14 new dashboard E2E tests for data panel, nutrition, and accessibility
All checks were successful
Deploy / deploy (push) Successful in 2m39s
- Added 8 data panel tests: HRV status, Body Battery, cycle day format,
  current phase, intensity minutes, phase limit, remaining minutes
- Added 4 nutrition panel tests: seed cycling, carb range, keto guidance,
  nutrition section header
- Added 4 accessibility tests: main landmark, skip navigation link,
  keyboard accessible overrides, focus indicators

Total dashboard E2E coverage: 24 tests (up from 10)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:43:56 +00:00
54b57d5160 Add 36 new E2E tests across 5 test files
All checks were successful
Deploy / deploy (push) Successful in 1m41s
New E2E test files:
- e2e/health.spec.ts: 3 tests for health/observability endpoints
- e2e/history.spec.ts: 7 tests for history page
- e2e/plan.spec.ts: 7 tests for exercise plan page
- e2e/decision-engine.spec.ts: 8 tests for decision display and overrides
- e2e/cycle.spec.ts: 11 tests for cycle tracking, settings, and period logging

Total E2E tests: 100 (up from 64)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:37:34 +00:00
2ade07e12a More E2E tests. 2026-01-13 17:29:41 +00:00
27f084f950 Fix Garmin token connection not persisting after save
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Root cause: The setup-db script was missing user field definitions
(garminConnected, tokens, etc.). Production PocketBase had no such
fields, so updates silently failed to persist.

Changes:
- Add user custom fields to setup-db.ts (matches e2e harness)
- Fix status route to use strict boolean check (=== true)
- Add verification in tokens route with helpful error message
- Add ENCRYPTION_KEY to playwright config for e2e tests
- Add comprehensive e2e tests for Garmin connection flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:20:50 +00:00
3b9e023736 Remove CI workflow. 2026-01-13 09:41:39 +00:00
8c59b3bd67 Add self-contained e2e test harness with ephemeral PocketBase
Some checks failed
CI / quality (push) Failing after 29s
Deploy / deploy (push) Successful in 2m37s
Previously, 15 e2e tests were skipped because TEST_USER_EMAIL and
TEST_USER_PASSWORD env vars weren't set. Now the test harness:

- Starts a fresh PocketBase instance in /tmp on port 8091
- Creates admin user, collections, and API rules automatically
- Seeds test user with period data for authenticated tests
- Cleans up temp directory after tests complete

Also fixes:
- Override toggle tests now use checkbox role (not button)
- Adds proper wait for OVERRIDES section before testing toggles
- Suppresses document.cookie lint warning with explanation

Test results: 64 e2e tests pass, 1014 unit tests pass

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:38:24 +00:00
eeeece17bf Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m38s
- NutritionPanel: Display seed switch alert on day 15 per dashboard spec
- MonthView: Add phase emojis to legend (🩸🌱🌸🌙🌑) per calendar spec
- DayCell: Show period indicator (🩸) for days 1-3 per calendar spec
- Auth middleware: Log client IP from x-forwarded-for/x-real-ip per observability spec
- Updated NutritionGuidance type to include seedSwitchAlert field
- /api/today now returns seedSwitchAlert in nutrition response

Test coverage: 1005 tests (15 new tests added)
- nutrition-panel.test.tsx: +4 tests
- month-view.test.tsx: +1 test
- day-cell.test.tsx: +5 tests
- auth-middleware.test.ts: +3 tests
- today/route.test.ts: +2 tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:33:14 +00:00
d613417e47 Fix spec compliance gaps in email and dashboard
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m39s
- Email subject now follows spec format: PhaseFlow: [STATUS] - Day [cycleDay] ([phase])
- Daily email includes seed switch alert on day 15 (using getSeedSwitchAlert)
- Data panel HRV status now color-coded: green=Balanced, red=Unbalanced, gray=Unknown
- Data panel shows progress bar for week intensity vs phase limit with color thresholds

Adds 13 new tests (990 total).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:20:18 +00:00
0ea8e2f2b5 Fix decision-engine override behavior: sleep/pms return GENTLE per spec
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 1m40s
The spec (decision-engine.md lines 93-94) clearly states:
- sleep override -> GENTLE
- pms override -> GENTLE

But the implementation was returning REST for all overrides. This fix:
- Updates decision-engine.ts to use OVERRIDE_DECISIONS with correct status/reason/icon per override type
- Updates tests to expect GENTLE for sleep and pms overrides
- Aligns implementation with specification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:11:47 +00:00
262c28d9bd Fix P3.10 documentation inconsistency
Some checks failed
CI / quality (push) Failing after 29s
Deploy / deploy (push) Successful in 2m39s
Update gap analysis note to reflect current test counts (977 unit tests across
50 files + 64 E2E tests) and complete status of all P0-P5 items.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:08:04 +00:00
aeb87355ed Fix P3.10 documentation inconsistency
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Has been cancelled
- Mark P3.10 E2E Test Suite as COMPLETE (was showing unchecked)
- Update file path from tests/e2e/ to e2e/ (actual location)
- Add reference to P5.4 which has full implementation details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:06:04 +00:00
e971fe683f Add toast notification system with sonner library
- Create Toaster component wrapping sonner at bottom-right position
- Add showToast utility with success/error/info methods
- Error toasts persist until dismissed, others auto-dismiss after 5s
- Migrate error handling to toasts across all pages:
  - Dashboard (override toggle errors)
  - Settings (save/load success/error)
  - Garmin settings (connection success/error)
  - Calendar (load errors)
  - Period History (load/delete errors)
- Add dark mode support for toast styling
- Add Toaster provider to root layout
- 27 new tests (23 toaster component + 4 integration)
- Total: 977 unit tests passing

P5.2 COMPLETE - All P0-P5 items now complete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:04:27 +00:00
38bea1ffd7 Add comprehensive E2E test suite for all user flows
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m28s
- Add e2e/auth.spec.ts (14 tests): Login page UI, form validation, error
  handling, protected route redirects, public routes
- Add e2e/dashboard.spec.ts (10 tests): Dashboard display, decision card,
  override toggles, navigation
- Add e2e/settings.spec.ts (15 tests): Settings form, Garmin settings,
  logout flow
- Add e2e/period-logging.spec.ts (9 tests): Period history page, API auth
- Add e2e/calendar.spec.ts (13 tests): Calendar view, navigation, ICS
  subscription, token endpoints

Total: 64 E2E tests (28 pass without auth, 36 skip when TEST_USER_EMAIL/
TEST_USER_PASSWORD not set)

Authenticated tests use test credentials via environment variables, allowing
full E2E coverage when PocketBase test user is available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:44:57 +00:00
cd103ac1cc Add CI pipeline with lint, typecheck, and unit tests
Some checks failed
CI / quality (push) Failing after 2m47s
Deploy / deploy (push) Successful in 1m41s
Creates Gitea Actions workflow that runs on pull requests and pushes
to main. Enforces quality gates (lint, typecheck, unit tests) in CI,
complementing the local Lefthook pre-commit hooks.

Features:
- Node.js 24 with pnpm 10 setup
- pnpm dependency caching for faster runs
- Linting with biome
- TypeScript type checking
- 950 unit tests via vitest

Completes P5.3 from the implementation plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:36:17 +00:00
07577dbdbb Add period history UI with CRUD operations
All checks were successful
Deploy / deploy (push) Successful in 2m27s
- Add GET /api/period-history route with pagination, cycle length
  calculation, and prediction accuracy tracking
- Add PATCH/DELETE /api/period-logs/[id] routes for editing and
  deleting period entries with ownership validation
- Add /period-history page with table view, edit/delete modals,
  and pagination controls
- Include 61 new tests covering all functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:33:36 +00:00
6e391a46be E2E tests. 2026-01-12 22:19:58 +00:00
dbf0c32588 Fix garmin status showing stale connection state
All checks were successful
Deploy / deploy (push) Successful in 1m37s
Fetch fresh user data from database in status endpoint instead of
relying on auth store cookie, which may be stale after token save.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:45:35 +00:00
6bd5eb663b Add Playwright E2E testing infrastructure
- Add playwright-web-flake to flake.nix for NixOS browser support
- Pin @playwright/test@1.56.1 to match nixpkgs version
- Create playwright.config.ts with Chromium-only, auto-start dev server
- Add e2e/smoke.spec.ts with initial smoke tests
- Add .mcp.json for Claude browser control via MCP
- Update .gitignore for playwright artifacts
- Remove E2E test skip from spec.md Known Limitations
- Update specs/testing.md to require three-tier testing approach

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:43:24 +00:00
30c5955a61 Fix garmin token expires_at format for PocketBase compatibility
Output expires_at as ISO 8601 date string instead of Unix timestamp.
PocketBase date fields expect ISO format, and the integer was causing
token saves to fail silently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:25:38 +00:00
ce80fb1ede Add database setup script and fix dark mode visibility
- Add scripts/setup-db.ts to programmatically create missing PocketBase
  collections (period_logs, dailyLogs) with proper relation fields
- Fix dark mode visibility across settings, login, calendar, and dashboard
  components by using semantic CSS tokens and dark: variants
- Add db:setup npm script and document usage in AGENTS.md
- Update vitest config to include scripts directory tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:23:20 +00:00
ca35b36efa Fix garth token serialization using TypeAdapter for Pydantic dataclasses
Garth's OAuth1Token and OAuth2Token are Pydantic dataclasses, not BaseModel
subclasses, so they require TypeAdapter for serialization instead of model_dump().
Also adds user-friendly error handling for authentication failures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:01 +00:00
c8a738d0c4 Fix garth token serialization using Pydantic v2 API
All checks were successful
Deploy / deploy (push) Successful in 1m36s
The garth library uses Pydantic dataclasses for OAuth tokens which don't
have a serialize() method. Use model_dump() instead, and fix expires_at
handling since it's an integer timestamp not a datetime object.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:49:16 +00:00
2408839b8b Fix 404 error when saving user preferences
Routes using withAuth were creating new unauthenticated PocketBase
clients, causing 404 errors when trying to update records. Modified
withAuth to pass the authenticated pb client to handlers so they can
use it for database operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:45:55 +00:00
df2f52ad50 Install deps for python script. 2026-01-12 15:00:31 +00:00
0e585e6bb4 Add period date setup modal for new users
All checks were successful
Deploy / deploy (push) Successful in 2m27s
Users without a lastPeriodDate can now set it via a modal opened from
the onboarding banner. The dashboard now fetches user data independently
so the banner shows even when /api/today fails due to missing period date.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:28:49 +00:00
72706bb91b Fix Invalid Date error in auth middleware
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Add parseDate helper that safely returns null for empty/invalid date
strings from PocketBase. This prevents RangeError when pino logger
tries to serialize Invalid Date objects via toISOString().

- Make garminTokenExpiresAt and lastPeriodDate nullable in User type
- Filter garmin-sync cron to skip users without required dates
- Add test assertions for null date handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:13:02 +00:00
63023550fd Symlink to agents.md 2026-01-12 14:01:02 +00:00
2ae6804cc4 Fix OAuth login by syncing auth state to cookie
All checks were successful
Deploy / deploy (push) Successful in 1m37s
Root cause: PocketBase SDK stores auth in localStorage, but Next.js
middleware checks for pb_auth cookie. The cookie was never being set
after successful OAuth login.

Fix: Add pb.authStore.onChange() listener that syncs auth state to
cookie on any change (login, logout, token refresh). This is the
idiomatic PocketBase pattern for Next.js SSR apps.

Also updates authentication spec to reflect that the cookie is
non-HttpOnly by design (client SDK needs read/write access).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:59:53 +00:00
e2afee2045 Add OAuth debugging console.log statements
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Temporary debugging to diagnose why authWithOAuth2 promise
doesn't resolve after successful code exchange.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:49:02 +00:00
5cac8f3267 Add color-coded backgrounds to DecisionCard
All checks were successful
Deploy / deploy (push) Successful in 2m26s
Per dashboard.md spec requirements:
- RED background and text for REST decisions
- YELLOW background and text for GENTLE/LIGHT/REDUCED decisions
- GREEN background and text for TRAIN decisions

Added 8 new tests for color-coded backgrounds (19 total).
Updated IMPLEMENTATION_PLAN.md to mark spec gap as complete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:11:10 +00:00
31932a88bf Add email sent/failed structured logging
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Implement email logging per observability spec:
- Add structured logging for email sent (info level) and failed (error level)
- Include userId, type, and recipient fields in log events
- Add userId parameter to email functions (sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning)
- Update cron routes (notifications, garmin-sync) to pass userId

6 new tests added to email.test.ts (now 30 tests total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:06:19 +00:00
13b58c3c32 Add logout functionality and Garmin sync structured logging
- Add POST /api/auth/logout endpoint with tests (5 tests)
- Add logout button to settings page (5 tests)
- Add structured logging to garmin-sync cron (sync start/complete/failure)
- Update IMPLEMENTATION_PLAN.md with spec gap analysis findings
- Total: 835 tests passing across 44 test files

Closes spec gaps from authentication.md (logout) and observability.md (logging)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:00:54 +00:00
e9a77fd79c Mark P4.4 Loading Performance as complete
All checks were successful
Deploy / deploy (push) Successful in 2m35s
Verified that loading performance requirements are met:
- Next.js loading.tsx files render immediately during navigation (< 100ms)
- Skeleton components exist for all routes (P3.8 already complete)
- Optimistic UI updates implemented for override toggles
- Suspense boundaries provided by Next.js App Router

All P4 UX Polish items are now complete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:44:50 +00:00
a977934c23 Fix critical bug: cycle phase boundaries now scale with cycle length
CRITICAL BUG FIX:
- Phase boundaries were hardcoded for 31-day cycle, breaking correct
  phase calculations for users with different cycle lengths (28, 35, etc.)
- Added getPhaseBoundaries(cycleLength) function in cycle.ts
- Updated getPhase() to accept cycleLength parameter (default 31)
- Updated all callers (API routes, components) to pass cycleLength
- Added 13 new tests for phase boundaries with 28, 31, and 35-day cycles

ICS IMPROVEMENTS:
- Fixed emojis to match calendar.md spec: 🩸🌱🌸🌙🌑
- Added CATEGORIES field for calendar app colors per spec:
  MENSTRUAL=Red, FOLLICULAR=Green, OVULATION=Pink,
  EARLY_LUTEAL=Yellow, LATE_LUTEAL=Orange
- Added 5 new tests for CATEGORIES

Updated IMPLEMENTATION_PLAN.md with discovered issues and test counts.

825 tests passing (up from 807)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:39:09 +00:00
58f6c5605a Add period prediction accuracy feedback (P4.5 complete)
All checks were successful
Deploy / deploy (push) Successful in 1m36s
Implements visual feedback for cycle prediction accuracy in ICS calendar feeds:

- Add predictedDate field to PeriodLog type for tracking predicted vs actual dates
- POST /api/cycle/period now calculates and stores predictedDate based on
  previous lastPeriodDate + cycleLength, returns daysEarly/daysLate in response
- ICS feed generates "(Predicted)" events when actual period start differs
  from predicted, with descriptions like "period arrived 2 days early"
- Calendar route fetches period logs and passes them to ICS generator

This creates an accuracy feedback loop helping users understand their cycle
variability over time per calendar.md spec.

807 tests passing across 43 test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:21:52 +00:00
c708c2ed8b Add login rate limiting (P4.6 complete)
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Implement client-side rate limiting for login page with 5 attempts
per minute, matching the spec requirement in authentication.md.

Features:
- Track login attempts with timestamps in component state
- Block login when 5+ attempts made within 60 seconds
- Show "Too many login attempts" error when rate limited
- Show remaining attempts warning after 3 failures
- Disable form/button when rate limited
- Auto-clear after 1 minute cooldown
- Works for both email/password and OIDC authentication

Tests:
- 6 new tests covering rate limiting scenarios (32 total)
- 796 tests passing across 43 test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:09:34 +00:00
2e7d8dc4ca Add automatic dark mode via prefers-color-scheme (P4.3 complete)
All checks were successful
Deploy / deploy (push) Successful in 1m36s
Switch from class-based dark mode to automatic system preference
detection using CSS prefers-color-scheme media query. The app now
respects the user's OS-level dark mode setting without requiring
a manual toggle, as specified in the dashboard requirements.

Changes:
- Update Tailwind custom variant to use @media (prefers-color-scheme: dark)
- Change .dark selector to media query wrapping :root variables
- No component changes needed - existing CSS variable system handles theming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:00:50 +00:00
4015f1ba3a Add calendar keyboard navigation (P4.2 complete)
All checks were successful
Deploy / deploy (push) Successful in 2m26s
Implement keyboard navigation for MonthView calendar:
- ArrowLeft/Right: navigate to previous/next day
- ArrowUp/Down: navigate to previous/next week (7 days)
- Home/End: navigate to first/last day of month
- Boundary navigation triggers month change

Features:
- Added role="grid" for proper ARIA semantics
- Added data-day attribute to DayCell for focus management
- Wrapped navigation handlers in useCallback for stability

Tests: 9 new tests for keyboard navigation (790 total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:55:18 +00:00
649fa29df2 Add accessibility improvements (P4.2 partial)
All checks were successful
Deploy / deploy (push) Successful in 1m36s
- Add skip navigation link to root layout
- Add semantic HTML landmarks (main element) to login and settings pages
- Add aria-labels to calendar day buttons with date, cycle day, and phase info
- Add id="main-content" to dashboard main element for skip link target
- Fix pre-existing type error in auth-middleware.test.ts

Tests: 781 passing (11 new accessibility tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:49:26 +00:00
2bfd93589b Add dashboard onboarding banners (P4.1)
All checks were successful
Deploy / deploy (push) Successful in 2m29s
Implement OnboardingBanner component that prompts new users to complete
setup with contextual banners for:
- Garmin connection (links to /settings/garmin)
- Period date (button with callback for date picker)
- Notification time (links to /settings)

Banners display at the top of the dashboard when setup is incomplete,
with icons and styled action buttons. Each banner uses role="alert"
for accessibility.

- Add OnboardingBanner component (16 tests)
- Integrate into dashboard page (5 new tests, 28 total)
- Update UserData interface to include garminConnected, notificationTime
- Test count: 770 tests across 43 files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:38:16 +00:00
f69e1fd614 Separate server-side and client-side PocketBase URLs
All checks were successful
Deploy / deploy (push) Successful in 2m29s
Server-side code (health checks, API routes) needs to use internal
sidecar URL (POCKETBASE_URL at runtime), while client-side needs
public URL (NEXT_PUBLIC_POCKETBASE_URL baked at build time).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:17:58 +00:00
9db8051e8d Set production PocketBase URL in Docker build
Some checks failed
Deploy / deploy (push) Failing after 1m40s
NEXT_PUBLIC_* vars are baked in at build time, not runtime. Updated
docker.nix to use production URL and added deployment config location
to AGENTS.md.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:50:31 +00:00
3567fbafd7 Expose PocketBase URL to client-side for OIDC auth
Some checks failed
Deploy / deploy (push) Failing after 6m37s
POCKETBASE_URL was only available server-side, causing the login page
to fall back to localhost:8090 in the browser. Renamed to
NEXT_PUBLIC_POCKETBASE_URL so Next.js bundles it into client code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:33:52 +00:00
6ebd9788e1 Fix docker extraCommands to copy hidden files
All checks were successful
Deploy / deploy (push) Successful in 1m36s
Same issue as installPhase - need to use /. suffix instead of /* glob
to include hidden directories like .next.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:06:28 +00:00
f2794cf396 Fix docker.nix to copy hidden .next directory
Some checks failed
Deploy / deploy (push) Failing after 1m35s
The glob * doesn't include hidden files/directories. Use /. suffix
to copy all contents including .next from standalone output.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:17:34 +00:00
8f643c299d Add Nomad deployment configuration and CI/CD pipeline
Some checks failed
Deploy / deploy (push) Failing after 1m43s
- Add docker.nix for Nix-based Docker image builds
- Update flake.nix with dockerImage package output
- Add output: standalone to next.config.ts for production builds
- Add /metrics endpoint for Prometheus scraping
- Add Gitea Actions workflow calling shared deploy-nomad.yaml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:20:20 +00:00
9c5b8466f6 Implement skeleton loading states for dashboard and routes (P3.8)
Add skeleton loading components per specs/dashboard.md requirements:
- DecisionCardSkeleton: Shimmer placeholder for status and reason
- DataPanelSkeleton: Skeleton rows for 5 metrics
- NutritionPanelSkeleton: Skeleton for nutrition guidance
- MiniCalendarSkeleton: Placeholder grid with navigation and legend
- OverrideTogglesSkeleton: 4 toggle placeholders
- CycleInfoSkeleton: Cycle day and phase placeholders
- DashboardSkeleton: Combined skeleton for route-level loading

Add Next.js loading.tsx files for instant loading states:
- src/app/loading.tsx (Dashboard)
- src/app/calendar/loading.tsx
- src/app/history/loading.tsx
- src/app/plan/loading.tsx
- src/app/settings/loading.tsx

Update dashboard page to use DashboardSkeleton instead of "Loading..." text.

Fix flaky garmin test with wider date tolerance for timezone variations.

29 new tests in skeletons.test.tsx (749 total tests passing).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:32:09 +00:00
714194f2d3 Implement structured logging for API routes (P3.7)
Replace console.error with pino structured logger across API routes
and add key event logging 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

Files updated:
- auth-middleware.ts: Added structured logging for auth failures
- cycle/period/route.ts: Added Period logged event + error logging
- calendar/[userId]/[token].ics/route.ts: Replaced console.error
- overrides/route.ts: Added Override toggled events
- today/route.ts: Added Decision calculated event

Tests: 720 passing (added 3 new structured logging tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:19:55 +00:00
00d902a396 Implement OIDC authentication with Pocket-ID (P2.18)
Add OIDC/OAuth2 authentication support to the login page with automatic
provider detection and email/password fallback.

Features:
- Auto-detect OIDC provider via PocketBase listAuthMethods() API
- Display "Sign In with Pocket-ID" button when OIDC is configured
- Use PocketBase authWithOAuth2() popup-based OAuth2 flow
- Fall back to email/password form when OIDC not available
- Loading states during authentication
- Error handling with user-friendly messages

The implementation checks for available auth methods on page load and
conditionally renders either the OIDC button or the email/password form.
This allows production deployments to use OIDC while development
environments can continue using email/password.

Tests: 24 tests (10 new OIDC tests added)
- OIDC button rendering when provider configured
- OIDC authentication flow with authWithOAuth2
- Loading and error states for OIDC
- Fallback to email/password when OIDC unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:12:29 +00:00
267d45f98a Add component tests for P3.11 (82 tests across 5 files)
- DecisionCard tests: 11 tests covering rendering, status icons, styling
- DataPanel tests: 18 tests covering biometrics display, null handling, styling
- NutritionPanel tests: 12 tests covering seeds, carbs, keto guidance display
- OverrideToggles tests: 18 tests covering toggle states, callbacks, styling
- DayCell tests: 23 tests covering phase coloring, today highlighting, click handling

Total tests now: 707 passing across 40 test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:02:34 +00:00
39198fdf8c Implement Plan page with phase overview and exercise reference (P2.13)
Add comprehensive training plan reference page that displays:
- Current phase status (day, phase name, training type, weekly limit)
- Phase overview cards for all 5 cycle phases with weekly intensity limits
- Strength training exercises reference with sets and reps
- Rebounding techniques organized by phase
- Weekly training guidelines for each phase

The page fetches cycle data from /api/cycle/current and highlights
the current phase. Implements full TDD with 16 tests covering loading
states, error handling, phase display, and exercise reference sections.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:56:52 +00:00
b2915bca9c Implement MiniCalendar dashboard widget (P2.14)
Complete the MiniCalendar component with:
- Full calendar grid showing all days of the month
- Phase colors applied to each day
- Today highlighting with ring indicator
- Navigation buttons (prev/next month, Today)
- Compact phase legend
- Integration into dashboard page (shows when lastPeriodDate exists)

Adds 23 new tests for the MiniCalendar component covering:
- Calendar grid rendering
- Phase color application
- Navigation functionality
- Cycle rollover handling
- Custom year/month props

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:47:28 +00:00
5a0cdf7450 Implement Prometheus metrics endpoint (P2.16)
Add comprehensive metrics collection for production monitoring:
- src/lib/metrics.ts: prom-client based metrics library with custom counters,
  gauges, and histograms for Garmin sync, email, and decision engine
- GET /api/metrics: Prometheus-format endpoint for scraping
- Integration into garmin-sync cron: sync duration, success/failure counts,
  active users gauge
- Integration into email.ts: daily and warning email counters
- Integration into decision-engine.ts: decision type counters

Custom metrics implemented:
- phaseflow_garmin_sync_total (counter with status label)
- phaseflow_garmin_sync_duration_seconds (histogram)
- phaseflow_email_sent_total (counter with type label)
- phaseflow_decision_engine_calls_total (counter with decision label)
- phaseflow_active_users (gauge)

33 new tests (18 library + 15 route), bringing total to 586 tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:40:42 +00:00
5ec3aba8b3 Implement structured logging with pino (P2.17)
Add pino-based logger module for production observability:
- JSON output to stdout for log aggregators (Loki, ELK)
- Configurable via LOG_LEVEL environment variable (defaults to "info")
- Log levels: error, warn, info, debug
- Error objects serialized with type, message, and stack trace
- Child logger support for bound context
- ISO 8601 timestamps in all log entries

Test coverage: 16 tests covering JSON format, log levels, error
serialization, and child loggers.

Total tests now: 553 passing across 31 test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:29:31 +00:00
2ffee63a59 Implement token expiration warnings (P3.9)
Add email warnings for Garmin token expiration at 14-day and 7-day thresholds.
When the garmin-sync cron job runs, it now checks each user's token expiry and
sends a warning email at exactly 14 days and 7 days before expiration.

Changes:
- Add sendTokenExpirationWarning() to email.ts with differentiated subject
  lines and urgency levels for 14-day vs 7-day warnings
- Integrate warning logic into garmin-sync cron route using daysUntilExpiry()
- Track warnings sent in sync response with new warningsSent counter
- Add 20 new tests (10 for email function, 10 for sync integration)

Test count: 517 → 537

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:24:19 +00:00
6c3dd34412 Implement health check endpoint (P2.15)
Add GET /api/health endpoint for deployment monitoring and load balancer
health probes. Returns 200 with status "ok" when PocketBase is reachable,
503 with status "unhealthy" when PocketBase connection fails.

Response includes timestamp (ISO 8601), version, and error message (on failure).
Uses PocketBase SDK's built-in health.check() method for connectivity testing.

14 tests covering healthy/unhealthy states and edge cases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:17:13 +00:00
141 changed files with 22610 additions and 1731 deletions

View File

@@ -6,10 +6,15 @@ APP_URL=https://phaseflow.yourdomain.com
NODE_ENV=development
# PocketBase
# POCKETBASE_URL is for server-side (API routes, health checks) - set at runtime
# NEXT_PUBLIC_POCKETBASE_URL is for client-side (browser) - baked at build time
POCKETBASE_URL=http://localhost:8090
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
# Email (Resend)
RESEND_API_KEY=re_xxxxxxxxxxxx
# Email (Mailgun)
MAILGUN_API_KEY=key-xxxxxxxxxxxx
MAILGUN_DOMAIN=yourdomain.com
MAILGUN_URL=https://api.eu.mailgun.net # Use https://api.mailgun.net for US region
EMAIL_FROM=phaseflow@yourdomain.com
# Encryption (for Garmin tokens)

View File

@@ -0,0 +1,16 @@
# ABOUTME: Gitea Actions workflow for deploying PhaseFlow to Nomad.
# ABOUTME: Builds Nix Docker image and deploys using shared workflow.
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
uses: alo/alo-cluster/.gitea/workflows/deploy-nomad.yaml@master
with:
service_name: phaseflow
secrets: inherit

7
.gitignore vendored
View File

@@ -13,6 +13,11 @@
# testing
/coverage
# playwright
/playwright-report/
/test-results/
e2e/.harness-state.json
# next.js
/.next/
/out/
@@ -54,3 +59,5 @@ result
__pycache__/
*.pyc
.venv/
.env.phaseflow

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}

View File

@@ -18,10 +18,46 @@ Run these after implementing to get immediate feedback:
## Operational Notes
- Database: PocketBase at `POCKETBASE_URL` env var
- Production URL: https://phaseflow.v.paler.net
- Database: PocketBase at `NEXT_PUBLIC_POCKETBASE_URL` env var
- Deployment config: `../alo-cluster/services/phaseflow.hcl` (Nomad job)
- Garmin tokens encrypted with AES-256 using `ENCRYPTION_KEY` (32 chars)
- Path aliases: `@/*` maps to `./src/*`
- Pre-commit hooks: Biome lint + Vitest tests via Lefthook
- CI/CD: Automatic deployment on git push to main (do not manually trigger Nomad jobs)
## Production Logs
Access production logs via Nomad CLI:
```bash
# Check job status and get current allocation ID
nomad job status phaseflow
# View app logs (replace ALLOC_ID with current allocation)
nomad alloc logs ALLOC_ID app
# Tail recent logs
nomad alloc logs ALLOC_ID app | tail -100
# Filter for specific log patterns
nomad alloc logs ALLOC_ID app | grep -E "pattern"
```
The allocation has two tasks: `app` (Next.js) and `pocketbase` (database).
## Database Setup
PocketBase requires these collections: `users`, `period_logs`, `dailyLogs`.
To create missing collections:
```bash
POCKETBASE_ADMIN_EMAIL=admin@example.com \
POCKETBASE_ADMIN_PASSWORD=yourpassword \
pnpm db:setup
```
The script reads `NEXT_PUBLIC_POCKETBASE_URL` from your environment and creates any missing collections. It's safe to run multiple times - existing collections are skipped.
## Codebase Patterns

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -2,660 +2,165 @@
This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate tasks.
## Current State Summary
## Current Status: Feature Complete
### Library Implementation
| File | Status | Gap Analysis |
|------|--------|--------------|
| `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready |
| `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 |
| `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 |
| `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 |
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
| `auth-middleware.ts` | **COMPLETE** | 6 tests covering `withAuth()` wrapper for API route protection |
| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login |
**Test Coverage:** 1014 unit tests (51 files) + 204 E2E tests (14 files) = 1218 total tests
### Missing Infrastructure Files (CONFIRMED NOT EXIST)
- ~~`src/lib/auth-middleware.ts`~~ - **CREATED** in P0.2
- ~~`src/middleware.ts`~~ - **CREATED** in P0.2
All P0-P5 items are complete. The project is feature complete.
### API Routes (15 total)
| Route | Status | Notes |
|-------|--------|-------|
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
| PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) |
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) |
| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) |
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) |
| POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) |
| DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) |
| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) |
| DELETE /api/garmin/tokens | **COMPLETE** | Clears tokens and disconnects Garmin (15 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) |
| 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/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) |
---
### Pages (7 total)
| Page | Status | Notes |
|------|--------|-------|
| Dashboard (`/`) | **COMPLETE** | Wired with /api/today, DecisionCard, DataPanel, NutritionPanel, OverrideToggles |
| Login (`/login`) | **COMPLETE** | Email/password form with auth, error handling, loading states |
| Settings (`/settings`) | **COMPLETE** | Form with cycleLength, notificationTime, timezone |
| 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 |
| History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests |
| Plan (`/plan`) | Placeholder | Needs phase details display |
## Architecture Summary
### Components
| Component | Status | Notes |
|-----------|--------|-------|
| `DecisionCard` | **COMPLETE** | Displays status, icon, reason |
| `DataPanel` | **COMPLETE** | Shows BB, HRV, intensity data |
| `NutritionPanel` | **COMPLETE** | Shows seeds, carbs, keto guidance |
| `OverrideToggles` | **COMPLETE** | Toggle buttons with callbacks |
| `DayCell` | **COMPLETE** | Phase-colored day with click handler |
| `MiniCalendar` | **Partial (~30%)** | Has header only, **MISSING: calendar grid** |
| `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend |
### Tech Stack
| Layer | Choice |
|-------|--------|
| Framework | Next.js 16 (App Router) |
| Runtime | Node.js 24 |
| Database | PocketBase |
| Validation | Zod |
| Testing | Vitest + jsdom + Playwright |
| Linting | Biome |
### Test Coverage
| Test File | Status |
|-----------|--------|
| `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/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 6 tests (withAuth wrapper, error handling) |
| `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/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) |
| `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) |
| `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/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/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/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/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/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/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/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
| `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) |
| E2E tests | **NONE** |
### Critical Business Rules (from Spec)
1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order)
### Critical Business Rules
1. **Override Priority:** flare > stress > sleep > pms (enforced in order)
2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable)
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:** Email at 14 days and 7 days before expiry
5. **ICS Feed:** Generates 90 days of phase events for calendar subscription
---
## P0: Critical Blockers
## Completed Implementation
These must be completed first - nothing else works without them.
### Library Files (12 files, 250+ tests)
| File | Tests | Key Functions |
|------|-------|---------------|
| `cycle.ts` | 22 | `getCycleDay`, `getPhase`, `getPhaseConfig`, dynamic phase boundaries |
| `nutrition.ts` | 17 | `getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific guidance |
| `email.ts` | 32 | `sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning` |
| `ics.ts` | 33 | `generateIcsFeed`, 90-day events, period prediction feedback, CATEGORIES |
| `encryption.ts` | 14 | AES-256-GCM encrypt/decrypt |
| `decision-engine.ts` | 24 | `getTrainingDecision`, `getDecisionWithOverrides`, 8 priority rules |
| `garmin.ts` | 33 | `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, token validation |
| `pocketbase.ts` | 10 | `createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies` |
| `auth-middleware.ts` | 12 | `withAuth()` wrapper, structured logging, IP logging |
| `middleware.ts` | 12 | Next.js page protection, redirects |
| `logger.ts` | 16 | Pino JSON output, log levels, child loggers |
| `metrics.ts` | 33 | Prometheus metrics, counters, gauges, histograms |
### P0.1: PocketBase Auth Helpers ✅ COMPLETE
- [x] Add authentication utilities to pocketbase.ts
- **Files:**
- `src/lib/pocketbase.ts` - Added `createPocketBaseClient()`, `getCurrentUser()`, `isAuthenticated()`, `loadAuthFromCookies()`
- **Tests:**
- `src/lib/pocketbase.test.ts` - 9 tests covering auth state management, cookie loading
- **Why:** Every protected route and page depends on these helpers
- **Blocking:** P0.2, P0.4, P1.1-P1.7, P2.2-P2.13
### API Routes (21 endpoints, 350+ tests)
| Route | Tests | Purpose |
|-------|-------|---------|
| POST /api/auth/logout | 5 | Session logout |
| GET /api/user | 4 | User profile |
| PATCH /api/user | 17 | Update preferences |
| POST /api/cycle/period | 13 | Log period start with prediction tracking |
| GET /api/cycle/current | 10 | Current cycle state |
| GET /api/today | 24 | Daily snapshot with decision |
| POST/DELETE /api/overrides | 14 | Override management |
| POST/DELETE /api/garmin/tokens | 15 | Token storage |
| GET /api/garmin/status | 11 | Connection status |
| POST /api/cron/garmin-sync | 32 | Daily data sync |
| POST /api/cron/notifications | 20 | Email notifications |
| GET /api/calendar/[userId]/[token].ics | 11 | ICS feed |
| POST /api/calendar/regenerate-token | 9 | Token regeneration |
| GET /api/history | 19 | Daily log history |
| GET /api/period-history | 18 | Period log history |
| PATCH/DELETE /api/period-logs/[id] | 16 | Period log management |
| GET /api/health | 14 | Health check |
| GET /metrics | 15 | Prometheus metrics |
### P0.2: Auth Middleware for API Routes ✅ COMPLETE
- [x] Create reusable auth middleware for protected API endpoints
- **Files:**
- `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers
- `src/middleware.ts` - Added Next.js middleware for page protection
- **Tests:**
- `src/lib/auth-middleware.test.ts` - 6 tests covering unauthorized rejection, user context passing, error handling
- `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets
- **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth
- **Depends On:** P0.1
- **Blocking:** P0.4, P1.1-P1.5
### Pages (8 pages, 230+ tests)
| Page | Tests | Features |
|------|-------|----------|
| Dashboard (`/`) | 28 | Decision card, data panel, nutrition, overrides, mini calendar |
| Login (`/login`) | 32 | OIDC + email/password, rate limiting |
| Settings (`/settings`) | 34 | Preferences, logout |
| Settings/Garmin | 27 | Token management, status |
| Calendar (`/calendar`) | 23 | Month view, ICS subscription |
| History (`/history`) | 26 | Daily log table |
| Period History | 27 | Period log table, edit/delete |
| Plan (`/plan`) | 16 | Phase overview, training reference |
### P0.3: Decision Engine Override Handling ✅ COMPLETE
- [x] Add override priority logic before algorithmic decision
- **Files:**
- `src/lib/decision-engine.ts` - Added `getDecisionWithOverrides(data, overrides)` function
- **Tests:**
- `src/lib/decision-engine.test.ts` - 24 tests covering all 8 priority rules + override scenarios
- **Override Priority (enforced in this order):**
1. `flare` - Always forces REST
2. `stress` - Forces REST
3. `sleep` - Forces REST
4. `pms` - Forces REST
- **Why:** Overrides are core to the user experience per spec
- **Blocking:** P1.4, P1.5
### Components (10 components, 200+ tests)
| Component | Tests | Purpose |
|-----------|-------|---------|
| DecisionCard | 19 | Status display with color coding |
| DataPanel | 29 | Biometrics display, HRV color coding, progress bar |
| NutritionPanel | 16 | Seeds, carbs, keto guidance |
| OverrideToggles | 18 | Override buttons |
| DayCell | 32 | Calendar day with phase colors, period indicator |
| MiniCalendar | 23 | Dashboard calendar widget |
| OnboardingBanner | 16 | Setup prompts |
| MonthView | 31 | Full calendar with navigation, keyboard nav |
| PeriodDateModal | 22 | Period input modal |
| Skeletons | 29 | Loading states with shimmer |
### P0.4: GET /api/user Implementation ✅ COMPLETE
- [x] Return authenticated user profile
- **Files:**
- `src/app/api/user/route.ts` - Implemented GET handler with `withAuth()` wrapper
- **Tests:**
- `src/app/api/user/route.test.ts` - 4 tests covering auth, response shape, sensitive field exclusion
- **Response Shape:**
- `id`, `email`, `garminConnected`, `cycleLength`, `lastPeriodDate`, `notificationTime`, `timezone`, `activeOverrides`
- Excludes sensitive fields: `garminOauth1Token`, `garminOauth2Token`, `calendarToken`
- **Why:** Dashboard and all pages need user context
- **Depends On:** P0.1, P0.2
- **Blocking:** P1.7, P2.9, P2.10
### E2E Tests (14 files, 204 tests)
| File | Tests | Coverage |
|------|-------|----------|
| smoke.spec.ts | 3 | Basic app functionality |
| auth.spec.ts | 20 | Login, protected routes, OIDC flow, session persistence |
| dashboard.spec.ts | 40 | Dashboard display, overrides, accessibility |
| settings.spec.ts | 27 | Settings form, validation, persistence, error recovery |
| garmin.spec.ts | 16 | Garmin connection, expiry warnings, network error recovery |
| period-logging.spec.ts | 19 | Period history, logging, modal flows |
| calendar.spec.ts | 34 | Calendar view, ICS feed, content validation, accessibility |
| decision-engine.spec.ts | 8 | Decision priority chain |
| cycle.spec.ts | 11 | Cycle tracking |
| history.spec.ts | 7 | History page |
| plan.spec.ts | 7 | Plan page |
| health.spec.ts | 3 | Health/observability |
| mobile.spec.ts | 7 | Mobile viewport behavior, responsive layout, calendar mobile |
| dark-mode.spec.ts | 2 | System preference detection, light/dark theme application |
---
## P1: Core Functionality
## E2E Test Enhancement Opportunities
Minimum viable product - app can be used for daily decisions.
These are optional enhancements to improve E2E coverage. Not required for feature completeness.
### P1.1: PATCH /api/user Implementation ✅ COMPLETE
- [x] Allow profile updates (cycleLength, notificationTime, timezone)
- **Files:**
- `src/app/api/user/route.ts` - Implemented PATCH handler with validation
- **Tests:**
- `src/app/api/user/route.test.ts` - 17 tests covering field validation, persistence, security
- **Validation Rules:**
- `cycleLength`: number, range 21-45 days
- `notificationTime`: string, HH:MM format (24-hour)
- `timezone`: non-empty string
- **Security:** Ignores attempts to update non-updatable fields (email, tokens)
- **Why:** Users need to configure their cycle and preferences
- **Depends On:** P0.1, P0.2
**Note:** `notifications.spec.ts` was considered but determined to be redundant with existing `settings.spec.ts` coverage which already tests notification time and timezone preferences thoroughly.
### P1.2: POST /api/cycle/period Implementation ✅ COMPLETE
- [x] Log period start date, update user record, create PeriodLog
- **Files:**
- `src/app/api/cycle/period/route.ts` - Implemented POST handler with validation
- **Tests:**
- `src/app/api/cycle/period/route.test.ts` - 8 tests covering auth, date validation, user update, PeriodLog creation
- **Why:** Cycle tracking is the foundation of all recommendations
- **Depends On:** P0.1, P0.2
### P1.3: GET /api/cycle/current Implementation ✅ COMPLETE
- [x] Return current cycle day, phase, and phase config
- **Files:**
- `src/app/api/cycle/current/route.ts` - Implemented GET using cycle.ts utilities with `withAuth()` wrapper
- **Tests:**
- `src/app/api/cycle/current/route.test.ts` - 10 tests covering auth, validation, all phases, cycle rollover, custom cycle lengths
- **Response Shape:**
- `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength`
- **Why:** Dashboard needs this for display
- **Depends On:** P0.1, P0.2, P1.2
### P1.4: GET /api/today Implementation ✅ COMPLETE
- [x] Return complete daily snapshot with decision, biometrics, nutrition
- **Files:**
- `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition
- **Tests:**
- `src/app/api/today/route.test.ts` - 22 tests covering auth, validation, decision calculation, overrides, phases, nutrition
- **Response Shape:**
- `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength`
- `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit)
- `nutrition` (seeds, carbRange, ketoGuidance)
- **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0
- **Why:** This is THE core API for the dashboard
- **Depends On:** P0.1, P0.2, P0.3, P1.3
### P1.5: POST/DELETE /api/overrides Implementation ✅ COMPLETE
- [x] Toggle override flags on user record
- **Files:**
- `src/app/api/overrides/route.ts` - Implemented POST (add) and DELETE (remove) handlers with validation
- **Tests:**
- `src/app/api/overrides/route.test.ts` - 14 tests covering auth, override types, persistence, validation, edge cases
- **Override Types:** flare, stress, sleep, pms
- **POST Response:** Returns updated user with new override added to activeOverrides array
- **DELETE Response:** Returns updated user with override removed from activeOverrides array
- **Validation:** Rejects invalid override types, duplicates on POST, missing overrides on DELETE
- **Why:** Emergency overrides are critical for flare days
- **Depends On:** P0.1, P0.2, P0.3
### P1.6: Login Page Implementation ✅ COMPLETE
- [x] Functional login form with PocketBase auth
- **Files:**
- `src/app/login/page.tsx` - Client component with email/password form, error handling, loading states, redirect
- **Tests:**
- `src/app/login/page.test.tsx` - 14 tests covering rendering, form submission, auth flow, error handling, validation
- **Infrastructure Added:**
- `src/test-setup.ts` - Global test setup with @testing-library/jest-dom and cleanup
- Updated `vitest.config.ts` to include setupFiles
- **Why:** Users need to authenticate to use the app
- **Depends On:** P0.1
### P1.7: Dashboard Page Implementation ✅ COMPLETE
- [x] Wire up dashboard with real data from /api/today
- [x] Integrate DecisionCard, DataPanel, NutritionPanel, OverrideToggles
- [x] Implement override toggle functionality with optimistic updates
- [x] Add error handling and loading states
- **Files:**
- `src/app/page.tsx` - Client component fetching /api/today, rendering all dashboard components
- **Tests:**
- `src/app/page.test.tsx` - 23 tests covering data fetching, component rendering, override toggles, error handling
- **Features Implemented:**
- Real-time decision display with cycle phase information
- Biometrics panel (HRV, Body Battery, Intensity Minutes)
- Nutrition guidance panel (seeds, carbs, keto)
- Override toggles with optimistic UI updates
- Error boundaries and loading states
- **Why:** This is the main user interface
- **Depends On:** P0.4, P1.3, P1.4, P1.5
---
## P2: Important Features
Full feature set for production use.
### P2.1: Garmin Data Fetching Functions ✅ COMPLETE
- [x] Add specific fetchers for HRV, Body Battery, Intensity Minutes
- **Files:**
- `src/lib/garmin.ts` - Added `fetchHrvStatus()`, `fetchBodyBattery()`, `fetchIntensityMinutes()`
- **Tests:**
- `src/lib/garmin.test.ts` - 33 tests covering API calls, response parsing, error handling (increased from 14 tests)
- **Functions Implemented:**
- `fetchHrvStatus()` - Fetches HRV status (balanced/unbalanced) from Garmin
- `fetchBodyBattery()` - Fetches current and yesterday's low body battery values
- `fetchIntensityMinutes()` - Fetches weekly moderate + vigorous intensity minutes
- **Why:** Real biometric data is required for accurate decisions
### P2.2: POST/DELETE /api/garmin/tokens Implementation ✅ COMPLETE
- [x] Store encrypted Garmin OAuth tokens
- **Files:**
- `src/app/api/garmin/tokens/route.ts` - POST/DELETE handlers with encryption, validation
- **Tests:**
- `src/app/api/garmin/tokens/route.test.ts` - 15 tests covering encryption, validation, storage, auth, deletion
- **Features Implemented:**
- POST: Accepts oauth1, oauth2, expires_at; encrypts tokens; stores in user record
- DELETE: Clears tokens and sets garminConnected to false
- Validation for required fields and types
- Returns daysUntilExpiry in POST response
- **Why:** Users need to connect their Garmin accounts
- **Depends On:** P0.1, P0.2
### P2.3: GET /api/garmin/status Implementation ✅ COMPLETE
- [x] Return Garmin connection status and days until expiry
- **Files:**
- `src/app/api/garmin/status/route.ts` - GET handler with expiry calculation
- **Tests:**
- `src/app/api/garmin/status/route.test.ts` - 11 tests covering connected/disconnected states, expiry calc, warning levels
- **Response Shape:**
- `connected` - Boolean indicating if tokens exist
- `daysUntilExpiry` - Days until token expires (null if not connected)
- `expired` - Boolean indicating if tokens have expired
- `warningLevel` - "critical" (≤7 days), "warning" (8-14 days), or null
- **Why:** Users need visibility into their Garmin connection
- **Depends On:** P0.1, P0.2, P2.1
### P2.4: POST /api/cron/garmin-sync Implementation ✅ COMPLETE
- [x] Daily sync of all Garmin data for all users
- **Files:**
- `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog
- **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
- **Features Implemented:**
- Fetches all users with garminConnected=true
- Skips users with expired tokens
- Decrypts OAuth2 tokens and fetches HRV, Body Battery, Intensity Minutes
- Calculates cycle day, phase, phase limit, remaining minutes
- Computes training decision using decision engine
- Creates DailyLog entries for each user
- Returns sync summary (usersProcessed, errors, skippedExpired, timestamp)
- **Why:** Automated data sync is required for morning notifications
- **Depends On:** P2.1, P2.2
### P2.5: POST /api/cron/notifications Implementation ✅ COMPLETE
- [x] Send daily email notifications at user's preferred time
- **Files:**
- `src/app/api/cron/notifications/route.ts` - Timezone-aware user matching, DailyLog fallback, email sending
- **Tests:**
- `src/app/api/cron/notifications/route.test.ts` - 20 tests covering timezone matching, DailyLog handling, email sending
- **Features Implemented:**
- Timezone-aware notification matching (finds users whose notificationTime matches current hour in their timezone)
- DailyLog-based notifications with fallback to real-time calculation when DailyLog missing
- Duplicate prevention (only sends once per user per hour)
- Nutrition guidance integration (seeds, carbs, keto)
- CRON_SECRET authentication
- Returns summary with emailsSent count and timestamp
- **Why:** Email notifications are a key feature per spec
- **Depends On:** P2.4
### P2.6: GET /api/calendar/[userId]/[token].ics Implementation ✅ COMPLETE
- [x] Return ICS feed for calendar subscription
- **Files:**
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validates token, generates ICS with 90 days of phase events
- **Tests:**
- `src/app/api/calendar/[userId]/[token].ics/route.test.ts` - 10 tests covering token validation, ICS generation, caching headers, error handling
- **Features Implemented:**
- Token-based authentication (no session required)
- Validates calendar token against user record
- Generates 90 days of phase events using `generateIcsFeed()`
- Returns proper Content-Type header (`text/calendar; charset=utf-8`)
- Caching headers for calendar client optimization
- 404 for non-existent users, 401 for invalid tokens
- **Why:** Calendar integration for external apps
### P2.7: POST /api/calendar/regenerate-token Implementation ✅ COMPLETE
- [x] Generate new calendar token
- **Files:**
- `src/app/api/calendar/regenerate-token/route.ts` - Creates random 32-char token, updates user
- **Tests:**
- `src/app/api/calendar/regenerate-token/route.test.ts` - 9 tests covering token generation, URL formatting, auth
- **Features Implemented:**
- Requires authentication via `withAuth()` middleware
- Generates cryptographically secure 32-character hex token
- Updates user's `calendarToken` field in database
- Returns new token and formatted calendar URL
- Old tokens immediately invalidated
- **Why:** Security feature for calendar URLs
- **Depends On:** P0.1, P0.2
### P2.8: GET /api/history Implementation ✅ COMPLETE
- [x] Return paginated historical daily logs
- **Files:**
- `src/app/api/history/route.ts` - Query DailyLog with pagination, date filtering, validation
- **Tests:**
- `src/app/api/history/route.test.ts` - 19 tests covering pagination, date filtering, auth, validation
- **Features Implemented:**
- Pagination with page/limit parameters (default: page=1, limit=20)
- Date filtering with startDate/endDate query params (YYYY-MM-DD format)
- Validation for all parameters with descriptive error messages
- Sort by date descending (most recent first)
- Returns items, total, page, limit, totalPages, hasMore
- **Why:** Users want to see their training history
- **Depends On:** P0.1, P0.2
### P2.9: Settings Page Implementation ✅ COMPLETE
- [x] User profile management UI
- **Files:**
- `src/app/settings/page.tsx` - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling
- **Tests:**
- `src/app/settings/page.test.tsx` - 24 tests covering rendering, data loading, form submission, validation, error handling
- **Why:** Users need to configure their preferences
- **Depends On:** P0.4, P1.1
### P2.10: Settings/Garmin Page Implementation ✅ COMPLETE
- [x] Garmin connection management UI
- **Files:**
- `src/app/settings/garmin/page.tsx` - Token input form, connection status, expiry warnings, disconnect button
- **Tests:**
- `src/app/settings/garmin/page.test.tsx` - 27 tests covering rendering, connection states, warning levels, token submission, disconnect flow
- `src/app/settings/page.test.tsx` - 3 additional tests for Garmin link (28 total)
- **Features Implemented:**
- Connection status display with green/red/gray indicators
- Token expiry warnings (yellow for 14 days, red for 7 days)
- Token input form with JSON validation
- Instructions for running bootstrap script
- Disconnect functionality
- Loading and error states
- **Why:** Users need to manage their Garmin connection
- **Depends On:** P0.4, P2.2, P2.3
### P2.11: Calendar Page Implementation ✅ COMPLETE
- [x] In-app calendar with phase visualization
- **Files:**
- `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration
- `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend
- **Tests:**
- `src/components/calendar/month-view.test.tsx` - 21 tests covering calendar grid, phase colors, navigation, legend
- `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration
- **Why:** Planning ahead is a key user need
- **Depends On:** P2.6
### P2.12: History Page Implementation ✅ COMPLETE
- [x] View past training decisions and data
- **Files:**
- `src/app/history/page.tsx` - Data fetching, table display, pagination, date filtering
- **Tests:**
- `src/app/history/page.test.tsx` - 26 tests covering rendering, data loading, pagination, filtering, error handling
- **Why:** Users want to review their training history
- **Depends On:** P2.8
### P2.13: Plan Page Implementation
- [ ] Phase-specific training plan view
- **Files:**
- `src/app/plan/page.tsx` - Current phase details, upcoming phases, limits
- **Tests:**
- E2E test: correct phase info displayed
- **Why:** Users want detailed training guidance
- **Depends On:** P0.4, P1.3
### P2.14: Mini Calendar Component
- [ ] Dashboard overview calendar
- **Files:**
- `src/components/dashboard/mini-calendar.tsx` - **Complete calendar grid with phase colors**
- **Tests:**
- Component test: renders current month, highlights today
- **Why:** Quick visual reference on dashboard
- **Note:** Component exists with header only, needs calendar grid (~70% remaining)
---
## P3: Polish and Quality
Testing, error handling, and refinements.
### P3.1: Decision Engine Tests ✅ COMPLETE
- [x] Comprehensive unit tests for all decision paths
- **Files:**
- `src/lib/decision-engine.test.ts` - All 8 priority rules, override combinations (24 tests)
- **Test Cases Covered:**
- HRV Unbalanced always forces REST (highest algorithmic priority)
- Override priority: flare > stress > sleep > pms
- Phase limits strictly enforced
- All override bypass and fallthrough scenarios
- **Why:** Critical logic is now fully tested
### P3.2: Nutrition Tests ✅ COMPLETE
- [x] Unit tests for nutrition guidance
- **Files:**
- `src/lib/nutrition.test.ts` - 17 tests covering seed cycling, carb ranges, keto guidance by phase
- **Test Cases Covered:**
- Seed cycling recommendations by phase (flax/pumpkin vs sunflower/sesame)
- Carb range calculations per phase
- Keto guidance by cycle day
- Edge cases and phase transitions
- **Why:** Nutrition advice accuracy is now fully tested
### P3.3: Email Tests ✅ COMPLETE
- [x] Unit tests for email composition
- **Files:**
- `src/lib/email.test.ts` - 14 tests covering email content, subject lines, formatting
- **Test Cases Covered:**
- Daily email composition with decision data
- Period confirmation email content
- Subject line formatting
- HTML email structure
- **Why:** Email formatting correctness is now fully tested
### P3.4: ICS Tests ✅ COMPLETE
- [x] Unit tests for calendar generation
- **Files:**
- `src/lib/ics.test.ts` - 23 tests covering ICS format validation, 90-day event generation, timezone handling
- **Test Cases Covered:**
- ICS feed generation with 90 days of phase events
- RFC 5545 format compliance
- Timezone handling (UTC conversion)
- Event boundaries and phase transitions
- **Why:** Calendar integration compatibility is now fully tested
### P3.5: Encryption Tests ✅ COMPLETE
- [x] Unit tests for encrypt/decrypt round-trip
- **Files:**
- `src/lib/encryption.test.ts` - 14 tests covering AES-256-GCM round-trip, error handling, key validation
- **Test Cases Covered:**
- Encrypt/decrypt round-trip verification
- Key validation and error handling
- IV generation uniqueness
- Malformed data handling
- **Why:** Token security is now fully tested
### P3.6: Garmin Tests ✅ COMPLETE
- [x] Unit tests for Garmin API interactions
- **Files:**
- `src/lib/garmin.test.ts` - 33 tests covering API calls, error handling, token expiry (expanded in P2.1)
- **Test Cases Covered:**
- fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes HTTP calls and response parsing
- isTokenExpired logic with various expiry dates
- daysUntilExpiry calculations
- Error handling for invalid tokens and network failures
- Response parsing for biometric data structures
- **Why:** External API integration robustness is now fully tested
### P3.7: Error Handling Improvements
- [ ] Add consistent error responses across all API routes
- **Files:**
- All route files - Standardize error format, add logging
- **Why:** Better debugging and user experience
### P3.8: Loading States
- [ ] Add loading indicators to all pages
- **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:**
- Test warning triggers at exactly 14 days and 7 days
- **Why:** Users need time to refresh tokens (per spec requirement)
### P3.10: E2E Test Suite
- [ ] Comprehensive end-to-end tests
- **Files:**
- `tests/e2e/*.spec.ts` - Full user flows
- **Test Scenarios:**
- Login flow
- Period logging and phase calculation
- Override toggle functionality
- Settings update flow
- Garmin connection flow
- Calendar subscription
- **Why:** Confidence in production deployment
---
## Implementation Order
```
P0.1 PocketBase Auth ──┬──> P0.2 Auth Middleware ──> P0.4 GET /api/user
P0.3 Override Logic ───┴──> P1.4 GET /api/today ──> P1.7 Dashboard
P1.1 PATCH /api/user ────> P2.9 Settings Page
P1.2 POST period ────────> P1.3 GET current ────> P1.7 Dashboard
P1.5 Overrides API ──────> P1.7 Dashboard
P1.6 Login Page
P2.1 Garmin fetchers ──> P2.2 Garmin tokens ──> P2.4 Cron sync ──> P2.5 Notifications
└──> P3.9 Token Warnings
P2.3 Garmin status ────> P2.10 Garmin settings
P2.6 ICS endpoint ─────> P2.11 Calendar page
P2.7 Regen token
P2.8 History API ──────> P2.12 History page
P2.13 Plan page
P2.14 Mini calendar
```
### Dependency Summary
| Task | Blocked By | Blocks |
|------|------------|--------|
| P0.1 | - | P0.2, P0.4, P1.1-P1.6, 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.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 |
---
## Completed
### Library
- [x] **cycle.ts** - Complete with 9 tests (`getCycleDay`, `getPhase`, `getPhaseConfig`, `getPhaseLimit`)
- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
- [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] **email.ts** - Complete with 14 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, email formatting) (P3.3)
- [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] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6)
### Components
- [x] **DecisionCard** - Displays decision status, icon, and reason
- [x] **DataPanel** - Shows body battery, HRV, intensity data
- [x] **NutritionPanel** - Shows seeds, carbs, keto guidance
- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms
- [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
### API Routes
- [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] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3)
- [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4)
- [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5)
- [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5)
- [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] **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/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] **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)
### Pages
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
- [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/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] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12)
### Test Infrastructure
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
---
## Discovered Issues
*Bugs and inconsistencies found during implementation*
- [x] ~~`src/lib/auth-middleware.ts` does not exist~~ - CREATED in P0.2
- [x] ~~`src/middleware.ts` does not exist~~ - CREATED in P0.2
- [x] ~~`garmin.ts` is only ~30% complete - missing specific biometric fetchers~~ - FIXED in P2.1 (added fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes)
- [x] ~~`pocketbase.ts` missing all auth helper functions~~ - FIXED in P0.1
- [x] ~~`src/app/api/today/route.ts` type error with null body battery values~~ - FIXED (added null coalescing)
### Completed Enhancements
| File | Tests Added | Focus Area |
|------|-------------|------------|
| dark-mode.spec.ts | +2 | System preference detection (light/dark mode) |
| garmin.spec.ts | +4 | Network error recovery (save, disconnect, status fetch, retry) |
| calendar.spec.ts | +4 | Accessibility (ARIA, keyboard nav) |
| settings.spec.ts | +1 | Error recovery on failed save |
| mobile.spec.ts | +3 | Calendar responsive behavior |
---
## Notes
1. **TDD Approach:** Each implementation task should follow TDD - write failing tests first, then implement
2. **Auth First:** P0 items unlock all other work; prioritize ruthlessly
3. **Incremental Delivery:** P1 completion = usable app without Garmin (manual data entry fallback)
4. **P2 Completion:** Full feature set with automation
5. **P3:** Quality and polish for production confidence
6. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7
7. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles
8. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms
9. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry
1. **TDD Approach:** Write failing tests first, then implement
2. **Auth First:** P0 items unlock all other work
3. **No Mock Mode:** Use real data and APIs only
4. **ABOUTME Comments:** All files start with 2-line ABOUTME
5. **Commit Format:** Descriptive message + Claude footer
6. **Never use --no-verify:** All commits go through pre-commit hooks
---
## Revision History
- 2026-01-13: Fixed E2E test reliability issues:
- Race conditions: Changed to single worker execution (tests share test user state)
- Stale data: GET /api/user and GET /api/cycle/current now fetch fresh data from database instead of stale auth cache
- Timing: Replaced fixed waitForTimeout calls with retry-based Playwright assertions
- Mobile test: Fixed strict mode violation by using exact heading match
- 2026-01-13: Marked notifications.spec.ts as redundant (notification preferences already covered in settings.spec.ts)
- 2026-01-13: Added dark-mode.spec.ts with 2 E2E tests (system preference detection for light/dark mode)
- 2026-01-13: Added 4 Garmin E2E tests (network error recovery for save, disconnect, status fetch, retry)
- 2026-01-13: Added 8 E2E tests (calendar accessibility, settings error recovery, calendar mobile behavior)
- 2026-01-13: Added mobile.spec.ts with 4 E2E tests (mobile viewport behavior, responsive layout)
- 2026-01-13: Added 6 auth E2E tests (OIDC button display, loading states, session persistence across pages/refresh)
- 2026-01-13: Added 5 settings persistence E2E tests (notification time, timezone, multi-field persistence)
- 2026-01-13: Added 5 period-logging E2E tests (modal flow, future date restriction, edit/delete flows)
- 2026-01-13: Added 5 Garmin E2E tests (expiry warnings, expired state, persistence, reconnection)
- 2026-01-13: Condensed plan after feature completion (reduced from 1514 to ~170 lines)
- 2026-01-12: Fixed spec gaps (email format, HRV colors, progress bar, emojis)
- 2026-01-11: Completed P5.1-P5.4 (period history, toast, CI, E2E)

111
docker.nix Normal file
View File

@@ -0,0 +1,111 @@
# ABOUTME: Nix expression for building PhaseFlow Docker image.
# ABOUTME: Creates standalone Next.js production bundle with minimal dependencies.
{ pkgs }:
let
src = pkgs.lib.cleanSource ./.;
# Build the Next.js application using pnpm
# Note: This builds with network access. For fully reproducible builds,
# consider using pnpm.fetchDeps or dream2nix in the future.
phaseflow = pkgs.stdenv.mkDerivation {
pname = "phaseflow";
version = "0.1.0";
inherit src;
nativeBuildInputs = with pkgs; [
nodejs_24
pnpm
cacert
];
# Allow network access for pnpm install
__noChroot = true;
# Enable network during build (requires trusted-users in nix.conf)
# Alternative: use sandbox = false for this derivation
impureEnvVars = pkgs.lib.fetchers.proxyImpureEnvVars ++ [
"HOME"
"npm_config_cache"
];
buildPhase = ''
export HOME=$TMPDIR
export NEXT_TELEMETRY_DISABLED=1
# Provide dummy env vars for build (actual values injected at runtime)
export RESEND_API_KEY="re_build_placeholder"
export ENCRYPTION_KEY="build_placeholder_32_chars_long!"
export CRON_SECRET="build_placeholder_secret"
export NEXT_PUBLIC_POCKETBASE_URL="https://pocketbase-phaseflow.v.paler.net"
export APP_URL="https://phaseflow.v.paler.net"
# Install dependencies
pnpm install --frozen-lockfile
# Build the Next.js app with standalone output
pnpm build
'';
# Disable broken symlink check - pnpm creates internal symlinks we don't need
dontCheckForBrokenSymlinks = true;
installPhase = ''
mkdir -p $out
# Copy standalone server (self-contained with minimal node_modules)
# Use /. to include hidden directories like .next
cp -r .next/standalone/. $out/
# Copy static assets (Next.js standalone requires these separately)
cp -r .next/static $out/.next/static
# Copy public assets
if [ -d public ]; then
cp -r public $out/public
fi
'';
};
in
pkgs.dockerTools.buildImage {
name = "gitea.v.paler.net/alo/phaseflow";
tag = "latest";
copyToRoot = pkgs.buildEnv {
name = "phaseflow-env";
paths = with pkgs; [
# System utilities
busybox
bash
# Node.js runtime
nodejs_24
# Docker filesystem helpers
dockerTools.usrBinEnv
dockerTools.binSh
dockerTools.fakeNss
dockerTools.caCertificates
];
};
# Copy the built application
extraCommands = ''
mkdir -p -m 1777 tmp
mkdir -p app
cp -r ${phaseflow}/. app/
'';
config = {
Env = [
"NODE_ENV=production"
"PORT=3000"
"HOSTNAME=0.0.0.0"
];
ExposedPorts = {
"3000/tcp" = {};
};
Cmd = [ "${pkgs.nodejs_24}/bin/node" "/app/server.js" ];
WorkingDir = "/app";
};
}

388
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,388 @@
// ABOUTME: E2E tests for authentication flows including login and logout.
// ABOUTME: Tests login page UI, form validation, rate limiting, and error handling.
import { expect, test } from "@playwright/test";
test.describe("authentication", () => {
test.describe("login page", () => {
test("login page shows loading state initially", async ({ page }) => {
await page.goto("/login");
// The page should load with some content visible
await expect(page).toHaveURL(/\/login/);
});
test("login page displays sign in option", async ({ page }) => {
await page.goto("/login");
// Wait for auth methods to load
// Either OIDC button or email/password form should be visible
await page.waitForLoadState("networkidle");
// Look for either OIDC sign-in button or email/password form
const oidcButton = page.getByRole("button", { name: /sign in with/i });
const emailInput = page.getByLabel(/email/i);
// At least one should be visible
const hasOidc = await oidcButton.isVisible().catch(() => false);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
expect(hasOidc || hasEmailForm).toBe(true);
});
test("email/password form validates empty fields", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown (vs OIDC)
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Try to submit empty form
const submitButton = page.getByRole("button", { name: /sign in/i });
await submitButton.click();
// Form should prevent submission via HTML5 validation or show error
// The form won't submit with empty required fields
await expect(emailInput).toBeFocused();
} else {
// OIDC mode - skip this test
test.skip();
}
});
test("shows error for invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Fill in invalid credentials
await emailInput.fill("invalid@example.com");
await page.getByLabel(/password/i).fill("wrongpassword");
// Submit the form
await page.getByRole("button", { name: /sign in/i }).click();
// Should show error message - use more specific selector to avoid matching Next.js route announcer
const errorMessage = page.locator('[role="alert"]').filter({
hasText: /invalid|failed|error|wrong|something went wrong/i,
});
await expect(errorMessage).toBeVisible({ timeout: 10000 });
} else {
// OIDC mode - skip this test
test.skip();
}
});
test("clears error when user types", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Fill in and submit invalid credentials
await emailInput.fill("invalid@example.com");
await page.getByLabel(/password/i).fill("wrongpassword");
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for error - use more specific selector
const errorMessage = page.locator('[role="alert"]').filter({
hasText: /invalid|failed|error|wrong|something went wrong/i,
});
await expect(errorMessage).toBeVisible({ timeout: 10000 });
// Type in email field
await emailInput.fill("new@example.com");
// Error should be cleared (non-rate-limit errors)
// Note: Rate limit errors persist
await expect(errorMessage)
.not.toBeVisible({ timeout: 2000 })
.catch(() => {
// If still visible, might be rate limit - that's acceptable
});
} else {
test.skip();
}
});
test("shows disabled state during login attempt", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Check if email/password form is shown
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (hasEmailForm) {
// Fill in credentials
await emailInput.fill("test@example.com");
await page.getByLabel(/password/i).fill("testpassword");
// Click submit and quickly check for disabled state
const submitButton = page.getByRole("button", { name: /sign in/i });
// Start the submission
const submitPromise = submitButton.click();
// The button should become disabled during submission
// Check that the button text changes to "Signing in..."
await expect(submitButton)
.toContainText(/signing in/i, { timeout: 1000 })
.catch(() => {
// May be too fast to catch - that's okay
});
await submitPromise;
} else {
test.skip();
}
});
});
test.describe("protected routes", () => {
test("dashboard redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/");
// Should either redirect to /login or show login link
const url = page.url();
const hasLoginInUrl = url.includes("/login");
const loginLink = page.getByRole("link", { name: /login|sign in/i });
if (!hasLoginInUrl) {
await expect(loginLink).toBeVisible();
} else {
await expect(page).toHaveURL(/\/login/);
}
});
test("settings redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/settings");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("calendar redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/calendar");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("history redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/history");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("plan redirects unauthenticated users to login", async ({ page }) => {
await page.goto("/plan");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("period-history redirects unauthenticated users to login", async ({
page,
}) => {
await page.goto("/period-history");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("public routes", () => {
test("login page is accessible without auth", async ({ page }) => {
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
// Should not redirect
});
test("health endpoint is accessible without auth", async ({ page }) => {
const response = await page.request.get("/api/health");
// Health endpoint returns 200 (ok) or 503 (unhealthy) - both are valid responses
expect([200, 503]).toContain(response.status());
const body = await response.json();
expect(["ok", "unhealthy"]).toContain(body.status);
});
});
test.describe("OIDC authentication flow", () => {
// Mock PocketBase auth-methods to return OIDC provider
test.beforeEach(async ({ page }) => {
await page.route("**/api/collections/users/auth-methods*", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
usernamePassword: true,
oauth2: {
enabled: true,
providers: [
{
name: "oidc",
displayName: "Test Provider",
state: "mock-state",
codeVerifier: "mock-verifier",
codeChallenge: "mock-challenge",
codeChallengeMethod: "S256",
authURL: "https://mock.example.com/auth",
},
],
},
}),
});
});
});
test("OIDC button shows provider name when configured", async ({
page,
}) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
const oidcButton = page.getByRole("button", { name: /sign in with/i });
await expect(oidcButton).toBeVisible();
await expect(oidcButton).toContainText("Test Provider");
});
test("OIDC button shows loading state during authentication", async ({
page,
}) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
// Find button by initial text
const oidcButton = page.getByRole("button", { name: /sign in with/i });
await expect(oidcButton).toBeVisible();
// Click and immediately check for loading state
// The button text changes to "Signing in..." so we need a different locator
await oidcButton.click();
// Find the button that shows loading state (text changed)
const loadingButton = page.getByRole("button", { name: /signing in/i });
await expect(loadingButton).toBeVisible();
await expect(loadingButton).toBeDisabled();
});
test("OIDC button is disabled when rate limited", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("networkidle");
const oidcButton = page.getByRole("button", { name: /sign in with/i });
// Initial state should not be disabled
await expect(oidcButton).not.toBeDisabled();
});
});
test.describe("session persistence", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard
await page.waitForURL("/", { timeout: 10000 });
});
test("session persists after page refresh", async ({ page }) => {
// Verify we're on dashboard
await expect(page).toHaveURL("/");
// Refresh the page
await page.reload();
await page.waitForLoadState("networkidle");
// Should still be on dashboard, not redirected to login
await expect(page).toHaveURL("/");
// Dashboard content should be visible (not login page)
const dashboardContent = page.getByRole("heading").first();
await expect(dashboardContent).toBeVisible();
});
test("session persists when navigating between pages", async ({ page }) => {
// Navigate to settings
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Should be on settings, not redirected to login
await expect(page).toHaveURL(/\/settings/);
// Navigate to calendar
await page.goto("/calendar");
await page.waitForLoadState("networkidle");
// Should be on calendar, not redirected to login
await expect(page).toHaveURL(/\/calendar/);
// Navigate back to dashboard
await page.goto("/");
await page.waitForLoadState("networkidle");
// Should still be authenticated
await expect(page).toHaveURL("/");
});
test("logout clears session and redirects to login", async ({ page }) => {
// Navigate to settings where logout button is located
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Find and click logout button
const logoutButton = page.getByRole("button", { name: /log ?out/i });
await expect(logoutButton).toBeVisible();
await logoutButton.click();
// Should redirect to login page
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
// Now try to access protected route - should redirect to login
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
});
});
});

755
e2e/calendar.spec.ts Normal file
View File

@@ -0,0 +1,755 @@
// ABOUTME: E2E tests for calendar functionality including ICS feed and calendar view.
// ABOUTME: Tests calendar display, navigation, and ICS subscription features.
import { test as baseTest } from "@playwright/test";
import { expect, test } from "./fixtures";
baseTest.describe("calendar", () => {
baseTest.describe("unauthenticated", () => {
baseTest(
"calendar page redirects to login when not authenticated",
async ({ page }) => {
await page.goto("/calendar");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
},
);
});
baseTest.describe("ICS endpoint", () => {
baseTest(
"ICS endpoint returns error for invalid user",
async ({ page }) => {
const response = await page.request.get(
"/api/calendar/invalid-user-id/invalid-token.ics",
);
// Should return 404 (user not found) or 500 (PocketBase not connected in test env)
expect([404, 500]).toContain(response.status());
},
);
baseTest(
"ICS endpoint returns error for invalid token",
async ({ page }) => {
// Need a valid user ID but invalid token - this would require setup
// For now, just verify the endpoint exists and returns appropriate error
const response = await page.request.get(
"/api/calendar/test/invalid.ics",
);
// Should return 404 (user not found), 401 (invalid token), or 500 (PocketBase not connected)
expect([401, 404, 500]).toContain(response.status());
},
);
});
baseTest.describe("calendar regenerate token API", () => {
baseTest("regenerate token requires authentication", async ({ page }) => {
const response = await page.request.post(
"/api/calendar/regenerate-token",
);
// Should return 401 Unauthorized
expect(response.status()).toBe(401);
});
});
});
test.describe("calendar authenticated", () => {
test("displays calendar page with heading", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Check for the main calendar heading (h1)
const heading = establishedPage.getByRole("heading", {
name: "Calendar",
exact: true,
});
await expect(heading).toBeVisible();
});
test("shows month view calendar", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Look for calendar grid structure
const calendarGrid = establishedPage
.getByRole("grid")
.or(establishedPage.locator('[data-testid="month-view"]'));
await expect(calendarGrid).toBeVisible();
});
test("shows month and year display", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Calendar should show current month/year
const monthYear = establishedPage.getByText(
/january|february|march|april|may|june|july|august|september|october|november|december/i,
);
await expect(monthYear.first()).toBeVisible();
});
test("has navigation controls for months", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Look for previous/next month buttons
const prevButton = establishedPage.getByRole("button", {
name: /prev|previous|←|back/i,
});
const nextButton = establishedPage.getByRole("button", {
name: /next|→|forward/i,
});
// At least one navigation control should be visible
const hasPrev = await prevButton.isVisible().catch(() => false);
const hasNext = await nextButton.isVisible().catch(() => false);
expect(hasPrev || hasNext).toBe(true);
});
test("can navigate to previous month", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
const prevButton = establishedPage.getByRole("button", {
name: /prev|previous|←/i,
});
const hasPrev = await prevButton.isVisible().catch(() => false);
if (hasPrev) {
// Click previous month button
await prevButton.click();
// Wait for update - verify page doesn't error
await establishedPage.waitForTimeout(500);
// Verify calendar is still rendered
const monthYear = establishedPage.getByText(
/january|february|march|april|may|june|july|august|september|october|november|december/i,
);
await expect(monthYear.first()).toBeVisible();
}
});
test("can navigate to next month", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
const nextButton = establishedPage.getByRole("button", { name: /next|→/i });
const hasNext = await nextButton.isVisible().catch(() => false);
if (hasNext) {
// Click next
await nextButton.click();
// Wait for update
await establishedPage.waitForTimeout(500);
}
});
test("shows ICS subscription section", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Look for calendar subscription / ICS section
const subscriptionText = establishedPage.getByText(
/subscribe|subscription|calendar.*url|ics/i,
);
const hasSubscription = await subscriptionText
.first()
.isVisible()
.catch(() => false);
// This may not be visible if user hasn't generated a token
if (hasSubscription) {
await expect(subscriptionText.first()).toBeVisible();
}
});
test("shows back navigation", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
const backLink = establishedPage.getByRole("link", {
name: /back|home|dashboard/i,
});
await expect(backLink).toBeVisible();
});
test("can navigate back to dashboard", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
const backLink = establishedPage.getByRole("link", {
name: /back|home|dashboard/i,
});
await backLink.click();
await expect(establishedPage).toHaveURL("/");
});
});
test.describe("calendar display features", () => {
test("today is highlighted in calendar view", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Today's date should be highlighted with distinct styling
const today = new Date();
const dayNumber = today.getDate().toString();
// Look for today button/cell with special styling
const todayCell = establishedPage
.locator('[data-today="true"]')
.or(establishedPage.locator('[aria-current="date"]'))
.or(
establishedPage.getByRole("button", {
name: new RegExp(`${dayNumber}`),
}),
);
const hasTodayHighlight = await todayCell
.first()
.isVisible()
.catch(() => false);
if (hasTodayHighlight) {
await expect(todayCell.first()).toBeVisible();
}
});
test("phase colors are visible in calendar days", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Calendar days should have phase coloring (background color classes)
const dayButtons = establishedPage.getByRole("button").filter({
has: establishedPage.locator('[class*="bg-"]'),
});
const hasColoredDays = await dayButtons
.first()
.isVisible()
.catch(() => false);
// If there's cycle data, some days should have color
if (hasColoredDays) {
await expect(dayButtons.first()).toBeVisible();
}
});
test("calendar shows phase legend", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Look for phase legend with phase names
const legendText = establishedPage.getByText(
/menstrual|follicular|ovulation|luteal/i,
);
const hasLegend = await legendText
.first()
.isVisible()
.catch(() => false);
if (hasLegend) {
await expect(legendText.first()).toBeVisible();
}
});
test("calendar has Today button for quick navigation", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
const todayButton = establishedPage.getByRole("button", { name: /today/i });
const hasTodayButton = await todayButton.isVisible().catch(() => false);
if (hasTodayButton) {
await expect(todayButton).toBeVisible();
}
});
test("can navigate multiple months and return to today", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Navigate forward a few months
const nextButton = establishedPage.getByRole("button", { name: /next|→/i });
const hasNext = await nextButton.isVisible().catch(() => false);
if (hasNext) {
await nextButton.click();
await establishedPage.waitForTimeout(300);
await nextButton.click();
await establishedPage.waitForTimeout(300);
// Look for Today button to return
const todayButton = establishedPage.getByRole("button", {
name: /today/i,
});
const hasTodayButton = await todayButton.isVisible().catch(() => false);
if (hasTodayButton) {
await todayButton.click();
await establishedPage.waitForTimeout(300);
// Should be back to current month
const currentMonth = new Date().toLocaleString("default", {
month: "long",
});
const monthText = establishedPage.getByText(
new RegExp(currentMonth, "i"),
);
const isCurrentMonth = await monthText
.first()
.isVisible()
.catch(() => false);
expect(isCurrentMonth).toBe(true);
}
}
});
});
test.describe("ICS feed - generate token flow", () => {
test("can generate calendar URL", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Established user has no token - should see generate button
const generateButton = establishedPage.getByRole("button", {
name: /generate/i,
});
await expect(generateButton).toBeVisible();
await generateButton.click();
await establishedPage.waitForTimeout(1000);
// After generating, URL should be displayed
const urlDisplay = establishedPage.getByText(/\.ics|calendar.*url/i);
await expect(urlDisplay.first()).toBeVisible();
});
test("shows generate or regenerate token button", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Look for generate/regenerate button
const tokenButton = establishedPage.getByRole("button", {
name: /generate|regenerate/i,
});
await expect(tokenButton).toBeVisible();
});
});
test.describe("ICS feed - with token", () => {
// Helper to ensure URL is generated
async function ensureCalendarUrlGenerated(
page: import("@playwright/test").Page,
): Promise<void> {
const urlInput = page.getByRole("textbox");
const hasUrl = await urlInput.isVisible().catch(() => false);
if (!hasUrl) {
// Generate the URL if not present
const generateButton = page.getByRole("button", { name: /generate/i });
if (await generateButton.isVisible().catch(() => false)) {
await generateButton.click();
await page.waitForTimeout(1000);
}
}
}
test("calendar URL is displayed after generation", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(establishedPage);
// URL should now be visible
const urlInput = establishedPage.getByRole("textbox");
await expect(urlInput).toBeVisible();
});
test("calendar URL contains user ID and token", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(establishedPage);
const urlInput = establishedPage.getByRole("textbox");
await expect(urlInput).toBeVisible();
const url = await urlInput.inputValue();
// URL should contain /api/calendar/ and end with .ics
expect(url).toContain("/api/calendar/");
expect(url).toContain(".ics");
});
test("shows copy button when URL exists", async ({ establishedPage }) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(establishedPage);
// Copy button should be visible after generating token
const copyButton = establishedPage.getByRole("button", { name: /copy/i });
await expect(copyButton).toBeVisible();
});
test("copy button copies URL to clipboard", async ({
establishedPage,
context,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(establishedPage);
// Grant clipboard permissions
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const copyButton = establishedPage.getByRole("button", { name: /copy/i });
await expect(copyButton).toBeVisible();
await copyButton.click();
// Verify clipboard has content (clipboard read may not work in all env)
const clipboardContent = await establishedPage
.evaluate(() => navigator.clipboard.readText())
.catch(() => null);
if (clipboardContent) {
expect(clipboardContent).toContain(".ics");
}
});
test("shows regenerate button after generating token", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(establishedPage);
// User with token should see regenerate option
const regenerateButton = establishedPage.getByRole("button", {
name: /regenerate/i,
});
await expect(regenerateButton).toBeVisible();
});
});
test.describe("ICS feed content validation", () => {
// Helper to ensure URL is generated
async function ensureCalendarUrlGenerated(
page: import("@playwright/test").Page,
): Promise<void> {
const urlInput = page.getByRole("textbox");
const hasUrl = await urlInput.isVisible().catch(() => false);
if (!hasUrl) {
const generateButton = page.getByRole("button", { name: /generate/i });
if (await generateButton.isVisible().catch(() => false)) {
await generateButton.click();
await page.waitForTimeout(1000);
}
}
}
async function getIcsContent(
page: import("@playwright/test").Page,
): Promise<string | null> {
const urlInput = page.getByRole("textbox");
const hasUrlInput = await urlInput.isVisible().catch(() => false);
if (!hasUrlInput) {
return null;
}
const url = await urlInput.inputValue();
const response = await page.request.get(url);
if (response.ok()) {
return await response.text();
}
return null;
}
test("ICS feed contains valid VCALENDAR structure", async ({
calendarPage,
}) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const icsContent = await getIcsContent(calendarPage);
expect(icsContent).not.toBeNull();
// Verify basic ICS structure
expect(icsContent).toContain("BEGIN:VCALENDAR");
expect(icsContent).toContain("END:VCALENDAR");
expect(icsContent).toContain("VERSION:2.0");
expect(icsContent).toContain("PRODID:");
});
test("ICS feed contains VEVENT entries", async ({ calendarPage }) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const icsContent = await getIcsContent(calendarPage);
expect(icsContent).not.toBeNull();
// Should have at least some events
expect(icsContent).toContain("BEGIN:VEVENT");
expect(icsContent).toContain("END:VEVENT");
});
test("ICS feed contains phase events with emojis", async ({
calendarPage,
}) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const icsContent = await getIcsContent(calendarPage);
expect(icsContent).not.toBeNull();
// Per calendar.md spec, events should have emojis:
// 🩸 Menstrual, 🌱 Follicular, 🌸 Ovulation, 🌙 Early Luteal, 🌑 Late Luteal
const phaseEmojis = ["🩸", "🌱", "🌸", "🌙", "🌑"];
const hasEmojis = phaseEmojis.some((emoji) => icsContent?.includes(emoji));
expect(hasEmojis).toBe(true);
});
test("ICS feed has CATEGORIES for calendar color coding", async ({
calendarPage,
}) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const icsContent = await getIcsContent(calendarPage);
expect(icsContent).not.toBeNull();
// Per calendar.md spec, phases have color categories:
// Red, Green, Pink, Yellow, Orange
const colorCategories = ["Red", "Green", "Pink", "Yellow", "Orange"];
const hasCategories = colorCategories.some((color) =>
icsContent?.includes(`CATEGORIES:${color}`),
);
// If user has cycle data, categories should be present
if (
icsContent?.includes("MENSTRUAL") ||
icsContent?.includes("FOLLICULAR")
) {
expect(hasCategories).toBe(true);
}
});
test("ICS feed spans approximately 90 days", async ({ calendarPage }) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const icsContent = await getIcsContent(calendarPage);
expect(icsContent).not.toBeNull();
// Count DTSTART entries to estimate event span
const dtStartMatches = icsContent?.match(/DTSTART/g);
// Should have multiple events (phases + warnings)
// 3 months of phases (~15 phase events) + warning events
if (dtStartMatches) {
expect(dtStartMatches.length).toBeGreaterThan(5);
}
});
test("ICS feed includes warning events", async ({ calendarPage }) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const icsContent = await getIcsContent(calendarPage);
expect(icsContent).not.toBeNull();
// Per ics.ts, warning events include these phrases
const warningIndicators = [
"Late Luteal Phase",
"CRITICAL PHASE",
"⚠️",
"🔴",
];
const hasWarnings = warningIndicators.some((indicator) =>
icsContent?.includes(indicator),
);
// Warnings should be present if feed has events
if (icsContent?.includes("BEGIN:VEVENT")) {
expect(hasWarnings).toBe(true);
}
});
test("ICS content type is text/calendar", async ({ calendarPage }) => {
await calendarPage.goto("/calendar");
await calendarPage.waitForLoadState("networkidle");
await ensureCalendarUrlGenerated(calendarPage);
const urlInput = calendarPage.getByRole("textbox");
await expect(urlInput).toBeVisible();
const url = await urlInput.inputValue();
const response = await calendarPage.request.get(url);
expect(response.ok()).toBe(true);
const contentType = response.headers()["content-type"];
expect(contentType).toContain("text/calendar");
});
});
test.describe("calendar accessibility", () => {
test("calendar grid has proper ARIA role and label", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Calendar should have role="grid" per WAI-ARIA calendar pattern
const calendarGrid = establishedPage.getByRole("grid", {
name: /calendar/i,
});
await expect(calendarGrid).toBeVisible();
});
test("day cells have descriptive aria-labels", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Day buttons should have descriptive aria-labels including date and phase info
const dayButtons = establishedPage.locator("button[data-day]");
const hasDayButtons = await dayButtons
.first()
.isVisible()
.catch(() => false);
if (!hasDayButtons) {
// No day buttons with data-day attribute - test different selector
return;
}
// Get the first visible day button's aria-label
const firstDayButton = dayButtons.first();
const ariaLabel = await firstDayButton.getAttribute("aria-label");
// Aria-label should contain date information (month and year)
expect(ariaLabel).toMatch(
/january|february|march|april|may|june|july|august|september|october|november|december/i,
);
expect(ariaLabel).toMatch(/\d{4}/); // Should contain year
});
test("keyboard navigation with arrow keys works", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Focus on a day button in the calendar grid
const dayButtons = establishedPage.locator("button[data-day]");
const hasDayButtons = await dayButtons
.first()
.isVisible()
.catch(() => false);
if (!hasDayButtons) {
return;
}
// Click a day button to focus it
const calendarGrid = establishedPage.getByRole("grid", {
name: /calendar/i,
});
const hasGrid = await calendarGrid.isVisible().catch(() => false);
if (!hasGrid) {
return;
}
// Focus the grid and press Tab to focus first day
await calendarGrid.focus();
await establishedPage.keyboard.press("Tab");
// Get currently focused element
const focusedBefore = await establishedPage.evaluate(() => {
const el = document.activeElement;
return el ? el.getAttribute("data-day") : null;
});
// Press ArrowRight to move to next day
await establishedPage.keyboard.press("ArrowRight");
// Get new focused element
const focusedAfter = await establishedPage.evaluate(() => {
const el = document.activeElement;
return el ? el.getAttribute("data-day") : null;
});
// If both values exist, verify navigation occurred
if (focusedBefore && focusedAfter) {
expect(focusedAfter).not.toBe(focusedBefore);
}
});
test("navigation buttons have accessible labels", async ({
establishedPage,
}) => {
await establishedPage.goto("/calendar");
await establishedPage.waitForLoadState("networkidle");
// Previous and next month buttons should have aria-labels
const prevButton = establishedPage.getByRole("button", {
name: /previous month/i,
});
const nextButton = establishedPage.getByRole("button", {
name: /next month/i,
});
const hasPrev = await prevButton.isVisible().catch(() => false);
const hasNext = await nextButton.isVisible().catch(() => false);
// At least one navigation button should be accessible
expect(hasPrev || hasNext).toBe(true);
if (hasPrev) {
await expect(prevButton).toBeVisible();
}
if (hasNext) {
await expect(nextButton).toBeVisible();
}
});
});

375
e2e/cycle.spec.ts Normal file
View File

@@ -0,0 +1,375 @@
// ABOUTME: E2E tests for cycle tracking functionality.
// ABOUTME: Tests cycle day display, phase transitions, and period logging.
import { expect, test } from "@playwright/test";
test.describe("cycle tracking", () => {
test.describe("cycle API", () => {
test("cycle/current endpoint requires authentication", async ({
request,
}) => {
const response = await request.get("/api/cycle/current");
// Should return 401 when not authenticated
expect(response.status()).toBe(401);
});
});
test.describe("cycle display", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
});
test("dashboard shows current cycle day", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Wait for loading to complete
await page
.waitForSelector('[aria-label="Loading cycle info"]', {
state: "detached",
timeout: 15000,
})
.catch(() => {});
// Look for cycle day text (e.g., "Day 12")
const dayText = page.getByText(/day \d+/i);
const hasDay = await dayText
.first()
.isVisible()
.catch(() => false);
// If no cycle data set, should show onboarding
const onboarding = page.getByText(/set.*period|log.*period/i);
const hasOnboarding = await onboarding
.first()
.isVisible()
.catch(() => false);
expect(hasDay || hasOnboarding).toBe(true);
});
test("dashboard shows current phase name", async ({ page }) => {
await page.waitForLoadState("networkidle");
await page
.waitForSelector('[aria-label="Loading cycle info"]', {
state: "detached",
timeout: 15000,
})
.catch(() => {});
// Look for phase name (one of the five phases)
const phases = [
"MENSTRUAL",
"FOLLICULAR",
"OVULATION",
"EARLY LUTEAL",
"LATE LUTEAL",
];
const phaseTexts = phases.map((p) => page.getByText(new RegExp(p, "i")));
let hasPhase = false;
for (const phaseText of phaseTexts) {
if (
await phaseText
.first()
.isVisible()
.catch(() => false)
) {
hasPhase = true;
break;
}
}
// If no cycle data, onboarding should be visible
const onboarding = page.getByText(/set.*period|log.*period/i);
const hasOnboarding = await onboarding
.first()
.isVisible()
.catch(() => false);
expect(hasPhase || hasOnboarding).toBe(true);
});
test("plan page shows all 5 phases", async ({ page }) => {
await page.goto("/plan");
await page.waitForLoadState("networkidle");
await page
.waitForSelector('[aria-label="Loading"]', {
state: "detached",
timeout: 15000,
})
.catch(() => {});
// Phase overview section should show all phases
const phaseOverview = page.getByRole("heading", {
name: "Phase Overview",
});
const hasOverview = await phaseOverview.isVisible().catch(() => false);
if (hasOverview) {
await expect(page.getByTestId("phase-MENSTRUAL")).toBeVisible();
await expect(page.getByTestId("phase-FOLLICULAR")).toBeVisible();
await expect(page.getByTestId("phase-OVULATION")).toBeVisible();
await expect(page.getByTestId("phase-EARLY_LUTEAL")).toBeVisible();
await expect(page.getByTestId("phase-LATE_LUTEAL")).toBeVisible();
}
});
test("phase cards show weekly intensity limits", async ({ page }) => {
await page.goto("/plan");
await page.waitForLoadState("networkidle");
await page
.waitForSelector('[aria-label="Loading"]', {
state: "detached",
timeout: 15000,
})
.catch(() => {});
const phaseOverview = page.getByRole("heading", {
name: "Phase Overview",
});
const hasOverview = await phaseOverview.isVisible().catch(() => false);
if (hasOverview) {
// Each phase card should show min/week limit - use testid for specificity
await expect(
page.getByTestId("phase-MENSTRUAL").getByText("30 min/week"),
).toBeVisible();
await expect(
page.getByTestId("phase-FOLLICULAR").getByText("120 min/week"),
).toBeVisible();
await expect(
page.getByTestId("phase-OVULATION").getByText("80 min/week"),
).toBeVisible();
await expect(
page.getByTestId("phase-EARLY_LUTEAL").getByText("100 min/week"),
).toBeVisible();
await expect(
page.getByTestId("phase-LATE_LUTEAL").getByText("50 min/week"),
).toBeVisible();
}
});
});
test.describe("cycle settings", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
});
test("settings page allows cycle length configuration", async ({
page,
}) => {
await page.goto("/settings");
await page.waitForLoadState("networkidle");
// Look for cycle length input
const cycleLengthInput = page.getByLabel(/cycle length/i);
const hasCycleLength = await cycleLengthInput
.isVisible()
.catch(() => false);
if (hasCycleLength) {
// Should be a number input
const inputType = await cycleLengthInput.getAttribute("type");
expect(inputType).toBe("number");
// Should have valid range (21-45 per spec)
const min = await cycleLengthInput.getAttribute("min");
const max = await cycleLengthInput.getAttribute("max");
expect(min).toBe("21");
expect(max).toBe("45");
}
});
test("settings page shows current cycle length value", async ({ page }) => {
await page.goto("/settings");
await page.waitForLoadState("networkidle");
const cycleLengthInput = page.getByLabel(/cycle length/i);
const hasCycleLength = await cycleLengthInput
.isVisible()
.catch(() => false);
if (hasCycleLength) {
// Should have a value between 21-45
const value = await cycleLengthInput.inputValue();
const numValue = Number.parseInt(value, 10);
expect(numValue).toBeGreaterThanOrEqual(21);
expect(numValue).toBeLessThanOrEqual(45);
}
});
});
test.describe("period logging", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
});
test("period history page is accessible", async ({ page }) => {
await page.goto("/period-history");
await page.waitForLoadState("networkidle");
// Should show Period History heading
const heading = page.getByRole("heading", { name: /period history/i });
await expect(heading).toBeVisible();
});
test("period history shows table or empty state", async ({ page }) => {
await page.goto("/period-history");
await page.waitForLoadState("networkidle");
// Should show either period data table or empty state
const table = page.locator("table");
const emptyState = page.getByText(/no periods|no history/i);
const hasTable = await table.isVisible().catch(() => false);
const hasEmpty = await emptyState
.first()
.isVisible()
.catch(() => false);
expect(hasTable || hasEmpty).toBe(true);
});
test("period history has link back to dashboard", async ({ page }) => {
await page.goto("/period-history");
await page.waitForLoadState("networkidle");
const dashboardLink = page.getByRole("link", { name: /dashboard/i });
const hasLink = await dashboardLink.isVisible().catch(() => false);
// May have different link text
const backLink = page.getByRole("link", { name: /back/i });
const hasBackLink = await backLink.isVisible().catch(() => false);
expect(hasLink || hasBackLink).toBe(true);
});
});
test.describe("calendar integration", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
});
test("calendar page shows phase colors in legend", async ({ page }) => {
await page.goto("/calendar");
await page.waitForLoadState("networkidle");
// Calendar should show phase legend with all phases
const legend = page.getByText(/menstrual|follicular|ovulation|luteal/i);
const hasLegend = await legend
.first()
.isVisible()
.catch(() => false);
if (hasLegend) {
// Check for phase emojis in legend per spec
const menstrualEmoji = page.getByText(/🩸.*menstrual/i);
const follicularEmoji = page.getByText(/🌱.*follicular/i);
const hasMenstrual = await menstrualEmoji
.isVisible()
.catch(() => false);
const hasFollicular = await follicularEmoji
.isVisible()
.catch(() => false);
expect(hasMenstrual || hasFollicular).toBe(true);
}
});
});
});

90
e2e/dark-mode.spec.ts Normal file
View File

@@ -0,0 +1,90 @@
// ABOUTME: E2E tests for dark mode system preference detection.
// ABOUTME: Verifies app respects OS-level dark mode setting via prefers-color-scheme.
import { expect, test } from "@playwright/test";
// Helper to parse background color and determine if it's light or dark
function isLightBackground(colorValue: string): boolean {
// Try rgb/rgba format: rgb(255, 255, 255) or rgba(255, 255, 255, 1)
const rgbMatch = colorValue.match(
/rgba?\s*\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/,
);
if (rgbMatch) {
const r = parseFloat(rgbMatch[1]);
const g = parseFloat(rgbMatch[2]);
const b = parseFloat(rgbMatch[3]);
// Calculate relative luminance (simplified)
const luminance = (r + g + b) / 3;
return luminance > 128;
}
// Try oklch format: oklch(1 0 0) where first value is lightness
const oklchMatch = colorValue.match(/oklch\s*\(\s*([\d.]+)/);
if (oklchMatch) {
const lightness = parseFloat(oklchMatch[1]);
return lightness > 0.5;
}
// Try color() format with oklch: color(oklch 1 0 0)
const colorOklchMatch = colorValue.match(/color\s*\(\s*oklch\s+([\d.]+)/);
if (colorOklchMatch) {
const lightness = parseFloat(colorOklchMatch[1]);
return lightness > 0.5;
}
// Try lab() format: lab(100 0 0) where first value is lightness 0-100
const labMatch = colorValue.match(/lab\s*\(\s*([\d.]+)/);
if (labMatch) {
const lightness = parseFloat(labMatch[1]);
return lightness > 50;
}
// Default: couldn't parse, assume we need to fail the test
return false;
}
test.describe("dark mode", () => {
test("applies light mode styling when system prefers light", async ({
page,
}) => {
// Set system preference to light mode
await page.emulateMedia({ colorScheme: "light" });
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");
// Get computed background color of the body
const bodyBgColor = await page.evaluate(() => {
return window.getComputedStyle(document.body).backgroundColor;
});
// In light mode, background should be white/light (oklch(1 0 0) = white)
const isLight = isLightBackground(bodyBgColor);
expect(
isLight,
`Expected light background in light mode, got: ${bodyBgColor}`,
).toBe(true);
});
test("applies dark mode styling when system prefers dark", async ({
page,
}) => {
// Set system preference to dark mode
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");
// Get computed background color of the body
const bodyBgColor = await page.evaluate(() => {
return window.getComputedStyle(document.body).backgroundColor;
});
// In dark mode, background should be dark (oklch(0.145 0 0) = near black)
const isLight = isLightBackground(bodyBgColor);
expect(
isLight,
`Expected dark background in dark mode, got: ${bodyBgColor}`,
).toBe(false);
});
});

1180
e2e/dashboard.spec.ts Normal file

File diff suppressed because it is too large Load Diff

386
e2e/decision-engine.spec.ts Normal file
View File

@@ -0,0 +1,386 @@
// ABOUTME: E2E tests for the decision engine integration through the dashboard UI.
// ABOUTME: Tests decision display, status colors, and override interactions.
import { expect, test } from "@playwright/test";
test.describe("decision engine", () => {
test.describe("decision display", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard
await page.waitForURL("/", { timeout: 10000 });
});
test("decision card shows one of the valid statuses", async ({ page }) => {
// Wait for dashboard to fully load (loading states to disappear)
await page.waitForLoadState("networkidle");
// Wait for loading indicators to disappear (skeleton loading states)
await page
.waitForSelector('[aria-label="Loading decision"]', {
state: "detached",
timeout: 15000,
})
.catch(() => {
// May not have loading indicator if already loaded
});
// Look for any of the valid decision statuses
const validStatuses = ["REST", "GENTLE", "LIGHT", "REDUCED", "TRAIN"];
// Wait for decision card or status text to appear
const decisionCard = page.locator('[data-testid="decision-card"]');
const statusText = page.getByText(/^(REST|GENTLE|LIGHT|REDUCED|TRAIN)$/);
const onboarding = page.getByText(/connect garmin|set.*period/i);
// Wait for one of these to be visible
await Promise.race([
decisionCard.waitFor({ timeout: 10000 }),
statusText.first().waitFor({ timeout: 10000 }),
onboarding.first().waitFor({ timeout: 10000 }),
]).catch(() => {
// One of them should appear
});
const hasDecisionCard = await decisionCard.isVisible().catch(() => false);
if (hasDecisionCard) {
const cardText = await decisionCard.textContent();
const hasValidStatus = validStatuses.some((status) =>
cardText?.includes(status),
);
expect(hasValidStatus).toBe(true);
} else {
// Check for any status text on the page (fallback)
const hasStatus = await statusText
.first()
.isVisible()
.catch(() => false);
// Either has decision card or shows onboarding (valid states)
const hasOnboarding = await onboarding
.first()
.isVisible()
.catch(() => false);
expect(hasStatus || hasOnboarding).toBe(true);
}
});
test("decision displays a reason", async ({ page }) => {
await page.waitForLoadState("networkidle");
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasDecisionCard = await decisionCard.isVisible().catch(() => false);
if (hasDecisionCard) {
// Decision card should contain some explanatory text (the reason)
const cardText = await decisionCard.textContent();
// Reason should be longer than just the status word
expect(cardText && cardText.length > 10).toBe(true);
}
});
test("REST status displays with appropriate styling", async ({ page }) => {
await page.waitForLoadState("networkidle");
// If REST is displayed, it should have red/danger styling
const restText = page.getByText("REST");
const hasRest = await restText
.first()
.isVisible()
.catch(() => false);
if (hasRest) {
// REST should be in a container with red background or text
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
// Check that card has some styling (we can't easily check colors in Playwright)
const cardClasses = await decisionCard.getAttribute("class");
expect(cardClasses).toBeTruthy();
}
}
});
test("TRAIN status displays with appropriate styling", async ({ page }) => {
await page.waitForLoadState("networkidle");
// If TRAIN is displayed, it should have green/success styling
const trainText = page.getByText("TRAIN");
const hasTrain = await trainText
.first()
.isVisible()
.catch(() => false);
if (hasTrain) {
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
const cardClasses = await decisionCard.getAttribute("class");
expect(cardClasses).toBeTruthy();
}
}
});
});
test.describe("override integration", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
});
test("flare override forces REST decision", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Wait for OVERRIDES section to appear
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
const hasOverrides = await overridesHeading
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!hasOverrides) {
test.skip();
return;
}
// Find flare mode checkbox
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
if (!hasFlare) {
test.skip();
return;
}
// Enable flare override
const wasChecked = await flareCheckbox.isChecked();
if (!wasChecked) {
await flareCheckbox.click();
await page.waitForTimeout(500);
}
// Decision should now show REST
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
const cardText = await decisionCard.textContent();
expect(cardText).toContain("REST");
}
// Clean up - disable flare override
if (!wasChecked) {
await flareCheckbox.click();
}
});
test("sleep override forces GENTLE decision", async ({ page }) => {
await page.waitForLoadState("networkidle");
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
const hasOverrides = await overridesHeading
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!hasOverrides) {
test.skip();
return;
}
// Find poor sleep checkbox
const sleepCheckbox = page.getByRole("checkbox", { name: /poor sleep/i });
const hasSleep = await sleepCheckbox.isVisible().catch(() => false);
if (!hasSleep) {
test.skip();
return;
}
// Enable sleep override
const wasChecked = await sleepCheckbox.isChecked();
if (!wasChecked) {
await sleepCheckbox.click();
await page.waitForTimeout(500);
}
// Decision should now show GENTLE (unless flare/stress are also active)
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
const cardText = await decisionCard.textContent();
// Sleep forces GENTLE, but flare/stress would override to REST
expect(cardText?.includes("GENTLE") || cardText?.includes("REST")).toBe(
true,
);
}
// Clean up
if (!wasChecked) {
await sleepCheckbox.click();
}
});
test("multiple overrides respect priority", async ({ page }) => {
await page.waitForLoadState("networkidle");
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
const hasOverrides = await overridesHeading
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!hasOverrides) {
test.skip();
return;
}
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
const sleepCheckbox = page.getByRole("checkbox", { name: /poor sleep/i });
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
const hasSleep = await sleepCheckbox.isVisible().catch(() => false);
if (!hasFlare || !hasSleep) {
test.skip();
return;
}
// Record initial states
const flareWasChecked = await flareCheckbox.isChecked();
const sleepWasChecked = await sleepCheckbox.isChecked();
// Enable both flare (REST) and sleep (GENTLE)
if (!flareWasChecked) await flareCheckbox.click();
if (!sleepWasChecked) await sleepCheckbox.click();
await page.waitForTimeout(500);
// Flare has higher priority, so should show REST
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (hasCard) {
const cardText = await decisionCard.textContent();
expect(cardText).toContain("REST"); // flare > sleep
}
// Clean up
if (!flareWasChecked) await flareCheckbox.click();
if (!sleepWasChecked) await sleepCheckbox.click();
});
test("disabling override restores original decision", async ({ page }) => {
await page.waitForLoadState("networkidle");
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
const hasOverrides = await overridesHeading
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!hasOverrides) {
test.skip();
return;
}
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
if (!hasFlare) {
test.skip();
return;
}
// Record initial decision
const decisionCard = page.locator('[data-testid="decision-card"]');
const hasCard = await decisionCard.isVisible().catch(() => false);
if (!hasCard) {
test.skip();
return;
}
const initialDecision = await decisionCard.textContent();
const flareWasChecked = await flareCheckbox.isChecked();
// Toggle flare on (if not already)
if (!flareWasChecked) {
// Wait for both API calls when clicking the checkbox
await Promise.all([
page.waitForResponse("**/api/overrides"),
page.waitForResponse("**/api/today"),
flareCheckbox.click(),
]);
// Should now be REST (flare mode forces rest)
const restDecision = await decisionCard.textContent();
expect(restDecision).toContain("REST");
// Toggle flare off and wait for API calls
await Promise.all([
page.waitForResponse("**/api/overrides"),
page.waitForResponse("**/api/today"),
flareCheckbox.click(),
]);
// Should return to original (or close to it)
const restoredDecision = await decisionCard.textContent();
// The exact decision may vary based on time, but it should change from REST
expect(
restoredDecision !== restDecision ||
initialDecision?.includes("REST"),
).toBe(true);
}
});
});
});

86
e2e/fixtures.ts Normal file
View File

@@ -0,0 +1,86 @@
// ABOUTME: Playwright test fixtures for different user states.
// ABOUTME: Provides pre-authenticated pages for onboarding, established, calendar, and garmin users.
import { test as base, type Page } from "@playwright/test";
import { TEST_USERS, type TestUserPreset } from "./pocketbase-harness";
/**
* Logs in a user via the email/password form.
* Throws if the email form is not visible (OIDC-only mode).
*/
async function loginUser(
page: Page,
email: string,
password: string,
): Promise<void> {
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
throw new Error(
"Email/password form not visible - app may be in OIDC-only mode",
);
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for successful redirect to dashboard
await page.waitForURL("/", { timeout: 15000 });
}
/**
* Creates a fixture for a specific user preset.
*/
function createUserFixture(preset: TestUserPreset) {
return async (
{ page }: { page: Page },
use: (page: Page) => Promise<void>,
) => {
const user = TEST_USERS[preset];
await loginUser(page, user.email, user.password);
await use(page);
};
}
/**
* Extended test fixtures providing pre-authenticated pages for each user type.
*
* Usage:
* import { test, expect } from './fixtures';
*
* test('onboarding user sees set date button', async ({ onboardingPage }) => {
* await onboardingPage.goto('/');
* // User has no period data, will see onboarding UI
* });
*
* test('established user sees dashboard', async ({ establishedPage }) => {
* await establishedPage.goto('/');
* // User has period data from 14 days ago
* });
*/
type TestFixtures = {
/** User with no period data - sees onboarding UI */
onboardingPage: Page;
/** User with period data (14 days ago) - sees normal dashboard */
establishedPage: Page;
/** User with period data and calendar token - can copy/regenerate URL */
calendarPage: Page;
/** User with valid Garmin tokens (90 days until expiry) */
garminPage: Page;
/** User with expired Garmin tokens */
garminExpiredPage: Page;
};
export const test = base.extend<TestFixtures>({
onboardingPage: createUserFixture("onboarding"),
establishedPage: createUserFixture("established"),
calendarPage: createUserFixture("calendar"),
garminPage: createUserFixture("garmin"),
garminExpiredPage: createUserFixture("garminExpired"),
});
export { expect } from "@playwright/test";

551
e2e/garmin.spec.ts Normal file
View File

@@ -0,0 +1,551 @@
// ABOUTME: E2E tests for Garmin token connection flow.
// ABOUTME: Tests saving tokens, verifying connection status, and disconnection.
import { expect, test } from "@playwright/test";
test.describe("garmin connection", () => {
test.describe("unauthenticated", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/settings/garmin");
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("authenticated", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard, then navigate to garmin settings
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings/garmin");
await page.waitForLoadState("networkidle");
// Clean up: Disconnect if already connected to ensure clean state
const disconnectButton = page.getByRole("button", {
name: /disconnect/i,
});
const isConnected = await disconnectButton.isVisible().catch(() => false);
if (isConnected) {
await disconnectButton.click();
await page.waitForTimeout(1000);
// Wait for disconnect to complete
await page.waitForLoadState("networkidle");
}
});
test("shows not connected initially for new user", async ({ page }) => {
// Verify initial state shows "Not Connected"
const notConnectedText = page.getByText(/not connected/i);
await expect(notConnectedText).toBeVisible();
// Token input should be visible
const tokenInput = page.locator("#tokenInput");
await expect(tokenInput).toBeVisible();
// Save button should be visible
const saveButton = page.getByRole("button", { name: /save tokens/i });
await expect(saveButton).toBeVisible();
});
test("can save valid tokens and become connected", async ({ page }) => {
// Verify initial state shows "Not Connected"
await expect(page.getByText(/not connected/i)).toBeVisible();
// Create valid token JSON - expires 90 days from now
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const validTokens = JSON.stringify({
oauth1: { token: "test-oauth1-token", secret: "test-oauth1-secret" },
oauth2: { access_token: "test-oauth2-access-token" },
expires_at: expiresAt.toISOString(),
});
// Enter tokens in textarea
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
// Click Save Tokens button
const saveButton = page.getByRole("button", { name: /save tokens/i });
await saveButton.click();
// Wait for success toast - sonner renders toasts with role="status"
const successToast = page.getByText(/tokens saved successfully/i);
await expect(successToast).toBeVisible({ timeout: 10000 });
// Verify status changes to "Connected" with green indicator
const connectedText = page.getByText("Connected", { exact: true });
await expect(connectedText).toBeVisible({ timeout: 10000 });
// Green indicator should be visible (the circular badge)
const greenIndicator = page.locator(".bg-green-500").first();
await expect(greenIndicator).toBeVisible();
// Disconnect button should now be visible
const disconnectButton = page.getByRole("button", {
name: /disconnect/i,
});
await expect(disconnectButton).toBeVisible();
// Token input should be hidden when connected
await expect(tokenInput).not.toBeVisible();
});
test("shows error toast for invalid JSON", async ({ page }) => {
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill("not valid json");
const saveButton = page.getByRole("button", { name: /save tokens/i });
await saveButton.click();
// Error toast should appear
const errorToast = page.getByText(/invalid json/i);
await expect(errorToast).toBeVisible({ timeout: 5000 });
});
test("shows error toast for missing required fields", async ({ page }) => {
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill('{"oauth1": {}}'); // Missing oauth2 and expires_at
const saveButton = page.getByRole("button", { name: /save tokens/i });
await saveButton.click();
// Error toast should appear for missing oauth2
const errorToast = page.getByText(/oauth2 is required/i);
await expect(errorToast).toBeVisible({ timeout: 5000 });
});
test("can disconnect after connecting", async ({ page }) => {
// First connect
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const validTokens = JSON.stringify({
oauth1: { token: "test-token", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Click disconnect
const disconnectButton = page.getByRole("button", {
name: /disconnect/i,
});
await disconnectButton.click();
// Wait for disconnect success toast
const successToast = page.getByText(/garmin disconnected/i);
await expect(successToast).toBeVisible({ timeout: 10000 });
// Verify status returns to "Not Connected"
await expect(page.getByText(/not connected/i)).toBeVisible({
timeout: 10000,
});
// Token input should be visible again
await expect(page.locator("#tokenInput")).toBeVisible();
});
test("shows days until expiry when connected", async ({ page }) => {
// Connect with tokens expiring in 45 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 45);
const validTokens = JSON.stringify({
oauth1: { token: "test-token", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Should show days until expiry (approximately 45 days)
const expiryText = page.getByText(/\d+ days/i);
await expect(expiryText).toBeVisible();
});
test("shows yellow warning banner when token expires in 10 days", async ({
page,
}) => {
// Connect with tokens expiring in 10 days (warning level: 8-14 days)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 10);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-warning", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Should show warning banner with yellow styling
const warningBanner = page.getByTestId("expiry-warning");
await expect(warningBanner).toBeVisible();
await expect(warningBanner).toContainText("Token expiring soon");
// Verify yellow warning styling (not red critical)
await expect(warningBanner).toHaveClass(/bg-yellow/);
});
test("shows red critical banner when token expires in 5 days", async ({
page,
}) => {
// Connect with tokens expiring in 5 days (critical level: <= 7 days)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 5);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-critical", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Should show critical banner with red styling
const warningBanner = page.getByTestId("expiry-warning");
await expect(warningBanner).toBeVisible();
await expect(warningBanner).toContainText("Token expires soon!");
// Verify red critical styling
await expect(warningBanner).toHaveClass(/bg-red/);
});
test("shows expired state with token input when tokens have expired", async ({
page,
}) => {
// Connect with tokens that expire yesterday (already expired)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() - 1);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-expired", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for save to complete
await page.waitForTimeout(1000);
// Should show expired state
const expiredText = page.getByText("Token Expired");
await expect(expiredText).toBeVisible({ timeout: 10000 });
// Token input should be visible to allow re-entry
await expect(page.locator("#tokenInput")).toBeVisible();
// Red indicator should be visible
const redIndicator = page.locator(".bg-red-500").first();
await expect(redIndicator).toBeVisible();
});
test("connection persists after page reload", async ({ page }) => {
// First connect with valid tokens
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 60);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-persist", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Should still show connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Green indicator should still be visible
const greenIndicator = page.locator(".bg-green-500").first();
await expect(greenIndicator).toBeVisible();
});
test("can reconnect after disconnecting", async ({ page }) => {
// First connect
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-reconnect", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Disconnect
await page.getByRole("button", { name: /disconnect/i }).click();
await expect(page.getByText(/not connected/i)).toBeVisible({
timeout: 10000,
});
// Reconnect with new tokens
const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 90);
const newTokens = JSON.stringify({
oauth1: {
token: "test-token-reconnect-new",
secret: "test-secret-new",
},
oauth2: { access_token: "test-access-token-new" },
expires_at: newExpiresAt.toISOString(),
});
const newTokenInput = page.locator("#tokenInput");
await newTokenInput.fill(newTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for reconnection
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Green indicator should be visible
const greenIndicator = page.locator(".bg-green-500").first();
await expect(greenIndicator).toBeVisible();
});
test("shows error toast when network fails during token save", async ({
page,
}) => {
// Intercept the POST request and simulate network failure
await page.route("**/api/garmin/tokens", (route) => {
if (route.request().method() === "POST") {
route.fulfill({
status: 500,
body: JSON.stringify({ error: "Internal server error" }),
});
} else {
route.continue();
}
});
// Enter valid tokens
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-network", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Error toast should appear
const errorToast = page.getByText(/internal server error/i);
await expect(errorToast).toBeVisible({ timeout: 5000 });
// Token input should still be visible for retry (this is the key behavior)
await expect(tokenInput).toBeVisible();
// Should NOT show success - either "Not Connected" or "Token Expired" state
// (depends on prior test state), but definitely not "Connected" without expiry
const connectedWithoutExpiry =
(await page.getByText("Connected", { exact: true }).isVisible()) &&
!(await page.getByText(/token expired/i).isVisible());
expect(connectedWithoutExpiry).toBe(false);
});
test("shows error toast when network fails during disconnect", async ({
page,
}) => {
// First connect successfully
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-disconnect-error", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
await page.getByRole("button", { name: /save tokens/i }).click();
// Wait for connected state
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
timeout: 10000,
});
// Now intercept DELETE request to simulate network failure
await page.route("**/api/garmin/tokens", (route) => {
if (route.request().method() === "DELETE") {
route.fulfill({
status: 500,
body: JSON.stringify({ error: "Failed to disconnect" }),
});
} else {
route.continue();
}
});
// Click disconnect
await page.getByRole("button", { name: /disconnect/i }).click();
// Error toast should appear
const errorToast = page.getByText(/failed to disconnect/i);
await expect(errorToast).toBeVisible({ timeout: 5000 });
// Should still show connected state (disconnect failed)
await expect(page.getByText("Connected", { exact: true })).toBeVisible();
});
test("shows error state when status fetch fails", async ({ page }) => {
// Intercept status fetch to simulate network failure
await page.route("**/api/garmin/status", (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: "Service unavailable" }),
});
});
// Navigate to garmin settings (need to re-navigate to trigger fresh fetch)
await page.goto("/settings/garmin");
await page.waitForLoadState("networkidle");
// Error alert should be visible (use specific text to avoid matching route announcer)
const errorAlert = page.getByText("Service unavailable");
await expect(errorAlert).toBeVisible({ timeout: 10000 });
// Error toast should also appear
const errorToast = page.getByText(/unable to fetch data/i);
await expect(errorToast).toBeVisible({ timeout: 5000 });
});
test("can retry and succeed after network failure", async ({ page }) => {
let requestCount = 0;
// First request fails, subsequent requests succeed
await page.route("**/api/garmin/tokens", (route) => {
if (route.request().method() === "POST") {
requestCount++;
if (requestCount === 1) {
// First attempt fails
route.fulfill({
status: 500,
body: JSON.stringify({ error: "Temporary failure" }),
});
} else {
// Subsequent attempts succeed
route.fulfill({
status: 200,
body: JSON.stringify({ success: true }),
});
}
} else {
route.continue();
}
});
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const validTokens = JSON.stringify({
oauth1: { token: "test-token-retry", secret: "test-secret" },
oauth2: { access_token: "test-access-token" },
expires_at: expiresAt.toISOString(),
});
const tokenInput = page.locator("#tokenInput");
await tokenInput.fill(validTokens);
// First attempt - should fail
await page.getByRole("button", { name: /save tokens/i }).click();
// Error toast should appear
const errorToast = page.getByText(/temporary failure/i);
await expect(errorToast).toBeVisible({ timeout: 5000 });
// Wait for toast to disappear or proceed with retry
await page.waitForTimeout(1000);
// Retry - should succeed now
await page.getByRole("button", { name: /save tokens/i }).click();
// Success toast should appear
const successToast = page.getByText(/tokens saved successfully/i);
await expect(successToast).toBeVisible({ timeout: 10000 });
});
});
});

50
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,50 @@
// ABOUTME: Playwright global setup - starts PocketBase and sets test environment variables.
// ABOUTME: Runs before all e2e tests to provide a fresh database with test data.
import * as fs from "node:fs";
import * as path from "node:path";
import { DEFAULT_CONFIG, start, TEST_USERS } from "./pocketbase-harness";
const STATE_FILE = path.join(__dirname, ".harness-state.json");
export default async function globalSetup(): Promise<void> {
console.log("Starting PocketBase for e2e tests...");
const state = await start(DEFAULT_CONFIG);
// Save state for teardown
fs.writeFileSync(
STATE_FILE,
JSON.stringify({
dataDir: state.dataDir,
url: state.url,
pid: state.process.pid,
}),
);
// Set environment variables for the test process
process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url;
process.env.POCKETBASE_URL = state.url;
// Export credentials for each test user type
process.env.TEST_USER_ONBOARDING_EMAIL = TEST_USERS.onboarding.email;
process.env.TEST_USER_ONBOARDING_PASSWORD = TEST_USERS.onboarding.password;
process.env.TEST_USER_ESTABLISHED_EMAIL = TEST_USERS.established.email;
process.env.TEST_USER_ESTABLISHED_PASSWORD = TEST_USERS.established.password;
process.env.TEST_USER_CALENDAR_EMAIL = TEST_USERS.calendar.email;
process.env.TEST_USER_CALENDAR_PASSWORD = TEST_USERS.calendar.password;
process.env.TEST_USER_GARMIN_EMAIL = TEST_USERS.garmin.email;
process.env.TEST_USER_GARMIN_PASSWORD = TEST_USERS.garmin.password;
process.env.TEST_USER_GARMIN_EXPIRED_EMAIL = TEST_USERS.garminExpired.email;
process.env.TEST_USER_GARMIN_EXPIRED_PASSWORD =
TEST_USERS.garminExpired.password;
// Keep backward compatibility - default to established user
process.env.TEST_USER_EMAIL = TEST_USERS.established.email;
process.env.TEST_USER_PASSWORD = TEST_USERS.established.password;
console.log(`PocketBase running at ${state.url}`);
console.log("Test users created:");
for (const [preset, user] of Object.entries(TEST_USERS)) {
console.log(` ${preset}: ${user.email}`);
}
}

54
e2e/global-teardown.ts Normal file
View File

@@ -0,0 +1,54 @@
// ABOUTME: Playwright global teardown - stops PocketBase and cleans up temp data.
// ABOUTME: Runs after all e2e tests to ensure clean shutdown.
import * as fs from "node:fs";
import * as path from "node:path";
const STATE_FILE = path.join(__dirname, ".harness-state.json");
interface HarnessStateFile {
dataDir: string;
url: string;
pid: number;
}
export default async function globalTeardown(): Promise<void> {
console.log("Stopping PocketBase...");
// Read the saved state
if (!fs.existsSync(STATE_FILE)) {
console.log("No harness state file found, nothing to clean up.");
return;
}
const state: HarnessStateFile = JSON.parse(
fs.readFileSync(STATE_FILE, "utf-8"),
);
// Kill the PocketBase process
if (state.pid) {
try {
process.kill(state.pid, "SIGTERM");
// Wait for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
// Force kill if still running
try {
process.kill(state.pid, "SIGKILL");
} catch {
// Process already dead, which is fine
}
} catch {
// Process might already be dead
}
}
// Clean up the temporary data directory
if (state.dataDir && fs.existsSync(state.dataDir)) {
fs.rmSync(state.dataDir, { recursive: true, force: true });
console.log(`Cleaned up temp directory: ${state.dataDir}`);
}
// Remove the state file
fs.unlinkSync(STATE_FILE);
console.log("PocketBase stopped and cleaned up.");
}

49
e2e/health.spec.ts Normal file
View File

@@ -0,0 +1,49 @@
// ABOUTME: E2E tests for health and observability endpoints.
// ABOUTME: Tests health check endpoint response and performance.
import { expect, test } from "@playwright/test";
test.describe("health and observability", () => {
test.describe("health endpoint", () => {
test("health endpoint returns 200 when healthy", async ({ request }) => {
const response = await request.get("/api/health");
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.status).toBe("ok");
expect(body).toHaveProperty("timestamp");
expect(body).toHaveProperty("version");
});
test("health endpoint responds quickly", async ({ request }) => {
const startTime = Date.now();
const response = await request.get("/api/health");
const endTime = Date.now();
expect(response.status()).toBe(200);
// E2E includes network latency; allow 500ms for full round-trip
// (the handler itself executes in <100ms per spec)
expect(endTime - startTime).toBeLessThan(500);
});
});
test.describe("metrics endpoint", () => {
test("metrics endpoint is accessible and returns Prometheus format", async ({
request,
}) => {
const response = await request.get("/api/metrics");
expect(response.status()).toBe(200);
const contentType = response.headers()["content-type"];
expect(contentType).toContain("text/plain");
const body = await response.text();
// Prometheus format should contain HELP and TYPE comments
expect(body).toMatch(/^# HELP/m);
expect(body).toMatch(/^# TYPE/m);
// Should contain our custom metrics
expect(body).toContain("phaseflow_");
});
});
});

154
e2e/history.spec.ts Normal file
View File

@@ -0,0 +1,154 @@
// ABOUTME: E2E tests for the history page showing past training decisions.
// ABOUTME: Tests table display, pagination, date filtering, and empty states.
import { expect, test } from "@playwright/test";
test.describe("history page", () => {
test.describe("unauthenticated", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/history");
// Should redirect to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("authenticated", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard then navigate to history
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/history");
});
test("displays history page with title", async ({ page }) => {
// Check for history page title
const heading = page.getByRole("heading", { name: "History" });
await expect(heading).toBeVisible();
});
test("shows date filter controls", async ({ page }) => {
// Check for date filter inputs
const startDateInput = page.getByLabel(/start date/i);
const endDateInput = page.getByLabel(/end date/i);
await expect(startDateInput).toBeVisible();
await expect(endDateInput).toBeVisible();
// Check for Apply and Clear buttons
const applyButton = page.getByRole("button", { name: /apply/i });
const clearButton = page.getByRole("button", { name: /clear/i });
await expect(applyButton).toBeVisible();
await expect(clearButton).toBeVisible();
});
test("shows table with correct columns when data exists", async ({
page,
}) => {
// Wait for data to load
await page.waitForLoadState("networkidle");
// Check if there's data or empty state
const table = page.locator("table");
const emptyState = page.getByText(/no history found/i);
const hasTable = await table.isVisible().catch(() => false);
const hasEmptyState = await emptyState.isVisible().catch(() => false);
if (hasTable) {
// Verify table headers exist
const headers = page.locator("thead th");
await expect(headers).toHaveCount(6);
// Check for specific column headers
await expect(
page.getByRole("columnheader", { name: /date/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /day.*phase/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /decision/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /body battery/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /hrv/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /intensity/i }),
).toBeVisible();
} else if (hasEmptyState) {
// Empty state is valid when no history data
await expect(emptyState).toBeVisible();
}
});
test("shows empty state when no data", async ({ page }) => {
// This test verifies empty state UI is present when applicable
await page.waitForLoadState("networkidle");
const emptyState = page.getByText(/no history found/i);
const table = page.locator("table tbody tr");
const hasRows = await table
.first()
.isVisible()
.catch(() => false);
const hasEmptyState = await emptyState.isVisible().catch(() => false);
// Either has data rows OR shows empty state (both valid)
expect(hasRows || hasEmptyState).toBe(true);
});
test("has link back to dashboard", async ({ page }) => {
const dashboardLink = page.getByRole("link", {
name: /back to dashboard/i,
});
await expect(dashboardLink).toBeVisible();
// Click and verify navigation
await dashboardLink.click();
await expect(page).toHaveURL("/");
});
test("shows entry count", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Look for entries count text (e.g., "5 entries")
const entriesText = page.getByText(/\d+ entries/);
const hasEntriesText = await entriesText.isVisible().catch(() => false);
// May not be visible if no data, check for either count or empty state
const emptyState = page.getByText(/no history found/i);
const hasEmptyState = await emptyState.isVisible().catch(() => false);
expect(hasEntriesText || hasEmptyState).toBe(true);
});
});
});

223
e2e/mobile.spec.ts Normal file
View File

@@ -0,0 +1,223 @@
// ABOUTME: E2E tests for mobile viewport behavior and responsive design.
// ABOUTME: Tests that the dashboard displays correctly on mobile-sized screens.
import { expect, test } from "@playwright/test";
// Mobile viewport: iPhone SE/8 (375x667)
const MOBILE_VIEWPORT = { width: 375, height: 667 };
test.describe("mobile viewport", () => {
test.describe("unauthenticated", () => {
test("login page renders correctly on mobile viewport", async ({
page,
}) => {
await page.setViewportSize(MOBILE_VIEWPORT);
await page.goto("/login");
// Login form should be visible - heading is "PhaseFlow"
const heading = page.getByRole("heading", { name: /phaseflow/i });
await expect(heading).toBeVisible();
// Email input should be visible
const emailInput = page.getByLabel(/email/i);
await expect(emailInput).toBeVisible();
// Viewport width should be mobile
const viewportSize = page.viewportSize();
expect(viewportSize?.width).toBe(375);
});
});
test.describe("authenticated", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Set mobile viewport before navigating
await page.setViewportSize(MOBILE_VIEWPORT);
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard
await page.waitForURL("/", { timeout: 10000 });
await page.waitForLoadState("networkidle");
});
test("dashboard displays correctly on mobile viewport", async ({
page,
}) => {
// Verify viewport is mobile size
const viewportSize = page.viewportSize();
expect(viewportSize?.width).toBe(375);
// Header should be visible
const header = page.getByRole("heading", { name: /phaseflow/i });
await expect(header).toBeVisible();
// Settings link should be visible
const settingsLink = page.getByRole("link", { name: /settings/i });
await expect(settingsLink).toBeVisible();
// Decision card should be visible
const decisionCard = page.locator('[data-testid="decision-card"]');
await expect(decisionCard).toBeVisible();
// Data panel should be visible
const dataPanel = page.getByText(/body battery|hrv/i).first();
await expect(dataPanel).toBeVisible();
});
test("dashboard uses single-column layout on mobile", async ({ page }) => {
// On mobile (<768px), the Data Panel and Nutrition Panel should stack vertically
// This is controlled by the md:grid-cols-2 class
// Find the grid container that holds Data Panel and Nutrition Panel
// It should NOT have two-column grid on mobile (should be single column)
const gridContainer = page.locator(".grid.gap-4").first();
const containerExists = await gridContainer
.isVisible()
.catch(() => false);
if (containerExists) {
// Get the computed grid template columns
const gridTemplateColumns = await gridContainer.evaluate((el) => {
return window.getComputedStyle(el).gridTemplateColumns;
});
// On mobile (375px < 768px), should NOT be two columns
// Single column would be "none" or a single value like "1fr"
// Two columns would be something like "1fr 1fr" or "repeat(2, 1fr)"
const isTwoColumn = gridTemplateColumns.includes(" ");
expect(isTwoColumn).toBe(false);
}
});
test("navigation elements are interactive on mobile", async ({ page }) => {
// Settings link should be clickable
const settingsLink = page.getByRole("link", { name: /settings/i });
await expect(settingsLink).toBeVisible();
// Click settings and verify navigation
await settingsLink.click();
await expect(page).toHaveURL(/\/settings/);
// Back button should work to return to dashboard
const backLink = page.getByRole("link", { name: /back|dashboard|home/i });
await expect(backLink).toBeVisible();
await backLink.click();
await expect(page).toHaveURL("/");
});
test("calendar page renders correctly on mobile viewport", async ({
page,
}) => {
// Navigate to calendar
await page.goto("/calendar");
await page.waitForLoadState("networkidle");
// Verify viewport is still mobile size
const viewportSize = page.viewportSize();
expect(viewportSize?.width).toBe(375);
// Calendar page title heading should be visible (exact match to avoid "Calendar Subscription")
const heading = page.getByRole("heading", {
name: "Calendar",
exact: true,
});
await expect(heading).toBeVisible({ timeout: 10000 });
// Calendar grid should be visible
const calendarGrid = page
.getByRole("grid")
.or(page.locator('[data-testid="month-view"]'));
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Month navigation should be visible
const monthYear = page.getByText(
/january|february|march|april|may|june|july|august|september|october|november|december/i,
);
await expect(monthYear.first()).toBeVisible();
});
test("calendar day cells are touch-friendly on mobile", async ({
page,
}) => {
// Navigate to calendar
await page.goto("/calendar");
await page.waitForLoadState("networkidle");
// Get day buttons
const dayButtons = page.locator("button[data-day]");
const hasDayButtons = await dayButtons
.first()
.isVisible()
.catch(() => false);
if (!hasDayButtons) {
test.skip();
return;
}
// Check that day buttons have reasonable tap target size
// Per dashboard spec: "Touch-friendly 44x44px minimum tap targets"
const firstDayButton = dayButtons.first();
const boundingBox = await firstDayButton.boundingBox();
if (boundingBox) {
// Width and height should be at least 32px for touch targets
// (some flexibility since mobile displays may compress slightly)
expect(boundingBox.width).toBeGreaterThanOrEqual(32);
expect(boundingBox.height).toBeGreaterThanOrEqual(32);
}
});
test("calendar navigation works on mobile", async ({ page }) => {
// Navigate to calendar
await page.goto("/calendar");
await page.waitForLoadState("networkidle");
// Find and click next month button
const nextButton = page.getByRole("button", {
name: /next|→|forward/i,
});
const hasNext = await nextButton.isVisible().catch(() => false);
if (hasNext) {
// Click next
await nextButton.click();
await page.waitForTimeout(500);
// Calendar should still be functional after navigation
const calendarGrid = page
.getByRole("grid")
.or(page.locator('[data-testid="month-view"]'));
await expect(calendarGrid).toBeVisible();
// Month display should still be visible
const monthYear = page.getByText(
/january|february|march|april|may|june|july|august|september|october|november|december/i,
);
await expect(monthYear.first()).toBeVisible();
}
});
});
});

487
e2e/period-logging.spec.ts Normal file
View File

@@ -0,0 +1,487 @@
// ABOUTME: E2E tests for period logging functionality.
// ABOUTME: Tests period start logging, date selection, and period history.
import { test as baseTest } from "@playwright/test";
import { expect, test } from "./fixtures";
baseTest.describe("period logging", () => {
baseTest.describe("unauthenticated", () => {
baseTest(
"period history page redirects to login when not authenticated",
async ({ page }) => {
await page.goto("/period-history");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
},
);
});
baseTest.describe("API endpoints", () => {
baseTest("period history API requires authentication", async ({ page }) => {
const response = await page.request.get("/api/period-history");
// Should return 401 Unauthorized
expect(response.status()).toBe(401);
});
baseTest("period log API requires authentication", async ({ page }) => {
const response = await page.request.post("/api/cycle/period", {
data: { startDate: "2024-01-15" },
});
// Should return 401 Unauthorized
expect(response.status()).toBe(401);
});
});
});
test.describe("period logging authenticated", () => {
test("dashboard shows period date prompt for new users", async ({
onboardingPage,
}) => {
await onboardingPage.goto("/");
// Onboarding user has no period data, should see onboarding banner
const onboardingBanner = onboardingPage.getByText(
/period|log your period|set.*date/i,
);
await expect(onboardingBanner.first()).toBeVisible();
});
test("period history page is accessible", async ({ establishedPage }) => {
await establishedPage.goto("/period-history");
// Should show period history content
await expect(establishedPage.getByRole("heading")).toBeVisible();
});
test("period history shows table or empty state", async ({
establishedPage,
}) => {
await establishedPage.goto("/period-history");
// Wait for loading to complete
await establishedPage.waitForLoadState("networkidle");
// Look for either table or empty state message
const table = establishedPage.getByRole("table");
const emptyState = establishedPage.getByText("No period history found");
const totalText = establishedPage.getByText(/\d+ periods/);
const hasTable = await table.isVisible().catch(() => false);
const hasEmpty = await emptyState.isVisible().catch(() => false);
const hasTotal = await totalText.isVisible().catch(() => false);
// Either table, empty state, or total count should be present
expect(hasTable || hasEmpty || hasTotal).toBe(true);
});
test("period history shows average cycle length if data exists", async ({
establishedPage,
}) => {
await establishedPage.goto("/period-history");
// Average cycle length is shown when there's enough data
const avgText = establishedPage.getByText(
/average.*cycle|cycle.*average|avg/i,
);
const hasAvg = await avgText
.first()
.isVisible()
.catch(() => false);
// This is optional - depends on having data
if (hasAvg) {
await expect(avgText.first()).toBeVisible();
}
});
test("period history shows back navigation", async ({ establishedPage }) => {
await establishedPage.goto("/period-history");
// Look for back link
const backLink = establishedPage.getByRole("link", {
name: /back|dashboard|home/i,
});
await expect(backLink).toBeVisible();
});
test("can navigate to period history from dashboard", async ({
establishedPage,
}) => {
// Look for navigation to period history
const periodHistoryLink = establishedPage.getByRole("link", {
name: /period.*history|history/i,
});
const hasLink = await periodHistoryLink.isVisible().catch(() => false);
if (hasLink) {
await periodHistoryLink.click();
await expect(establishedPage).toHaveURL(/\/period-history/);
}
});
});
test.describe("period logging flow - onboarding user", () => {
test("period date modal opens from dashboard onboarding banner", async ({
onboardingPage,
}) => {
await onboardingPage.goto("/");
// Onboarding user should see "Set date" button
const setDateButton = onboardingPage.getByRole("button", {
name: /set date/i,
});
await expect(setDateButton).toBeVisible();
// Click the set date button
await setDateButton.click();
// Modal should open with "Set Period Date" title
const modalTitle = onboardingPage.getByRole("heading", {
name: /set period date/i,
});
await expect(modalTitle).toBeVisible();
// Should have a date input
const dateInput = onboardingPage.locator('input[type="date"]');
await expect(dateInput).toBeVisible();
// Should have Cancel and Save buttons
await expect(
onboardingPage.getByRole("button", { name: /cancel/i }),
).toBeVisible();
await expect(
onboardingPage.getByRole("button", { name: /save/i }),
).toBeVisible();
// Cancel should close the modal
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
await expect(modalTitle).not.toBeVisible();
});
test("period date input restricts future dates", async ({
onboardingPage,
}) => {
await onboardingPage.goto("/");
// Open the modal
const setDateButton = onboardingPage.getByRole("button", {
name: /set date/i,
});
await setDateButton.click();
// Get the date input and check its max attribute
const dateInput = onboardingPage.locator('input[type="date"]');
await expect(dateInput).toBeVisible();
// The max attribute should be set to today's date (YYYY-MM-DD format)
const maxValue = await dateInput.getAttribute("max");
expect(maxValue).toBeTruthy();
// Parse max date and verify it's today or earlier
const maxDate = new Date(maxValue as string);
const today = new Date();
today.setHours(0, 0, 0, 0);
maxDate.setHours(0, 0, 0, 0);
expect(maxDate.getTime()).toBeLessThanOrEqual(today.getTime());
// Close modal
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
});
test("logging period from modal updates dashboard cycle info", async ({
onboardingPage,
}) => {
await onboardingPage.goto("/");
// Click the set date button
const setDateButton = onboardingPage.getByRole("button", {
name: /set date/i,
});
await setDateButton.click();
// Wait for modal to be visible
const modalTitle = onboardingPage.getByRole("heading", {
name: /set period date/i,
});
await expect(modalTitle).toBeVisible();
// Calculate a valid date (7 days ago)
const testDate = new Date();
testDate.setDate(testDate.getDate() - 7);
const dateStr = testDate.toISOString().split("T")[0];
// Fill in the date
const dateInput = onboardingPage.locator('input[type="date"]');
await dateInput.fill(dateStr);
// Click Save button
await onboardingPage.getByRole("button", { name: /save/i }).click();
// Modal should close after successful save
await expect(modalTitle).not.toBeVisible({ timeout: 10000 });
// Wait for network activity to settle
await onboardingPage.waitForLoadState("networkidle");
// Look for cycle day display (e.g., "Day 8 · Follicular" or similar)
// The page fetches /api/cycle/period, then /api/today and /api/user
// Content only renders when both todayData and userData are available
// Use .first() as the pattern may match multiple elements on the page
const cycleInfo = onboardingPage.getByText(/day\s+\d+\s+·/i).first();
await expect(cycleInfo).toBeVisible({ timeout: 15000 });
});
});
test.describe("period logging flow - established user", () => {
test("period date cannot be in the future", async ({ establishedPage }) => {
// Navigate to period history
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for an "Add Period" or "Log Period" button
const addButton = establishedPage.getByRole("button", {
name: /add.*period|log.*period|new.*period/i,
});
const hasAddButton = await addButton.isVisible().catch(() => false);
if (!hasAddButton) {
// Established user may have an edit button instead - also valid
const editButton = establishedPage.getByRole("button", { name: /edit/i });
const hasEdit = await editButton
.first()
.isVisible()
.catch(() => false);
expect(hasEdit).toBe(true);
}
});
test("period history displays cycle length between periods", async ({
establishedPage,
}) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for cycle length column or text
const cycleLengthText = establishedPage.getByText(
/cycle.*length|\d+\s*days/i,
);
const hasCycleLength = await cycleLengthText
.first()
.isVisible()
.catch(() => false);
// If there's period data, cycle length should be visible
const table = establishedPage.getByRole("table");
const hasTable = await table.isVisible().catch(() => false);
if (hasTable) {
// Table has header for cycle length
const header = establishedPage.getByRole("columnheader", {
name: /cycle.*length|days/i,
});
const hasHeader = await header.isVisible().catch(() => false);
expect(hasHeader || hasCycleLength).toBe(true);
}
});
test("period history shows prediction accuracy when available", async ({
establishedPage,
}) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for prediction-related text (early/late, accuracy)
const predictionText = establishedPage.getByText(
/early|late|accuracy|predicted/i,
);
const hasPrediction = await predictionText
.first()
.isVisible()
.catch(() => false);
// Prediction info may not be visible if not enough data
if (hasPrediction) {
await expect(predictionText.first()).toBeVisible();
}
});
test("can delete period log from history", async ({ establishedPage }) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for delete button
const deleteButton = establishedPage.getByRole("button", {
name: /delete/i,
});
const hasDelete = await deleteButton
.first()
.isVisible()
.catch(() => false);
if (hasDelete) {
// Delete button exists for period entries
await expect(deleteButton.first()).toBeVisible();
}
});
test("can edit period log from history", async ({ establishedPage }) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for edit button
const editButton = establishedPage.getByRole("button", { name: /edit/i });
const hasEdit = await editButton
.first()
.isVisible()
.catch(() => false);
if (hasEdit) {
// Edit button exists for period entries
await expect(editButton.first()).toBeVisible();
}
});
test("edit period modal flow changes date successfully", async ({
establishedPage,
}) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for edit button and table to ensure we have data
const editButton = establishedPage
.getByRole("button", { name: /edit/i })
.first();
await expect(editButton).toBeVisible();
// Get the original date from the first row
const firstRow = establishedPage.locator("tbody tr").first();
const originalDateCell = firstRow.locator("td").first();
const originalDateText = await originalDateCell.textContent();
// Click edit button
await editButton.click();
// Edit modal should appear
const editModalTitle = establishedPage.getByRole("heading", {
name: /edit period date/i,
});
await expect(editModalTitle).toBeVisible();
// Get the date input in the edit modal
const editDateInput = establishedPage.locator("#editDate");
await expect(editDateInput).toBeVisible();
// Calculate a new date (21 days ago to avoid conflicts)
const newDate = new Date();
newDate.setDate(newDate.getDate() - 21);
const newDateStr = newDate.toISOString().split("T")[0];
// Clear and fill new date
await editDateInput.fill(newDateStr);
// Click Save in the edit modal
await establishedPage.getByRole("button", { name: /save/i }).click();
// Modal should close
await expect(editModalTitle).not.toBeVisible();
// Wait for table to refresh
await establishedPage.waitForLoadState("networkidle");
// Verify the date changed (the row should have new date text)
const updatedDateCell = establishedPage
.locator("tbody tr")
.first()
.locator("td")
.first();
const updatedDateText = await updatedDateCell.textContent();
// If we had original data, verify it changed
if (originalDateText) {
// Format the new date to match display format (e.g., "Jan 1, 2024")
const formattedNewDate = newDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
expect(updatedDateText).toContain(
formattedNewDate.split(",")[0].split(" ")[0],
);
}
});
test("delete period confirmation flow removes entry", async ({
establishedPage,
}) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for delete button
const deleteButton = establishedPage
.getByRole("button", { name: /delete/i })
.first();
await expect(deleteButton).toBeVisible();
// Get the total count text before deletion
const totalText = establishedPage.getByText(/\d+ periods/);
const hasTotal = await totalText.isVisible().catch(() => false);
let originalCount = 0;
if (hasTotal) {
const countMatch = (await totalText.textContent())?.match(
/(\d+) periods/,
);
if (countMatch) {
originalCount = parseInt(countMatch[1], 10);
}
}
// Click delete button
await deleteButton.click();
// Confirmation modal should appear
const confirmModalTitle = establishedPage.getByRole("heading", {
name: /delete period/i,
});
await expect(confirmModalTitle).toBeVisible();
// Should show warning message
const warningText = establishedPage.getByText(/are you sure.*delete/i);
await expect(warningText).toBeVisible();
// Should have Cancel and Confirm buttons
await expect(
establishedPage.getByRole("button", { name: /cancel/i }),
).toBeVisible();
await expect(
establishedPage.getByRole("button", { name: /confirm/i }),
).toBeVisible();
// Click Confirm to delete
await establishedPage.getByRole("button", { name: /confirm/i }).click();
// Modal should close
await expect(confirmModalTitle).not.toBeVisible();
// Wait for page to refresh
await establishedPage.waitForLoadState("networkidle");
// If we had a count, verify it decreased
if (originalCount > 1) {
const newTotalText = establishedPage.getByText(/\d+ periods/);
const newTotalVisible = await newTotalText.isVisible().catch(() => false);
if (newTotalVisible) {
const newCountMatch = (await newTotalText.textContent())?.match(
/(\d+) periods/,
);
if (newCountMatch) {
const newCount = parseInt(newCountMatch[1], 10);
expect(newCount).toBe(originalCount - 1);
}
}
}
});
});

166
e2e/plan.spec.ts Normal file
View File

@@ -0,0 +1,166 @@
// ABOUTME: E2E tests for the exercise plan reference page.
// ABOUTME: Tests phase display, training guidelines, and current status.
import { expect, test } from "@playwright/test";
test.describe("plan page", () => {
test.describe("unauthenticated", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/plan");
// Should redirect to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("authenticated", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard then navigate to plan
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/plan");
});
test("displays exercise plan page with title", async ({ page }) => {
// Check for plan page title
const heading = page.getByRole("heading", { name: "Exercise Plan" });
await expect(heading).toBeVisible();
});
test("shows current cycle status section", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Wait for page to finish loading - look for Current Status or error state
const statusSection = page.getByRole("heading", {
name: "Current Status",
});
// Use text content to find error alert (avoid Next.js route announcer)
const errorAlert = page.getByText(/error:/i);
try {
// Wait for Current Status section to be visible (data loaded successfully)
await expect(statusSection).toBeVisible({ timeout: 10000 });
// Should show day number
await expect(page.getByText(/day \d+/i)).toBeVisible({ timeout: 5000 });
// Should show training type
await expect(page.getByText(/training type:/i)).toBeVisible({
timeout: 5000,
});
// Should show weekly limit
await expect(page.getByText(/weekly limit:/i)).toBeVisible({
timeout: 5000,
});
} catch {
// If status section not visible, check for error alert
await expect(errorAlert).toBeVisible({ timeout: 5000 });
}
});
test("shows all 5 phase cards", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Phase Overview section
const phaseOverview = page.getByRole("heading", {
name: "Phase Overview",
});
const hasPhaseOverview = await phaseOverview
.isVisible()
.catch(() => false);
if (hasPhaseOverview) {
// Should show all 5 phase cards using data-testid
await expect(page.getByTestId("phase-MENSTRUAL")).toBeVisible();
await expect(page.getByTestId("phase-FOLLICULAR")).toBeVisible();
await expect(page.getByTestId("phase-OVULATION")).toBeVisible();
await expect(page.getByTestId("phase-EARLY_LUTEAL")).toBeVisible();
await expect(page.getByTestId("phase-LATE_LUTEAL")).toBeVisible();
}
});
test("shows strength training reference table", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Strength Training section
const strengthSection = page.getByRole("heading", {
name: /strength training/i,
});
const hasStrength = await strengthSection.isVisible().catch(() => false);
if (hasStrength) {
// Should have exercise table
const table = page.locator("table");
await expect(table).toBeVisible();
// Check for some exercises
await expect(page.getByText("Squats")).toBeVisible();
await expect(page.getByText("Push-ups")).toBeVisible();
await expect(page.getByText("Plank")).toBeVisible();
}
});
test("shows rebounding techniques", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Rebounding Techniques section
const reboundingSection = page.getByRole("heading", {
name: /rebounding techniques/i,
});
const hasRebounding = await reboundingSection
.isVisible()
.catch(() => false);
if (hasRebounding) {
// Should show techniques section - use first() for specific match
await expect(
page.getByText("Health bounce, lymphatic drainage"),
).toBeVisible();
}
});
test("shows weekly guidelines", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Check for Weekly Guidelines section
const weeklySection = page.getByRole("heading", {
name: "Weekly Guidelines",
});
const hasWeekly = await weeklySection.isVisible().catch(() => false);
if (hasWeekly) {
// Should show guidelines for each phase - use exact matches
await expect(
page.getByRole("heading", { name: "Menstrual Phase (Days 1-3)" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Follicular Phase (Days 4-14)" }),
).toBeVisible();
}
});
});
});

View File

@@ -0,0 +1,140 @@
// ABOUTME: Integration tests for the PocketBase e2e test harness.
// ABOUTME: Verifies the harness can start, setup, and stop PocketBase instances.
// @vitest-environment node
import PocketBase from "pocketbase";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
DEFAULT_CONFIG,
getState,
type HarnessState,
start,
stop,
} from "./pocketbase-harness";
describe("pocketbase-harness", () => {
describe("start/stop lifecycle", () => {
let state: HarnessState;
beforeAll(async () => {
state = await start();
}, 60000);
afterAll(async () => {
await stop();
});
it("returns a valid harness state", () => {
expect(state).toBeDefined();
expect(state.url).toBe(`http://127.0.0.1:${DEFAULT_CONFIG.port}`);
expect(state.dataDir).toContain("pocketbase-e2e-");
expect(state.process).toBeDefined();
});
it("getState returns the current state while running", () => {
const currentState = getState();
expect(currentState).toBe(state);
});
it("PocketBase is accessible at the expected URL", async () => {
const response = await fetch(`${state.url}/api/health`);
expect(response.ok).toBe(true);
});
it("admin can authenticate", async () => {
const pb = new PocketBase(state.url);
pb.autoCancellation(false);
const auth = await pb
.collection("_superusers")
.authWithPassword(
DEFAULT_CONFIG.adminEmail,
DEFAULT_CONFIG.adminPassword,
);
expect(auth.token).toBeDefined();
});
it("test user can authenticate", async () => {
const pb = new PocketBase(state.url);
pb.autoCancellation(false);
const auth = await pb
.collection("users")
.authWithPassword(
DEFAULT_CONFIG.testUserEmail,
DEFAULT_CONFIG.testUserPassword,
);
expect(auth.token).toBeDefined();
expect(auth.record.email).toBe(DEFAULT_CONFIG.testUserEmail);
});
it("test user has period data configured", async () => {
const pb = new PocketBase(state.url);
pb.autoCancellation(false);
await pb
.collection("users")
.authWithPassword(
DEFAULT_CONFIG.testUserEmail,
DEFAULT_CONFIG.testUserPassword,
);
const user = pb.authStore.record;
expect(user).toBeDefined();
expect(user?.lastPeriodDate).toBeDefined();
expect(user?.cycleLength).toBe(28);
expect(user?.timezone).toBe("UTC");
});
it("period_logs collection exists with test data", async () => {
const pb = new PocketBase(state.url);
pb.autoCancellation(false);
await pb
.collection("users")
.authWithPassword(
DEFAULT_CONFIG.testUserEmail,
DEFAULT_CONFIG.testUserPassword,
);
const userId = pb.authStore.record?.id;
const logs = await pb
.collection("period_logs")
.getList(1, 10, { filter: `user="${userId}"` });
expect(logs.totalItems).toBeGreaterThan(0);
expect(logs.items[0].startDate).toBeDefined();
});
it("dailyLogs collection exists", async () => {
const pb = new PocketBase(state.url);
pb.autoCancellation(false);
await pb
.collection("_superusers")
.authWithPassword(
DEFAULT_CONFIG.adminEmail,
DEFAULT_CONFIG.adminPassword,
);
const collections = await pb.collections.getFullList();
const collectionNames = collections.map((c) => c.name);
expect(collectionNames).toContain("period_logs");
expect(collectionNames).toContain("dailyLogs");
});
});
describe("after stop", () => {
it("getState returns null after stop", async () => {
// Start and immediately stop
await start({ ...DEFAULT_CONFIG, port: 8092 });
await stop();
const state = getState();
expect(state).toBeNull();
}, 60000);
});
});

554
e2e/pocketbase-harness.ts Normal file
View File

@@ -0,0 +1,554 @@
// ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase.
// ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests.
import { type ChildProcess, execSync, spawn } from "node:child_process";
import { createCipheriv, randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import PocketBase from "pocketbase";
import {
addUserFields,
createCollection,
getExistingCollectionNames,
getMissingCollections,
setupApiRules,
} from "../scripts/setup-db";
/**
* Test user presets for different e2e test scenarios.
*/
export type TestUserPreset =
| "onboarding"
| "established"
| "calendar"
| "garmin"
| "garminExpired";
/**
* Configuration for each test user type.
*/
export const TEST_USERS: Record<
TestUserPreset,
{ email: string; password: string }
> = {
onboarding: {
email: "e2e-onboarding@phaseflow.local",
password: "e2e-onboarding-123",
},
established: {
email: "e2e-test@phaseflow.local",
password: "e2e-test-password-123",
},
calendar: {
email: "e2e-calendar@phaseflow.local",
password: "e2e-calendar-123",
},
garmin: {
email: "e2e-garmin@phaseflow.local",
password: "e2e-garmin-123",
},
garminExpired: {
email: "e2e-garmin-expired@phaseflow.local",
password: "e2e-garmin-expired-123",
},
};
/**
* Configuration for the test harness.
*/
export interface HarnessConfig {
port: number;
adminEmail: string;
adminPassword: string;
testUserEmail: string;
testUserPassword: string;
}
/**
* Default configuration for e2e tests.
*/
export const DEFAULT_CONFIG: HarnessConfig = {
port: 8091,
adminEmail: "admin@e2e-test.local",
adminPassword: "admin-password-e2e-123",
testUserEmail: "e2e-test@phaseflow.local",
testUserPassword: "e2e-test-password-123",
};
/**
* State of a running PocketBase harness instance.
*/
export interface HarnessState {
process: ChildProcess;
dataDir: string;
url: string;
config: HarnessConfig;
}
let currentState: HarnessState | null = null;
/**
* Gets the URL for the PocketBase instance.
*/
function getPocketBaseUrl(port: number): string {
return `http://127.0.0.1:${port}`;
}
/**
* Creates a temporary directory for PocketBase data.
*/
function createTempDataDir(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pocketbase-e2e-"));
return tempDir;
}
/**
* Waits for PocketBase to be ready by polling the health endpoint.
*/
async function waitForReady(url: string, timeoutMs = 30000): Promise<void> {
const startTime = Date.now();
const healthUrl = `${url}/api/health`;
while (Date.now() - startTime < timeoutMs) {
try {
const response = await fetch(healthUrl);
if (response.ok) {
return;
}
} catch {
// Server not ready yet, continue polling
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`PocketBase did not become ready within ${timeoutMs}ms`);
}
/**
* Sleeps for the specified number of milliseconds.
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Creates the admin superuser using the PocketBase CLI.
* Retries on database lock errors since PocketBase may still be running migrations.
*/
async function createAdminUser(
dataDir: string,
email: string,
password: string,
maxRetries = 5,
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
execSync(
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
{
stdio: "pipe",
},
);
return;
} catch (err) {
lastError = err as Error;
const errorMsg = String(lastError.message || lastError);
// Retry on database lock errors
if (
errorMsg.includes("database is locked") ||
errorMsg.includes("SQLITE_BUSY")
) {
await sleep(100 * (attempt + 1)); // Exponential backoff: 100ms, 200ms, 300ms...
continue;
}
// For other errors, throw immediately
throw err;
}
}
throw lastError;
}
/**
* Sets up the database collections using the SDK.
*/
async function setupCollections(pb: PocketBase): Promise<void> {
// Add custom fields to users collection
await addUserFields(pb);
// Create period_logs and dailyLogs collections
const existingNames = await getExistingCollectionNames(pb);
const missing = getMissingCollections(existingNames);
for (const collection of missing) {
await createCollection(pb, collection);
}
// Set up API rules
await setupApiRules(pb);
}
/**
* Retries an async operation with exponential backoff.
*/
async function retryAsync<T>(
operation: () => Promise<T>,
maxRetries = 5,
baseDelayMs = 100,
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err as Error;
const errorMsg = String(lastError.message || lastError);
// Retry on transient errors (database busy, connection issues)
if (
errorMsg.includes("database is locked") ||
errorMsg.includes("SQLITE_BUSY") ||
errorMsg.includes("Failed to create record")
) {
await sleep(baseDelayMs * (attempt + 1));
continue;
}
// For other errors, throw immediately
throw err;
}
}
throw lastError;
}
/**
* Encrypts a string using AES-256-GCM (matches src/lib/encryption.ts format).
* Uses the test encryption key from playwright.config.ts.
*/
function encryptForTest(plaintext: string): string {
const key = Buffer.from(
"e2e-test-encryption-key-32chars".padEnd(32, "0").slice(0, 32),
);
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
/**
* Creates the onboarding test user (no period data).
*/
async function createOnboardingUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.onboarding;
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
}),
);
return user.id;
}
/**
* Creates the established test user with period data (default user).
*/
async function createEstablishedUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.established;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates the calendar test user with period data and calendar token.
*/
async function createCalendarUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.calendar;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
calendarToken: "e2e-test-calendar-token-12345678",
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates the Garmin test user with period data and valid Garmin tokens.
*/
async function createGarminUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.garmin;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
// Token expires 90 days in the future
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
const oauth1Token = encryptForTest(
JSON.stringify({
oauth_token: "test-oauth1-token",
oauth_token_secret: "test-oauth1-secret",
}),
);
const oauth2Token = encryptForTest(
JSON.stringify({
access_token: "test-access-token",
refresh_token: "test-refresh-token",
token_type: "Bearer",
expires_in: 7776000,
}),
);
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
garminConnected: true,
garminOauth1Token: oauth1Token,
garminOauth2Token: oauth2Token,
garminTokenExpiresAt: expiresAt.toISOString(),
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates the Garmin expired test user with period data and expired Garmin tokens.
*/
async function createGarminExpiredUser(pb: PocketBase): Promise<string> {
const { email, password } = TEST_USERS.garminExpired;
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
// Token expired 1 day ago
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() - 1);
const oauth1Token = encryptForTest(
JSON.stringify({
oauth_token: "test-expired-oauth1-token",
oauth_token_secret: "test-expired-oauth1-secret",
}),
);
const oauth2Token = encryptForTest(
JSON.stringify({
access_token: "test-expired-access-token",
refresh_token: "test-expired-refresh-token",
token_type: "Bearer",
expires_in: 7776000,
}),
);
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
garminConnected: true,
garminOauth1Token: oauth1Token,
garminOauth2Token: oauth2Token,
garminTokenExpiresAt: expiredAt.toISOString(),
}),
);
await retryAsync(() =>
pb.collection("period_logs").create({
user: user.id,
startDate: lastPeriodDate,
}),
);
return user.id;
}
/**
* Creates all test users for e2e tests.
*/
async function createAllTestUsers(pb: PocketBase): Promise<void> {
await createOnboardingUser(pb);
await createEstablishedUser(pb);
await createCalendarUser(pb);
await createGarminUser(pb);
await createGarminExpiredUser(pb);
}
/**
* Starts a fresh PocketBase instance for e2e testing.
*/
export async function start(
config: HarnessConfig = DEFAULT_CONFIG,
): Promise<HarnessState> {
if (currentState) {
throw new Error(
"PocketBase harness is already running. Call stop() first.",
);
}
const dataDir = createTempDataDir();
const url = getPocketBaseUrl(config.port);
// Start PocketBase process
const pbProcess = spawn(
"pocketbase",
["serve", `--dir=${dataDir}`, `--http=127.0.0.1:${config.port}`],
{
stdio: "pipe",
detached: false,
},
);
// Handle process errors
pbProcess.on("error", (err) => {
console.error("PocketBase process error:", err);
});
// Wait for PocketBase to be ready
await waitForReady(url);
// Create admin user via CLI (with retry for database lock during migrations)
await createAdminUser(dataDir, config.adminEmail, config.adminPassword);
// Connect to PocketBase as admin
const pb = new PocketBase(url);
pb.autoCancellation(false);
await pb
.collection("_superusers")
.authWithPassword(config.adminEmail, config.adminPassword);
// Set up collections
await setupCollections(pb);
// Create all test users for different e2e scenarios
await createAllTestUsers(pb);
currentState = {
process: pbProcess,
dataDir,
url,
config,
};
return currentState;
}
/**
* Stops the running PocketBase instance and cleans up.
*/
export async function stop(): Promise<void> {
if (!currentState) {
return;
}
const { process: pbProcess, dataDir } = currentState;
// Kill the PocketBase process
pbProcess.kill("SIGTERM");
// Wait a moment for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
// Force kill if still running
if (!pbProcess.killed) {
pbProcess.kill("SIGKILL");
}
// Clean up the temporary data directory
fs.rmSync(dataDir, { recursive: true, force: true });
currentState = null;
}
/**
* Gets the current harness state if running.
*/
export function getState(): HarnessState | null {
return currentState;
}

969
e2e/settings.spec.ts Normal file
View File

@@ -0,0 +1,969 @@
// ABOUTME: E2E tests for settings page including preferences and logout.
// ABOUTME: Tests form rendering, validation, submission, and logout functionality.
import { expect, test } from "@playwright/test";
test.describe("settings", () => {
test.describe("unauthenticated", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/settings");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
test("garmin settings redirects to login when not authenticated", async ({
page,
}) => {
await page.goto("/settings/garmin");
// Should redirect to /login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe("authenticated", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login via the login page
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for redirect to dashboard, then navigate to settings
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings");
await page.waitForLoadState("networkidle");
});
test("displays settings form with required fields", async ({ page }) => {
// Check for cycle length input
const cycleLengthInput = page.getByLabel(/cycle length/i);
await expect(cycleLengthInput).toBeVisible();
// Check for notification time input
const notificationTimeInput = page.getByLabel(/notification time/i);
await expect(notificationTimeInput).toBeVisible();
// Check for timezone input
const timezoneInput = page.getByLabel(/timezone/i);
await expect(timezoneInput).toBeVisible();
});
test("shows save button", async ({ page }) => {
const saveButton = page.getByRole("button", { name: /save/i });
await expect(saveButton).toBeVisible();
});
test("shows logout button", async ({ page }) => {
const logoutButton = page.getByRole("button", { name: /log ?out/i });
await expect(logoutButton).toBeVisible();
});
test("shows link to garmin settings", async ({ page }) => {
const garminLink = page.getByRole("link", { name: /manage|garmin/i });
await expect(garminLink).toBeVisible();
});
test("shows back to dashboard link", async ({ page }) => {
const backLink = page.getByRole("link", { name: /back|dashboard/i });
await expect(backLink).toBeVisible();
});
test("can update cycle length", async ({ page }) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
// Clear and enter new value
await cycleLengthInput.fill("30");
// Click save
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Should show success message or no error
await page.waitForTimeout(1000);
// Either success message or value persisted
const errorMessage = page.locator('[role="alert"]').filter({
hasText: /error|failed/i,
});
const hasError = await errorMessage.isVisible().catch(() => false);
// No error means success
expect(hasError).toBe(false);
});
test("validates cycle length range", async ({ page }) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
// Enter invalid value (too low)
await cycleLengthInput.fill("10");
// Click save
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Should show validation error or HTML5 validation
await page.waitForTimeout(500);
});
test("can navigate to garmin settings", async ({ page }) => {
const garminLink = page.getByRole("link", { name: /manage|garmin/i });
await garminLink.click();
await expect(page).toHaveURL(/\/settings\/garmin/);
});
test("can navigate back to dashboard", async ({ page }) => {
const backLink = page.getByRole("link", { name: /back|dashboard/i });
await backLink.click();
await expect(page).toHaveURL("/");
});
test("logout redirects to login", async ({ page }) => {
const logoutButton = page.getByRole("button", { name: /log ?out/i });
await logoutButton.click();
// Should redirect to login page
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
});
});
test.describe("garmin settings", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
// Login and navigate to garmin settings
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings/garmin");
await page.waitForLoadState("networkidle");
});
test("displays garmin connection status", async ({ page }) => {
// Look for connection status indicator
const statusText = page.getByText(/connected|not connected|status/i);
await expect(statusText.first()).toBeVisible();
});
test("shows back navigation", async ({ page }) => {
const backLink = page.getByRole("link", { name: /back|settings/i });
await expect(backLink).toBeVisible();
});
});
test.describe("settings form validation", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings");
await page.waitForLoadState("networkidle");
});
test("notification time field accepts valid HH:MM format", async ({
page,
}) => {
const notificationTimeInput = page.getByLabel(/notification time/i);
const isVisible = await notificationTimeInput
.isVisible()
.catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Enter a valid time
await notificationTimeInput.fill("07:00");
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Wait for save
await page.waitForTimeout(1000);
// No error should be shown
const errorMessage = page.locator('[role="alert"]').filter({
hasText: /error|failed|invalid/i,
});
const hasError = await errorMessage.isVisible().catch(() => false);
expect(hasError).toBe(false);
});
test("cycle length rejects value below minimum (21)", async ({ page }) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Enter invalid value (too low)
await cycleLengthInput.fill("20");
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Wait for validation
await page.waitForTimeout(500);
// Either HTML5 validation or error message should appear
// Input should have min attribute or form shows error
const inputMin = await cycleLengthInput.getAttribute("min");
if (inputMin) {
expect(Number.parseInt(inputMin, 10)).toBeGreaterThanOrEqual(21);
}
});
test("cycle length rejects value above maximum (45)", async ({ page }) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Enter invalid value (too high)
await cycleLengthInput.fill("50");
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Wait for validation
await page.waitForTimeout(500);
// Input should have max attribute
const inputMax = await cycleLengthInput.getAttribute("max");
if (inputMax) {
expect(Number.parseInt(inputMax, 10)).toBeLessThanOrEqual(45);
}
});
test("timezone field is editable", async ({ page }) => {
const timezoneInput = page.getByLabel(/timezone/i);
const isVisible = await timezoneInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Timezone could be select or input
const inputType = await timezoneInput.evaluate((el) =>
el.tagName.toLowerCase(),
);
if (inputType === "select") {
// Should have options
const options = timezoneInput.locator("option");
const optionCount = await options.count();
expect(optionCount).toBeGreaterThan(0);
} else {
// Should be able to type in it
const isEditable = await timezoneInput.isEditable();
expect(isEditable).toBe(true);
}
});
test("current cycle length value is displayed", async ({ page }) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Should have a current value
const value = await cycleLengthInput.inputValue();
// Value should be a number in valid range
const numValue = Number.parseInt(value, 10);
if (!Number.isNaN(numValue)) {
expect(numValue).toBeGreaterThanOrEqual(21);
expect(numValue).toBeLessThanOrEqual(45);
}
});
test("settings changes persist after page reload", async ({ page }) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get current value
const originalValue = await cycleLengthInput.inputValue();
// Set a different valid value
const newValue = originalValue === "28" ? "30" : "28";
await cycleLengthInput.fill(newValue);
// Save
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await page.waitForTimeout(1500);
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Check the value persisted
const cycleLengthAfter = page.getByLabel(/cycle length/i);
const afterValue = await cycleLengthAfter.inputValue();
// Either it persisted or was rejected - check it's a valid number
const numValue = Number.parseInt(afterValue, 10);
expect(numValue).toBeGreaterThanOrEqual(21);
expect(numValue).toBeLessThanOrEqual(45);
// Restore original value
await cycleLengthAfter.fill(originalValue);
await saveButton.click();
await page.waitForTimeout(500);
});
test("save button shows loading state during submission", async ({
page,
}) => {
const saveButton = page.getByRole("button", { name: /save/i });
const isVisible = await saveButton.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Initial state should not be disabled
const isDisabledBefore = await saveButton.isDisabled();
expect(isDisabledBefore).toBe(false);
// Click save and quickly check for loading state
await saveButton.click();
// Wait for submission to complete
await page.waitForTimeout(1000);
// After completion, button should be enabled again
const isDisabledAfter = await saveButton.isDisabled();
expect(isDisabledAfter).toBe(false);
});
test("notification time changes persist after page reload", async ({
page,
}) => {
const notificationTimeInput = page.getByLabel(/notification time/i);
const isVisible = await notificationTimeInput
.isVisible()
.catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get current value
const originalValue = await notificationTimeInput.inputValue();
// Set a different valid time (toggle between 08:00 and 09:00)
const newValue = originalValue === "08:00" ? "09:00" : "08:00";
await notificationTimeInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Check the value persisted
const notificationTimeAfter = page.getByLabel(/notification time/i);
const afterValue = await notificationTimeAfter.inputValue();
expect(afterValue).toBe(newValue);
// Restore original value
await notificationTimeAfter.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("timezone changes persist after page reload", async ({ page }) => {
const timezoneInput = page.getByLabel(/timezone/i);
const isVisible = await timezoneInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get current value
const originalValue = await timezoneInput.inputValue();
// Set a different timezone (toggle between two common timezones)
const newValue =
originalValue === "America/New_York"
? "America/Los_Angeles"
: "America/New_York";
await timezoneInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Check the value persisted
const timezoneAfter = page.getByLabel(/timezone/i);
const afterValue = await timezoneAfter.inputValue();
expect(afterValue).toBe(newValue);
// Restore original value
await timezoneAfter.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("multiple settings changes persist after page reload", async ({
page,
}) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const notificationTimeInput = page.getByLabel(/notification time/i);
const timezoneInput = page.getByLabel(/timezone/i);
const cycleLengthVisible = await cycleLengthInput
.isVisible()
.catch(() => false);
const notificationTimeVisible = await notificationTimeInput
.isVisible()
.catch(() => false);
const timezoneVisible = await timezoneInput
.isVisible()
.catch(() => false);
if (!cycleLengthVisible || !notificationTimeVisible || !timezoneVisible) {
test.skip();
return;
}
// Get all original values
const originalCycleLength = await cycleLengthInput.inputValue();
const originalNotificationTime = await notificationTimeInput.inputValue();
const originalTimezone = await timezoneInput.inputValue();
// Set different values for all fields
const newCycleLength = originalCycleLength === "28" ? "30" : "28";
const newNotificationTime =
originalNotificationTime === "08:00" ? "09:00" : "08:00";
const newTimezone =
originalTimezone === "America/New_York"
? "America/Los_Angeles"
: "America/New_York";
await cycleLengthInput.fill(newCycleLength);
await notificationTimeInput.fill(newNotificationTime);
await timezoneInput.fill(newTimezone);
// Save all changes at once and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Verify all values persisted
const cycleLengthAfter = page.getByLabel(/cycle length/i);
const notificationTimeAfter = page.getByLabel(/notification time/i);
const timezoneAfter = page.getByLabel(/timezone/i);
expect(await cycleLengthAfter.inputValue()).toBe(newCycleLength);
expect(await notificationTimeAfter.inputValue()).toBe(
newNotificationTime,
);
expect(await timezoneAfter.inputValue()).toBe(newTimezone);
// Restore all original values
await cycleLengthAfter.fill(originalCycleLength);
await notificationTimeAfter.fill(originalNotificationTime);
await timezoneAfter.fill(originalTimezone);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("cycle length persistence verifies exact saved value", async ({
page,
}) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get current value
const originalValue = await cycleLengthInput.inputValue();
// Set a specific different valid value
const newValue = originalValue === "28" ? "31" : "28";
await cycleLengthInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Check the exact value persisted (not just range validation)
const cycleLengthAfter = page.getByLabel(/cycle length/i);
const afterValue = await cycleLengthAfter.inputValue();
expect(afterValue).toBe(newValue);
// Restore original value
await cycleLengthAfter.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("settings form shows correct values after save without reload", async ({
page,
}) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const notificationTimeInput = page.getByLabel(/notification time/i);
const cycleLengthVisible = await cycleLengthInput
.isVisible()
.catch(() => false);
const notificationTimeVisible = await notificationTimeInput
.isVisible()
.catch(() => false);
if (!cycleLengthVisible || !notificationTimeVisible) {
test.skip();
return;
}
// Get original values
const originalCycleLength = await cycleLengthInput.inputValue();
const originalNotificationTime = await notificationTimeInput.inputValue();
// Change values
const newCycleLength = originalCycleLength === "28" ? "29" : "28";
const newNotificationTime =
originalNotificationTime === "08:00" ? "10:00" : "08:00";
await cycleLengthInput.fill(newCycleLength);
await notificationTimeInput.fill(newNotificationTime);
// Save
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await page.waitForTimeout(1500);
// Verify values are still showing the new values without reload
expect(await cycleLengthInput.inputValue()).toBe(newCycleLength);
expect(await notificationTimeInput.inputValue()).toBe(
newNotificationTime,
);
// Restore original values
await cycleLengthInput.fill(originalCycleLength);
await notificationTimeInput.fill(originalNotificationTime);
await saveButton.click();
await page.waitForTimeout(500);
});
});
test.describe("error recovery", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings");
await page.waitForLoadState("networkidle");
});
test("shows error message and allows retry when save fails", async ({
page,
}) => {
const cycleLengthInput = page.getByLabel(/cycle length/i);
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get original value for restoration
const originalValue = await cycleLengthInput.inputValue();
// Intercept the save request and make it fail once, then succeed
let failureCount = 0;
await page.route("**/api/user", async (route) => {
if (route.request().method() === "PATCH") {
if (failureCount === 0) {
failureCount++;
// First request fails with server error
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Server error" }),
});
} else {
// Subsequent requests succeed - let them through
await route.continue();
}
} else {
await route.continue();
}
});
// Change the cycle length
const newValue = originalValue === "28" ? "32" : "28";
await cycleLengthInput.fill(newValue);
// Click save - should fail
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Wait for error handling to complete
await page.waitForTimeout(1000);
// The key test is that the form remains usable after a failed save
// Error handling may show a toast or just keep the form editable
// Verify form is still editable (not stuck in loading state)
const isEditable = await cycleLengthInput.isEditable();
expect(isEditable).toBe(true);
// Verify save button is enabled for retry
const isButtonEnabled = !(await saveButton.isDisabled());
expect(isButtonEnabled).toBe(true);
// Try saving again - should succeed this time
await saveButton.click();
await page.waitForTimeout(1500);
// Form should still be functional
const isEditableAfterRetry = await cycleLengthInput.isEditable();
expect(isEditableAfterRetry).toBe(true);
// Clean up route interception
await page.unroute("**/api/user");
// Restore original value
await cycleLengthInput.fill(originalValue);
await saveButton.click();
await page.waitForTimeout(500);
});
});
test.describe("intensity goals section", () => {
test.beforeEach(async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip();
return;
}
await page.goto("/login");
await page.waitForLoadState("networkidle");
const emailInput = page.getByLabel(/email/i);
const hasEmailForm = await emailInput.isVisible().catch(() => false);
if (!hasEmailForm) {
test.skip();
return;
}
await emailInput.fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings");
await page.waitForLoadState("networkidle");
});
test("displays Weekly Intensity Goals section", async ({ page }) => {
const sectionHeading = page.getByRole("heading", {
name: /weekly intensity goals/i,
});
await expect(sectionHeading).toBeVisible();
});
test("displays input for menstrual phase goal", async ({ page }) => {
const menstrualInput = page.getByLabel(/menstrual/i);
await expect(menstrualInput).toBeVisible();
});
test("displays input for follicular phase goal", async ({ page }) => {
const follicularInput = page.getByLabel(/follicular/i);
await expect(follicularInput).toBeVisible();
});
test("displays input for ovulation phase goal", async ({ page }) => {
const ovulationInput = page.getByLabel(/ovulation/i);
await expect(ovulationInput).toBeVisible();
});
test("displays input for early luteal phase goal", async ({ page }) => {
const earlyLutealInput = page.getByLabel(/early luteal/i);
await expect(earlyLutealInput).toBeVisible();
});
test("displays input for late luteal phase goal", async ({ page }) => {
const lateLutealInput = page.getByLabel(/late luteal/i);
await expect(lateLutealInput).toBeVisible();
});
test("can modify menstrual phase goal and save", async ({ page }) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get original value
const originalValue = await menstrualInput.inputValue();
// Set a different value
const newValue = originalValue === "75" ? "80" : "75";
await menstrualInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Restore original value
await menstrualInput.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("persists intensity goal value after page reload", async ({
page,
}) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Get original value
const originalValue = await menstrualInput.inputValue();
// Set a different value
const newValue = originalValue === "75" ? "85" : "75";
await menstrualInput.fill(newValue);
// Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page
await page.reload();
await page.waitForLoadState("networkidle");
// Check the value persisted
const menstrualAfter = page.getByLabel(/menstrual/i);
const afterValue = await menstrualAfter.inputValue();
expect(afterValue).toBe(newValue);
// Restore original value
await menstrualAfter.fill(originalValue);
await saveButton.click();
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
});
test("intensity goal inputs have number type and min attribute", async ({
page,
}) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Check type attribute
const inputType = await menstrualInput.getAttribute("type");
expect(inputType).toBe("number");
// Check min attribute
const inputMin = await menstrualInput.getAttribute("min");
expect(inputMin).toBe("0");
});
test("all intensity goal inputs are disabled while saving", async ({
page,
}) => {
const menstrualInput = page.getByLabel(/menstrual/i);
const isVisible = await menstrualInput.isVisible().catch(() => false);
if (!isVisible) {
test.skip();
return;
}
// Start saving (slow down the response to catch disabled state)
await page.route("**/api/user", async (route) => {
if (route.request().method() === "PATCH") {
// Delay response to allow testing disabled state
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
} else {
await route.continue();
}
});
const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click();
// Check inputs are disabled during save
await expect(menstrualInput).toBeDisabled();
// Wait for save to complete
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Clean up route interception
await page.unroute("**/api/user");
});
});
});

36
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,36 @@
// ABOUTME: Smoke tests to verify basic application functionality.
// ABOUTME: Tests that the app loads and critical pages are accessible.
import { expect, test } from "@playwright/test";
test.describe("smoke tests", () => {
test("app loads with correct title", async ({ page }) => {
await page.goto("/");
// Verify the app loads by checking the page title
await expect(page).toHaveTitle("PhaseFlow");
});
test("login page is accessible", async ({ page }) => {
await page.goto("/login");
// Verify login page loads
await expect(page).toHaveURL(/\/login/);
});
test("unauthenticated root redirects or shows login option", async ({
page,
}) => {
await page.goto("/");
// The app should either redirect to login or show a login link
// Check for either condition
const url = page.url();
const hasLoginInUrl = url.includes("/login");
const loginLink = page.getByRole("link", { name: /login|sign in/i });
// At least one should be true: either we're on login page or there's a login link
if (!hasLoginInUrl) {
await expect(loginLink).toBeVisible();
}
});
});

68
flake.lock generated
View File

@@ -1,5 +1,23 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767892417,
@@ -16,9 +34,57 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 0,
"narHash": "sha256-u+rxA79a0lyhG+u+oPBRtTDtzz8kvkc9a6SWSt9ekVc=",
"path": "/nix/store/0283cbhm47kd3lr9zmc5fvdrx9qkav8s-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"playwright-web-flake": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1764622772,
"narHash": "sha256-WCvvlB9sH6u8MQUkFnlxx7jDh7kIebTDK/JHi6pPqSA=",
"owner": "pietdevries94",
"repo": "playwright-web-flake",
"rev": "88e0e6c69b9086619b0c4d8713b2bfaf81a21c40",
"type": "github"
},
"original": {
"owner": "pietdevries94",
"ref": "1.56.1",
"repo": "playwright-web-flake",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"playwright-web-flake": "playwright-web-flake"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},

View File

@@ -1,50 +1,89 @@
# ABOUTME: Nix flake for PhaseFlow development environment.
# ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Ralph sandbox shell.
# ABOUTME: Nix flake for PhaseFlow development environment and Docker build.
# ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output.
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
outputs = { nixpkgs, ... }:
outputs = { nixpkgs, playwright-web-flake, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
playwright-driver = playwright-web-flake.packages.${system}.playwright-driver;
# Custom Python package: garth (not in nixpkgs)
garth = pkgs.python3Packages.buildPythonPackage {
pname = "garth";
version = "0.5.21";
src = pkgs.fetchPypi {
pname = "garth";
version = "0.5.21";
sha256 = "sha256-jZeVldHU6iOhtGarSmCVXRObcfiG9GSQvhQPzuWE2rQ=";
};
format = "pyproject";
nativeBuildInputs = [ pkgs.python3Packages.hatchling ];
propagatedBuildInputs = with pkgs.python3Packages; [
pydantic
requests-oauthlib
requests
];
doCheck = false;
};
# Python with garth for Garmin auth scripts
pythonWithGarth = pkgs.python3.withPackages (ps: [ garth ]);
# Common packages for development
commonPackages = with pkgs; [
nodejs_24
pnpm
git
pocketbase
commonPackages = [
pkgs.nodejs_24
pkgs.pnpm
pkgs.git
pkgs.pocketbase
pythonWithGarth
];
in {
# Docker image for production deployment
packages.${system} = {
dockerImage = import ./docker.nix { inherit pkgs; };
default = import ./docker.nix { inherit pkgs; };
};
devShells.${system} = {
# Default development shell with all tools
default = pkgs.mkShell {
packages = commonPackages ++ (with pkgs; [
turbo
lefthook
]);
packages = commonPackages ++ [
pkgs.turbo
pkgs.lefthook
playwright-driver
];
# For native modules (sharp, better-sqlite3, etc.)
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
# Playwright browser configuration for NixOS (from playwright-web-flake)
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
};
# Ralph sandbox shell with minimal permissions
# Used for autonomous Ralph loop execution
ralph = pkgs.mkShell {
packages = commonPackages ++ (with pkgs; [
# Claude CLI (assumes installed globally or via npm)
# Add any other tools Ralph needs here
]);
packages = commonPackages ++ [
playwright-driver
];
# Restrictive environment for sandboxed execution
shellHook = ''
echo "🔒 Ralph Sandbox Environment"
echo " Limited to: nodejs, pnpm, git"
echo " Limited to: nodejs, pnpm, git, playwright"
echo ""
'';
# For native modules
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
# Playwright browser configuration for NixOS (from playwright-web-flake)
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
};
};
};

View File

@@ -1,7 +1,22 @@
// ABOUTME: Next.js configuration for PhaseFlow application.
// ABOUTME: Configures standalone output and injects git commit hash for build verification.
import { execSync } from "node:child_process";
import type { NextConfig } from "next";
// Get git commit hash at build time for deployment verification
function getGitCommit(): string {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch {
return "unknown";
}
}
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
env: {
GIT_COMMIT: process.env.GIT_COMMIT || getGitCommit(),
},
};
export default nextConfig;

View File

@@ -9,29 +9,40 @@
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"db:setup": "npx tsx scripts/setup-db.ts"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"form-data": "^4.0.1",
"ics": "^3.8.1",
"lucide-react": "^0.562.0",
"mailgun.js": "^11.1.0",
"next": "16.1.1",
"node-cron": "^4.2.1",
"oauth-1.0a": "^2.2.6",
"pino": "^10.1.1",
"pocketbase": "^0.26.5",
"prom-client": "^15.1.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"resend": "^6.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@playwright/test": "1.56.1",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/react": "^19",

63
playwright.config.ts Normal file
View File

@@ -0,0 +1,63 @@
// ABOUTME: Playwright E2E test configuration for browser-based testing.
// ABOUTME: Configures Chromium-only headless testing with automatic dev server startup.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
// Test directory for E2E tests
testDir: "./e2e",
// Global setup/teardown for PocketBase harness
globalSetup: "./e2e/global-setup.ts",
globalTeardown: "./e2e/global-teardown.ts",
// Exclude vitest test files
testIgnore: ["**/pocketbase-harness.test.ts"],
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry failed tests on CI only
retries: process.env.CI ? 2 : 0,
// Run tests sequentially since all tests share the same test user
// Parallel execution causes race conditions when tests modify user state
workers: 1,
// Reporter configuration
reporter: [["html", { open: "never" }], ["list"]],
// Shared settings for all projects
use: {
// Base URL for navigation actions like page.goto('/')
baseURL: "http://localhost:3000",
// Collect trace on first retry for debugging
trace: "on-first-retry",
// Take screenshot on failure
screenshot: "only-on-failure",
},
// Configure projects - Chromium only per requirements
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Run dev server before starting tests
// Note: POCKETBASE_URL is set for the test PocketBase instance on port 8091
// We never reuse existing servers to ensure the correct PocketBase URL is used
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: false,
timeout: 120 * 1000, // 2 minutes for Next.js to start
env: {
// Use the test PocketBase instance (port 8091)
NEXT_PUBLIC_POCKETBASE_URL: "http://127.0.0.1:8091",
POCKETBASE_URL: "http://127.0.0.1:8091",
// Required for Garmin token encryption
ENCRYPTION_KEY: "e2e-test-encryption-key-32chars",
},
},
});

472
pnpm-lock.yaml generated
View File

@@ -16,31 +16,46 @@ importers:
version: 2.1.1
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1
version: 0.45.1(@opentelemetry/api@1.9.0)
form-data:
specifier: ^4.0.1
version: 4.0.5
ics:
specifier: ^3.8.1
version: 3.8.1
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
mailgun.js:
specifier: ^11.1.0
version: 11.1.0
next:
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)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
node-cron:
specifier: ^4.2.1
version: 4.2.1
oauth-1.0a:
specifier: ^2.2.6
version: 2.2.6
pino:
specifier: ^10.1.1
version: 10.1.1
pocketbase:
specifier: ^0.26.5
version: 0.26.5
prom-client:
specifier: ^15.1.3
version: 15.1.3
react:
specifier: 19.2.3
version: 19.2.3
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
resend:
specifier: ^6.7.0
version: 6.7.0
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -51,6 +66,9 @@ importers:
'@biomejs/biome':
specifier: 2.3.11
version: 2.3.11
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.18
@@ -63,6 +81,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.1
version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^20
version: 20.19.27
@@ -95,7 +116,7 @@ importers:
version: 5.9.3
vitest:
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:
@@ -961,6 +982,18 @@ packages:
cpu: [x64]
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==}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@@ -1089,9 +1122,6 @@ packages:
cpu: [x64]
os: [win32]
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1209,6 +1239,12 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1301,6 +1337,19 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
baseline-browser-mapping@2.9.14:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
hasBin: true
@@ -1308,6 +1357,9 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -1316,6 +1368,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
caniuse-lite@1.0.30001763:
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
@@ -1333,6 +1389,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -1366,6 +1426,10 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -1476,6 +1540,10 @@ packages:
sqlite3:
optional: true
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
@@ -1487,9 +1555,25 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
@@ -1521,9 +1605,6 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1533,21 +1614,66 @@ packages:
picomatch:
optional: true
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1685,9 +1811,25 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mailgun.js@11.1.0:
resolution: {integrity: sha512-pXYcQT3nU32gMjUjZpl2FdQN4Vv2iobqYiXqyyevk0vXTKQj8Or0ifLXLNAGqMHnymTjV0OphBpurkchvHsRAg==}
engines: {node: '>=18.0.0'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@@ -1728,9 +1870,16 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
oauth-1.0a@2.2.6:
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
obug@2.1.1:
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:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
@@ -1744,6 +1893,26 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
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
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.56.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
engines: {node: '>=18'}
hasBin: true
pocketbase@0.26.5:
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
@@ -1759,13 +1928,26 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
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:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
@@ -1782,6 +1964,10 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
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:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -1790,15 +1976,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resend@6.7.0:
resolution: {integrity: sha512-2ZV0NDZsh4Gh+Nd1hvluZIitmGJ59O4+OxMufymG6Y8uz1Jgt2uS1seSENnkIUlmwg7/dwmfIJC9rAufByz7wA==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -1810,6 +1987,10 @@ packages:
runes2@1.1.4:
resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -1833,6 +2014,15 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1844,12 +2034,13 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -1870,9 +2061,6 @@ packages:
babel-plugin-macros:
optional: true
svix@1.84.1:
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -1886,6 +2074,13 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
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:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
@@ -1946,9 +2141,8 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
url-join@4.0.1:
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
@@ -2653,6 +2847,14 @@ snapshots:
'@next/swc-win32-x64-msvc@16.1.1':
optional: true
'@opentelemetry/api@1.9.0': {}
'@pinojs/redact@0.4.0': {}
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rollup/rollup-android-arm-eabi@4.55.1':
@@ -2730,8 +2932,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.55.1':
optional: true
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15':
@@ -2837,6 +3037,10 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
@@ -2946,12 +3150,28 @@ snapshots:
assertion-error@2.0.1: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
base-64@1.0.0: {}
baseline-browser-mapping@2.9.14: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bintrees@1.0.2: {}
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.14
@@ -2962,6 +3182,11 @@ snapshots:
buffer-from@1.1.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
caniuse-lite@1.0.30001763: {}
chai@6.2.2: {}
@@ -2974,6 +3199,10 @@ snapshots:
clsx@2.1.1: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
convert-source-map@2.0.0: {}
css-tree@3.1.0:
@@ -3003,6 +3232,8 @@ snapshots:
decimal.js@10.6.0: {}
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
@@ -3020,7 +3251,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.45.1: {}
drizzle-orm@0.45.1(@opentelemetry/api@1.9.0):
optionalDependencies:
'@opentelemetry/api': 1.9.0
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
electron-to-chromium@1.5.267: {}
@@ -3031,8 +3270,23 @@ snapshots:
entities@6.0.1: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild-register@3.6.0(esbuild@0.25.12):
dependencies:
debug: 4.4.3
@@ -3131,23 +3385,66 @@ snapshots:
expect-type@1.3.0: {}
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-tsconfig@4.13.0:
dependencies:
resolve-pkg-maps: 1.0.0
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.8.0
@@ -3279,15 +3576,31 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mailgun.js@11.1.0:
dependencies:
axios: 1.13.2
base-64: 1.0.0
url-join: 4.0.1
transitivePeerDependencies:
- debug
math-intrinsics@1.1.0: {}
mdn-data@2.12.2: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
min-indent@1.0.1: {}
ms@2.1.3: {}
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)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.1
'@swc/helpers': 0.5.15
@@ -3306,6 +3619,8 @@ snapshots:
'@next/swc-linux-x64-musl': 16.1.1
'@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.56.1
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -3315,8 +3630,12 @@ snapshots:
node-releases@2.0.27: {}
oauth-1.0a@2.2.6: {}
obug@2.1.1: {}
on-exit-leak-free@2.1.2: {}
parse5@8.0.0:
dependencies:
entities: 6.0.1
@@ -3327,6 +3646,34 @@ snapshots:
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
playwright-core@1.56.1: {}
playwright@1.56.1:
dependencies:
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
pocketbase@0.26.5: {}
postcss@8.4.31:
@@ -3347,10 +3694,21 @@ snapshots:
ansi-styles: 5.2.0
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: {}
proxy-from-env@1.1.0: {}
punycode@2.3.1: {}
quick-format-unescaped@4.0.4: {}
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
@@ -3362,6 +3720,8 @@ snapshots:
react@19.2.3: {}
real-require@0.2.0: {}
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -3369,10 +3729,6 @@ snapshots:
require-from-string@2.0.2: {}
resend@6.7.0:
dependencies:
svix: 1.84.1
resolve-pkg-maps@1.0.0: {}
rollup@4.55.1:
@@ -3408,6 +3764,8 @@ snapshots:
runes2@1.1.4: {}
safe-stable-stringify@2.5.0: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -3453,6 +3811,15 @@ snapshots:
siginfo@2.0.0: {}
sonic-boom@4.2.0:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -3462,12 +3829,9 @@ snapshots:
source-map@0.6.1: {}
stackback@0.0.2: {}
split2@4.2.0: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
stackback@0.0.2: {}
std-env@3.10.0: {}
@@ -3482,11 +3846,6 @@ snapshots:
optionalDependencies:
'@babel/core': 7.28.5
svix@1.84.1:
dependencies:
standardwebhooks: 1.0.0
uuid: 10.0.0
symbol-tree@3.2.4: {}
tailwind-merge@3.4.0: {}
@@ -3495,6 +3854,14 @@ snapshots:
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: {}
tinybench@2.9.0: {}
@@ -3540,7 +3907,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uuid@10.0.0: {}
url-join@4.0.1: {}
vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2):
dependencies:
@@ -3556,7 +3923,7 @@ snapshots:
jiti: 2.6.1
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:
'@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))
@@ -3579,6 +3946,7 @@ snapshots:
vite: 7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 20.19.27
jsdom: 27.4.0
transitivePeerDependencies:

View File

@@ -10,10 +10,15 @@ Usage:
python3 garmin_auth.py
"""
import json
import sys
from datetime import datetime
from getpass import getpass
try:
import garth
from garth.auth_tokens import OAuth1Token, OAuth2Token
from garth.exc import GarthHTTPError
from pydantic import TypeAdapter
except ImportError:
print("Error: garth library not installed.")
print("Please install it with: pip install garth")
@@ -23,15 +28,33 @@ email = input("Garmin email: ")
password = getpass("Garmin password: ")
# MFA handled automatically - prompts if needed
try:
garth.login(email, password)
except GarthHTTPError as e:
if "401" in str(e):
print("\nError: Invalid email or password.", file=sys.stderr)
else:
print(f"\nError: Authentication failed - {e}", file=sys.stderr)
exit(1)
except Exception as e:
print(f"\nError: {e}", file=sys.stderr)
exit(1)
# Serialize Pydantic dataclasses using TypeAdapter
oauth1_adapter = TypeAdapter(OAuth1Token)
oauth2_adapter = TypeAdapter(OAuth2Token)
expires_at_ts = garth.client.oauth2_token.expires_at
refresh_expires_at_ts = garth.client.oauth2_token.refresh_token_expires_at
tokens = {
"oauth1": garth.client.oauth1_token.serialize(),
"oauth2": garth.client.oauth2_token.serialize(),
"expires_at": garth.client.oauth2_token.expires_at.isoformat()
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
"oauth2": oauth2_adapter.dump_python(garth.client.oauth2_token, mode='json'),
"expires_at": datetime.fromtimestamp(expires_at_ts).isoformat(),
"refresh_token_expires_at": datetime.fromtimestamp(refresh_expires_at_ts).isoformat()
}
print("\n--- Copy everything below this line ---")
print(json.dumps(tokens, indent=2))
print("--- Copy everything above this line ---")
print(f"\nTokens expire: {tokens['expires_at']}")
print(f"\nAccess token expires: {tokens['expires_at']}")
print(f"Refresh token expires: {tokens['refresh_token_expires_at']} (re-run script before this date)")

231
scripts/setup-db.test.ts Normal file
View File

@@ -0,0 +1,231 @@
// ABOUTME: Tests for the database setup script that creates PocketBase collections.
// ABOUTME: Verifies collection definitions and setup logic without hitting real PocketBase.
import { describe, expect, it, vi } from "vitest";
import {
DAILY_LOGS_COLLECTION,
getExistingCollectionNames,
getMissingCollections,
PERIOD_LOGS_COLLECTION,
USER_CUSTOM_FIELDS,
} from "./setup-db";
describe("PERIOD_LOGS_COLLECTION", () => {
it("has correct name", () => {
expect(PERIOD_LOGS_COLLECTION.name).toBe("period_logs");
});
it("has required fields", () => {
const fieldNames = PERIOD_LOGS_COLLECTION.fields.map((f) => f.name);
expect(fieldNames).toContain("user");
expect(fieldNames).toContain("startDate");
expect(fieldNames).toContain("predictedDate");
});
it("has user field as relation to users", () => {
const userField = PERIOD_LOGS_COLLECTION.fields.find(
(f) => f.name === "user",
);
expect(userField?.type).toBe("relation");
expect(userField?.collectionId).toBe("users");
expect(userField?.maxSelect).toBe(1);
expect(userField?.cascadeDelete).toBe(true);
});
it("has startDate as required date", () => {
const startDateField = PERIOD_LOGS_COLLECTION.fields.find(
(f) => f.name === "startDate",
);
expect(startDateField?.type).toBe("date");
expect(startDateField?.required).toBe(true);
});
it("has predictedDate as optional date", () => {
const predictedDateField = PERIOD_LOGS_COLLECTION.fields.find(
(f) => f.name === "predictedDate",
);
expect(predictedDateField?.type).toBe("date");
expect(predictedDateField?.required).toBe(false);
});
});
describe("DAILY_LOGS_COLLECTION", () => {
it("has correct name", () => {
expect(DAILY_LOGS_COLLECTION.name).toBe("dailyLogs");
});
it("has all required fields", () => {
const fieldNames = DAILY_LOGS_COLLECTION.fields.map((f) => f.name);
// Core fields
expect(fieldNames).toContain("user");
expect(fieldNames).toContain("date");
expect(fieldNames).toContain("cycleDay");
expect(fieldNames).toContain("phase");
// Garmin biometric fields
expect(fieldNames).toContain("bodyBatteryCurrent");
expect(fieldNames).toContain("bodyBatteryYesterdayLow");
expect(fieldNames).toContain("hrvStatus");
expect(fieldNames).toContain("weekIntensityMinutes");
// Decision fields
expect(fieldNames).toContain("phaseLimit");
expect(fieldNames).toContain("remainingMinutes");
expect(fieldNames).toContain("trainingDecision");
expect(fieldNames).toContain("decisionReason");
expect(fieldNames).toContain("notificationSentAt");
});
it("has user field as relation to users", () => {
const userField = DAILY_LOGS_COLLECTION.fields.find(
(f) => f.name === "user",
);
expect(userField?.type).toBe("relation");
expect(userField?.collectionId).toBe("users");
expect(userField?.maxSelect).toBe(1);
expect(userField?.cascadeDelete).toBe(true);
});
it("has trainingDecision as required text", () => {
const field = DAILY_LOGS_COLLECTION.fields.find(
(f) => f.name === "trainingDecision",
);
expect(field?.type).toBe("text");
expect(field?.required).toBe(true);
});
});
describe("getExistingCollectionNames", () => {
it("extracts collection names from PocketBase response", async () => {
const mockPb = {
collections: {
getFullList: vi
.fn()
.mockResolvedValue([
{ name: "users" },
{ name: "period_logs" },
{ name: "_superusers" },
]),
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
const names = await getExistingCollectionNames(mockPb as any);
expect(names).toEqual(["users", "period_logs", "_superusers"]);
});
it("returns empty array when no collections exist", async () => {
const mockPb = {
collections: {
getFullList: vi.fn().mockResolvedValue([]),
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
const names = await getExistingCollectionNames(mockPb as any);
expect(names).toEqual([]);
});
});
describe("getMissingCollections", () => {
it("returns both collections when none exist", () => {
const existing = ["users"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(2);
expect(missing.map((c) => c.name)).toContain("period_logs");
expect(missing.map((c) => c.name)).toContain("dailyLogs");
});
it("returns only dailyLogs when period_logs exists", () => {
const existing = ["users", "period_logs"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe("dailyLogs");
});
it("returns only period_logs when dailyLogs exists", () => {
const existing = ["users", "dailyLogs"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe("period_logs");
});
it("returns empty array when all collections exist", () => {
const existing = ["users", "period_logs", "dailyLogs"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(0);
});
});
describe("USER_CUSTOM_FIELDS garmin token max lengths", () => {
it("should have sufficient max length for garminOauth2Token field", () => {
const oauth2Field = USER_CUSTOM_FIELDS.find(
(f) => f.name === "garminOauth2Token",
);
expect(oauth2Field).toBeDefined();
expect(oauth2Field?.max).toBeGreaterThanOrEqual(10000);
});
it("should have sufficient max length for garminOauth1Token field", () => {
const oauth1Field = USER_CUSTOM_FIELDS.find(
(f) => f.name === "garminOauth1Token",
);
expect(oauth1Field).toBeDefined();
expect(oauth1Field?.max).toBeGreaterThanOrEqual(10000);
});
});
describe("setupApiRules", () => {
it("configures user-owned record rules for period_logs and dailyLogs", async () => {
const { setupApiRules } = await import("./setup-db");
const updateMock = vi.fn().mockResolvedValue({});
const mockPb = {
collections: {
getOne: vi.fn().mockImplementation((name: string) => {
return Promise.resolve({ id: `${name}-id`, name });
}),
update: updateMock,
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
await setupApiRules(mockPb as any);
// Should have called getOne for users, period_logs, and dailyLogs
expect(mockPb.collections.getOne).toHaveBeenCalledWith("users");
expect(mockPb.collections.getOne).toHaveBeenCalledWith("period_logs");
expect(mockPb.collections.getOne).toHaveBeenCalledWith("dailyLogs");
// Check users collection rules
expect(updateMock).toHaveBeenCalledWith("users-id", {
viewRule: "",
updateRule: "id = @request.auth.id",
});
// Check period_logs collection rules
expect(updateMock).toHaveBeenCalledWith("period_logs-id", {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
});
// Check dailyLogs collection rules
expect(updateMock).toHaveBeenCalledWith("dailyLogs-id", {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
});
});
});

415
scripts/setup-db.ts Normal file
View File

@@ -0,0 +1,415 @@
// ABOUTME: Database setup script for creating PocketBase collections.
// ABOUTME: Run with: POCKETBASE_ADMIN_EMAIL=... POCKETBASE_ADMIN_PASSWORD=... pnpm db:setup
import PocketBase from "pocketbase";
/**
* Collection field definition for PocketBase.
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
*/
export interface CollectionField {
name: string;
type: string;
required?: boolean;
// Text field max length (PocketBase defaults to 5000 if not specified)
max?: number;
// Relation field properties (top-level, not in options)
collectionId?: string;
maxSelect?: number;
cascadeDelete?: boolean;
}
/**
* Collection definition for PocketBase.
*/
export interface CollectionDefinition {
name: string;
type: string;
fields: CollectionField[];
}
/**
* Period logs collection schema - tracks menstrual cycle start dates.
* Note: collectionId will be resolved at runtime to the actual users collection ID.
*/
export const PERIOD_LOGS_COLLECTION: CollectionDefinition = {
name: "period_logs",
type: "base",
fields: [
{
name: "user",
type: "relation",
required: true,
collectionId: "users", // Will be resolved to actual ID at runtime
maxSelect: 1,
cascadeDelete: true,
},
{
name: "startDate",
type: "date",
required: true,
},
{
name: "predictedDate",
type: "date",
required: false,
},
],
};
/**
* Daily logs collection schema - daily training snapshots with biometrics.
* Note: collectionId will be resolved at runtime to the actual users collection ID.
*/
export const DAILY_LOGS_COLLECTION: CollectionDefinition = {
name: "dailyLogs",
type: "base",
fields: [
{
name: "user",
type: "relation",
required: true,
collectionId: "users", // Will be resolved to actual ID at runtime
maxSelect: 1,
cascadeDelete: true,
},
{
name: "date",
type: "date",
required: true,
},
{
name: "cycleDay",
type: "number",
required: true,
},
{
name: "phase",
type: "text",
required: true,
},
{
name: "bodyBatteryCurrent",
type: "number",
required: false,
},
{
name: "bodyBatteryYesterdayLow",
type: "number",
required: false,
},
{
name: "hrvStatus",
type: "text",
required: false,
},
{
name: "weekIntensityMinutes",
type: "number",
required: false,
},
{
name: "phaseLimit",
type: "number",
required: false,
},
{
name: "remainingMinutes",
type: "number",
required: false,
},
{
name: "trainingDecision",
type: "text",
required: true,
},
{
name: "decisionReason",
type: "text",
required: true,
},
{
name: "notificationSentAt",
type: "date",
required: false,
},
],
};
/**
* All collections that should exist in the database.
*/
const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
/**
* Custom fields to add to the users collection.
* These are required for Garmin integration and app functionality.
*/
export const USER_CUSTOM_FIELDS: CollectionField[] = [
{ name: "garminConnected", type: "bool" },
{ name: "garminOauth1Token", type: "text", max: 20000 },
{ name: "garminOauth2Token", type: "text", max: 20000 },
{ name: "garminTokenExpiresAt", type: "date" },
{ name: "garminRefreshTokenExpiresAt", type: "date" },
{ name: "calendarToken", type: "text" },
{ name: "lastPeriodDate", type: "date" },
{ name: "cycleLength", type: "number" },
{ name: "notificationTime", type: "text" },
{ name: "timezone", type: "text" },
{ name: "activeOverrides", type: "json" },
// Phase-specific intensity goals (weekly minutes)
{ name: "intensityGoalMenstrual", type: "number" },
{ name: "intensityGoalFollicular", type: "number" },
{ name: "intensityGoalOvulation", type: "number" },
{ name: "intensityGoalEarlyLuteal", type: "number" },
{ name: "intensityGoalLateLuteal", type: "number" },
];
/**
* Adds or updates custom fields on the users collection.
* For new fields: adds them. For existing fields: updates max constraint if different.
* This is idempotent - safe to run multiple times.
*/
export async function addUserFields(pb: PocketBase): Promise<void> {
const usersCollection = await pb.collections.getOne("users");
// Build a map of existing fields by name
const existingFieldsMap = new Map<string, Record<string, unknown>>(
(usersCollection.fields || []).map((f: Record<string, unknown>) => [
f.name as string,
f,
]),
);
// Separate new fields from fields that need updating
const newFields: CollectionField[] = [];
const fieldsToUpdate: string[] = [];
for (const definedField of USER_CUSTOM_FIELDS) {
const existingField = existingFieldsMap.get(definedField.name);
if (!existingField) {
newFields.push(definedField);
} else if (
definedField.max !== undefined &&
existingField.max !== definedField.max
) {
fieldsToUpdate.push(definedField.name);
existingField.max = definedField.max;
}
}
const hasChanges = newFields.length > 0 || fieldsToUpdate.length > 0;
if (hasChanges) {
// Combine existing fields (with updates) and new fields
const allFields = [...(usersCollection.fields || []), ...newFields];
await pb.collections.update(usersCollection.id, {
fields: allFields,
});
if (newFields.length > 0) {
console.log(
` Added ${newFields.length} field(s) to users:`,
newFields.map((f) => f.name),
);
}
if (fieldsToUpdate.length > 0) {
console.log(
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
fieldsToUpdate,
);
}
} else {
console.log(" All user fields already exist with correct settings.");
}
}
/**
* Gets the names of existing collections from PocketBase.
*/
export async function getExistingCollectionNames(
pb: PocketBase,
): Promise<string[]> {
const collections = await pb.collections.getFullList();
return collections.map((c) => c.name);
}
/**
* Returns collection definitions that don't exist in the database.
*/
export function getMissingCollections(
existingNames: string[],
): CollectionDefinition[] {
return REQUIRED_COLLECTIONS.filter(
(collection) => !existingNames.includes(collection.name),
);
}
/**
* Resolves collection name to actual collection ID.
*/
async function resolveCollectionId(
pb: PocketBase,
nameOrId: string,
): Promise<string> {
const collection = await pb.collections.getOne(nameOrId);
return collection.id;
}
/**
* Creates a collection in PocketBase.
*/
export async function createCollection(
pb: PocketBase,
collection: CollectionDefinition,
): Promise<void> {
// Resolve any collection names to actual IDs for relation fields
const fields = await Promise.all(
collection.fields.map(async (field) => {
const baseField: Record<string, unknown> = {
name: field.name,
type: field.type,
required: field.required ?? false,
};
// For relation fields, resolve collectionId and add relation-specific props
if (field.type === "relation" && field.collectionId) {
const resolvedId = await resolveCollectionId(pb, field.collectionId);
baseField.collectionId = resolvedId;
baseField.maxSelect = field.maxSelect ?? 1;
baseField.cascadeDelete = field.cascadeDelete ?? false;
}
return baseField;
}),
);
await pb.collections.create({
name: collection.name,
type: collection.type,
fields,
});
}
/**
* Sets up API rules for collections to allow user access.
* Configures row-level security so users can only access their own records.
*/
export async function setupApiRules(pb: PocketBase): Promise<void> {
// Allow users to view any user record (needed for ICS calendar feed)
// and update only their own record
const usersCollection = await pb.collections.getOne("users");
await pb.collections.update(usersCollection.id, {
viewRule: "",
updateRule: "id = @request.auth.id",
});
// Allow users to read/write their own period_logs
const periodLogs = await pb.collections.getOne("period_logs");
await pb.collections.update(periodLogs.id, {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
});
// Allow users to read/write their own dailyLogs
const dailyLogs = await pb.collections.getOne("dailyLogs");
await pb.collections.update(dailyLogs.id, {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
});
}
/**
* Main setup function - creates missing collections.
*/
async function main(): Promise<void> {
// Validate environment
const pocketbaseUrl =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://localhost:8090";
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
console.error(
"Error: POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD are required",
);
console.error("Usage:");
console.error(
" POCKETBASE_ADMIN_EMAIL=admin@example.com POCKETBASE_ADMIN_PASSWORD=secret pnpm db:setup",
);
process.exit(1);
}
console.log(`Connecting to PocketBase at ${pocketbaseUrl}...`);
const pb = new PocketBase(pocketbaseUrl);
pb.autoCancellation(false);
// Authenticate as admin
try {
await pb
.collection("_superusers")
.authWithPassword(adminEmail, adminPassword);
console.log("Authenticated as admin");
} catch (error) {
console.error("Failed to authenticate as admin:", error);
process.exit(1);
}
// Add custom fields to users collection
console.log("Checking users collection fields...");
await addUserFields(pb);
// Get existing collections
const existingNames = await getExistingCollectionNames(pb);
console.log(
`Found ${existingNames.length} existing collections:`,
existingNames,
);
// Find and create missing collections
const missing = getMissingCollections(existingNames);
if (missing.length === 0) {
console.log("All required collections already exist.");
} else {
console.log(
`Creating ${missing.length} missing collection(s):`,
missing.map((c) => c.name),
);
for (const collection of missing) {
try {
await createCollection(pb, collection);
console.log(` Created: ${collection.name}`);
} catch (error) {
console.error(` Failed to create ${collection.name}:`, error);
process.exit(1);
}
}
}
// Set up API rules for all collections
console.log("Configuring API rules...");
await setupApiRules(pb);
console.log(" API rules configured.");
console.log("Database setup complete!");
}
// Run main function when executed directly
const isMainModule = typeof require !== "undefined" && require.main === module;
// For ES modules / tsx execution
if (isMainModule || process.argv[1]?.includes("setup-db")) {
main().catch((error) => {
console.error("Setup failed:", error);
process.exit(1);
});
}

View File

@@ -645,7 +645,7 @@ APP_URL=https://phaseflow.yourdomain.com
NODE_ENV=production
# PocketBase
POCKETBASE_URL=http://localhost:8090
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
# Email (Resend)
RESEND_API_KEY=xxx
@@ -700,7 +700,7 @@ job "phaseflow" {
data = <<EOF
{{ with nomadVar "nomad/jobs/phaseflow" }}
APP_URL={{ .app_url }}
POCKETBASE_URL={{ .pocketbase_url }}
NEXT_PUBLIC_POCKETBASE_URL={{ .pocketbase_url }}
RESEND_API_KEY={{ .resend_key }}
ENCRYPTION_KEY={{ .encryption_key }}
CRON_SECRET={{ .cron_secret }}
@@ -835,7 +835,7 @@ The following are **out of scope** for MVP:
| Hormonal birth control | May disrupt natural cycle phases |
| API versioning | Single version; breaking changes via deprecation |
| Formal API documentation | Endpoints documented in spec only |
| E2E tests | Unit + integration tests only (authorized skip) |
| Multi-user support | Single-user design only |
---

View File

@@ -9,7 +9,7 @@ When I access PhaseFlow, I want to securely log in with my identity provider, so
Using PocketBase for authentication and data storage, with OIDC (Pocket-ID) as the primary identity provider.
**Connection:**
- `POCKETBASE_URL` environment variable
- `NEXT_PUBLIC_POCKETBASE_URL` environment variable
- `src/lib/pocketbase.ts` initializes client
## Login Flow
@@ -41,8 +41,10 @@ POCKETBASE_OIDC_ISSUER_URL=https://id.yourdomain.com
### Session Management
- PocketBase manages session tokens automatically
- Auth state persisted in browser (cookie/localStorage)
- PocketBase SDK stores auth in localStorage by default
- `pb.authStore.onChange()` listener syncs auth state to `pb_auth` cookie
- Cookie enables Next.js middleware to check auth server-side
- Cookie is non-HttpOnly (PocketBase SDK requires client-side access)
- Session expires after 14 days of inactivity
## Pages
@@ -73,7 +75,7 @@ All routes except `/login` require authentication.
API routes access current user via:
```typescript
const pb = new PocketBase(process.env.POCKETBASE_URL);
const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL);
// Auth token from request cookies
const user = pb.authStore.model;
```
@@ -159,7 +161,7 @@ User profile management:
- [ ] GET `/api/user` returns current user data
- [ ] PATCH `/api/user` updates user record
- [ ] Logout clears session completely
- [ ] Auth cookie is HttpOnly and Secure
- [ ] Auth cookie syncs via onChange listener (non-HttpOnly per PocketBase design)
## Future Enhancements

View File

@@ -6,7 +6,7 @@ When I make changes to the codebase, I want automated tests to catch regressions
## Testing Strategy
PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are not required for MVP (authorized skip).
PhaseFlow uses a **three-tier testing approach**: unit tests, integration tests, and end-to-end tests.
### Test Types
@@ -14,6 +14,7 @@ PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are
|------|-------|-------|----------|
| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` |
| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` |
| E2E | Full user flows, browser interactions | Playwright | `e2e/*.spec.ts` |
## Framework
@@ -148,9 +149,96 @@ describe('GET /api/today', () => {
});
```
## End-to-End Tests
Test complete user flows through the browser using Playwright.
### Framework
**Playwright** - Cross-browser E2E testing with auto-waiting and tracing.
**Configuration (`playwright.config.ts`):**
```typescript
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
```
### Priority Targets
| Flow | Tests |
|------|-------|
| Authentication | Login page loads, login redirects, logout works |
| Dashboard | Shows decision, displays cycle info, override toggles work |
| Settings | Garmin token paste, preferences save |
| Calendar | ICS feed accessible, calendar view renders |
| Period logging | "Period Started" updates cycle |
### Example Test
```typescript
// e2e/dashboard.spec.ts
import { expect, test } from "@playwright/test";
test.describe("dashboard", () => {
test("shows training decision for authenticated user", async ({ page }) => {
// Login first (or use auth state)
await page.goto("/");
// Verify decision card is visible
await expect(page.getByTestId("decision-card")).toBeVisible();
// Verify cycle day is displayed
await expect(page.getByText(/Day \d+ of \d+/)).toBeVisible();
});
test("override toggles update decision", async ({ page }) => {
await page.goto("/");
// Enable flare mode
await page.getByLabel("Flare Mode").click();
// Decision should change to gentle
await expect(page.getByText(/GENTLE/)).toBeVisible();
});
});
```
### NixOS Setup
E2E tests require browser binaries. On NixOS, use `playwright-web-flake`:
```nix
# In flake.nix inputs
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
# In devShell
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
```
## File Naming
Tests colocated with source files:
Tests colocated with source files (unit/integration) or in `e2e/` directory (E2E):
```
src/
@@ -164,22 +252,33 @@ src/
today/
route.ts
route.test.ts
e2e/
smoke.spec.ts
dashboard.spec.ts
auth.spec.ts
settings.spec.ts
```
## Running Tests
```bash
# Run all tests
npm test
# Run with coverage
npm run test:coverage
# Run unit/integration tests
pnpm test:run
# Run in watch mode
npm run test:watch
pnpm test
# Run specific file
npm test -- src/lib/cycle.test.ts
# Run E2E tests (headless)
pnpm test:e2e
# Run E2E tests (visible browser)
pnpm test:e2e:headed
# Run E2E tests with UI mode
pnpm test:e2e:ui
# Run all tests (unit + E2E)
pnpm test:run && pnpm test:e2e
```
## Coverage Expectations
@@ -190,15 +289,20 @@ No strict coverage thresholds for MVP, but aim for:
## Success Criteria
1. All tests pass in CI before merge
2. Core decision engine logic has comprehensive tests
1. All tests (unit, integration, E2E) pass in CI before merge
2. Core decision engine logic has comprehensive unit tests
3. Phase scaling tested for multiple cycle lengths
4. API auth tested for protected routes
5. Critical user flows covered by E2E tests
## Acceptance Tests
- [ ] `npm test` runs without errors
- [ ] `pnpm test:run` runs without errors
- [ ] `pnpm test:e2e` runs without errors
- [ ] Unit tests cover decision engine logic
- [ ] Unit tests cover cycle phase calculations
- [ ] Integration tests verify API authentication
- [ ] E2E tests verify login flow
- [ ] E2E tests verify dashboard displays correctly
- [ ] E2E tests verify period logging works
- [ ] Tests run in CI pipeline

View File

@@ -0,0 +1,125 @@
// ABOUTME: Tests for the logout API endpoint.
// ABOUTME: Verifies session clearing and cookie deletion for user logout.
import { cookies } from "next/headers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock next/headers
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
// Mock logger
vi.mock("@/lib/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe("POST /api/auth/logout", () => {
let mockCookieStore: {
get: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
set: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
mockCookieStore = {
get: vi.fn(),
delete: vi.fn(),
set: vi.fn(),
};
vi.mocked(cookies).mockResolvedValue(
mockCookieStore as unknown as Awaited<ReturnType<typeof cookies>>,
);
});
afterEach(() => {
vi.resetModules();
});
it("should clear the pb_auth cookie", async () => {
mockCookieStore.get.mockReturnValue({ value: "some_auth_token" });
const { POST } = await import("./route");
const request = new Request("http://localhost/api/auth/logout", {
method: "POST",
});
const response = await POST(request as unknown as Request);
expect(mockCookieStore.delete).toHaveBeenCalledWith("pb_auth");
expect(response.status).toBe(200);
});
it("should return success response with redirect URL", async () => {
mockCookieStore.get.mockReturnValue({ value: "some_auth_token" });
const { POST } = await import("./route");
const request = new Request("http://localhost/api/auth/logout", {
method: "POST",
});
const response = await POST(request as unknown as Request);
const data = await response.json();
expect(data).toEqual({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
});
});
it("should succeed even when no session exists", async () => {
mockCookieStore.get.mockReturnValue(undefined);
const { POST } = await import("./route");
const request = new Request("http://localhost/api/auth/logout", {
method: "POST",
});
const response = await POST(request as unknown as Request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
});
it("should log logout event", async () => {
mockCookieStore.get.mockReturnValue({ value: "some_auth_token" });
const { logger } = await import("@/lib/logger");
const { POST } = await import("./route");
const request = new Request("http://localhost/api/auth/logout", {
method: "POST",
});
await POST(request as unknown as Request);
expect(logger.info).toHaveBeenCalledWith("User logged out");
});
it("should handle errors gracefully", async () => {
mockCookieStore.delete.mockImplementation(() => {
throw new Error("Cookie deletion failed");
});
const { logger } = await import("@/lib/logger");
const { POST } = await import("./route");
const request = new Request("http://localhost/api/auth/logout", {
method: "POST",
});
const response = await POST(request as unknown as Request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Logout failed");
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,33 @@
// ABOUTME: Logout API endpoint that clears user session.
// ABOUTME: Deletes auth cookie and returns success with redirect URL.
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { logger } from "@/lib/logger";
/**
* POST /api/auth/logout
*
* Clears the user's authentication session by deleting the pb_auth cookie.
* Returns a success response with redirect URL.
*/
export async function POST(_request: Request): Promise<NextResponse> {
try {
const cookieStore = await cookies();
// Delete the PocketBase auth cookie
cookieStore.delete("pb_auth");
logger.info("User logged out");
return NextResponse.json({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
});
} catch (error) {
logger.error({ err: error }, "Logout failed");
return NextResponse.json({ error: "Logout failed" }, { status: 500 });
}
}

View File

@@ -4,15 +4,18 @@
import type { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/types";
import type { PeriodLog, User } from "@/types";
// Module-level variable to control mock user lookup
let mockUsers: Map<string, User> = new Map();
let mockPeriodLogs: PeriodLog[] = [];
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
collection: vi.fn((name: string) => {
if (name === "users") {
return {
getOne: vi.fn((userId: string) => {
const user = mockUsers.get(userId);
if (!user) {
@@ -24,12 +27,29 @@ vi.mock("@/lib/pocketbase", () => ({
id: user.id,
email: user.email,
calendarToken: user.calendarToken,
lastPeriodDate: user.lastPeriodDate.toISOString(),
// biome-ignore lint/style/noNonNullAssertion: mock user has valid date
lastPeriodDate: user.lastPeriodDate!.toISOString(),
cycleLength: user.cycleLength,
garminConnected: user.garminConnected,
};
}),
};
}
if (name === "period_logs") {
return {
getFullList: vi.fn(() =>
mockPeriodLogs.map((log) => ({
id: log.id,
user: log.user,
startDate: log.startDate.toISOString(),
predictedDate: log.predictedDate?.toISOString() ?? null,
created: log.created.toISOString(),
})),
),
};
}
return {};
}),
})),
}));
@@ -59,12 +79,18 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "valid-calendar-token-abc123def",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -73,6 +99,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
vi.clearAllMocks();
mockUsers = new Map();
mockUsers.set("user123", mockUser);
mockPeriodLogs = [];
});
// Helper to create route context with params
@@ -228,4 +255,47 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
expect(response.status).toBe(401);
});
it("passes period logs to ICS generator for prediction accuracy", async () => {
mockPeriodLogs = [
{
id: "log1",
user: "user123",
startDate: new Date("2025-01-10"),
predictedDate: new Date("2025-01-12"), // 2 days early
created: new Date("2025-01-10"),
},
{
id: "log2",
user: "user123",
startDate: new Date("2024-12-15"),
predictedDate: null, // First log, no prediction
created: new Date("2024-12-15"),
},
];
const mockRequest = {} as NextRequest;
const context = createRouteContext(
"user123",
"valid-calendar-token-abc123def",
);
await GET(mockRequest, context);
expect(mockGenerateIcsFeed).toHaveBeenCalledWith(
expect.objectContaining({
periodLogs: expect.arrayContaining([
expect.objectContaining({
id: "log1",
startDate: expect.any(Date),
predictedDate: expect.any(Date),
}),
expect.objectContaining({
id: "log2",
predictedDate: null,
}),
]),
}),
);
});
});

View File

@@ -3,6 +3,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { generateIcsFeed } from "@/lib/ics";
import { logger } from "@/lib/logger";
import { createPocketBaseClient } from "@/lib/pocketbase";
interface RouteParams {
@@ -13,11 +14,13 @@ interface RouteParams {
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { userId, token } = await params;
const { userId, token: rawToken } = await params;
// Strip .ics suffix if present (Next.js may include it in the param)
const token = rawToken.endsWith(".ics") ? rawToken.slice(0, -4) : rawToken;
const pb = createPocketBaseClient();
try {
// Fetch user from database
const pb = createPocketBaseClient();
const user = await pb.collection("users").getOne(userId);
// Check if user has a calendar token set
@@ -36,11 +39,26 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
);
}
// Fetch period logs for prediction accuracy display
const periodLogs = await pb.collection("period_logs").getFullList({
filter: `user = "${userId}"`,
sort: "-startDate",
});
// Generate ICS feed with 90 days of events (3 months)
const icsContent = generateIcsFeed({
lastPeriodDate: new Date(user.lastPeriodDate as string),
cycleLength: user.cycleLength as number,
monthsAhead: 3,
periodLogs: periodLogs.map((log) => ({
id: log.id,
user: log.user as string,
startDate: new Date(log.startDate as string),
predictedDate: log.predictedDate
? new Date(log.predictedDate as string)
: null,
created: new Date(log.created as string),
})),
});
// Return ICS content with appropriate headers
@@ -61,8 +79,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Re-throw unexpected errors
console.error("Calendar feed error:", error);
logger.error({ err: error, userId }, "Calendar feed error");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },

View File

@@ -12,14 +12,12 @@ let currentMockUser: User | null = null;
// Track PocketBase update calls
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
update: mockPbUpdate,
})),
})),
}));
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
@@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -43,12 +41,18 @@ describe("POST /api/calendar/regenerate-token", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "old-calendar-token-abc123",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};

View File

@@ -5,7 +5,6 @@ import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { createPocketBaseClient } from "@/lib/pocketbase";
/**
* Generates a cryptographically secure random 32-character alphanumeric token.
@@ -17,12 +16,11 @@ function generateToken(): string {
return randomBytes(32).toString("hex").slice(0, 32);
}
export const POST = withAuth(async (_request, user) => {
export const POST = withAuth(async (_request, user, pb) => {
// Generate new random token
const newToken = generateToken();
// Update user record with new token
const pb = createPocketBaseClient();
await pb.collection("users").update(user.id, {
calendarToken: newToken,
});

View File

@@ -1,6 +1,6 @@
// ABOUTME: Unit tests for Garmin sync cron endpoint.
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/types";
@@ -8,6 +8,21 @@ import type { User } from "@/types";
let mockUsers: User[] = [];
// Track DailyLog creations
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
// Track user updates
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Track DailyLog queries for upsert
const mockGetFirstListItem = vi.fn();
// Track the filter string passed to getFirstListItem
let lastDailyLogFilter: string | null = null;
// Helper to parse date values - handles both Date objects and ISO strings
function parseDate(value: unknown): Date | null {
if (!value) return null;
if (value instanceof Date) return value;
if (typeof value !== "string") return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
@@ -19,16 +34,54 @@ vi.mock("@/lib/pocketbase", () => ({
}
return [];
}),
getFirstListItem: vi.fn(async (filter: string) => {
if (name === "dailyLogs") {
lastDailyLogFilter = filter;
}
return mockGetFirstListItem(filter);
}),
create: mockPbCreate,
update: mockPbUpdate,
authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }),
})),
})),
mapRecordToUser: vi.fn((record: Record<string, unknown>) => ({
id: record.id,
email: record.email,
garminConnected: record.garminConnected,
garminOauth1Token: record.garminOauth1Token,
garminOauth2Token: record.garminOauth2Token,
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
calendarToken: record.calendarToken,
lastPeriodDate: parseDate(record.lastPeriodDate),
cycleLength: record.cycleLength,
notificationTime: record.notificationTime,
timezone: record.timezone,
activeOverrides: record.activeOverrides || [],
intensityGoalMenstrual: (record.intensityGoalMenstrual as number) ?? 75,
intensityGoalFollicular: (record.intensityGoalFollicular as number) ?? 150,
intensityGoalOvulation: (record.intensityGoalOvulation as number) ?? 100,
intensityGoalEarlyLuteal:
(record.intensityGoalEarlyLuteal as number) ?? 120,
intensityGoalLateLuteal: (record.intensityGoalLateLuteal as number) ?? 50,
created: new Date(record.created as string),
updated: new Date(record.updated as string),
})),
}));
// Mock decryption
const mockDecrypt = vi.fn((ciphertext: string) => {
// Return mock OAuth2 token JSON
if (ciphertext.includes("oauth2")) {
return JSON.stringify({ accessToken: "mock-token-123" });
return JSON.stringify({ access_token: "mock-token-123" });
}
// Return mock OAuth1 token JSON (needed for refresh flow)
if (ciphertext.includes("oauth1")) {
return JSON.stringify({
oauth_token: "mock-oauth1-token",
oauth_token_secret: "mock-oauth1-secret",
});
}
return ciphertext.replace("encrypted:", "");
});
@@ -44,6 +97,7 @@ const mockFetchBodyBattery = vi
.mockResolvedValue({ current: 85, yesterdayLow: 45 });
const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60);
const mockIsTokenExpired = vi.fn().mockReturnValue(false);
const mockDaysUntilExpiry = vi.fn().mockReturnValue(30);
vi.mock("@/lib/garmin", () => ({
fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args),
@@ -51,6 +105,30 @@ vi.mock("@/lib/garmin", () => ({
fetchIntensityMinutes: (...args: unknown[]) =>
mockFetchIntensityMinutes(...args),
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args),
}));
// Mock email sending
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({
sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args),
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
sendPeriodConfirmationEmail: (...args: unknown[]) =>
mockSendPeriodConfirmationEmail(...args),
}));
// Mock logger (required for route to run without side effects)
vi.mock("@/lib/logger", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
import { POST } from "./route";
@@ -67,12 +145,18 @@ describe("POST /api/cron/garmin-sync", () => {
garminOauth1Token: "encrypted:oauth1-token",
garminOauth2Token: "encrypted:oauth2-token",
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 28,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
...overrides,
@@ -92,8 +176,18 @@ describe("POST /api/cron/garmin-sync", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
mockUsers = [];
lastDailyLogFilter = null;
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
// Default: no existing dailyLog found (404)
const notFoundError = new Error("Record not found");
(notFoundError as { status?: number }).status = 404;
mockGetFirstListItem.mockRejectedValue(notFoundError);
process.env.CRON_SECRET = validSecret;
process.env.POCKETBASE_ADMIN_EMAIL = "admin@test.com";
process.env.POCKETBASE_ADMIN_PASSWORD = "test-password";
});
describe("Authentication", () => {
@@ -120,6 +214,26 @@ describe("POST /api/cron/garmin-sync", () => {
expect(response.status).toBe(401);
});
it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => {
process.env.POCKETBASE_ADMIN_EMAIL = "";
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Server misconfiguration");
});
it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => {
process.env.POCKETBASE_ADMIN_PASSWORD = "";
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Server misconfiguration");
});
});
describe("User fetching", () => {
@@ -156,6 +270,37 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.usersProcessed).toBe(0);
expect(body.success).toBe(true);
});
it("handles date fields as ISO strings from PocketBase", async () => {
// PocketBase returns date fields as ISO strings, not Date objects
// This simulates the raw response from pb.collection("users").getFullList()
const rawPocketBaseRecord = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted:oauth1-token",
garminOauth2Token: "encrypted:oauth2-token",
garminTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date
garminRefreshTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date
calendarToken: "cal-token",
lastPeriodDate: "2025-01-01T00:00:00.000Z", // ISO string, not Date
cycleLength: 28,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
created: "2024-01-01T00:00:00.000Z",
updated: "2025-01-10T00:00:00.000Z",
};
// Cast to User to simulate what getFullList<User>() returns
mockUsers = [rawPocketBaseRecord as unknown as User];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.usersProcessed).toBe(1);
expect(body.errors).toBe(0);
});
});
describe("Token handling", () => {
@@ -167,9 +312,12 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
});
it("skips users with expired tokens", async () => {
mockIsTokenExpired.mockReturnValue(true);
mockUsers = [createMockUser()];
it("skips users with expired refresh tokens", async () => {
// Set refresh token to expired (in the past)
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -214,12 +362,15 @@ describe("POST /api/cron/garmin-sync", () => {
);
});
it("fetches intensity minutes", async () => {
it("fetches intensity minutes with today's date", async () => {
mockUsers = [createMockUser()];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockFetchIntensityMinutes).toHaveBeenCalledWith("mock-token-123");
expect(mockFetchIntensityMinutes).toHaveBeenCalledWith(
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
"mock-token-123",
);
});
});
@@ -289,8 +440,10 @@ describe("POST /api/cron/garmin-sync", () => {
);
});
it("sets date to today's date string", async () => {
it("sets date to YYYY-MM-DD format string", async () => {
mockUsers = [createMockUser()];
// Simple YYYY-MM-DD format for PocketBase date field compatibility
// PocketBase filters don't accept ISO format with T separator
const today = new Date().toISOString().split("T")[0];
await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -303,6 +456,78 @@ describe("POST /api/cron/garmin-sync", () => {
});
});
describe("DailyLog upsert behavior", () => {
it("uses range query to find existing dailyLog", async () => {
mockUsers = [createMockUser()];
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 86400000)
.toISOString()
.split("T")[0];
await POST(createMockRequest(`Bearer ${validSecret}`));
// Should use range query with >= and < operators, not exact match
expect(lastDailyLogFilter).toContain(`date>="${today}"`);
expect(lastDailyLogFilter).toContain(`date<"${tomorrow}"`);
expect(lastDailyLogFilter).toContain('user="user123"');
});
it("updates existing dailyLog when found", async () => {
mockUsers = [createMockUser()];
// Existing dailyLog found
mockGetFirstListItem.mockResolvedValue({ id: "existing-log-123" });
await POST(createMockRequest(`Bearer ${validSecret}`));
// Should update, not create
expect(mockPbUpdate).toHaveBeenCalledWith(
"existing-log-123",
expect.objectContaining({
user: "user123",
hrvStatus: "Balanced",
}),
);
expect(mockPbCreate).not.toHaveBeenCalled();
});
it("creates new dailyLog only when not found (404)", async () => {
mockUsers = [createMockUser()];
// No existing dailyLog (404 error)
const notFoundError = new Error("Record not found");
(notFoundError as { status?: number }).status = 404;
mockGetFirstListItem.mockRejectedValue(notFoundError);
await POST(createMockRequest(`Bearer ${validSecret}`));
// Should create, not update
expect(mockPbCreate).toHaveBeenCalledWith(
expect.objectContaining({
user: "user123",
}),
);
expect(mockPbUpdate).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ user: "user123" }),
);
});
it("propagates non-404 errors from getFirstListItem", async () => {
mockUsers = [createMockUser()];
// Database error (not 404)
const dbError = new Error("Database connection failed");
(dbError as { status?: number }).status = 500;
mockGetFirstListItem.mockRejectedValue(dbError);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
// Should not try to create a new record
expect(mockPbCreate).not.toHaveBeenCalled();
// Should count as error
const body = await response.json();
expect(body.errors).toBe(1);
});
});
describe("Error handling", () => {
it("continues processing other users when one fails", async () => {
mockUsers = [
@@ -335,7 +560,10 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.errors).toBe(1);
});
it("handles body battery null values", async () => {
it("stores null when body battery is null from Garmin", async () => {
// When Garmin API returns null for body battery values (no data available),
// we store null and the UI displays "N/A". The decision engine skips
// body battery rules when values are null.
mockUsers = [createMockUser()];
mockFetchBodyBattery.mockResolvedValue({
current: null,
@@ -381,5 +609,176 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.timestamp).toBeDefined();
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", () => {
// Use fake timers to ensure consistent date calculations
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
// Helper to create a date N days from now
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
it("sends warning email when refresh token expires in exactly 14 days", async () => {
mockUsers = [
createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(14),
}),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
14,
"user123",
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
});
it("sends warning email when refresh token expires in exactly 7 days", async () => {
mockUsers = [
createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(7),
}),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
7,
"user123",
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
});
it("does not send warning when refresh token expires in 30 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when refresh token expires in 15 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when refresh token expires in 8 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when refresh token expires in 6 days", async () => {
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(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",
garminRefreshTokenExpiresAt: daysFromNow(14),
}),
createMockUser({
id: "user2",
email: "user2@example.com",
garminRefreshTokenExpiresAt: daysFromNow(7),
}),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user1@example.com",
14,
"user1",
);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user2@example.com",
7,
"user2",
);
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",
garminRefreshTokenExpiresAt: daysFromNow(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 refresh tokens", async () => {
// Expired refresh tokens are skipped entirely (not synced), so no warning
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
];
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
});
// Note: Structured logging is implemented in the route but testing the mock
// integration is complex due to vitest module hoisting. The logging calls
// (logger.info for sync start/complete, logger.error for failures) are
// verified through manual testing and code review. See route.ts lines 79, 146, 162.
});

View File

@@ -2,23 +2,35 @@
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
import { NextResponse } from "next/server";
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
import { getCycleDay, getPhase, getUserPhaseLimit } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { decrypt } from "@/lib/encryption";
import { sendTokenExpirationWarning } from "@/lib/email";
import { decrypt, encrypt } from "@/lib/encryption";
import {
fetchBodyBattery,
fetchHrvStatus,
fetchIntensityMinutes,
isTokenExpired,
} from "@/lib/garmin";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { GarminTokens, User } from "@/types";
import {
exchangeOAuth1ForOAuth2,
isAccessTokenExpired,
type OAuth1TokenData,
} from "@/lib/garmin-auth";
import { logger } from "@/lib/logger";
import {
activeUsersGauge,
garminSyncDuration,
garminSyncTotal,
} from "@/lib/metrics";
import { createPocketBaseClient, mapRecordToUser } from "@/lib/pocketbase";
interface SyncResult {
success: boolean;
usersProcessed: number;
errors: number;
skippedExpired: number;
tokensRefreshed: number;
warningsSent: number;
timestamp: string;
}
@@ -31,73 +43,175 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const syncStartTime = Date.now();
const result: SyncResult = {
success: true,
usersProcessed: 0,
errors: 0,
skippedExpired: 0,
tokensRefreshed: 0,
warningsSent: 0,
timestamp: new Date().toISOString(),
};
const pb = createPocketBaseClient();
// Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues)
const allUsers = await pb.collection("users").getFullList<User>();
const users = allUsers.filter((u) => u.garminConnected);
// Authenticate as admin to bypass API rules and list all users
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
logger.error("Missing POCKETBASE_ADMIN_EMAIL or POCKETBASE_ADMIN_PASSWORD");
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
try {
await pb
.collection("_superusers")
.authWithPassword(adminEmail, adminPassword);
} catch (authError) {
logger.error(
{ err: authError },
"Failed to authenticate as PocketBase admin",
);
return NextResponse.json(
{ error: "Database authentication failed" },
{ status: 500 },
);
}
// Fetch all users and map to typed User objects (PocketBase returns dates as strings)
// Filter to users with Garmin connected and required date fields
const rawUsers = await pb.collection("users").getFullList();
const allUsers = rawUsers.map(mapRecordToUser);
const users = allUsers.filter(
(u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate,
);
// YYYY-MM-DD format for both Garmin API calls and PocketBase storage
// PocketBase date filters don't accept ISO format with T separator
const today = new Date().toISOString().split("T")[0];
for (const user of users) {
try {
// Check if tokens are expired
const tokens: GarminTokens = {
oauth1: user.garminOauth1Token,
oauth2: user.garminOauth2Token,
expires_at: user.garminTokenExpiresAt.toISOString(),
};
const userSyncStartTime = Date.now();
if (isTokenExpired(tokens)) {
try {
// Check if refresh token is expired (user needs to re-auth via Python script)
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
if (user.garminRefreshTokenExpiresAt) {
const refreshTokenExpired =
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
if (refreshTokenExpired) {
logger.info(
{ userId: user.id },
"Refresh token expired, skipping user",
);
result.skippedExpired++;
continue;
}
}
// Decrypt OAuth2 token
// Log sync start
logger.info({ userId: user.id }, "Garmin sync start");
// Check for refresh token expiration warnings (exactly 14 or 7 days)
if (user.garminRefreshTokenExpiresAt) {
const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt);
const now = new Date();
const diffMs = refreshExpiry.getTime() - now.getTime();
const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (daysRemaining === 14 || daysRemaining === 7) {
try {
await sendTokenExpirationWarning(
user.email,
daysRemaining,
user.id,
);
result.warningsSent++;
} catch {
// Continue processing even if warning email fails
}
}
}
// Decrypt tokens
const oauth1Json = decrypt(user.garminOauth1Token);
const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData;
const oauth2Json = decrypt(user.garminOauth2Token);
const oauth2Data = JSON.parse(oauth2Json);
const accessToken = oauth2Data.accessToken;
let oauth2Data = JSON.parse(oauth2Json);
// Check if access token needs refresh
// biome-ignore lint/style/noNonNullAssertion: filtered above
const accessTokenExpiresAt = user.garminTokenExpiresAt!;
if (isAccessTokenExpired(accessTokenExpiresAt)) {
logger.info({ userId: user.id }, "Access token expired, refreshing");
try {
const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data);
oauth2Data = refreshResult.oauth2;
// Update stored tokens
const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data));
await pb.collection("users").update(user.id, {
garminOauth2Token: encryptedOauth2,
garminTokenExpiresAt: refreshResult.expires_at,
garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at,
});
result.tokensRefreshed++;
logger.info({ userId: user.id }, "Access token refreshed");
} catch (refreshError) {
logger.error(
{ userId: user.id, err: refreshError },
"Failed to refresh access token",
);
result.errors++;
garminSyncTotal.inc({ status: "failure" });
continue;
}
}
const accessToken = oauth2Data.access_token;
// Fetch Garmin data
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
fetchHrvStatus(today, accessToken),
fetchBodyBattery(today, accessToken),
fetchIntensityMinutes(accessToken),
fetchIntensityMinutes(today, accessToken),
]);
// Calculate cycle info
// Calculate cycle info (lastPeriodDate guaranteed non-null by filter above)
const cycleDay = getCycleDay(
user.lastPeriodDate,
// biome-ignore lint/style/noNonNullAssertion: filtered above
user.lastPeriodDate!,
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const phaseLimit = getPhaseLimit(phase);
const phase = getPhase(cycleDay, user.cycleLength);
const phaseLimit = getUserPhaseLimit(phase, user);
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
// Calculate training decision
// Pass null body battery values through - decision engine handles null gracefully
const decision = getDecisionWithOverrides(
{
hrvStatus,
bbYesterdayLow: bodyBattery.yesterdayLow ?? 100,
bbYesterdayLow: bodyBattery.yesterdayLow,
phase,
weekIntensity: weekIntensityMinutes,
phaseLimit,
bbCurrent: bodyBattery.current ?? 100,
bbCurrent: bodyBattery.current,
},
user.activeOverrides,
);
// Create DailyLog entry
await pb.collection("dailyLogs").create({
// Upsert DailyLog entry - update existing record for today or create new one
// Store null for body battery when Garmin returns null - the UI displays "N/A"
// and the decision engine skips body battery rules when values are null.
// Use YYYY-MM-DD format for PocketBase date field compatibility
const dailyLogData = {
user: user.id,
date: today,
cycleDay,
@@ -111,13 +225,69 @@ export async function POST(request: Request) {
trainingDecision: decision.status,
decisionReason: decision.reason,
notificationSentAt: null,
});
};
// Check if record already exists for this user today
// Use range query (>= and <) to match the today route query pattern
// This ensures we find records regardless of how the date was stored
const tomorrow = new Date(Date.now() + 86400000)
.toISOString()
.split("T")[0];
try {
const existing = await pb
.collection("dailyLogs")
.getFirstListItem(
`user="${user.id}" && date>="${today}" && date<"${tomorrow}"`,
);
await pb.collection("dailyLogs").update(existing.id, dailyLogData);
logger.info(
{ userId: user.id, dailyLogId: existing.id },
"DailyLog updated",
);
} catch (err) {
// Check if it's a 404 (not found) vs other error
if ((err as { status?: number }).status === 404) {
const created = await pb.collection("dailyLogs").create(dailyLogData);
logger.info(
{ userId: user.id, dailyLogId: created.id },
"DailyLog created",
);
} else {
// Propagate non-404 errors
logger.error({ userId: user.id, err }, "Failed to upsert dailyLog");
throw err;
}
}
// Log sync complete with metrics
const userSyncDuration = Date.now() - userSyncStartTime;
logger.info(
{
userId: user.id,
duration_ms: userSyncDuration,
metrics: {
bodyBattery: bodyBattery.current,
hrvStatus,
},
},
"Garmin sync complete",
);
result.usersProcessed++;
} catch {
garminSyncTotal.inc({ status: "success" });
} catch (error) {
// Log sync failure
logger.error({ userId: user.id, err: error }, "Garmin sync failure");
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);
}

View File

@@ -9,7 +9,8 @@ let mockUsers: User[] = [];
let mockDailyLogs: DailyLog[] = [];
const mockPbUpdate = vi.fn().mockResolvedValue({ id: "log123" });
// Mock PocketBase
// Mock PocketBase with admin auth
const mockAuthWithPassword = vi.fn().mockResolvedValue({ id: "admin" });
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn((name: string) => ({
@@ -23,15 +24,32 @@ vi.mock("@/lib/pocketbase", () => ({
return [];
}),
update: mockPbUpdate,
authWithPassword: (email: string, password: string) =>
mockAuthWithPassword(email, password),
})),
})),
}));
// Mock logger
vi.mock("@/lib/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock email sending
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args),
sendPeriodConfirmationEmail: (...args: unknown[]) =>
mockSendPeriodConfirmationEmail(...args),
}));
import { POST } from "./route";
@@ -48,12 +66,18 @@ describe("POST /api/cron/notifications", () => {
garminOauth1Token: "encrypted:oauth1-token",
garminOauth2Token: "encrypted:oauth2-token",
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
...overrides,
@@ -98,6 +122,9 @@ describe("POST /api/cron/notifications", () => {
mockUsers = [];
mockDailyLogs = [];
process.env.CRON_SECRET = validSecret;
process.env.POCKETBASE_ADMIN_EMAIL = "admin@example.com";
process.env.POCKETBASE_ADMIN_PASSWORD = "admin-password";
mockAuthWithPassword.mockResolvedValue({ id: "admin" });
// Mock current time to 07:00 UTC
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
@@ -131,6 +158,36 @@ describe("POST /api/cron/notifications", () => {
expect(response.status).toBe(401);
});
it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => {
process.env.POCKETBASE_ADMIN_EMAIL = "";
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Server misconfiguration");
});
it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => {
process.env.POCKETBASE_ADMIN_PASSWORD = "";
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Server misconfiguration");
});
it("returns 500 when PocketBase admin auth fails", async () => {
mockAuthWithPassword.mockRejectedValueOnce(new Error("Auth failed"));
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Database authentication failed");
});
});
describe("User time matching", () => {
@@ -193,6 +250,112 @@ describe("POST /api/cron/notifications", () => {
});
});
describe("Quarter-hour time matching", () => {
it("sends notification at exact 15-minute slot (07:15)", async () => {
// Current time is 07:15 UTC
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
mockUsers = [
createMockUser({ notificationTime: "07:15", timezone: "UTC" }),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).toHaveBeenCalled();
});
it("rounds down notification time to nearest 15-minute slot (07:10 -> 07:00)", async () => {
// Current time is 07:00 UTC
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
// User set 07:10, which rounds down to 07:00 slot
mockUsers = [
createMockUser({ notificationTime: "07:10", timezone: "UTC" }),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).toHaveBeenCalled();
});
it("rounds down notification time (07:29 -> 07:15)", async () => {
// Current time is 07:15 UTC
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
// User set 07:29, which rounds down to 07:15 slot
mockUsers = [
createMockUser({ notificationTime: "07:29", timezone: "UTC" }),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).toHaveBeenCalled();
});
it("does not send notification when minute slot does not match", async () => {
// Current time is 07:00 UTC
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
// User wants 07:15, but current slot is 07:00
mockUsers = [
createMockUser({ notificationTime: "07:15", timezone: "UTC" }),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).not.toHaveBeenCalled();
});
it("handles 30-minute slot correctly", async () => {
// Current time is 07:30 UTC
vi.setSystemTime(new Date("2025-01-15T07:30:00Z"));
mockUsers = [
createMockUser({ notificationTime: "07:30", timezone: "UTC" }),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).toHaveBeenCalled();
});
it("handles 45-minute slot correctly", async () => {
// Current time is 07:45 UTC
vi.setSystemTime(new Date("2025-01-15T07:45:00Z"));
mockUsers = [
createMockUser({ notificationTime: "07:45", timezone: "UTC" }),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).toHaveBeenCalled();
});
it("handles timezone with 15-minute matching", async () => {
// Current time is 07:15 UTC = 02:15 America/New_York (EST is UTC-5)
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
mockUsers = [
createMockUser({
notificationTime: "02:15",
timezone: "America/New_York",
}),
];
mockDailyLogs = [createMockDailyLog()];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
expect(mockSendDailyEmail).toHaveBeenCalled();
});
});
describe("DailyLog handling", () => {
it("does not send notification if no DailyLog exists for today", async () => {
mockUsers = [

View File

@@ -3,6 +3,7 @@
import { NextResponse } from "next/server";
import { sendDailyEmail } from "@/lib/email";
import { logger } from "@/lib/logger";
import { getNutritionGuidance } from "@/lib/nutrition";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyLog, DecisionStatus, User } from "@/types";
@@ -17,19 +18,40 @@ interface NotificationResult {
timestamp: string;
}
// Get the current hour in a specific timezone
function getCurrentHourInTimezone(timezone: string): number {
// Get current quarter-hour slot (0, 15, 30, 45) in user's timezone
function getCurrentQuarterHourSlot(timezone: string): {
hour: number;
minute: number;
} {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "numeric",
minute: "numeric",
hour12: false,
});
return parseInt(formatter.format(new Date()), 10);
const parts = formatter.formatToParts(new Date());
const hour = Number.parseInt(
parts.find((p) => p.type === "hour")?.value ?? "0",
10,
);
const minute = Number.parseInt(
parts.find((p) => p.type === "minute")?.value ?? "0",
10,
);
// Round down to nearest 15-min slot
const slot = Math.floor(minute / 15) * 15;
return { hour, minute: slot };
}
// Extract hour from "HH:MM" format
function getNotificationHour(notificationTime: string): number {
return parseInt(notificationTime.split(":")[0], 10);
// Extract quarter-hour slot from "HH:MM" format
function getNotificationSlot(notificationTime: string): {
hour: number;
minute: number;
} {
const [h, m] = notificationTime.split(":").map(Number);
// Round down to nearest 15-min slot
const slot = Math.floor(m / 15) * 15;
return { hour: h, minute: slot };
}
// Map decision status to icon
@@ -69,8 +91,35 @@ export async function POST(request: Request) {
const pb = createPocketBaseClient();
// Authenticate as admin to bypass API rules and list all users
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
logger.error("Missing POCKETBASE_ADMIN_EMAIL or POCKETBASE_ADMIN_PASSWORD");
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
try {
await pb
.collection("_superusers")
.authWithPassword(adminEmail, adminPassword);
} catch (authError) {
logger.error(
{ err: authError },
"Failed to authenticate as PocketBase admin",
);
return NextResponse.json(
{ error: "Database authentication failed" },
{ status: 500 },
);
}
// Fetch all users
const users = await pb.collection("users").getFullList<User>();
logger.info({ userCount: users.length }, "Fetched users for notifications");
// Get today's date for querying daily logs
const today = new Date().toISOString().split("T")[0];
@@ -95,11 +144,14 @@ export async function POST(request: Request) {
for (const user of users) {
try {
// Check if current hour in user's timezone matches their notification time
const currentHour = getCurrentHourInTimezone(user.timezone);
const notificationHour = getNotificationHour(user.notificationTime);
// Check if current quarter-hour slot in user's timezone matches their notification time
const currentSlot = getCurrentQuarterHourSlot(user.timezone);
const notificationSlot = getNotificationSlot(user.notificationTime);
if (currentHour !== notificationHour) {
if (
currentSlot.hour !== notificationSlot.hour ||
currentSlot.minute !== notificationSlot.minute
) {
result.skippedWrongTime++;
continue;
}
@@ -121,7 +173,8 @@ export async function POST(request: Request) {
const nutrition = getNutritionGuidance(dailyLog.cycleDay);
// Send email
await sendDailyEmail({
await sendDailyEmail(
{
to: user.email,
cycleDay: dailyLog.cycleDay,
phase: dailyLog.phase,
@@ -139,7 +192,9 @@ export async function POST(request: Request) {
seeds: nutrition.seeds,
carbRange: nutrition.carbRange,
ketoGuidance: nutrition.ketoGuidance,
});
},
user.id,
);
// Update notificationSentAt timestamp
await pb.collection("dailyLogs").update(dailyLog.id, {

View File

@@ -9,9 +9,29 @@ import type { User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Create mock PocketBase getOne function that returns fresh user data
const mockPbGetOne = vi.fn().mockImplementation(() => {
if (!currentMockUser) {
throw new Error("User not found");
}
return Promise.resolve({
id: currentMockUser.id,
email: currentMockUser.email,
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
cycleLength: currentMockUser.cycleLength,
});
});
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getOne: mockPbGetOne,
})),
};
// Mock PocketBase client for database operations
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({})),
createPocketBaseClient: vi.fn(() => mockPb),
loadAuthFromCookies: vi.fn(),
isAuthenticated: vi.fn(() => currentMockUser !== null),
getCurrentUser: vi.fn(() => currentMockUser),
@@ -24,7 +44,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -39,12 +59,18 @@ describe("GET /api/cycle/current", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
...overrides,
@@ -116,15 +142,16 @@ describe("GET /api/cycle/current", () => {
expect(body.phaseConfig).toBeDefined();
expect(body.phaseConfig.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120);
expect(body.phaseConfig.weeklyLimit).toBe(150);
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
expect(body.phaseConfig.days).toEqual([4, 14]);
expect(body.phaseConfig.dailyAvg).toBe(17);
// Phase configs days are for reference; actual boundaries are calculated dynamically
expect(body.phaseConfig.days).toEqual([4, 15]);
expect(body.phaseConfig.dailyAvg).toBe(21);
});
it("calculates daysUntilNextPhase correctly", async () => {
// Cycle day 10, in FOLLICULAR (days 4-14)
// Days until OVULATION starts (day 15): 15 - 10 = 5
// Cycle day 10, in FOLLICULAR (days 4-15 for 31-day cycle)
// Days until OVULATION starts (day 16): 16 - 10 = 6
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,
@@ -135,7 +162,7 @@ describe("GET /api/cycle/current", () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body.daysUntilNextPhase).toBe(5);
expect(body.daysUntilNextPhase).toBe(6);
});
it("returns correct data for MENSTRUAL phase", async () => {
@@ -152,15 +179,16 @@ describe("GET /api/cycle/current", () => {
expect(body.cycleDay).toBe(3);
expect(body.phase).toBe("MENSTRUAL");
expect(body.phaseConfig.weeklyLimit).toBe(30);
expect(body.phaseConfig.weeklyLimit).toBe(75);
expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR
});
it("returns correct data for OVULATION phase", async () => {
// Set lastPeriodDate so cycle day = 15 (start of OVULATION)
// If current is 2025-01-10, need lastPeriodDate = 2024-12-27 (14 days ago)
// For 31-day cycle, OVULATION is days 16-17
// Set lastPeriodDate so cycle day = 16 (start of OVULATION)
// If current is 2025-01-10, need lastPeriodDate = 2024-12-26 (15 days ago)
currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-27"),
lastPeriodDate: new Date("2024-12-26"),
cycleLength: 31,
});
@@ -169,10 +197,10 @@ describe("GET /api/cycle/current", () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body.cycleDay).toBe(15);
expect(body.cycleDay).toBe(16);
expect(body.phase).toBe("OVULATION");
expect(body.phaseConfig.weeklyLimit).toBe(80);
expect(body.daysUntilNextPhase).toBe(2); // Day 17 is EARLY_LUTEAL
expect(body.phaseConfig.weeklyLimit).toBe(100);
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
});
it("returns correct data for LATE_LUTEAL phase", async () => {

View File

@@ -3,41 +3,55 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import {
getCycleDay,
getPhase,
getPhaseConfig,
PHASE_CONFIGS,
} from "@/lib/cycle";
import { getCycleDay, getPhase, getPhaseConfig } from "@/lib/cycle";
// Phase boundaries per spec: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
function getNextPhaseStart(currentPhase: string, cycleLength: number): number {
switch (currentPhase) {
case "MENSTRUAL":
return 4; // FOLLICULAR starts at 4
case "FOLLICULAR":
return cycleLength - 15; // OVULATION starts at (cycleLength - 15)
case "OVULATION":
return cycleLength - 13; // EARLY_LUTEAL starts at (cycleLength - 13)
case "EARLY_LUTEAL":
return cycleLength - 6; // LATE_LUTEAL starts at (cycleLength - 6)
case "LATE_LUTEAL":
return 1; // New cycle starts
default:
return 1;
}
}
/**
* Calculates the number of days until the next phase begins.
* For LATE_LUTEAL, calculates days until new cycle starts (MENSTRUAL).
*/
function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
const currentPhase = getPhase(cycleDay);
const currentConfig = getPhaseConfig(currentPhase);
const currentPhase = getPhase(cycleDay, cycleLength);
// For LATE_LUTEAL, calculate days until new cycle
if (currentPhase === "LATE_LUTEAL") {
return cycleLength - cycleDay + 1;
}
// Find next phase start day
const currentIndex = PHASE_CONFIGS.findIndex((c) => c.name === currentPhase);
const nextConfig = PHASE_CONFIGS[currentIndex + 1];
if (nextConfig) {
return nextConfig.days[0] - cycleDay;
const nextPhaseStart = getNextPhaseStart(currentPhase, cycleLength);
return nextPhaseStart - cycleDay;
}
// Fallback: days until end of current phase + 1
return currentConfig.days[1] - cycleDay + 1;
}
export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale
const freshUser = await pb.collection("users").getOne(user.id);
export const GET = withAuth(async (_request, user) => {
// Validate user has required cycle data
if (!user.lastPeriodDate) {
const lastPeriodDate = freshUser.lastPeriodDate
? new Date(freshUser.lastPeriodDate as string)
: null;
const cycleLength = (freshUser.cycleLength as number) || 28;
if (!lastPeriodDate) {
return NextResponse.json(
{
error:
@@ -48,20 +62,16 @@ export const GET = withAuth(async (_request, user) => {
}
// Calculate current cycle position
const cycleDay = getCycleDay(
user.lastPeriodDate,
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
const phase = getPhase(cycleDay, cycleLength);
const phaseConfig = getPhaseConfig(phase);
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength);
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, cycleLength);
return NextResponse.json({
cycleDay,
phase,
phaseConfig,
daysUntilNextPhase,
cycleLength: user.cycleLength,
cycleLength,
});
});

View File

@@ -13,17 +13,13 @@ let currentMockUser: User | null = null;
const mockPbUpdate = vi.fn();
const mockPbCreate = vi.fn();
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
// Create mock PocketBase client
const mockPb = {
collection: vi.fn((_name: string) => ({
update: mockPbUpdate,
create: mockPbCreate,
})),
})),
loadAuthFromCookies: vi.fn(),
isAuthenticated: vi.fn(() => currentMockUser !== null),
getCurrentUser: vi.fn(() => currentMockUser),
}));
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
@@ -32,7 +28,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -47,12 +43,18 @@ describe("POST /api/cycle/period", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2024-12-15"),
cycleLength: 28,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -205,4 +207,105 @@ describe("POST /api/cycle/period", () => {
const body = await response.json();
expect(body.error).toBe("Failed to update period date");
});
describe("prediction accuracy tracking", () => {
it("calculates and stores predictedDate based on previous cycle", async () => {
// User's last period was 2024-12-15 with 28-day cycle
// Predicted next period: 2024-12-15 + 28 days = 2025-01-12
currentMockUser = mockUser;
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
// Verify PeriodLog was created with predictedDate
expect(mockPbCreate).toHaveBeenCalledWith(
expect.objectContaining({
user: "user123",
startDate: "2025-01-10",
predictedDate: "2025-01-12", // lastPeriodDate (Dec 15) + cycleLength (28)
}),
);
});
it("returns prediction accuracy information in response", async () => {
currentMockUser = mockUser;
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.predictedDate).toBe("2025-01-12");
expect(body.daysEarly).toBe(2); // Arrived 2 days early
});
it("handles period arriving late (positive daysLate)", async () => {
currentMockUser = mockUser;
// Period arrives 3 days after predicted (2025-01-15 instead of 2025-01-12)
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-15" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.predictedDate).toBe("2025-01-12");
expect(body.daysLate).toBe(3);
});
it("sets predictedDate to null when user has no previous lastPeriodDate", async () => {
// First period log - no previous cycle data
currentMockUser = {
...mockUser,
lastPeriodDate: null as unknown as Date,
};
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
// Should not include predictedDate for first log
expect(mockPbCreate).toHaveBeenCalledWith(
expect.objectContaining({
user: "user123",
startDate: "2025-01-10",
predictedDate: null,
}),
);
});
it("handles period arriving on predicted date exactly", async () => {
currentMockUser = mockUser;
// Period arrives exactly on predicted date (2025-01-12)
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-12" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.predictedDate).toBe("2025-01-12");
expect(body.daysEarly).toBeUndefined();
expect(body.daysLate).toBeUndefined();
});
});
});

View File

@@ -5,7 +5,7 @@ import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { getCycleDay, getPhase } from "@/lib/cycle";
import { createPocketBaseClient } from "@/lib/pocketbase";
import { logger } from "@/lib/logger";
interface PeriodLogRequest {
startDate?: string;
@@ -34,7 +34,7 @@ function isFutureDate(dateStr: string): boolean {
return inputDate > today;
}
export const POST = withAuth(async (request: NextRequest, user) => {
export const POST = withAuth(async (request: NextRequest, user, pb) => {
try {
const body = (await request.json()) as PeriodLogRequest;
@@ -62,32 +62,62 @@ export const POST = withAuth(async (request: NextRequest, user) => {
);
}
const pb = createPocketBaseClient();
// Calculate predicted date based on previous cycle (if exists)
let predictedDateStr: string | null = null;
if (user.lastPeriodDate) {
const previousPeriod = new Date(user.lastPeriodDate);
const predictedDate = new Date(previousPeriod);
predictedDate.setDate(previousPeriod.getDate() + user.cycleLength);
predictedDateStr = predictedDate.toISOString().split("T")[0];
}
// Update user's lastPeriodDate
await pb.collection("users").update(user.id, {
lastPeriodDate: body.startDate,
});
// Create PeriodLog record
// Create PeriodLog record with prediction data
await pb.collection("period_logs").create({
user: user.id,
startDate: body.startDate,
predictedDate: predictedDateStr,
});
// Calculate updated cycle information
const lastPeriodDate = new Date(body.startDate);
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, user.cycleLength);
// Calculate prediction accuracy
let daysEarly: number | undefined;
let daysLate: number | undefined;
if (predictedDateStr) {
const actual = new Date(body.startDate);
const predicted = new Date(predictedDateStr);
const diffDays = Math.floor(
(predicted.getTime() - actual.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays > 0) {
daysEarly = diffDays;
} else if (diffDays < 0) {
daysLate = Math.abs(diffDays);
}
}
// Log successful period logging per observability spec
logger.info({ userId: user.id, date: body.startDate }, "Period logged");
return NextResponse.json({
message: "Period start date logged successfully",
lastPeriodDate: body.startDate,
cycleDay,
phase,
...(predictedDateStr && { predictedDate: predictedDateStr }),
...(daysEarly !== undefined && { daysEarly }),
...(daysLate !== undefined && { daysLate }),
});
} catch (error) {
console.error("Period logging error:", error);
logger.error({ err: error, userId: user.id }, "Period logging error");
return NextResponse.json(
{ error: "Failed to update period date" },
{ status: 500 },

View File

@@ -9,11 +9,26 @@ import type { User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Create a mock PocketBase client that returns the current mock user
const createMockPb = () => ({
collection: vi.fn(() => ({
getOne: vi.fn(() =>
Promise.resolve(
currentMockUser
? {
...currentMockUser,
garminTokenExpiresAt:
currentMockUser.garminTokenExpiresAt?.toISOString(),
}
: null,
),
),
})),
});
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(),
})),
createPocketBaseClient: vi.fn(() => createMockPb()),
}));
// Mock the auth-middleware module
@@ -23,7 +38,8 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
const mockPb = createMockPb();
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -55,12 +71,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -84,12 +106,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -113,12 +141,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -140,12 +174,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -169,12 +209,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: pastDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -200,12 +246,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -229,12 +281,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -258,12 +316,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -287,12 +351,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -313,12 +383,18 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};

View File

@@ -5,31 +5,48 @@ import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
export const GET = withAuth(async (_request, user) => {
const connected = user.garminConnected;
export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database (auth store cookie may be stale)
const freshUser = await pb.collection("users").getOne(user.id);
// Use strict equality to handle undefined (field missing from schema)
const connected = freshUser.garminConnected === true;
if (!connected) {
return NextResponse.json({
return NextResponse.json(
{
connected: false,
daysUntilExpiry: null,
expired: false,
warningLevel: null,
});
},
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
}
const expiresAt =
user.garminTokenExpiresAt instanceof Date
? user.garminTokenExpiresAt.toISOString()
: String(user.garminTokenExpiresAt);
// Use refresh token expiry for user-facing warnings (when they need to re-auth)
// Fall back to access token expiry if refresh expiry not set
const refreshTokenExpiresAt = freshUser.garminRefreshTokenExpiresAt
? String(freshUser.garminRefreshTokenExpiresAt)
: "";
const accessTokenExpiresAt = freshUser.garminTokenExpiresAt
? String(freshUser.garminTokenExpiresAt)
: "";
const tokens = {
oauth1: "",
oauth2: "",
expires_at: expiresAt,
expires_at: accessTokenExpiresAt,
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
};
const days = daysUntilExpiry(tokens);
const expired = isTokenExpired(tokens);
// Check if refresh token is expired (user needs to re-authenticate)
const expired = refreshTokenExpiresAt
? new Date(refreshTokenExpiresAt) <= new Date()
: isTokenExpired(tokens);
let warningLevel: "warning" | "critical" | null = null;
if (days <= 7) {
@@ -38,10 +55,15 @@ export const GET = withAuth(async (_request, user) => {
warningLevel = "warning";
}
return NextResponse.json({
return NextResponse.json(
{
connected: true,
daysUntilExpiry: days,
expired,
warningLevel,
});
},
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
});

View File

@@ -12,14 +12,16 @@ let currentMockUser: User | null = null;
// Track PocketBase update calls
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
// Track PocketBase getOne calls - returns user with garminConnected: true after update
const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true });
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
update: mockPbUpdate,
getOne: mockPbGetOne,
})),
})),
}));
};
// Track encryption calls
const mockEncrypt = vi.fn((plaintext: string) => `encrypted:${plaintext}`);
@@ -36,7 +38,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -51,12 +53,18 @@ describe("POST /api/garmin/tokens", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -139,6 +147,7 @@ describe("POST /api/garmin/tokens", () => {
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
garminTokenExpiresAt: expiresAt,
garminRefreshTokenExpiresAt: expect.any(String),
garminConnected: true,
});
});
@@ -265,12 +274,18 @@ describe("DELETE /api/garmin/tokens", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -302,6 +317,7 @@ describe("DELETE /api/garmin/tokens", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: null,
garminRefreshTokenExpiresAt: null,
garminConnected: false,
});
});

View File

@@ -5,11 +5,11 @@ import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { encrypt } from "@/lib/encryption";
import { daysUntilExpiry } from "@/lib/garmin";
import { createPocketBaseClient } from "@/lib/pocketbase";
import { logger } from "@/lib/logger";
export const POST = withAuth(async (request, user) => {
export const POST = withAuth(async (request, user, pb) => {
const body = await request.json();
const { oauth1, oauth2, expires_at } = body;
const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
// Validate required fields
if (!oauth1) {
@@ -52,24 +52,62 @@ export const POST = withAuth(async (request, user) => {
);
}
// Validate refresh_token_expires_at if provided
let refreshTokenExpiresAt = refresh_token_expires_at;
if (refreshTokenExpiresAt) {
const refreshExpiryDate = new Date(refreshTokenExpiresAt);
if (Number.isNaN(refreshExpiryDate.getTime())) {
return NextResponse.json(
{ error: "refresh_token_expires_at must be a valid date" },
{ status: 400 },
);
}
} else {
// If not provided, estimate refresh token expiry as ~30 days from now
refreshTokenExpiresAt = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
).toISOString();
}
// Encrypt tokens before storing
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
// Update user record
const pb = createPocketBaseClient();
await pb.collection("users").update(user.id, {
garminOauth1Token: encryptedOauth1,
garminOauth2Token: encryptedOauth2,
garminTokenExpiresAt: expires_at,
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
garminConnected: true,
});
// Calculate days until expiry
// Verify the update persisted (catches schema issues where field doesn't exist)
const updatedUser = await pb.collection("users").getOne(user.id);
if (updatedUser.garminConnected !== true) {
logger.error(
{
userId: user.id,
expected: true,
actual: updatedUser.garminConnected,
},
"garminConnected field not persisted - check PocketBase schema",
);
return NextResponse.json(
{
error:
"Failed to save connection status. The garminConnected field may be missing from the database schema. Run pnpm db:setup to fix.",
},
{ status: 500 },
);
}
// Calculate days until refresh token expiry (what users care about)
const expiryDays = daysUntilExpiry({
oauth1: "",
oauth2: "",
expires_at,
refresh_token_expires_at: refreshTokenExpiresAt,
});
return NextResponse.json({
@@ -79,13 +117,12 @@ export const POST = withAuth(async (request, user) => {
});
});
export const DELETE = withAuth(async (_request, user) => {
const pb = createPocketBaseClient();
export const DELETE = withAuth(async (_request, user, pb) => {
await pb.collection("users").update(user.id, {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: null,
garminRefreshTokenExpiresAt: null,
garminConnected: false,
});

View 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);
});
});
});

View 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 },
);
}
}

View File

@@ -12,14 +12,12 @@ let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetList = vi.fn();
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getList: mockGetList,
})),
})),
}));
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
@@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -43,12 +41,18 @@ describe("GET /api/history", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};

View File

@@ -3,7 +3,6 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyLog } from "@/types";
// Validation constants
@@ -24,7 +23,7 @@ function isValidDateFormat(dateStr: string): boolean {
return !Number.isNaN(date.getTime());
}
export const GET = withAuth(async (request, user) => {
export const GET = withAuth(async (request, user, pb) => {
const { searchParams } = request.nextUrl;
// Parse and validate page parameter
@@ -77,7 +76,6 @@ export const GET = withAuth(async (request, user) => {
const filter = filters.join(" && ");
// Query PocketBase
const pb = createPocketBaseClient();
const result = await pb
.collection("dailyLogs")
.getList<DailyLog>(page, limit, {

View 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_]+/);
});
});
});

View 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,
},
});
}

View File

@@ -14,21 +14,8 @@ let lastUpdateCall: {
data: { activeOverrides: OverrideType[] };
} | null = null;
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
};
}),
}));
// Mock the pocketbase module
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
update: vi.fn(
async (id: string, data: { activeOverrides: OverrideType[] }) => {
@@ -44,7 +31,18 @@ vi.mock("@/lib/pocketbase", () => ({
},
),
})),
})),
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser, mockPb);
};
}),
}));
import { DELETE, POST } from "./route";
@@ -57,12 +55,18 @@ describe("POST /api/overrides", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: overrides,
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
});
@@ -189,12 +193,18 @@ describe("DELETE /api/overrides", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: overrides,
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
});

View File

@@ -4,7 +4,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { createPocketBaseClient } from "@/lib/pocketbase";
import { logger } from "@/lib/logger";
import type { OverrideType } from "@/types";
const VALID_OVERRIDE_TYPES: OverrideType[] = [
@@ -26,7 +26,7 @@ function isValidOverrideType(value: unknown): value is OverrideType {
* Request body: { override: OverrideType }
* Response: { activeOverrides: OverrideType[] }
*/
export const POST = withAuth(async (request: NextRequest, user) => {
export const POST = withAuth(async (request: NextRequest, user, pb) => {
const body = await request.json();
if (!body.override) {
@@ -54,11 +54,16 @@ export const POST = withAuth(async (request: NextRequest, user) => {
: [...currentOverrides, overrideToAdd];
// Update the user record in PocketBase
const pb = createPocketBaseClient();
await pb
.collection("users")
.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 });
});
@@ -67,7 +72,7 @@ export const POST = withAuth(async (request: NextRequest, user) => {
* Request body: { override: OverrideType }
* Response: { activeOverrides: OverrideType[] }
*/
export const DELETE = withAuth(async (request: NextRequest, user) => {
export const DELETE = withAuth(async (request: NextRequest, user, pb) => {
const body = await request.json();
if (!body.override) {
@@ -93,10 +98,15 @@ export const DELETE = withAuth(async (request: NextRequest, user) => {
const newOverrides = currentOverrides.filter((o) => o !== overrideToRemove);
// Update the user record in PocketBase
const pb = createPocketBaseClient();
await pb
.collection("users")
.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 });
});

View File

@@ -0,0 +1,429 @@
// ABOUTME: Unit tests for period history API route.
// ABOUTME: Tests GET /api/period-history for pagination, cycle length calculation, and auth.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PeriodLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetList = vi.fn();
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getList: mockGetList,
})),
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser, mockPb);
};
}),
}));
import { GET } from "./route";
describe("GET /api/period-history", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockPeriodLogs: PeriodLog[] = [
{
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
},
{
id: "period2",
user: "user123",
startDate: new Date("2024-12-18"),
predictedDate: new Date("2024-12-19"),
created: new Date("2024-12-18T10:00:00Z"),
},
{
id: "period3",
user: "user123",
startDate: new Date("2024-11-20"),
predictedDate: null,
created: new Date("2024-11-20T10:00:00Z"),
},
];
// Helper to create mock request with query parameters
function createMockRequest(params: Record<string, string> = {}): NextRequest {
const url = new URL("http://localhost:3000/api/period-history");
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return {
url: url.toString(),
nextUrl: url,
} as unknown as NextRequest;
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns paginated period logs for authenticated user", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(3);
expect(body.total).toBe(3);
expect(body.page).toBe(1);
expect(body.limit).toBe(20);
expect(body.hasMore).toBe(false);
});
it("calculates cycle lengths between consecutive periods", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Period 1 (Jan 15) - Period 2 (Dec 18) = 28 days
expect(body.items[0].cycleLength).toBe(28);
// Period 2 (Dec 18) - Period 3 (Nov 20) = 28 days
expect(body.items[1].cycleLength).toBe(28);
// Period 3 is the first log, no previous period to calculate from
expect(body.items[2].cycleLength).toBeNull();
});
it("calculates average cycle length", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Average of 28 and 28 = 28
expect(body.averageCycleLength).toBe(28);
});
it("returns null average when only one period exists", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockPeriodLogs[2]], // Only one period
totalItems: 1,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.averageCycleLength).toBeNull();
});
it("uses default pagination values (page=1, limit=20)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("respects page parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 3,
page: 2,
});
const mockRequest = createMockRequest({ page: "2" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
2,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("respects limit parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 5,
page: 1,
});
const mockRequest = createMockRequest({ limit: "10" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
10,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-startDate",
}),
);
});
it("returns empty array when no logs exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(0);
expect(body.total).toBe(0);
expect(body.hasMore).toBe(false);
expect(body.averageCycleLength).toBeNull();
});
it("returns 400 for invalid page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for non-numeric page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "abc" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for invalid limit value (too low)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns 400 for invalid limit value (too high)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "101" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns hasMore=true when more pages exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: Array(20).fill(mockPeriodLogs[0]),
totalItems: 50,
totalPages: 3,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(true);
});
it("returns hasMore=false on last page", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockPeriodLogs[0]],
totalItems: 41,
totalPages: 3,
page: 3,
});
const mockRequest = createMockRequest({ page: "3" });
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(false);
});
it("sorts by startDate descending (most recent first)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
sort: "-startDate",
}),
);
});
it("only returns logs for the authenticated user", async () => {
currentMockUser = { ...mockUser, id: "different-user" };
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="different-user"'),
}),
);
});
it("includes prediction accuracy for each period", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: mockPeriodLogs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Period 1: actual Jan 15, predicted Jan 16 -> 1 day early
expect(body.items[0].daysEarly).toBe(1);
expect(body.items[0].daysLate).toBe(0);
// Period 2: actual Dec 18, predicted Dec 19 -> 1 day early
expect(body.items[1].daysEarly).toBe(1);
expect(body.items[1].daysLate).toBe(0);
// Period 3: no prediction (first log)
expect(body.items[2].daysEarly).toBeNull();
expect(body.items[2].daysLate).toBeNull();
});
});

View File

@@ -0,0 +1,149 @@
// ABOUTME: API route for retrieving period history with calculated cycle lengths.
// ABOUTME: GET /api/period-history returns paginated period logs with cycle statistics.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import type { PeriodLog } from "@/types";
// Pagination constants
const MIN_PAGE = 1;
const MIN_LIMIT = 1;
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
interface PeriodLogWithCycleLength extends PeriodLog {
cycleLength: number | null;
daysEarly: number | null;
daysLate: number | null;
}
interface PeriodHistoryResponse {
items: PeriodLogWithCycleLength[];
total: number;
page: number;
limit: number;
totalPages: number;
hasMore: boolean;
averageCycleLength: number | null;
}
function calculateDaysBetween(date1: Date, date2: Date): number {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d1.getTime() - d2.getTime());
return Math.round(diffTime / (1000 * 60 * 60 * 24));
}
export const GET = withAuth(async (request: NextRequest, user, pb) => {
const { searchParams } = new URL(request.url);
// Parse and validate pagination parameters
const pageParam = searchParams.get("page");
const limitParam = searchParams.get("limit");
let page = MIN_PAGE;
let limit = DEFAULT_LIMIT;
// Validate page parameter
if (pageParam !== null) {
const parsedPage = Number.parseInt(pageParam, 10);
if (Number.isNaN(parsedPage) || parsedPage < MIN_PAGE) {
return NextResponse.json(
{ error: "Invalid page: must be a positive integer" },
{ status: 400 },
);
}
page = parsedPage;
}
// Validate limit parameter
if (limitParam !== null) {
const parsedLimit = Number.parseInt(limitParam, 10);
if (
Number.isNaN(parsedLimit) ||
parsedLimit < MIN_LIMIT ||
parsedLimit > MAX_LIMIT
) {
return NextResponse.json(
{
error: `Invalid limit: must be between ${MIN_LIMIT} and ${MAX_LIMIT}`,
},
{ status: 400 },
);
}
limit = parsedLimit;
}
// Query period logs for user
const result = await pb
.collection("period_logs")
.getList<PeriodLog>(page, limit, {
filter: `user="${user.id}"`,
sort: "-startDate",
});
// Calculate cycle lengths between consecutive periods
// Periods are sorted by startDate descending (most recent first)
const itemsWithCycleLength: PeriodLogWithCycleLength[] = result.items.map(
(log, index) => {
let cycleLength: number | null = null;
// If there's a next period (earlier period), calculate cycle length
const nextPeriod = result.items[index + 1];
if (nextPeriod) {
cycleLength = calculateDaysBetween(log.startDate, nextPeriod.startDate);
}
// Calculate prediction accuracy
let daysEarly: number | null = null;
let daysLate: number | null = null;
if (log.predictedDate) {
const actualDate = new Date(log.startDate);
const predictedDate = new Date(log.predictedDate);
const diffDays = Math.round(
(actualDate.getTime() - predictedDate.getTime()) /
(1000 * 60 * 60 * 24),
);
if (diffDays < 0) {
daysEarly = Math.abs(diffDays);
daysLate = 0;
} else {
daysEarly = 0;
daysLate = diffDays;
}
}
return {
...log,
cycleLength,
daysEarly,
daysLate,
};
},
);
// Calculate average cycle length (only if we have at least 2 periods)
const cycleLengths = itemsWithCycleLength
.map((log) => log.cycleLength)
.filter((length): length is number => length !== null);
const averageCycleLength =
cycleLengths.length > 0
? Math.round(
cycleLengths.reduce((sum, len) => sum + len, 0) / cycleLengths.length,
)
: null;
const response: PeriodHistoryResponse = {
items: itemsWithCycleLength,
total: result.totalItems,
page: result.page,
limit,
totalPages: result.totalPages,
hasMore: result.page < result.totalPages,
averageCycleLength,
};
return NextResponse.json(response, { status: 200 });
});

View File

@@ -0,0 +1,440 @@
// ABOUTME: Unit tests for period log edit and delete API routes.
// ABOUTME: Tests PATCH and DELETE /api/period-logs/[id] for auth, validation, and ownership.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PeriodLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetOne = vi.fn();
const mockUpdate = vi.fn();
const mockDelete = vi.fn();
const mockGetList = vi.fn();
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getOne: mockGetOne,
update: mockUpdate,
delete: mockDelete,
getList: mockGetList,
})),
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (
request: NextRequest,
context?: { params?: Promise<{ id: string }> },
) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser, mockPb, context);
};
}),
}));
import { DELETE, PATCH } from "./route";
describe("PATCH /api/period-logs/[id]", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockPeriodLog: PeriodLog = {
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
};
// Helper to create mock request with JSON body
function createMockRequest(body: unknown): NextRequest {
return {
json: async () => body,
} as unknown as NextRequest;
}
// Helper to create params context
function createParamsContext(id: string) {
return {
params: Promise.resolve({ id }),
};
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetOne.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when period log not found", async () => {
currentMockUser = mockUser;
mockGetOne.mockRejectedValue({ status: 404 });
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(
mockRequest,
createParamsContext("nonexistent"),
);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBe("Period log not found");
});
it("returns 403 when user does not own the period log", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue({
...mockPeriodLog,
user: "different-user",
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error).toBe("Access denied");
});
it("returns 400 when startDate is missing", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const mockRequest = createMockRequest({});
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("startDate");
});
it("returns 400 when startDate format is invalid", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const mockRequest = createMockRequest({ startDate: "not-a-date" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("startDate");
});
it("returns 400 when startDate is in the future", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const futureDateStr = futureDate.toISOString().split("T")[0];
const mockRequest = createMockRequest({ startDate: futureDateStr });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("future");
});
it("updates period log with valid startDate", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockUpdate.mockResolvedValue({
...mockPeriodLog,
startDate: new Date("2025-01-14"),
});
// Mock that this is the most recent period
mockGetList.mockResolvedValue({
items: [mockPeriodLog],
totalItems: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith(
"period1",
expect.objectContaining({
startDate: "2025-01-14",
}),
);
});
it("updates user.lastPeriodDate if editing the most recent period", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockUpdate.mockResolvedValue({
...mockPeriodLog,
startDate: new Date("2025-01-14"),
});
// This is the most recent period
mockGetList.mockResolvedValue({
items: [mockPeriodLog],
totalItems: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
await PATCH(mockRequest, createParamsContext("period1"));
// Check that user was updated with new lastPeriodDate
expect(mockUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: "2025-01-14",
}),
);
});
it("does not update user.lastPeriodDate if editing an older period", async () => {
currentMockUser = mockUser;
const olderPeriod = {
...mockPeriodLog,
id: "period2",
startDate: new Date("2024-12-18"),
};
mockGetOne.mockResolvedValue(olderPeriod);
mockUpdate.mockResolvedValue({
...olderPeriod,
startDate: new Date("2024-12-17"),
});
// There's a more recent period
mockGetList.mockResolvedValue({
items: [mockPeriodLog, olderPeriod],
totalItems: 2,
});
const mockRequest = createMockRequest({ startDate: "2024-12-17" });
await PATCH(mockRequest, createParamsContext("period2"));
// User should not be updated since this isn't the most recent period
// Only period_logs should be updated
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockPb.collection).toHaveBeenCalledWith("period_logs");
});
it("returns updated period log in response", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
const updatedLog = { ...mockPeriodLog, startDate: new Date("2025-01-14") };
mockUpdate.mockResolvedValue(updatedLog);
mockGetList.mockResolvedValue({
items: [mockPeriodLog],
totalItems: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
const response = await PATCH(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.id).toBe("period1");
});
});
describe("DELETE /api/period-logs/[id]", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockPeriodLog: PeriodLog = {
id: "period1",
user: "user123",
startDate: new Date("2025-01-15"),
predictedDate: new Date("2025-01-16"),
created: new Date("2025-01-15T10:00:00Z"),
};
// Helper to create mock request
function createMockRequest(): NextRequest {
return {} as unknown as NextRequest;
}
// Helper to create params context
function createParamsContext(id: string) {
return {
params: Promise.resolve({ id }),
};
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetOne.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest();
const response = await DELETE(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 404 when period log not found", async () => {
currentMockUser = mockUser;
mockGetOne.mockRejectedValue({ status: 404 });
const mockRequest = createMockRequest();
const response = await DELETE(
mockRequest,
createParamsContext("nonexistent"),
);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBe("Period log not found");
});
it("returns 403 when user does not own the period log", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue({
...mockPeriodLog,
user: "different-user",
});
const mockRequest = createMockRequest();
const response = await DELETE(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(403);
const body = await response.json();
expect(body.error).toBe("Access denied");
});
it("deletes period log successfully", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockDelete.mockResolvedValue(true);
// After deletion, previous period becomes most recent
mockGetList.mockResolvedValue({
items: [
{ ...mockPeriodLog, id: "period2", startDate: new Date("2024-12-18") },
],
totalItems: 1,
});
const mockRequest = createMockRequest();
const response = await DELETE(mockRequest, createParamsContext("period1"));
expect(response.status).toBe(200);
expect(mockDelete).toHaveBeenCalledWith("period1");
const body = await response.json();
expect(body.success).toBe(true);
});
it("updates user.lastPeriodDate to previous period when deleting most recent", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockDelete.mockResolvedValue(true);
// After deletion, previous period becomes most recent
const previousPeriod = {
...mockPeriodLog,
id: "period2",
startDate: new Date("2024-12-18"),
};
mockGetList.mockResolvedValue({
items: [previousPeriod],
totalItems: 1,
});
const mockRequest = createMockRequest();
await DELETE(mockRequest, createParamsContext("period1"));
// Check that user was updated with previous period's date
expect(mockUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: expect.any(String),
}),
);
});
it("sets user.lastPeriodDate to null when deleting the only period", async () => {
currentMockUser = mockUser;
mockGetOne.mockResolvedValue(mockPeriodLog);
mockDelete.mockResolvedValue(true);
// No periods remaining after deletion
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
});
const mockRequest = createMockRequest();
await DELETE(mockRequest, createParamsContext("period1"));
// Check that user was updated with null lastPeriodDate
expect(mockUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: null,
}),
);
});
});

View File

@@ -0,0 +1,185 @@
// ABOUTME: API route for editing and deleting individual period logs.
// ABOUTME: PATCH updates startDate, DELETE removes period entry.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type PocketBase from "pocketbase";
import { withAuth } from "@/lib/auth-middleware";
import type { PeriodLog, User } from "@/types";
// Date format regex: YYYY-MM-DD
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
interface RouteContext {
params?: Promise<{ id: string }>;
}
// Helper to format date as YYYY-MM-DD
function formatDateStr(date: Date): string {
return date.toISOString().split("T")[0];
}
// Helper to check if a period log is the most recent for a user
async function isMostRecentPeriod(
pb: PocketBase,
userId: string,
periodLogId: string,
): Promise<boolean> {
const result = await pb.collection("period_logs").getList<PeriodLog>(1, 1, {
filter: `user="${userId}"`,
sort: "-startDate",
});
if (result.items.length === 0) {
return false;
}
return result.items[0].id === periodLogId;
}
// Helper to get the most recent period after deletion
async function getMostRecentPeriodAfterDeletion(
pb: PocketBase,
userId: string,
): Promise<PeriodLog | null> {
const result = await pb.collection("period_logs").getList<PeriodLog>(1, 1, {
filter: `user="${userId}"`,
sort: "-startDate",
});
return result.items[0] || null;
}
export const PATCH = withAuth(
async (
request: NextRequest,
user: User,
pb: PocketBase,
context?: RouteContext,
) => {
// Get ID from route params
const { id } = await (context?.params ?? Promise.resolve({ id: "" }));
// Fetch the period log
let periodLog: PeriodLog;
try {
periodLog = await pb.collection("period_logs").getOne<PeriodLog>(id);
} catch (error) {
// Handle PocketBase 404 errors (can be Error or plain object)
const err = error as { status?: number };
if (err.status === 404) {
return NextResponse.json(
{ error: "Period log not found" },
{ status: 404 },
);
}
throw error;
}
// Check ownership
if (periodLog.user !== user.id) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// Parse request body
const body = await request.json();
// Validate startDate is present
if (!body.startDate) {
return NextResponse.json(
{ error: "startDate is required" },
{ status: 400 },
);
}
// Validate startDate format
if (!DATE_REGEX.test(body.startDate)) {
return NextResponse.json(
{ error: "startDate must be in YYYY-MM-DD format" },
{ status: 400 },
);
}
// Validate startDate is a valid date
const parsedDate = new Date(body.startDate);
if (Number.isNaN(parsedDate.getTime())) {
return NextResponse.json(
{ error: "startDate is not a valid date" },
{ status: 400 },
);
}
// Validate startDate is not in the future
const today = new Date();
today.setHours(23, 59, 59, 999); // End of today
if (parsedDate > today) {
return NextResponse.json(
{ error: "startDate cannot be in the future" },
{ status: 400 },
);
}
// Update the period log
const updatedPeriodLog = await pb
.collection("period_logs")
.update<PeriodLog>(id, {
startDate: body.startDate,
});
// If this is the most recent period, update user.lastPeriodDate
const isLatest = await isMostRecentPeriod(pb, user.id, id);
if (isLatest) {
await pb.collection("users").update(user.id, {
lastPeriodDate: body.startDate,
});
}
return NextResponse.json(updatedPeriodLog, { status: 200 });
},
);
export const DELETE = withAuth(
async (
_request: NextRequest,
user: User,
pb: PocketBase,
context?: RouteContext,
) => {
// Get ID from route params
const { id } = await (context?.params ?? Promise.resolve({ id: "" }));
// Fetch the period log
let periodLog: PeriodLog;
try {
periodLog = await pb.collection("period_logs").getOne<PeriodLog>(id);
} catch (error) {
// Handle PocketBase 404 errors (can be Error or plain object)
const err = error as { status?: number };
if (err.status === 404) {
return NextResponse.json(
{ error: "Period log not found" },
{ status: 404 },
);
}
throw error;
}
// Check ownership
if (periodLog.user !== user.id) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// Delete the period log
await pb.collection("period_logs").delete(id);
// Update user.lastPeriodDate to the previous period (or null if no more periods)
const previousPeriod = await getMostRecentPeriodAfterDeletion(pb, user.id);
await pb.collection("users").update(user.id, {
lastPeriodDate: previousPeriod
? formatDateStr(new Date(previousPeriod.startDate))
: null,
});
return NextResponse.json({ success: true }, { status: 200 });
},
);

View File

@@ -0,0 +1,59 @@
// ABOUTME: Test endpoint for verifying email configuration.
// ABOUTME: Sends a test email to verify Mailgun integration works.
import { NextResponse } from "next/server";
import type { DailyEmailData } from "@/lib/email";
import { sendDailyEmail } from "@/lib/email";
export async function POST(request: Request) {
// Verify cron secret (reuse same auth as cron endpoints)
const authHeader = request.headers.get("authorization");
const expectedSecret = process.env.CRON_SECRET;
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const to = body.to as string;
if (!to) {
return NextResponse.json(
{ error: "Missing 'to' email address in request body" },
{ status: 400 },
);
}
const testData: DailyEmailData = {
to,
cycleDay: 15,
phase: "OVULATION",
decision: {
status: "TRAIN",
reason: "This is a test email to verify Mailgun configuration works!",
icon: "🧪",
},
bodyBatteryCurrent: 85,
bodyBatteryYesterdayLow: 45,
hrvStatus: "Balanced",
weekIntensity: 60,
phaseLimit: 80,
remainingMinutes: 20,
seeds: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
carbRange: "100-150g",
ketoGuidance: "No - exit keto, need carbs for ovulation",
};
try {
await sendDailyEmail(testData, "test-user");
return NextResponse.json({
success: true,
message: `Test email sent to ${to}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ success: false, error: message },
{ status: 500 },
);
}
}

View File

@@ -9,27 +9,67 @@ import type { DailyLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Module-level variable to control mock daily log in tests
// Module-level variable to control mock daily log for today in tests
let currentMockDailyLog: DailyLog | null = null;
// Mock PocketBase client for database operations
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
getFirstListItem: vi.fn(async () => {
// Module-level variable to control mock daily log for fallback (most recent)
let fallbackMockDailyLog: DailyLog | null = null;
// Track the filter string passed to getFirstListItem
let lastDailyLogFilter: string | null = null;
// Create mock PocketBase client
const mockPb = {
collection: vi.fn((collectionName: string) => ({
// Mock getOne for fetching fresh user data
getOne: vi.fn(async () => {
if (collectionName === "users" && currentMockUser) {
// Return user data in PocketBase record format
return {
id: currentMockUser.id,
email: currentMockUser.email,
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
cycleLength: currentMockUser.cycleLength,
activeOverrides: currentMockUser.activeOverrides,
garminConnected: currentMockUser.garminConnected,
};
}
throw new Error("Record not found");
}),
getFirstListItem: vi.fn(async (filter: string) => {
// Capture the filter for testing
if (collectionName === "dailyLogs") {
lastDailyLogFilter = filter;
// Check if this is a query for today's log (has date range filter)
const isTodayQuery =
filter.includes("date>=") && filter.includes("date<");
if (isTodayQuery) {
if (!currentMockDailyLog) {
const error = new Error("No DailyLog found");
const error = new Error("No DailyLog found for today");
(error as { status?: number }).status = 404;
throw error;
}
return currentMockDailyLog;
}
// This is the fallback query for most recent log
if (fallbackMockDailyLog) {
return fallbackMockDailyLog;
}
if (currentMockDailyLog) {
return currentMockDailyLog;
}
const error = new Error("No DailyLog found");
(error as { status?: number }).status = 404;
throw error;
}
const error = new Error("No DailyLog found");
(error as { status?: number }).status = 404;
throw error;
}),
})),
})),
loadAuthFromCookies: vi.fn(),
isAuthenticated: vi.fn(() => currentMockUser !== null),
getCurrentUser: vi.fn(() => currentMockUser),
}));
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
@@ -38,7 +78,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -53,12 +93,18 @@ describe("GET /api/today", () => {
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
...overrides,
@@ -89,6 +135,8 @@ describe("GET /api/today", () => {
vi.clearAllMocks();
currentMockUser = null;
currentMockDailyLog = null;
fallbackMockDailyLog = null;
lastDailyLogFilter = null;
// Mock current date to 2025-01-10 for predictable testing
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-10T12:00:00Z"));
@@ -350,12 +398,12 @@ describe("GET /api/today", () => {
const body = await response.json();
expect(body.phaseConfig.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120);
expect(body.phaseConfig.weeklyLimit).toBe(150);
});
it("returns days until next phase", async () => {
// Cycle day 10 in FOLLICULAR (days 4-14)
// Next phase (OVULATION) starts day 15, so 5 days away
// Cycle day 10 in FOLLICULAR (days 4-15 for 31-day cycle)
// Next phase (OVULATION) starts day 16, so 6 days away
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"),
});
@@ -366,7 +414,7 @@ describe("GET /api/today", () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body.daysUntilNextPhase).toBe(5);
expect(body.daysUntilNextPhase).toBe(6);
});
});
@@ -461,6 +509,58 @@ describe("GET /api/today", () => {
);
expect(body.nutrition.carbRange).toBe("75-125g");
});
it("returns seed switch alert on day 15", async () => {
// Set to cycle day 15 - the seed switch day
currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-27"), // 14 days ago = day 15
});
currentMockDailyLog = createMockDailyLog({
cycleDay: 15,
phase: "OVULATION",
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.nutrition.seedSwitchAlert).toBe(
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
);
});
it("returns null seed switch alert on other days", async () => {
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"), // cycle day 10
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.nutrition.seedSwitchAlert).toBeNull();
});
});
describe("dailyLog query", () => {
it("queries dailyLogs with YYYY-MM-DD date format using range operators", async () => {
// PocketBase accepts simple YYYY-MM-DD in comparison operators
// Use >= today and < tomorrow for exact day match
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog();
await GET(mockRequest);
// Verify filter uses YYYY-MM-DD format with range operators
expect(lastDailyLogFilter).toBeDefined();
expect(lastDailyLogFilter).toContain('date>="2025-01-10"');
expect(lastDailyLogFilter).toContain('date<"2025-01-11"');
// Should NOT contain ISO format with T separator
expect(lastDailyLogFilter).not.toContain("T");
});
});
describe("biometrics data", () => {
@@ -495,10 +595,10 @@ describe("GET /api/today", () => {
expect(response.status).toBe(200);
const body = await response.json();
// Defaults when no Garmin data
// Defaults when no Garmin data - null values indicate no data available
expect(body.biometrics.hrvStatus).toBe("Unknown");
expect(body.biometrics.bodyBatteryCurrent).toBe(100);
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100);
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
expect(body.biometrics.weekIntensityMinutes).toBe(0);
});
@@ -511,9 +611,95 @@ describe("GET /api/today", () => {
expect(response.status).toBe(200);
const body = await response.json();
// With defaults (BB=100, HRV=Unknown), should allow training
// unless in restrictive phase
// With null body battery, decision engine skips BB rules
// and allows training unless in restrictive phase
expect(body.decision.status).toBe("TRAIN");
});
});
describe("DailyLog fallback to most recent", () => {
it("returns lastSyncedAt as today when today's DailyLog exists", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.lastSyncedAt).toBe("2025-01-10");
});
it("uses yesterday's DailyLog when today's does not exist", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = null; // No today's log
// Yesterday's log with different biometrics
fallbackMockDailyLog = createMockDailyLog({
date: new Date("2025-01-09"),
hrvStatus: "Balanced",
bodyBatteryCurrent: 72,
bodyBatteryYesterdayLow: 38,
weekIntensityMinutes: 90,
phaseLimit: 150,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Should use fallback data
expect(body.biometrics.hrvStatus).toBe("Balanced");
expect(body.biometrics.bodyBatteryCurrent).toBe(72);
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(38);
expect(body.biometrics.weekIntensityMinutes).toBe(90);
});
it("returns lastSyncedAt as yesterday's date when using fallback", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = null;
fallbackMockDailyLog = createMockDailyLog({
date: new Date("2025-01-09"),
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.lastSyncedAt).toBe("2025-01-09");
});
it("returns null lastSyncedAt when no logs exist at all", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = null;
fallbackMockDailyLog = null;
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.lastSyncedAt).toBeNull();
// Should use DEFAULT_BIOMETRICS with null for body battery
expect(body.biometrics.hrvStatus).toBe("Unknown");
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
});
it("handles fallback log with string date format", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = null;
fallbackMockDailyLog = createMockDailyLog({
date: "2025-01-08T10:00:00Z" as unknown as Date,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.lastSyncedAt).toBe("2025-01-08");
});
});
});

View File

@@ -7,30 +7,36 @@ import {
getCycleDay,
getPhase,
getPhaseConfig,
getPhaseLimit,
PHASE_CONFIGS,
getUserPhaseLimit,
} from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { getNutritionGuidance } from "@/lib/nutrition";
import { createPocketBaseClient } from "@/lib/pocketbase";
import { logger } from "@/lib/logger";
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
import { mapRecordToUser } from "@/lib/pocketbase";
import type { DailyData, DailyLog, HrvStatus } from "@/types";
// Default biometrics when no Garmin data is available
const DEFAULT_BIOMETRICS: {
hrvStatus: HrvStatus;
bodyBatteryCurrent: number;
bodyBatteryYesterdayLow: number;
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
weekIntensityMinutes: number;
} = {
hrvStatus: "Unknown",
bodyBatteryCurrent: 100,
bodyBatteryYesterdayLow: 100,
bodyBatteryCurrent: null,
bodyBatteryYesterdayLow: null,
weekIntensityMinutes: 0,
};
export const GET = withAuth(async (_request, user) => {
export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale
// (e.g., after logging a period, the cookie still has old data)
const freshUserRecord = await pb.collection("users").getOne(user.id);
const freshUser = mapRecordToUser(freshUserRecord);
// Validate required user data
if (!user.lastPeriodDate) {
if (!freshUser.lastPeriodDate) {
return NextResponse.json(
{
error:
@@ -39,51 +45,112 @@ export const GET = withAuth(async (_request, user) => {
{ status: 400 },
);
}
const lastPeriodDate = freshUser.lastPeriodDate;
const cycleLength = freshUser.cycleLength;
const activeOverrides = freshUser.activeOverrides || [];
// Calculate cycle information
const cycleDay = getCycleDay(
new Date(user.lastPeriodDate),
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
const phase = getPhase(cycleDay, cycleLength);
const phaseConfig = getPhaseConfig(phase);
const phaseLimit = getPhaseLimit(phase);
// Calculate days until next phase
const currentPhaseIndex = PHASE_CONFIGS.findIndex((c) => c.name === phase);
const nextPhaseIndex = (currentPhaseIndex + 1) % PHASE_CONFIGS.length;
const nextPhaseStartDay = PHASE_CONFIGS[nextPhaseIndex].days[0];
const phaseLimit = getUserPhaseLimit(phase, freshUser);
// Calculate days until next phase using dynamic boundaries
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
let daysUntilNextPhase: number;
if (nextPhaseIndex === 0) {
// Currently in LATE_LUTEAL, next phase is MENSTRUAL (start of new cycle)
daysUntilNextPhase = user.cycleLength - cycleDay + 1;
if (phase === "LATE_LUTEAL") {
daysUntilNextPhase = cycleLength - cycleDay + 1;
} else if (phase === "MENSTRUAL") {
daysUntilNextPhase = 4 - cycleDay;
} else if (phase === "FOLLICULAR") {
daysUntilNextPhase = cycleLength - 15 - cycleDay;
} else if (phase === "OVULATION") {
daysUntilNextPhase = cycleLength - 13 - cycleDay;
} else {
daysUntilNextPhase = nextPhaseStartDay - cycleDay;
// EARLY_LUTEAL
daysUntilNextPhase = cycleLength - 6 - cycleDay;
}
// Try to fetch today's DailyLog for biometrics
// Try to fetch today's DailyLog for biometrics, fall back to most recent
// Sort by date DESC to get the most recent record if multiple exist
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
try {
const pb = createPocketBaseClient();
let lastSyncedAt: string | null = null;
// Use YYYY-MM-DD format with >= and < operators for PocketBase date field
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog");
try {
// First try to get today's log
const dailyLog = await pb
.collection("dailyLogs")
.getFirstListItem<DailyLog>(`user="${user.id}" && date~"${today}"`);
.getFirstListItem<DailyLog>(
`user="${user.id}" && date>="${today}" && date<"${tomorrow}"`,
{ sort: "-date" },
);
logger.info(
{
userId: user.id,
dailyLogId: dailyLog.id,
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
},
"Found dailyLog for today",
);
biometrics = {
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent:
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
bodyBatteryYesterdayLow:
dailyLog.bodyBatteryYesterdayLow ??
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
};
lastSyncedAt = today;
} catch {
// No today's log - try to get most recent
logger.info(
{ userId: user.id },
"No dailyLog for today, trying most recent",
);
try {
const dailyLog = await pb
.collection("dailyLogs")
.getFirstListItem<DailyLog>(`user="${user.id}"`, { sort: "-date" });
// Extract date from the log for "last synced" indicator
const dateValue = dailyLog.date as unknown as string | Date;
lastSyncedAt =
typeof dateValue === "string"
? dateValue.split("T")[0]
: dateValue.toISOString().split("T")[0];
logger.info(
{
userId: user.id,
dailyLogId: dailyLog.id,
lastSyncedAt,
},
"Using most recent dailyLog as fallback",
);
biometrics = {
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
};
} catch {
// No daily log found - use defaults
// No logs at all - truly new user
logger.warn(
{ userId: user.id },
"No dailyLog found at all, using defaults",
);
}
}
// Build DailyData for decision engine
@@ -97,10 +164,23 @@ export const GET = withAuth(async (_request, user) => {
};
// Get training decision with override handling
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
const decision = getDecisionWithOverrides(
dailyData,
activeOverrides as import("@/types").OverrideType[],
);
// Get nutrition guidance
const nutrition = getNutritionGuidance(cycleDay);
// Log decision calculation per observability spec
logger.info(
{ userId: user.id, decision: decision.status, reason: decision.reason },
"Decision calculated",
);
// Get nutrition guidance with seed switch alert
const baseNutrition = getNutritionGuidance(cycleDay);
const nutrition = {
...baseNutrition,
seedSwitchAlert: getSeedSwitchAlert(cycleDay),
};
return NextResponse.json({
decision,
@@ -108,8 +188,9 @@ export const GET = withAuth(async (_request, user) => {
phase,
phaseConfig,
daysUntilNextPhase,
cycleLength: user.cycleLength,
cycleLength,
biometrics,
nutrition,
lastSyncedAt,
});
});

View File

@@ -12,14 +12,31 @@ let currentMockUser: User | null = null;
// Track PocketBase update calls
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
// Track PocketBase getOne calls - returns the current mock user data
const mockPbGetOne = vi.fn().mockImplementation(() => {
if (!currentMockUser) {
throw new Error("User not found");
}
return Promise.resolve({
id: currentMockUser.id,
email: currentMockUser.email,
garminConnected: currentMockUser.garminConnected,
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
cycleLength: currentMockUser.cycleLength,
notificationTime: currentMockUser.notificationTime,
timezone: currentMockUser.timezone,
activeOverrides: currentMockUser.activeOverrides,
calendarToken: currentMockUser.calendarToken,
});
});
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
update: mockPbUpdate,
getOne: mockPbGetOne,
})),
})),
}));
};
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
@@ -28,7 +45,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
return handler(request, currentMockUser, mockPb);
};
}),
}));
@@ -43,12 +60,18 @@ describe("GET /api/user", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: ["flare"],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -57,6 +80,7 @@ describe("GET /api/user", () => {
vi.clearAllMocks();
currentMockUser = null;
mockPbUpdate.mockClear();
mockPbGetOne.mockClear();
});
it("returns user profile when authenticated", async () => {
@@ -78,17 +102,27 @@ describe("GET /api/user", () => {
expect(body.timezone).toBe("America/New_York");
});
it("does not expose sensitive token fields", async () => {
it("does not expose sensitive Garmin token fields", async () => {
currentMockUser = mockUser;
const mockRequest = {} as NextRequest;
const response = await GET(mockRequest);
const body = await response.json();
// Should NOT include encrypted tokens
// Should NOT include encrypted Garmin tokens
expect(body.garminOauth1Token).toBeUndefined();
expect(body.garminOauth2Token).toBeUndefined();
expect(body.calendarToken).toBeUndefined();
});
it("includes calendarToken for calendar subscription URL", async () => {
currentMockUser = mockUser;
const mockRequest = {} as NextRequest;
const response = await GET(mockRequest);
const body = await response.json();
// calendarToken is needed by the calendar page to display the subscription URL
expect(body.calendarToken).toBe("cal-secret-token");
});
it("includes activeOverrides array", async () => {
@@ -121,12 +155,18 @@ describe("PATCH /api/user", () => {
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: ["flare"],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
@@ -135,6 +175,7 @@ describe("PATCH /api/user", () => {
vi.clearAllMocks();
currentMockUser = null;
mockPbUpdate.mockClear();
mockPbGetOne.mockClear();
});
// Helper to create mock request with JSON body
@@ -372,9 +413,8 @@ describe("PATCH /api/user", () => {
expect(body.cycleLength).toBe(32);
expect(body.notificationTime).toBe("07:30");
expect(body.timezone).toBe("America/New_York");
// Should not expose sensitive fields
// Should not expose sensitive Garmin token fields
expect(body.garminOauth1Token).toBeUndefined();
expect(body.garminOauth2Token).toBeUndefined();
expect(body.calendarToken).toBeUndefined();
});
});

View File

@@ -4,7 +4,6 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import { createPocketBaseClient } from "@/lib/pocketbase";
// Validation constants
const CYCLE_LENGTH_MIN = 21;
@@ -14,24 +13,35 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
/**
* GET /api/user
* Returns the authenticated user's profile.
* Fetches fresh data from database to ensure updates are reflected.
* Excludes sensitive fields like encrypted tokens.
*/
export const GET = withAuth(async (_request, user) => {
export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale
const freshUser = await pb.collection("users").getOne(user.id);
// Format date for consistent API response
const lastPeriodDate = user.lastPeriodDate
? user.lastPeriodDate.toISOString().split("T")[0]
const lastPeriodDate = freshUser.lastPeriodDate
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
: null;
return NextResponse.json({
id: user.id,
email: user.email,
garminConnected: user.garminConnected,
cycleLength: user.cycleLength,
return NextResponse.json(
{
id: freshUser.id,
email: freshUser.email,
garminConnected: freshUser.garminConnected ?? false,
cycleLength: freshUser.cycleLength,
lastPeriodDate,
notificationTime: user.notificationTime,
timezone: user.timezone,
activeOverrides: user.activeOverrides,
});
notificationTime: freshUser.notificationTime,
timezone: freshUser.timezone,
activeOverrides: freshUser.activeOverrides ?? [],
calendarToken: (freshUser.calendarToken as string) || null,
},
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
});
/**
@@ -81,7 +91,7 @@ function validateTimezone(value: unknown): string | null {
* Updates the authenticated user's profile.
* Allowed fields: cycleLength, notificationTime, timezone
*/
export const PATCH = withAuth(async (request: NextRequest, user) => {
export const PATCH = withAuth(async (request: NextRequest, user, pb) => {
const body = await request.json();
// Build update object with only valid, updatable fields
@@ -132,7 +142,6 @@ export const PATCH = withAuth(async (request: NextRequest, user) => {
}
// Update the user record in PocketBase
const pb = createPocketBaseClient();
await pb.collection("users").update(user.id, updates);
// Build updated user profile for response

View 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>
);
}

View File

@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
}),
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -30,6 +40,9 @@ describe("CalendarPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
@@ -134,6 +147,21 @@ describe("CalendarPage", () => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
it("shows error toast when fetching fails", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Network error" }),
});
render(<CalendarPage />);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
});
});
describe("month navigation", () => {

View File

@@ -5,6 +5,7 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { MonthView } from "@/components/calendar/month-view";
import { showToast } from "@/components/ui/toaster";
interface User {
id: string;
@@ -30,12 +31,15 @@ export default function CalendarPage() {
const res = await fetch("/api/user");
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to fetch user");
const message = data.error || "Failed to fetch user";
setError(message);
showToast.error("Unable to fetch data. Retry?");
return;
}
setUser(data);
} catch {
setError("Failed to fetch user data");
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}

View File

@@ -1,7 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@custom-variant dark (@media (prefers-color-scheme: dark));
@theme inline {
--color-background: var(--background);
@@ -81,7 +81,8 @@
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
@@ -114,6 +115,7 @@
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
}
@layer base {
* {

View 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>
);
}

75
src/app/layout.test.tsx Normal file
View File

@@ -0,0 +1,75 @@
// ABOUTME: Unit tests for the root layout component.
// ABOUTME: Tests accessibility features including skip navigation link.
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Mock next/font/google before importing the layout
vi.mock("next/font/google", () => ({
Geist: () => ({
variable: "--font-geist-sans",
className: "geist-sans",
}),
Geist_Mono: () => ({
variable: "--font-geist-mono",
className: "geist-mono",
}),
}));
// Mock the Toaster component to avoid sonner dependencies in tests
vi.mock("@/components/ui/toaster", () => ({
Toaster: () => <div data-testid="toaster">Toast Provider</div>,
}));
import RootLayout from "./layout";
describe("RootLayout", () => {
describe("accessibility", () => {
it("renders a skip navigation link as the first focusable element", () => {
render(
<RootLayout>
<main id="main-content">Test content</main>
</RootLayout>,
);
const skipLink = screen.getByRole("link", {
name: /skip to main content/i,
});
expect(skipLink).toBeInTheDocument();
expect(skipLink).toHaveAttribute("href", "#main-content");
});
it("skip link has visually hidden styles but is focusable", () => {
render(
<RootLayout>
<main id="main-content">Test content</main>
</RootLayout>,
);
const skipLink = screen.getByRole("link", {
name: /skip to main content/i,
});
expect(skipLink).toHaveClass("sr-only");
expect(skipLink).toHaveClass("focus:not-sr-only");
});
it("renders children within the body", () => {
render(
<RootLayout>
<div data-testid="child-content">Test content</div>
</RootLayout>,
);
expect(screen.getByTestId("child-content")).toBeInTheDocument();
});
it("renders the Toaster component for toast notifications", () => {
render(
<RootLayout>
<main id="main-content">Test content</main>
</RootLayout>,
);
expect(screen.getByTestId("toaster")).toBeInTheDocument();
});
});
});

View File

@@ -1,8 +1,9 @@
// ABOUTME: Root layout for PhaseFlow application.
// ABOUTME: Configures fonts, metadata, and global styles.
// ABOUTME: Configures fonts, metadata, Toaster provider, and global styles.
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -30,7 +31,14 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-background focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:text-blue-600 dark:focus:text-blue-400 focus:underline focus:border focus:border-input"
>
Skip to main content
</a>
{children}
<Toaster />
</body>
</html>
);

20
src/app/loading.tsx Normal file
View 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>
);
}

View File

@@ -13,11 +13,19 @@ vi.mock("next/navigation", () => ({
// Mock PocketBase
const mockAuthWithPassword = vi.fn();
const mockAuthWithOAuth2 = vi.fn();
const mockListAuthMethods = vi.fn();
vi.mock("@/lib/pocketbase", () => ({
pb: {
collection: () => ({
authWithPassword: mockAuthWithPassword,
authWithOAuth2: mockAuthWithOAuth2,
listAuthMethods: mockListAuthMethods,
}),
authStore: {
isValid: true,
token: "mock-test-token-for-debugging",
},
},
}));
@@ -26,23 +34,34 @@ import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no OIDC configured, show email/password form
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
});
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 />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
it("renders a sign in button", () => {
it("renders a sign in button", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in/i }),
).toBeInTheDocument();
});
});
it("renders the PhaseFlow branding", () => {
render(<LoginPage />);
@@ -50,26 +69,35 @@ describe("LoginPage", () => {
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
});
it("has email input with type email", () => {
it("has email input with type email", async () => {
render(<LoginPage />);
await waitFor(() => {
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute("type", "email");
});
});
it("has password input with type password", () => {
it("has password input with type password", async () => {
render(<LoginPage />);
await waitFor(() => {
const passwordInput = screen.getByLabelText(/password/i);
expect(passwordInput).toHaveAttribute("type", "password");
});
});
});
describe("form submission", () => {
it("calls PocketBase auth with email and password on submit", async () => {
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -90,6 +118,11 @@ describe("LoginPage", () => {
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -113,6 +146,11 @@ describe("LoginPage", () => {
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -141,6 +179,11 @@ describe("LoginPage", () => {
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -166,6 +209,11 @@ describe("LoginPage", () => {
);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -186,6 +234,11 @@ describe("LoginPage", () => {
);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -210,6 +263,11 @@ describe("LoginPage", () => {
);
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -235,6 +293,11 @@ describe("LoginPage", () => {
it("does not submit with empty email", async () => {
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -248,6 +311,11 @@ describe("LoginPage", () => {
it("does not submit with empty password", async () => {
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -258,4 +326,492 @@ describe("LoginPage", () => {
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();
});
});
describe("accessibility", () => {
it("wraps content in a main element", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByRole("main")).toBeInTheDocument();
});
});
it("has proper heading structure with h1", async () => {
render(<LoginPage />);
await waitFor(() => {
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent(/phaseflow/i);
});
});
});
describe("rate limiting", () => {
it("shows rate limit error after 5 failed attempts", async () => {
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// 6th attempt should show rate limit error
fireEvent.change(emailInput, {
target: { value: "another@example.com" },
});
fireEvent.change(passwordInput, { target: { value: "password" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/too many login attempts/i),
).toBeInTheDocument();
});
// Should not have made 6th auth call
expect(mockAuthWithPassword).toHaveBeenCalledTimes(5);
});
it("disables form when rate limited", async () => {
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// Should be rate limited and button disabled
await waitFor(() => {
expect(submitButton).toBeDisabled();
});
});
it("re-enables form after cooldown period expires", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await vi.waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await vi.waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// Should be rate limited
await vi.waitFor(() => {
expect(submitButton).toBeDisabled();
});
// Advance time by 61 seconds to expire cooldown
await vi.advanceTimersByTimeAsync(61000);
// Form should be re-enabled
await vi.waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in/i }),
).not.toBeDisabled();
});
vi.useRealTimers();
});
it("resets attempt count on successful login", async () => {
// First 4 failed attempts, then success
mockAuthWithPassword
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 4 failed attempts
for (let i = 0; i < 4; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// 5th attempt succeeds - should reset counter (though component unmounts)
fireEvent.change(emailInput, { target: { value: "good@example.com" } });
fireEvent.change(passwordInput, { target: { value: "correctpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
it("shows remaining attempts warning after 3 failures", async () => {
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 3 failed login attempts
for (let i = 0; i < 3; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// Should show remaining attempts warning
await waitFor(() => {
expect(screen.getByText(/2 attempts remaining/i)).toBeInTheDocument();
});
});
it("rate limits OIDC login attempts", async () => {
// 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",
},
],
},
});
mockAuthWithOAuth2.mockRejectedValue(
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,
});
// Make 5 failed OIDC attempts
for (let i = 0; i < 5; i++) {
fireEvent.click(oidcButton);
await waitFor(() => {
expect(mockAuthWithOAuth2).toHaveBeenCalledTimes(i + 1);
});
}
// 6th attempt should show rate limit error
fireEvent.click(oidcButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/too many login attempts/i),
).toBeInTheDocument();
});
// Should not have made 6th auth call
expect(mockAuthWithOAuth2).toHaveBeenCalledTimes(5);
});
});
});

View File

@@ -1,18 +1,145 @@
// 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";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { pb } from "@/lib/pocketbase";
interface AuthProvider {
name: string;
displayName: string;
state: string;
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: string;
authURL: string;
}
const RATE_LIMIT_MAX_ATTEMPTS = 5;
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [oidcProvider, setOidcProvider] = useState<AuthProvider | null>(null);
const [loginAttempts, setLoginAttempts] = useState<number[]>([]);
const [isRateLimited, setIsRateLimited] = useState(false);
// Get recent attempts within the rate limit window
const getRecentAttempts = useCallback(() => {
const now = Date.now();
return loginAttempts.filter(
(timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
}, [loginAttempts]);
// Calculate remaining attempts
const getRemainingAttempts = useCallback(() => {
const recentAttempts = getRecentAttempts();
return Math.max(0, RATE_LIMIT_MAX_ATTEMPTS - recentAttempts.length);
}, [getRecentAttempts]);
// Check if rate limited
const checkRateLimited = useCallback(() => {
const recentAttempts = getRecentAttempts();
return recentAttempts.length >= RATE_LIMIT_MAX_ATTEMPTS;
}, [getRecentAttempts]);
// Record a login attempt
const recordAttempt = useCallback(() => {
const now = Date.now();
setLoginAttempts((prev) => [
...prev.filter((t) => now - t < RATE_LIMIT_WINDOW_MS),
now,
]);
}, []);
// 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();
}, []);
// Check rate limit status and set up cooldown timer
useEffect(() => {
if (checkRateLimited()) {
setIsRateLimited(true);
setError("Too many login attempts. Please try again in 1 minute.");
// Set a timer to clear rate limit after window expires
const oldestAttempt = loginAttempts[0];
const timeUntilReset = oldestAttempt
? RATE_LIMIT_WINDOW_MS - (Date.now() - oldestAttempt)
: RATE_LIMIT_WINDOW_MS;
const timer = setTimeout(
() => {
setIsRateLimited(false);
setError(null);
// Clean up old attempts
setLoginAttempts((prev) =>
prev.filter((t) => Date.now() - t < RATE_LIMIT_WINDOW_MS),
);
},
Math.max(0, timeUntilReset),
);
return () => clearTimeout(timer);
}
setIsRateLimited(false);
}, [loginAttempts, checkRateLimited]);
const handleOidcLogin = async () => {
// Check rate limit before attempting
if (checkRateLimited()) {
setError("Too many login attempts. Please try again in 1 minute.");
return;
}
setIsLoading(true);
setError(null);
try {
await pb.collection("users").authWithOAuth2({ provider: "oidc" });
// Reset attempts on successful login
setLoginAttempts([]);
router.push("/");
} catch (err) {
// Record the failed attempt
recordAttempt();
const message =
err instanceof Error ? err.message : "Authentication failed";
const remaining = getRemainingAttempts() - 1; // -1 because we just recorded an attempt
if (remaining > 0 && remaining <= 2) {
setError(
`${message}. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`,
);
} else {
setError(message);
}
setIsLoading(false);
}
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -22,16 +149,33 @@ export default function LoginPage() {
return;
}
// Check rate limit before attempting
if (checkRateLimited()) {
setError("Too many login attempts. Please try again in 1 minute.");
return;
}
setIsLoading(true);
setError(null);
try {
await pb.collection("users").authWithPassword(email, password);
// Reset attempts on successful login
setLoginAttempts([]);
router.push("/");
} catch (err) {
// Record the failed attempt
recordAttempt();
const message =
err instanceof Error ? err.message : "Invalid credentials";
const remaining = getRemainingAttempts() - 1; // -1 because we just recorded an attempt
if (remaining > 0 && remaining <= 2) {
setError(
`${message}. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`,
);
} else {
setError(message);
}
setIsLoading(false);
}
};
@@ -41,31 +185,63 @@ export default function LoginPage() {
value: string,
) => {
setter(value);
// Clear error when user starts typing again
if (error) {
// Clear error when user starts typing again (but not rate limit errors)
if (error && !isRateLimited) {
setError(null);
}
};
// Show loading state while checking auth methods
if (isCheckingAuth) {
return (
<div className="flex min-h-screen items-center justify-center">
<main
id="main-content"
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-muted-foreground">Loading...</div>
</div>
</main>
);
}
return (
<main
id="main-content"
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>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div
role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded"
>
{error}
</div>
)}
{oidcProvider ? (
// OIDC login button
<button
type="button"
onClick={handleOidcLogin}
disabled={isLoading || isRateLimited}
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>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Email
</label>
@@ -75,7 +251,7 @@ export default function LoginPage() {
value={email}
onChange={(e) => handleInputChange(setEmail, e.target.value)}
disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
required
/>
</div>
@@ -83,7 +259,7 @@ export default function LoginPage() {
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Password
</label>
@@ -93,20 +269,21 @@ export default function LoginPage() {
value={password}
onChange={(e) => handleInputChange(setPassword, e.target.value)}
disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
disabled={isLoading || isRateLimited}
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"}
</button>
</form>
)}
</div>
</div>
</main>
);
}

16
src/app/metrics/route.ts Normal file
View File

@@ -0,0 +1,16 @@
// ABOUTME: Prometheus metrics endpoint at /metrics for standard scraping path.
// ABOUTME: Re-exports the same metrics as /api/metrics for Prometheus autodiscovery.
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,
},
});
}

View File

@@ -3,6 +3,16 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -47,11 +57,17 @@ const mockUserResponse = {
activeOverrides: [],
cycleLength: 31,
lastPeriodDate: "2024-01-01",
garminConnected: true,
notificationTime: "07:00",
timezone: "America/New_York",
};
describe("Dashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
});
describe("rendering", () => {
@@ -94,7 +110,10 @@ describe("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();
});
});
@@ -225,7 +244,7 @@ describe("Dashboard", () => {
render(<Dashboard />);
await waitFor(() => {
expect(screen.getByText(/hrv.*balanced/i)).toBeInTheDocument();
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
});
});
@@ -490,14 +509,55 @@ describe("Dashboard", () => {
expect(screen.getByText(/flare mode active/i)).toBeInTheDocument();
});
});
it("shows error toast when toggle fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUserResponse),
});
render(<Dashboard />);
await waitFor(() => {
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
});
// Clear mock and set up for failed toggle
mockFetch.mockClear();
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to update override" }),
});
const flareCheckbox = screen.getByRole("checkbox", {
name: /flare mode/i,
});
fireEvent.click(flareCheckbox);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to update override",
);
});
});
});
describe("error handling", () => {
it("shows error message when /api/today fails", async () => {
mockFetch.mockResolvedValueOnce({
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ error: "Internal server error" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUserResponse),
});
render(<Dashboard />);
@@ -509,7 +569,8 @@ describe("Dashboard", () => {
});
it("shows setup message when user has no lastPeriodDate", async () => {
mockFetch.mockResolvedValueOnce({
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
@@ -517,12 +578,22 @@ describe("Dashboard", () => {
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
// Multiple alerts may be present (error alert + onboarding banner)
const alerts = screen.getAllByRole("alert");
expect(alerts.length).toBeGreaterThan(0);
// Check for the specific help text about getting started
expect(
screen.getByText(/please log your period start date to get started/i),
@@ -564,8 +635,448 @@ describe("Dashboard", () => {
render(<Dashboard />);
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();
});
});
});
describe("onboarding banners", () => {
it("shows no onboarding banners when setup is complete", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUserResponse),
});
render(<Dashboard />);
await waitFor(() => {
expect(screen.getByText("TRAIN")).toBeInTheDocument();
});
// Should not have any onboarding messages
expect(
screen.queryByText(/Connect your Garmin to get started/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/Set your last period date/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/Set your preferred notification time/i),
).not.toBeInTheDocument();
});
it("shows Garmin banner when not connected", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
garminConnected: false,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByText(/Connect your Garmin to get started/i),
).toBeInTheDocument();
});
// Verify the link points to Garmin settings
const link = screen.getByRole("link", { name: /Connect/i });
expect(link).toHaveAttribute("href", "/settings/garmin");
});
it("shows notification time banner when not set", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
notificationTime: "",
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByText(/Set your preferred notification time/i),
).toBeInTheDocument();
});
// Verify the link points to settings
const link = screen.getByRole("link", { name: /Configure/i });
expect(link).toHaveAttribute("href", "/settings");
});
it("shows multiple banners when multiple items need setup", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
garminConnected: false,
notificationTime: "",
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByText(/Connect your Garmin to get started/i),
).toBeInTheDocument();
expect(
screen.getByText(/Set your preferred notification time/i),
).toBeInTheDocument();
});
});
it("shows period date banner with action button", async () => {
// Note: When lastPeriodDate is null, /api/today returns 400 error
// But we still want to show the onboarding banner if userData shows no period
// This test checks that the banner appears when userData indicates no period
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
Promise.resolve({
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
// The error state handles this case with a specific message
await waitFor(() => {
expect(
screen.getByText(/please log your period start date to get started/i),
).toBeInTheDocument();
});
});
});
describe("period date modal flow", () => {
it("shows onboarding banner when todayData fails but userData shows no lastPeriodDate", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
Promise.resolve({
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByText(/Set your last period date for accurate tracking/i),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /set date/i }),
).toBeInTheDocument();
});
});
it("opens period date modal when clicking Set date button", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
Promise.resolve({
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /set date/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /set date/i }));
await waitFor(() => {
expect(
screen.getByRole("dialog", { name: /set period date/i }),
).toBeInTheDocument();
});
});
it("calls POST /api/cycle/period when submitting date in modal", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
Promise.resolve({
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /set date/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /set date/i }));
await waitFor(() => {
expect(
screen.getByRole("dialog", { name: /set period date/i }),
).toBeInTheDocument();
});
// Set up mock for the period POST and subsequent refetch
mockFetch.mockClear();
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
message: "Period start date logged successfully",
lastPeriodDate: "2024-01-15",
cycleDay: 1,
phase: "MENSTRUAL",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: "2024-01-15",
}),
});
const dateInput = screen.getByLabelText(
/when did your last period start/i,
);
fireEvent.change(dateInput, { target: { value: "2024-01-15" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/cycle/period", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startDate: "2024-01-15" }),
});
});
});
it("closes modal and refetches data after successful submission", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
Promise.resolve({
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /set date/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /set date/i }));
await waitFor(() => {
expect(
screen.getByRole("dialog", { name: /set period date/i }),
).toBeInTheDocument();
});
// Set up mock for the period POST and successful refetch
mockFetch.mockClear();
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
message: "Period start date logged successfully",
lastPeriodDate: "2024-01-15",
cycleDay: 1,
phase: "MENSTRUAL",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTodayResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: "2024-01-15",
}),
});
const dateInput = screen.getByLabelText(
/when did your last period start/i,
);
fireEvent.change(dateInput, { target: { value: "2024-01-15" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
// Modal should close and dashboard should show normal content
await waitFor(() => {
expect(
screen.queryByRole("dialog", { name: /set period date/i }),
).not.toBeInTheDocument();
// Dashboard should now show the decision card
expect(screen.getByText("TRAIN")).toBeInTheDocument();
});
});
it("shows error in modal when API call fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () =>
Promise.resolve({
error:
"User has no lastPeriodDate set. Please log your period start date first.",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockUserResponse,
lastPeriodDate: null,
}),
});
render(<Dashboard />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /set date/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /set date/i }));
await waitFor(() => {
expect(
screen.getByRole("dialog", { name: /set period date/i }),
).toBeInTheDocument();
});
// Set up mock for failed API call
mockFetch.mockClear();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ error: "Failed to update period date" }),
});
const dateInput = screen.getByLabelText(
/when did your last period start/i,
);
fireEvent.change(dateInput, { target: { value: "2024-01-15" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
// Error should appear in modal (there may be multiple alerts - dashboard error + modal error)
await waitFor(() => {
const alerts = screen.getAllByRole("alert");
const modalError = alerts.find((alert) =>
alert.textContent?.includes("Failed to update period date"),
);
expect(modalError).toBeInTheDocument();
});
// Modal should still be open
expect(
screen.getByRole("dialog", { name: /set period date/i }),
).toBeInTheDocument();
});
});
});

View File

@@ -6,8 +6,13 @@ import { useCallback, useEffect, useState } from "react";
import { DataPanel } from "@/components/dashboard/data-panel";
import { DecisionCard } from "@/components/dashboard/decision-card";
import { MiniCalendar } from "@/components/dashboard/mini-calendar";
import { NutritionPanel } from "@/components/dashboard/nutrition-panel";
import { OnboardingBanner } from "@/components/dashboard/onboarding-banner";
import { OverrideToggles } from "@/components/dashboard/override-toggles";
import { PeriodDateModal } from "@/components/dashboard/period-date-modal";
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
import { showToast } from "@/components/ui/toaster";
import type {
CyclePhase,
Decision,
@@ -37,6 +42,11 @@ interface UserData {
id: string;
email: string;
activeOverrides: OverrideType[];
lastPeriodDate: string | null;
cycleLength: number;
garminConnected: boolean;
notificationTime: string;
timezone: string;
}
export default function Dashboard() {
@@ -44,6 +54,17 @@ export default function Dashboard() {
const [userData, setUserData] = useState<UserData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [calendarYear, setCalendarYear] = useState(new Date().getFullYear());
const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth());
const [showPeriodModal, setShowPeriodModal] = useState(false);
const handleCalendarMonthChange = useCallback(
(year: number, month: number) => {
setCalendarYear(year);
setCalendarMonth(month);
},
[],
);
const fetchTodayData = useCallback(async () => {
const response = await fetch("/api/today");
@@ -69,10 +90,53 @@ export default function Dashboard() {
useEffect(() => {
async function loadData() {
try {
setLoading(true);
setError(null);
// Fetch userData and todayData independently so we can show the
// onboarding banner even if todayData fails due to missing lastPeriodDate
const [todayResult, userResult] = await Promise.allSettled([
fetchTodayData(),
fetchUserData(),
]);
if (userResult.status === "fulfilled") {
setUserData(userResult.value);
}
if (todayResult.status === "fulfilled") {
setTodayData(todayResult.value);
} else {
setError(
todayResult.reason instanceof Error
? todayResult.reason.message
: "Failed to fetch today data",
);
}
setLoading(false);
}
loadData();
}, [fetchTodayData, fetchUserData]);
const handlePeriodDateSubmit = async (date: string) => {
const response = await fetch("/api/cycle/period", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startDate: date }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to update period date");
}
// Close modal and refetch all data
setShowPeriodModal(false);
setError(null);
const [today, user] = await Promise.all([
fetchTodayData(),
fetchUserData(),
@@ -80,15 +144,7 @@ export default function Dashboard() {
setTodayData(today);
setUserData(user);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
}
loadData();
}, [fetchTodayData, fetchUserData]);
};
const handleOverrideToggle = async (override: OverrideType) => {
if (!userData) return;
@@ -118,9 +174,9 @@ export default function Dashboard() {
const newTodayData = await fetchTodayData();
setTodayData(newTodayData);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to toggle override",
);
const message =
err instanceof Error ? err.message : "Failed to toggle override";
showToast.error(message);
}
};
@@ -138,14 +194,11 @@ export default function Dashboard() {
</div>
</header>
<main className="container mx-auto p-6">
{loading && (
<div className="text-center py-12">
<p className="text-zinc-500">Loading...</p>
</div>
)}
<main id="main-content" className="container mx-auto p-6">
{loading && <DashboardSkeleton />}
{error && (
<div className="space-y-6">
<div role="alert" className="text-center py-12">
<p className="text-red-500">Error: {error}</p>
{error.includes("lastPeriodDate") && (
@@ -154,10 +207,32 @@ export default function Dashboard() {
</p>
)}
</div>
{/* Show onboarding banner even in error state if userData shows missing period date */}
{userData && !userData.lastPeriodDate && (
<OnboardingBanner
status={{
garminConnected: userData.garminConnected,
lastPeriodDate: userData.lastPeriodDate,
notificationTime: userData.notificationTime,
}}
onSetPeriodDate={() => setShowPeriodModal(true)}
/>
)}
</div>
)}
{!loading && !error && todayData && userData && (
<div className="space-y-6">
{/* Onboarding Banners */}
<OnboardingBanner
status={{
garminConnected: userData.garminConnected,
lastPeriodDate: userData.lastPeriodDate,
notificationTime: userData.notificationTime,
}}
onSetPeriodDate={() => setShowPeriodModal(true)}
/>
{/* Cycle Info */}
<div className="text-center">
<p className="text-lg font-medium">
@@ -194,9 +269,26 @@ export default function Dashboard() {
activeOverrides={userData.activeOverrides}
onToggle={handleOverrideToggle}
/>
{/* Mini Calendar */}
{userData.lastPeriodDate && (
<MiniCalendar
lastPeriodDate={new Date(userData.lastPeriodDate)}
cycleLength={userData.cycleLength}
year={calendarYear}
month={calendarMonth}
onMonthChange={handleCalendarMonthChange}
/>
)}
</div>
)}
</main>
<PeriodDateModal
isOpen={showPeriodModal}
onClose={() => setShowPeriodModal(false)}
onSubmit={handlePeriodDateSubmit}
/>
</div>
);
}

View File

@@ -0,0 +1,671 @@
// ABOUTME: Unit tests for the Period History page component.
// ABOUTME: Tests data loading, table rendering, edit/delete functionality, and pagination.
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
import PeriodHistoryPage from "./page";
describe("PeriodHistoryPage", () => {
const mockPeriodLog = {
id: "period1",
user: "user123",
startDate: "2025-01-15",
predictedDate: "2025-01-16",
created: "2025-01-15T10:00:00Z",
cycleLength: 28,
daysEarly: 1,
daysLate: 0,
};
const mockHistoryResponse = {
items: [mockPeriodLog],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
hasMore: false,
averageCycleLength: 28,
};
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
});
});
describe("rendering", () => {
it("renders the period history heading", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /period history/i }),
).toBeInTheDocument();
});
});
it("renders a back link to dashboard", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("link", { name: /back to dashboard/i }),
).toHaveAttribute("href", "/");
});
});
it("renders the period history table with headers", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
const columnHeaders = screen.getAllByRole("columnheader");
expect(columnHeaders.length).toBeGreaterThanOrEqual(4);
expect(columnHeaders[0]).toHaveTextContent(/date/i);
expect(columnHeaders[1]).toHaveTextContent(/cycle length/i);
expect(columnHeaders[2]).toHaveTextContent(/prediction accuracy/i);
expect(columnHeaders[3]).toHaveTextContent(/actions/i);
});
});
});
describe("data loading", () => {
it("fetches period history data on mount", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/period-history"),
);
});
});
it("shows loading state while fetching", async () => {
let resolveHistory: (value: unknown) => void = () => {};
const historyPromise = new Promise((resolve) => {
resolveHistory = resolve;
});
mockFetch.mockReturnValue({
ok: true,
json: () => historyPromise,
});
render(<PeriodHistoryPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
resolveHistory(mockHistoryResponse);
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
it("displays period log entries in the table", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
// Check that the log entry data is displayed
expect(screen.getByText(/jan 15, 2025/i)).toBeInTheDocument();
// Check the table contains cycle length (use getAllByText since it appears twice)
const cycleLengthElements = screen.getAllByText(/28 days/i);
expect(cycleLengthElements.length).toBeGreaterThanOrEqual(1);
});
});
it("displays prediction accuracy", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
// 1 day early
expect(screen.getByText(/1 day early/i)).toBeInTheDocument();
});
});
it("displays multiple period entries", async () => {
const logs = [
mockPeriodLog,
{
...mockPeriodLog,
id: "period2",
startDate: "2024-12-18",
cycleLength: 28,
},
{
...mockPeriodLog,
id: "period3",
startDate: "2024-11-20",
cycleLength: null,
},
];
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
items: logs,
total: 3,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/jan 15, 2025/i)).toBeInTheDocument();
expect(screen.getByText(/dec 18, 2024/i)).toBeInTheDocument();
expect(screen.getByText(/nov 20, 2024/i)).toBeInTheDocument();
});
});
});
describe("average cycle length", () => {
it("displays average cycle length", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/average cycle/i)).toBeInTheDocument();
// The text "28 days" appears in both average section and table
const cycleLengthElements = screen.getAllByText(/28 days/i);
expect(cycleLengthElements.length).toBeGreaterThanOrEqual(1);
});
});
it("shows no average when only one period exists", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
averageCycleLength: null,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.queryByText(/average cycle.*\d+ days/i),
).not.toBeInTheDocument();
});
});
});
describe("empty state", () => {
it("shows empty state message when no periods exist", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
items: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
hasMore: false,
averageCycleLength: null,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/no period history/i)).toBeInTheDocument();
});
});
});
describe("error handling", () => {
it("shows error message on fetch failure", async () => {
mockFetch.mockResolvedValue({
ok: false,
json: () =>
Promise.resolve({ error: "Failed to fetch period history" }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/failed to fetch period history/i),
).toBeInTheDocument();
});
});
it("shows generic error for network failures", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
it("shows error toast on fetch failure", async () => {
mockFetch.mockResolvedValue({
ok: false,
json: () =>
Promise.resolve({ error: "Failed to fetch period history" }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
});
it("shows error toast when delete fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
})
.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to delete period" }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to delete period",
);
});
});
});
describe("pagination", () => {
it("shows pagination info", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 1,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
});
});
it("renders previous and next buttons", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 2,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /previous/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /next/i }),
).toBeInTheDocument();
});
});
it("disables previous button on first page", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 1,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /previous/i }),
).toBeDisabled();
});
});
it("disables next button on last page", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 3,
totalPages: 3,
hasMore: false,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /next/i })).toBeDisabled();
});
});
it("fetches next page when next button is clicked", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 1,
totalPages: 3,
hasMore: true,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /next/i }),
).toBeInTheDocument();
});
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 50,
page: 2,
totalPages: 3,
hasMore: true,
}),
});
fireEvent.click(screen.getByRole("button", { name: /next/i }));
await waitFor(() => {
expect(mockFetch).toHaveBeenLastCalledWith(
expect.stringContaining("page=2"),
);
});
});
it("hides pagination when there is only one page", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 5,
page: 1,
totalPages: 1,
hasMore: false,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /previous/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /next/i }),
).not.toBeInTheDocument();
});
});
});
describe("edit functionality", () => {
it("renders edit button for each period", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /edit/i }),
).toBeInTheDocument();
});
});
it("shows edit modal when edit button is clicked", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /edit/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByLabelText(/period start date/i)).toBeInTheDocument();
});
});
it("submits edit with new date", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ ...mockPeriodLog, startDate: "2025-01-14" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /edit/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await waitFor(() => {
expect(screen.getByLabelText(/period start date/i)).toBeInTheDocument();
});
const dateInput = screen.getByLabelText(/period start date/i);
fireEvent.change(dateInput, { target: { value: "2025-01-14" } });
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"/api/period-logs/period1",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ startDate: "2025-01-14" }),
}),
);
});
});
});
describe("delete functionality", () => {
it("renders delete button for each period", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
});
it("shows confirmation dialog when delete button is clicked", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
});
it("deletes period when confirmed", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistoryResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ ...mockHistoryResponse, items: [], total: 0 }),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
const confirmButton = screen.getByRole("button", { name: /confirm/i });
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"/api/period-logs/period1",
expect.objectContaining({
method: "DELETE",
}),
);
});
});
it("cancels delete when cancel button is clicked", async () => {
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /delete/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
const cancelButton = screen.getByRole("button", { name: /cancel/i });
fireEvent.click(cancelButton);
await waitFor(() => {
expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument();
});
// Should not have made a delete call
expect(mockFetch).toHaveBeenCalledTimes(1); // Only initial fetch
});
});
describe("total entries display", () => {
it("shows total entries count", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockHistoryResponse,
total: 12,
}),
});
render(<PeriodHistoryPage />);
await waitFor(() => {
expect(screen.getByText(/12 periods/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,438 @@
// ABOUTME: Period history view showing all logged periods with cycle length calculations.
// ABOUTME: Allows editing and deleting period entries with confirmation dialogs.
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { showToast } from "@/components/ui/toaster";
interface PeriodLogWithCycleLength {
id: string;
user: string;
startDate: string;
predictedDate: string | null;
created: string;
cycleLength: number | null;
daysEarly: number | null;
daysLate: number | null;
}
interface PeriodHistoryResponse {
items: PeriodLogWithCycleLength[];
total: number;
page: number;
limit: number;
totalPages: number;
hasMore: boolean;
averageCycleLength: number | null;
}
/**
* Formats a date string for display.
*/
function formatDate(dateStr: string | Date): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Formats prediction accuracy for display.
*/
function formatPredictionAccuracy(
daysEarly: number | null,
daysLate: number | null,
): string {
if (daysEarly === null && daysLate === null) {
return "-";
}
if (daysEarly && daysEarly > 0) {
return `${daysEarly} day${daysEarly > 1 ? "s" : ""} early`;
}
if (daysLate && daysLate > 0) {
return `${daysLate} day${daysLate > 1 ? "s" : ""} late`;
}
return "On time";
}
export default function PeriodHistoryPage() {
const [data, setData] = useState<PeriodHistoryResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
// Edit modal state
const [editingPeriod, setEditingPeriod] =
useState<PeriodLogWithCycleLength | null>(null);
const [editDate, setEditDate] = useState("");
const [editError, setEditError] = useState<string | null>(null);
// Delete confirmation state
const [deletingPeriod, setDeletingPeriod] =
useState<PeriodLogWithCycleLength | null>(null);
const fetchHistory = useCallback(async (pageNum: number) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set("page", pageNum.toString());
const response = await fetch(`/api/period-history?${params.toString()}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "Failed to fetch period history");
}
setData(result);
setPage(result.page);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchHistory(1);
}, [fetchHistory]);
const handlePreviousPage = () => {
if (page > 1) {
fetchHistory(page - 1);
}
};
const handleNextPage = () => {
if (data?.hasMore) {
fetchHistory(page + 1);
}
};
const handleEditClick = (period: PeriodLogWithCycleLength) => {
setEditingPeriod(period);
// Extract date portion from ISO string or Date object
const dateStr = new Date(period.startDate).toISOString().split("T")[0];
setEditDate(dateStr);
setEditError(null);
};
const handleEditCancel = () => {
setEditingPeriod(null);
setEditDate("");
setEditError(null);
};
const handleEditSave = async () => {
if (!editingPeriod) return;
try {
const response = await fetch(`/api/period-logs/${editingPeriod.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ startDate: editDate }),
});
if (!response.ok) {
const result = await response.json();
throw new Error(result.error || "Failed to update period");
}
// Close modal and refresh data
setEditingPeriod(null);
setEditDate("");
fetchHistory(page);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setEditError(message);
}
};
const handleDeleteClick = (period: PeriodLogWithCycleLength) => {
setDeletingPeriod(period);
};
const handleDeleteCancel = () => {
setDeletingPeriod(null);
};
const handleDeleteConfirm = async () => {
if (!deletingPeriod) return;
try {
const response = await fetch(`/api/period-logs/${deletingPeriod.id}`, {
method: "DELETE",
});
if (!response.ok) {
const result = await response.json();
throw new Error(result.error || "Failed to delete period");
}
// Close modal and refresh data
setDeletingPeriod(null);
fetchHistory(page);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to delete. Try again.");
setDeletingPeriod(null);
}
};
if (loading && !data) {
return (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Period History</h1>
<p className="text-gray-500">Loading...</p>
</div>
);
}
return (
<div className="container mx-auto p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Period History</h1>
<Link
href="/"
className="text-blue-600 hover:text-blue-700 hover:underline"
>
Back to Dashboard
</Link>
</div>
{error && (
<div
role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
>
{error}
</div>
)}
{/* Average Cycle Length */}
{data && data.averageCycleLength !== null && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-700">
<span className="font-medium">Average Cycle:</span>{" "}
{data.averageCycleLength} days
</p>
</div>
)}
{/* Total Entries */}
{data && (
<p className="text-sm text-gray-600 mb-4">{data.total} periods</p>
)}
{/* Empty State */}
{data && data.items.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p>No period history found</p>
<p className="text-sm mt-2">
Log your period to start tracking your cycle.
</p>
</div>
)}
{/* Period History Table */}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cycle Length
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Prediction Accuracy
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.items.map((period) => (
<tr key={period.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{formatDate(period.startDate)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{period.cycleLength !== null
? `${period.cycleLength} days`
: "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{formatPredictionAccuracy(
period.daysEarly,
period.daysLate,
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditClick(period)}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteClick(period)}
className="text-red-600 hover:text-red-800 hover:underline"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handlePreviousPage}
disabled={page <= 1}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page} of {data.totalPages}
</span>
<button
type="button"
onClick={handleNextPage}
disabled={!data.hasMore}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
{/* Edit Modal */}
{editingPeriod && (
// biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by form focus
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleEditCancel}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="edit-modal-title"
className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 id="edit-modal-title" className="text-lg font-semibold mb-4">
Edit Period Date
</h2>
{editError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded mb-4 text-sm">
{editError}
</div>
)}
<div className="mb-4">
<label
htmlFor="editDate"
className="block text-sm font-medium text-gray-700 mb-1"
>
Period Start Date
</label>
<input
id="editDate"
type="date"
value={editDate}
onChange={(e) => setEditDate(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleEditCancel}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
Cancel
</button>
<button
type="button"
onClick={handleEditSave}
className="rounded-md px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Save
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deletingPeriod && (
// biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by buttons
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={handleDeleteCancel}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2 id="delete-modal-title" className="text-lg font-semibold mb-4">
Delete Period
</h2>
<p className="text-gray-600 mb-6">
Are you sure you want to delete the period from{" "}
<span className="font-medium">
{formatDate(deletingPeriod.startDate)}
</span>
? This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleDeleteCancel}
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteConfirm}
className="rounded-md px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
>
Confirm
</button>
</div>
</div>
</div>
)}
</div>
);
}

47
src/app/plan/loading.tsx Normal file
View 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
View 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");
});
});
});
});

View File

@@ -1,11 +1,301 @@
// 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() {
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 (
<div className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
{/* Exercise plan content will be implemented here */}
<p className="text-gray-500">Exercise plan placeholder</p>
<p className="text-zinc-500">Loading...</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>
);
}

View File

@@ -14,6 +14,16 @@ vi.mock("next/link", () => ({
}) => <a href={href}>{children}</a>,
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -23,6 +33,9 @@ import GarminSettingsPage from "./page";
describe("GarminSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
// Default mock for disconnected state
mockFetch.mockResolvedValue({
ok: true,
@@ -266,8 +279,7 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/invalid json format/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith("Invalid JSON format");
});
});
@@ -285,8 +297,7 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/oauth2.*required/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith("oauth2 is required");
});
});
@@ -349,7 +360,7 @@ describe("GarminSettingsPage", () => {
});
});
it("shows success message after saving tokens", async () => {
it("shows success toast after saving tokens", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -400,7 +411,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/tokens saved/i)).toBeInTheDocument();
expect(mockShowToast.success).toHaveBeenCalledWith(
"Tokens saved successfully",
);
});
});
@@ -457,7 +470,7 @@ describe("GarminSettingsPage", () => {
});
});
it("shows error when save fails", async () => {
it("shows error toast when save fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -493,8 +506,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/failed to save tokens/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to save tokens",
);
});
});
});
@@ -561,7 +575,7 @@ describe("GarminSettingsPage", () => {
});
});
it("shows disconnected message after successful disconnect", async () => {
it("shows success toast after successful disconnect", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -603,7 +617,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(disconnectButton);
await waitFor(() => {
expect(screen.getByText(/garmin disconnected/i)).toBeInTheDocument();
expect(mockShowToast.success).toHaveBeenCalledWith(
"Garmin disconnected successfully",
);
});
});
@@ -651,7 +667,7 @@ describe("GarminSettingsPage", () => {
resolveDisconnect({ success: true, garminConnected: false });
});
it("shows error when disconnect fails", async () => {
it("shows error toast when disconnect fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -682,8 +698,9 @@ describe("GarminSettingsPage", () => {
fireEvent.click(disconnectButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/failed to disconnect/i)).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Failed to disconnect",
);
});
});
});
@@ -703,26 +720,19 @@ describe("GarminSettingsPage", () => {
});
});
it("clears error when user modifies input", async () => {
it("shows error toast on load failure", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Network error" }),
});
render(<GarminSettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument();
});
const textarea = screen.getByLabelText(/paste tokens/i);
fireEvent.change(textarea, { target: { value: "invalid json" } });
const saveButton = screen.getByRole("button", { name: /save tokens/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
fireEvent.change(textarea, { target: { value: '{"oauth1": {}}' } });
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
});
});
});

View File

@@ -4,6 +4,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { showToast } from "@/components/ui/toaster";
interface GarminStatus {
connected: boolean;
@@ -17,13 +18,12 @@ export default function GarminSettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState("");
const fetchStatus = useCallback(async () => {
setLoading(true);
setError(null);
setLoadError(null);
try {
const response = await fetch("/api/garmin/status");
@@ -36,7 +36,8 @@ export default function GarminSettingsPage() {
setStatus(data);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
setLoadError(message);
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}
@@ -48,12 +49,6 @@ export default function GarminSettingsPage() {
const handleTokenChange = (value: string) => {
setTokenInput(value);
if (error) {
setError(null);
}
if (success) {
setSuccess(null);
}
};
const validateTokens = (
@@ -90,13 +85,11 @@ export default function GarminSettingsPage() {
const handleSaveTokens = async () => {
const validation = validateTokens(tokenInput);
if (!validation.valid) {
setError(validation.error);
showToast.error(validation.error);
return;
}
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/garmin/tokens", {
@@ -111,12 +104,12 @@ export default function GarminSettingsPage() {
throw new Error(data.error || "Failed to save tokens");
}
setSuccess("Tokens saved successfully");
showToast.success("Tokens saved successfully");
setTokenInput("");
await fetchStatus();
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to save. Try again.");
} finally {
setSaving(false);
}
@@ -124,8 +117,6 @@ export default function GarminSettingsPage() {
const handleDisconnect = async () => {
setDisconnecting(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/garmin/tokens", {
@@ -138,17 +129,22 @@ export default function GarminSettingsPage() {
throw new Error(data.error || "Failed to disconnect");
}
setSuccess("Garmin disconnected successfully");
showToast.success("Garmin disconnected successfully");
await fetchStatus();
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to disconnect. Try again.");
} finally {
setDisconnecting(false);
}
};
const showTokenInput = !status?.connected || status?.expired;
// Show token input when:
// - Not connected
// - Token expired
// - Warning level active (so user can proactively paste new tokens)
const showTokenInput =
!status?.connected || status?.expired || status?.warningLevel;
if (loading) {
return (
@@ -156,7 +152,7 @@ export default function GarminSettingsPage() {
<h1 className="text-2xl font-bold mb-8">
Settings &gt; Garmin Connection
</h1>
<p className="text-gray-500">Loading...</p>
<p className="text-muted-foreground">Loading...</p>
</div>
);
}
@@ -173,31 +169,27 @@ export default function GarminSettingsPage() {
</Link>
</div>
{error && (
{loadError && (
<div
role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
>
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6">
{success}
{loadError}
</div>
)}
<div className="max-w-lg space-y-6">
{/* Connection Status Section */}
<div className="border border-gray-200 rounded-lg p-6">
<div className="border border-input rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Connection Status</h2>
{status?.connected && !status.expired ? (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-green-700 font-medium">Connected</span>
<span className="text-green-700 dark:text-green-400 font-medium">
Connected
</span>
</div>
{status.warningLevel && (
@@ -205,8 +197,8 @@ export default function GarminSettingsPage() {
data-testid="expiry-warning"
className={`px-4 py-3 rounded ${
status.warningLevel === "critical"
? "bg-red-50 border border-red-200 text-red-700"
: "bg-yellow-50 border border-yellow-200 text-yellow-700"
? "bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400"
: "bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-400"
}`}
>
{status.warningLevel === "critical"
@@ -215,7 +207,7 @@ export default function GarminSettingsPage() {
</div>
)}
<p className="text-gray-600">
<p className="text-muted-foreground">
Token expires in{" "}
<span className="font-medium">
{status.daysUntilExpiry} days
@@ -235,33 +227,39 @@ export default function GarminSettingsPage() {
<div className="space-y-4">
<div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-red-700 font-medium">Token Expired</span>
<span className="text-red-700 dark:text-red-400 font-medium">
Token Expired
</span>
</div>
<p className="text-gray-600">
<p className="text-muted-foreground">
Your Garmin tokens have expired. Please generate new tokens and
paste them below.
</p>
</div>
) : (
<div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-gray-400 rounded-full" />
<span className="text-gray-600">Not Connected</span>
<span className="w-3 h-3 bg-muted-foreground rounded-full" />
<span className="text-muted-foreground">Not Connected</span>
</div>
)}
</div>
{/* Token Input Section */}
{showTokenInput && (
<div className="border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
<div className="border border-input rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">
{status?.connected && status?.warningLevel
? "Refresh Tokens"
: "Connect Garmin"}
</h2>
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">
<p className="font-medium mb-2">Instructions:</p>
<ol className="list-decimal list-inside space-y-1">
<li>
Run{" "}
<code className="bg-blue-100 px-1 rounded">
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">
python3 scripts/garmin_auth.py
</code>{" "}
locally
@@ -274,7 +272,7 @@ export default function GarminSettingsPage() {
<div>
<label
htmlFor="tokenInput"
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-foreground mb-1"
>
Paste Tokens (JSON)
</label>
@@ -285,7 +283,7 @@ export default function GarminSettingsPage() {
onChange={(e) => handleTokenChange(e.target.value)}
disabled={saving}
placeholder='{"oauth1": {...}, "oauth2": {...}, "expires_at": "..."}'
className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono text-sm"
className="block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed font-mono text-sm"
/>
</div>

View 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>
);
}

View File

@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
}),
}));
// Mock showToast utility with vi.hoisted to avoid hoisting issues
const mockShowToast = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock("@/components/ui/toaster", () => ({
showToast: mockShowToast,
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -27,10 +37,18 @@ describe("SettingsPage", () => {
garminConnected: false,
activeOverrides: [],
lastPeriodDate: "2024-01-01",
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
};
beforeEach(() => {
vi.clearAllMocks();
mockShowToast.success.mockClear();
mockShowToast.error.mockClear();
mockShowToast.info.mockClear();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
@@ -227,6 +245,11 @@ describe("SettingsPage", () => {
cycleLength: 30,
notificationTime: "08:00",
timezone: "America/New_York",
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
}),
});
});
@@ -296,13 +319,13 @@ describe("SettingsPage", () => {
expect(cycleLengthInput).toBeDisabled();
expect(screen.getByLabelText(/notification time/i)).toBeDisabled();
expect(screen.getByLabelText(/timezone/i)).toBeDisabled();
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
});
resolveSave(mockUser);
});
it("shows success message on save", async () => {
it("shows success toast on save", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -323,11 +346,13 @@ describe("SettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
expect(mockShowToast.success).toHaveBeenCalledWith(
"Settings saved successfully",
);
});
});
it("shows error on save failure", async () => {
it("shows error toast on save failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -349,10 +374,9 @@ describe("SettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/cycleLength must be between 21 and 45/i),
).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith(
"cycleLength must be between 21 and 45",
);
});
});
@@ -377,7 +401,7 @@ describe("SettingsPage", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalled();
});
expect(screen.getByLabelText(/cycle length/i)).not.toBeDisabled();
@@ -444,8 +468,161 @@ describe("SettingsPage", () => {
});
});
describe("error handling", () => {
it("clears error when user starts typing", async () => {
describe("toast notifications", () => {
it("shows toast with fetch error on load failure", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to fetch user" }),
});
render(<SettingsPage />);
await waitFor(() => {
expect(mockShowToast.error).toHaveBeenCalledWith(
"Unable to fetch data. Retry?",
);
});
});
});
describe("accessibility", () => {
it("wraps content in a main element", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("main")).toBeInTheDocument();
});
});
it("has proper heading structure with h1", async () => {
render(<SettingsPage />);
await waitFor(() => {
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent(/settings/i);
});
});
});
describe("logout", () => {
it("renders a logout button", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
});
it("calls POST /api/auth/logout when logout button clicked", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
}),
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/auth/logout", {
method: "POST",
});
});
});
it("redirects to login page after logout", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
}),
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login");
});
});
it("shows loading state while logging out", async () => {
let resolveLogout: (value: unknown) => void = () => {};
const logoutPromise = new Promise((resolve) => {
resolveLogout = resolve;
});
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockReturnValueOnce({
ok: true,
json: () => logoutPromise,
});
render(<SettingsPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /logging out/i }),
).toBeInTheDocument();
});
resolveLogout({
success: true,
message: "Logged out successfully",
redirectTo: "/login",
});
});
it("shows error toast if logout fails", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -453,29 +630,90 @@ describe("SettingsPage", () => {
})
.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to save" }),
json: () => Promise.resolve({ error: "Logout failed" }),
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /log out/i }),
).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
const logoutButton = screen.getByRole("button", { name: /log out/i });
fireEvent.click(logoutButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(mockShowToast.error).toHaveBeenCalledWith("Logout failed");
});
});
});
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
describe("intensity goals section", () => {
it("renders Weekly Intensity Goals section heading", async () => {
render(<SettingsPage />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /weekly intensity goals/i }),
).toBeInTheDocument();
});
});
it("clears success message when user modifies form", async () => {
it("renders input for menstrual phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
});
it("renders input for follicular phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument();
});
});
it("renders input for ovulation phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument();
});
});
it("renders input for early luteal phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument();
});
});
it("renders input for late luteal phase goal", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument();
});
});
it("pre-fills intensity goal inputs with current user values", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toHaveValue(75);
expect(screen.getByLabelText(/follicular/i)).toHaveValue(150);
expect(screen.getByLabelText(/ovulation/i)).toHaveValue(100);
expect(screen.getByLabelText(/early luteal/i)).toHaveValue(120);
expect(screen.getByLabelText(/late luteal/i)).toHaveValue(50);
});
});
it("includes intensity goals in PATCH request when saving", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
@@ -483,26 +721,100 @@ describe("SettingsPage", () => {
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
json: () =>
Promise.resolve({ ...mockUser, intensityGoalMenstrual: 80 }),
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
const menstrualInput = screen.getByLabelText(/menstrual/i);
fireEvent.change(menstrualInput, { target: { value: "80" } });
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: expect.stringContaining('"intensityGoalMenstrual":80'),
});
});
});
it("has number type for all intensity goal inputs", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/follicular/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/ovulation/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/early luteal/i)).toHaveAttribute(
"type",
"number",
);
expect(screen.getByLabelText(/late luteal/i)).toHaveAttribute(
"type",
"number",
);
});
});
it("validates minimum value of 0 for intensity goals", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute("min", "0");
});
});
it("disables intensity goal inputs while saving", async () => {
let resolveSave: (value: unknown) => void = () => {};
const savePromise = new Promise((resolve) => {
resolveSave = resolve;
});
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
})
.mockReturnValueOnce({
ok: true,
json: () => savePromise,
});
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
});
const saveButton = screen.getByRole("button", { name: /save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
expect(screen.getByLabelText(/menstrual/i)).toBeDisabled();
expect(screen.getByLabelText(/follicular/i)).toBeDisabled();
expect(screen.getByLabelText(/ovulation/i)).toBeDisabled();
expect(screen.getByLabelText(/early luteal/i)).toBeDisabled();
expect(screen.getByLabelText(/late luteal/i)).toBeDisabled();
});
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
expect(screen.queryByText(/settings saved/i)).not.toBeInTheDocument();
resolveSave(mockUser);
});
});
});

View File

@@ -3,7 +3,9 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { showToast } from "@/components/ui/toaster";
interface UserData {
id: string;
@@ -14,22 +16,33 @@ interface UserData {
garminConnected: boolean;
activeOverrides: string[];
lastPeriodDate: string | null;
intensityGoalMenstrual: number;
intensityGoalFollicular: number;
intensityGoalOvulation: number;
intensityGoalEarlyLuteal: number;
intensityGoalLateLuteal: number;
}
export default function SettingsPage() {
const router = useRouter();
const [userData, setUserData] = useState<UserData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [cycleLength, setCycleLength] = useState(28);
const [notificationTime, setNotificationTime] = useState("08:00");
const [timezone, setTimezone] = useState("");
const [intensityGoalMenstrual, setIntensityGoalMenstrual] = useState(75);
const [intensityGoalFollicular, setIntensityGoalFollicular] = useState(150);
const [intensityGoalOvulation, setIntensityGoalOvulation] = useState(100);
const [intensityGoalEarlyLuteal, setIntensityGoalEarlyLuteal] = useState(120);
const [intensityGoalLateLuteal, setIntensityGoalLateLuteal] = useState(50);
const fetchUserData = useCallback(async () => {
setLoading(true);
setError(null);
setLoadError(null);
try {
const response = await fetch("/api/user");
@@ -43,9 +56,15 @@ export default function SettingsPage() {
setCycleLength(data.cycleLength);
setNotificationTime(data.notificationTime);
setTimezone(data.timezone);
setIntensityGoalMenstrual(data.intensityGoalMenstrual ?? 75);
setIntensityGoalFollicular(data.intensityGoalFollicular ?? 150);
setIntensityGoalOvulation(data.intensityGoalOvulation ?? 100);
setIntensityGoalEarlyLuteal(data.intensityGoalEarlyLuteal ?? 120);
setIntensityGoalLateLuteal(data.intensityGoalLateLuteal ?? 50);
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
setLoadError(message);
showToast.error("Unable to fetch data. Retry?");
} finally {
setLoading(false);
}
@@ -60,20 +79,12 @@ export default function SettingsPage() {
value: T,
) => {
setter(value);
if (error) {
setError(null);
}
if (success) {
setSuccess(null);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch("/api/user", {
@@ -83,6 +94,11 @@ export default function SettingsPage() {
cycleLength,
notificationTime,
timezone,
intensityGoalMenstrual,
intensityGoalFollicular,
intensityGoalOvulation,
intensityGoalEarlyLuteal,
intensityGoalLateLuteal,
}),
});
@@ -93,26 +109,48 @@ export default function SettingsPage() {
}
setUserData(data);
setSuccess("Settings saved successfully");
showToast.success("Settings saved successfully");
} catch (err) {
const message = err instanceof Error ? err.message : "An error occurred";
setError(message);
showToast.error(message || "Failed to save. Try again.");
} finally {
setSaving(false);
}
};
const handleLogout = async () => {
setLoggingOut(true);
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Logout failed");
}
router.push(data.redirectTo || "/login");
} catch (err) {
const message = err instanceof Error ? err.message : "Logout failed";
showToast.error(message);
setLoggingOut(false);
}
};
if (loading) {
return (
<div className="container mx-auto p-8">
<main id="main-content" className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Settings</h1>
<p className="text-gray-500">Loading...</p>
</div>
<p className="text-muted-foreground">Loading...</p>
</main>
);
}
return (
<div className="container mx-auto p-8">
<main id="main-content" className="container mx-auto p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Settings</h1>
<Link
@@ -123,34 +161,30 @@ export default function SettingsPage() {
</Link>
</div>
{error && (
{loadError && (
<div
role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
>
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6">
{success}
{loadError}
</div>
)}
<div className="max-w-lg">
<div className="mb-6">
<span className="block text-sm font-medium text-gray-700">Email</span>
<p className="mt-1 text-gray-900">{userData?.email}</p>
<span className="block text-sm font-medium text-foreground">
Email
</span>
<p className="mt-1 text-foreground">{userData?.email}</p>
</div>
<div className="mb-6 p-4 border border-gray-200 rounded-lg">
<div className="mb-6 p-4 border border-input rounded-lg">
<div className="flex items-center justify-between">
<div>
<span className="block text-sm font-medium text-gray-700">
<span className="block text-sm font-medium text-foreground">
Garmin Connection
</span>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
{userData?.garminConnected
? "Connected to Garmin"
: "Not connected"}
@@ -169,7 +203,7 @@ export default function SettingsPage() {
<div>
<label
htmlFor="cycleLength"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Cycle Length (days)
</label>
@@ -183,10 +217,10 @@ export default function SettingsPage() {
handleInputChange(setCycleLength, Number(e.target.value))
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
required
/>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
Typical range: 21-45 days
</p>
</div>
@@ -194,7 +228,7 @@ export default function SettingsPage() {
<div>
<label
htmlFor="notificationTime"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Notification Time
</label>
@@ -206,10 +240,10 @@ export default function SettingsPage() {
handleInputChange(setNotificationTime, e.target.value)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed [color-scheme:light] dark:[color-scheme:dark]"
required
/>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
Time to receive daily email notification
</p>
</div>
@@ -217,7 +251,7 @@ export default function SettingsPage() {
<div>
<label
htmlFor="timezone"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Timezone
</label>
@@ -227,15 +261,141 @@ export default function SettingsPage() {
value={timezone}
onChange={(e) => handleInputChange(setTimezone, e.target.value)}
disabled={saving}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
placeholder="America/New_York"
required
/>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
IANA timezone (e.g., America/New_York, Europe/London)
</p>
</div>
<div className="pt-6">
<h2 className="text-lg font-medium text-foreground mb-4">
Weekly Intensity Goals
</h2>
<p className="text-sm text-muted-foreground mb-4">
Target weekly intensity minutes for each cycle phase
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor="intensityGoalMenstrual"
className="block text-sm font-medium text-foreground"
>
Menstrual
</label>
<input
id="intensityGoalMenstrual"
type="number"
min="0"
value={intensityGoalMenstrual}
onChange={(e) =>
handleInputChange(
setIntensityGoalMenstrual,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div>
<label
htmlFor="intensityGoalFollicular"
className="block text-sm font-medium text-foreground"
>
Follicular
</label>
<input
id="intensityGoalFollicular"
type="number"
min="0"
value={intensityGoalFollicular}
onChange={(e) =>
handleInputChange(
setIntensityGoalFollicular,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div>
<label
htmlFor="intensityGoalOvulation"
className="block text-sm font-medium text-foreground"
>
Ovulation
</label>
<input
id="intensityGoalOvulation"
type="number"
min="0"
value={intensityGoalOvulation}
onChange={(e) =>
handleInputChange(
setIntensityGoalOvulation,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div>
<label
htmlFor="intensityGoalEarlyLuteal"
className="block text-sm font-medium text-foreground"
>
Early Luteal
</label>
<input
id="intensityGoalEarlyLuteal"
type="number"
min="0"
value={intensityGoalEarlyLuteal}
onChange={(e) =>
handleInputChange(
setIntensityGoalEarlyLuteal,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
<div className="col-span-2 sm:col-span-1">
<label
htmlFor="intensityGoalLateLuteal"
className="block text-sm font-medium text-foreground"
>
Late Luteal
</label>
<input
id="intensityGoalLateLuteal"
type="number"
min="0"
value={intensityGoalLateLuteal}
onChange={(e) =>
handleInputChange(
setIntensityGoalLateLuteal,
Number(e.target.value),
)
}
disabled={saving}
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
@@ -246,7 +406,19 @@ export default function SettingsPage() {
</button>
</div>
</form>
<div className="mt-8 pt-8 border-t border-input">
<h2 className="text-lg font-medium text-foreground mb-4">Account</h2>
<button
type="button"
onClick={handleLogout}
disabled={loggingOut}
className="rounded-md bg-red-600 px-4 py-2 text-white font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:bg-red-400 disabled:cursor-not-allowed"
>
{loggingOut ? "Logging out..." : "Log Out"}
</button>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,273 @@
// 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();
});
});
describe("accessibility", () => {
it("has aria-label with date and phase information", () => {
render(
<DayCell
{...baseProps}
date={new Date("2026-01-15")}
cycleDay={5}
phase="FOLLICULAR"
/>,
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute(
"aria-label",
"January 15, 2026 - Cycle day 5 - Follicular phase",
);
});
it("includes today indicator in aria-label when isToday", () => {
render(
<DayCell
{...baseProps}
date={new Date("2026-01-15")}
cycleDay={5}
phase="FOLLICULAR"
isToday={true}
/>,
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute(
"aria-label",
"January 15, 2026 - Cycle day 5 - Follicular phase (today)",
);
});
it("formats phase name correctly for screen readers", () => {
render(<DayCell {...baseProps} phase="EARLY_LUTEAL" />);
const button = screen.getByRole("button");
expect(button.getAttribute("aria-label")).toContain("Early Luteal phase");
});
it("formats LATE_LUTEAL phase name correctly", () => {
render(<DayCell {...baseProps} phase="LATE_LUTEAL" />);
const button = screen.getByRole("button");
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
});
});
describe("period indicator", () => {
it("shows period indicator dot on cycle day 1", () => {
render(<DayCell {...baseProps} cycleDay={1} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("shows period indicator dot on cycle day 2", () => {
render(<DayCell {...baseProps} cycleDay={2} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("shows period indicator dot on cycle day 3", () => {
render(<DayCell {...baseProps} cycleDay={3} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("does not show period indicator on cycle day 4", () => {
render(<DayCell {...baseProps} cycleDay={4} phase="FOLLICULAR" />);
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
});
it("does not show period indicator on cycle day 10", () => {
render(<DayCell {...baseProps} cycleDay={10} phase="FOLLICULAR" />);
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
});
});
});

View File

@@ -8,6 +8,7 @@ interface DayCellProps {
phase: CyclePhase;
isToday: boolean;
onClick?: () => void;
dataDay?: number;
}
const PHASE_COLORS: Record<CyclePhase, string> = {
@@ -18,20 +19,54 @@ const PHASE_COLORS: Record<CyclePhase, string> = {
LATE_LUTEAL: "bg-red-100",
};
const PHASE_DISPLAY_NAMES: Record<CyclePhase, string> = {
MENSTRUAL: "Menstrual",
FOLLICULAR: "Follicular",
OVULATION: "Ovulation",
EARLY_LUTEAL: "Early Luteal",
LATE_LUTEAL: "Late Luteal",
};
function formatAriaLabel(
date: Date,
cycleDay: number,
phase: CyclePhase,
isToday: boolean,
): string {
const dateStr = date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
const phaseName = PHASE_DISPLAY_NAMES[phase];
const todaySuffix = isToday ? " (today)" : "";
return `${dateStr} - Cycle day ${cycleDay} - ${phaseName} phase${todaySuffix}`;
}
export function DayCell({
date,
cycleDay,
phase,
isToday,
onClick,
dataDay,
}: DayCellProps) {
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
const isPeriodDay = cycleDay >= 1 && cycleDay <= 3;
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
data-day={dataDay}
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
>
<span className="text-sm font-medium">{date.getDate()}</span>
<span className="text-sm font-medium">
{date.getDate()}
{isPeriodDay && <span className="ml-0.5">🩸</span>}
</span>
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
</button>
);

View File

@@ -70,16 +70,21 @@ describe("MonthView", () => {
it("highlights today's date", () => {
render(<MonthView {...baseProps} />);
// Jan 15 is "today" - find the button containing "15"
const todayCell = screen.getByRole("button", { name: /^15\s*Day 15/i });
// Jan 15 is "today" - aria-label includes date, cycle day, and phase
// For 28-day cycle, day 15 is EARLY_LUTEAL (days 15-21)
const todayCell = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
expect(todayCell).toHaveClass("ring-2", "ring-black");
});
it("does not highlight non-today dates", () => {
render(<MonthView {...baseProps} />);
// Jan 1 is not today
const otherCell = screen.getByRole("button", { name: /^1\s*Day 1/i });
// Jan 1 is not today - aria-label includes date, cycle day, and phase
const otherCell = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
});
expect(otherCell).not.toHaveClass("ring-2");
});
});
@@ -89,39 +94,49 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Days 1-3 are MENSTRUAL (bg-blue-100)
const day1 = screen.getByRole("button", { name: /^1\s*Day 1/i });
const day1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase/i,
});
expect(day1).toHaveClass("bg-blue-100");
});
it("applies follicular phase color to days 4-14", () => {
it("applies follicular phase color to days 4-12", () => {
render(<MonthView {...baseProps} />);
// Day 5 is FOLLICULAR (bg-green-100)
const day5 = screen.getByRole("button", { name: /^5\s*Day 5/i });
// For 28-day cycle, FOLLICULAR is days 4-12
const day5 = screen.getByRole("button", {
name: /January 5, 2026 - Cycle day 5 - Follicular phase/i,
});
expect(day5).toHaveClass("bg-green-100");
});
it("applies ovulation phase color to days 15-16", () => {
it("applies ovulation phase color to days 13-14", () => {
render(<MonthView {...baseProps} />);
// Day 15 is OVULATION (bg-purple-100)
const day15 = screen.getByRole("button", { name: /^15\s*Day 15/i });
expect(day15).toHaveClass("bg-purple-100");
// For 28-day cycle, OVULATION is days 13-14
const day13 = screen.getByRole("button", {
name: /January 13, 2026 - Cycle day 13 - Ovulation phase/i,
});
expect(day13).toHaveClass("bg-purple-100");
});
it("applies early luteal phase color to days 17-24", () => {
it("applies early luteal phase color to days 15-21", () => {
render(<MonthView {...baseProps} />);
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
const day20 = screen.getByRole("button", { name: /^20\s*Day 20/i });
expect(day20).toHaveClass("bg-yellow-100");
// For 28-day cycle, EARLY_LUTEAL is days 15-21
const day18 = screen.getByRole("button", {
name: /January 18, 2026 - Cycle day 18 - Early Luteal phase/i,
});
expect(day18).toHaveClass("bg-yellow-100");
});
it("applies late luteal phase color to days 25-31", () => {
it("applies late luteal phase color to days 22-28", () => {
render(<MonthView {...baseProps} />);
// Day 25 is LATE_LUTEAL (bg-red-100)
const day25 = screen.getByRole("button", { name: /^25\s*Day 25/i });
// For 28-day cycle, LATE_LUTEAL is days 22-28
const day25 = screen.getByRole("button", {
name: /January 25, 2026 - Cycle day 25 - Late Luteal phase/i,
});
expect(day25).toHaveClass("bg-red-100");
});
});
@@ -203,6 +218,18 @@ describe("MonthView", () => {
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
});
it("displays phase emojis per spec", () => {
render(<MonthView {...baseProps} />);
// Spec requires: 🩸 Menstrual | 🌱 Follicular | 🌸 Ovulation | 🌙 Early Luteal | 🌑 Late Luteal
// Look for complete legend items to avoid matching period indicator emojis
expect(screen.getByText(/🩸 Menstrual/)).toBeInTheDocument();
expect(screen.getByText(/🌱 Follicular/)).toBeInTheDocument();
expect(screen.getByText(/🌸 Ovulation/)).toBeInTheDocument();
expect(screen.getByText(/🌙 Early Luteal/)).toBeInTheDocument();
expect(screen.getByText(/🌑 Late Luteal/)).toBeInTheDocument();
});
});
describe("cycle rollover", () => {
@@ -219,11 +246,16 @@ describe("MonthView", () => {
);
// Jan 1 should be day 28 (late luteal)
const jan1 = screen.getByRole("button", { name: /^1\s*Day 28/i });
// Button now has aria-label with full date, cycle day, and phase
const jan1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 28 - Late Luteal phase/i,
});
expect(jan1).toHaveClass("bg-red-100"); // LATE_LUTEAL
// Jan 2 should be day 1 (menstrual)
const jan2 = screen.getByRole("button", { name: /^2\s*Day 1/i });
const jan2 = screen.getByRole("button", {
name: /January 2, 2026 - Cycle day 1 - Menstrual phase/i,
});
expect(jan2).toHaveClass("bg-blue-100"); // MENSTRUAL
});
});
@@ -246,4 +278,156 @@ describe("MonthView", () => {
expect(screen.getByText("29")).toBeInTheDocument();
});
});
describe("keyboard navigation", () => {
// For 28-day cycle:
// MENSTRUAL: 1-3, FOLLICULAR: 4-12, OVULATION: 13-14, EARLY_LUTEAL: 15-21, LATE_LUTEAL: 22-28
it("moves focus to next day when pressing ArrowRight", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowRight to move to Jan 16 - day 16 is EARLY_LUTEAL
fireEvent.keyDown(jan15, { key: "ArrowRight" });
const jan16 = screen.getByRole("button", {
name: /January 16, 2026 - Cycle day 16 - Early Luteal phase$/i,
});
expect(document.activeElement).toBe(jan16);
});
it("moves focus to previous day when pressing ArrowLeft", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowLeft to move to Jan 14 - day 14 is OVULATION
fireEvent.keyDown(jan15, { key: "ArrowLeft" });
const jan14 = screen.getByRole("button", {
name: /January 14, 2026 - Cycle day 14 - Ovulation phase$/i,
});
expect(document.activeElement).toBe(jan14);
});
it("moves focus to same day next week when pressing ArrowDown", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowDown to move to Jan 22 (7 days later) - day 22 is LATE_LUTEAL
fireEvent.keyDown(jan15, { key: "ArrowDown" });
const jan22 = screen.getByRole("button", {
name: /January 22, 2026 - Cycle day 22 - Late Luteal phase$/i,
});
expect(document.activeElement).toBe(jan22);
});
it("moves focus to same day previous week when pressing ArrowUp", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowUp to move to Jan 8 (7 days earlier) - day 8 is FOLLICULAR
fireEvent.keyDown(jan15, { key: "ArrowUp" });
const jan8 = screen.getByRole("button", {
name: /January 8, 2026 - Cycle day 8 - Follicular phase$/i,
});
expect(document.activeElement).toBe(jan8);
});
it("calls onMonthChange when navigating past end of month with ArrowRight", () => {
const onMonthChange = vi.fn();
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
// Focus on Jan 31 (last day of month)
// With lastPeriod Jan 1, cycleLength 28: Jan 31 = cycle day 3 (MENSTRUAL)
const jan31 = screen.getByRole("button", {
name: /January 31, 2026 - Cycle day 3 - Menstrual phase$/i,
});
jan31.focus();
// Press ArrowRight - should trigger month change to February
fireEvent.keyDown(jan31, { key: "ArrowRight" });
expect(onMonthChange).toHaveBeenCalledWith(2026, 1);
});
it("calls onMonthChange when navigating before start of month with ArrowLeft", () => {
const onMonthChange = vi.fn();
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
// Focus on Jan 1 (first day of month)
const jan1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
});
jan1.focus();
// Press ArrowLeft - should trigger month change to December 2025
fireEvent.keyDown(jan1, { key: "ArrowLeft" });
expect(onMonthChange).toHaveBeenCalledWith(2025, 11);
});
it("wraps focus at row boundaries for Home and End keys", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press Home to move to first day of month
fireEvent.keyDown(jan15, { key: "Home" });
const jan1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
});
expect(document.activeElement).toBe(jan1);
});
it("moves focus to last day when pressing End key", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press End to move to last day of month
fireEvent.keyDown(jan15, { key: "End" });
// With lastPeriod Jan 1, cycleLength 28: Jan 31 = cycle day 3 (MENSTRUAL)
const jan31 = screen.getByRole("button", {
name: /January 31, 2026 - Cycle day 3 - Menstrual phase$/i,
});
expect(document.activeElement).toBe(jan31);
});
it("calendar grid has proper role for accessibility", () => {
render(<MonthView {...baseProps} />);
expect(screen.getByRole("grid")).toBeInTheDocument();
});
});
});

Some files were not shown because too many files have changed in this diff Show More