Document spec gaps: auth, phase scaling, observability, testing
Address 21 previously undefined behaviors across specs: - Authentication: Replace email/password with OIDC (Pocket-ID) - Cycle tracking: Add fixed-luteal phase scaling formula with examples - Calendar: Document period logging behavior (preserve predictions) - Garmin: Clarify connection is required (no phase-only mode) - Dashboard: Add UI states, dark mode, onboarding, accessibility - Notifications: Document timezone batching approach - New specs: observability.md (health, metrics, logging) - New specs: testing.md (unit + integration strategy) - Main spec: Add backup/recovery, known limitations, API updates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
68
spec.md
68
spec.md
@@ -425,14 +425,18 @@ interface PeriodLog {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Data Retention
|
||||||
|
|
||||||
|
All logs (DailyLog, PeriodLog) are retained indefinitely. No automatic deletion or archival. SQLite handles large datasets efficiently for single-user scenarios.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Routes
|
## API Routes
|
||||||
|
|
||||||
```
|
```
|
||||||
# Auth (PocketBase handles user auth)
|
# Auth (PocketBase handles via OIDC)
|
||||||
POST /api/auth/login
|
GET /api/auth/login - Initiate OIDC flow
|
||||||
POST /api/auth/register
|
GET /api/auth/callback - OIDC callback handler
|
||||||
POST /api/auth/logout
|
POST /api/auth/logout
|
||||||
|
|
||||||
# Garmin Token Management
|
# Garmin Token Management
|
||||||
@@ -450,7 +454,8 @@ GET /api/cycle/current - Current phase info
|
|||||||
|
|
||||||
# Daily
|
# Daily
|
||||||
GET /api/today - Today's decision + all data
|
GET /api/today - Today's decision + all data
|
||||||
GET /api/history - Historical logs
|
GET /api/history - Historical logs (cursor-based pagination)
|
||||||
|
Query params: ?cursor=<lastId>&limit=20
|
||||||
|
|
||||||
# Calendar
|
# Calendar
|
||||||
GET /api/calendar/:userId/:token.ics - ICS feed (public, token-protected)
|
GET /api/calendar/:userId/:token.ics - ICS feed (public, token-protected)
|
||||||
@@ -460,6 +465,10 @@ POST /api/calendar/regenerate-token - Generate new calendar token
|
|||||||
POST /api/overrides - Set active overrides
|
POST /api/overrides - Set active overrides
|
||||||
DELETE /api/overrides/:type - Remove override
|
DELETE /api/overrides/:type - Remove override
|
||||||
|
|
||||||
|
# Observability
|
||||||
|
GET /api/health - Health check for monitoring
|
||||||
|
GET /metrics - Prometheus metrics endpoint
|
||||||
|
|
||||||
# Cron (internal, protected)
|
# Cron (internal, protected)
|
||||||
POST /api/cron/garmin-sync - Fetch Garmin data (6 AM)
|
POST /api/cron/garmin-sync - Fetch Garmin data (6 AM)
|
||||||
POST /api/cron/notifications - Send emails (7 AM)
|
POST /api/cron/notifications - Send emails (7 AM)
|
||||||
@@ -773,7 +782,7 @@ EOF
|
|||||||
|----------|----------|
|
|----------|----------|
|
||||||
| Garmin API unavailable | Use last known values, note in email |
|
| Garmin API unavailable | Use last known values, note in email |
|
||||||
| Garmin tokens expired | Email user with re-auth instructions |
|
| Garmin tokens expired | Email user with re-auth instructions |
|
||||||
| No Garmin data yet | Use phase-only decision |
|
| Garmin not connected | Block app usage, show onboarding prompt |
|
||||||
| User hasn't set period date | Prompt in dashboard, block email until set |
|
| User hasn't set period date | Prompt in dashboard, block email until set |
|
||||||
| Email delivery failure | Log, retry once |
|
| Email delivery failure | Log, retry once |
|
||||||
| Invalid ICS request | Return 404 |
|
| Invalid ICS request | Return 404 |
|
||||||
@@ -785,11 +794,51 @@ EOF
|
|||||||
- Garmin tokens encrypted at rest (AES-256)
|
- Garmin tokens encrypted at rest (AES-256)
|
||||||
- ICS feed URL contains random token (not guessable)
|
- ICS feed URL contains random token (not guessable)
|
||||||
- Cron endpoints protected by secret header
|
- Cron endpoints protected by secret header
|
||||||
- PocketBase handles user auth
|
- PocketBase handles user auth via OIDC
|
||||||
- HTTPS enforced via Traefik
|
- HTTPS enforced via Traefik
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Backup & Recovery
|
||||||
|
|
||||||
|
PocketBase stores all data in a single SQLite database file.
|
||||||
|
|
||||||
|
**Backup Procedure:**
|
||||||
|
```bash
|
||||||
|
# Stop PocketBase or use SQLite backup API
|
||||||
|
cp /path/to/pb_data/data.db /path/to/backups/data-$(date +%Y%m%d).db
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to backup:**
|
||||||
|
- `pb_data/data.db` - Main database (users, logs, settings)
|
||||||
|
- `pb_data/storage/` - Any uploaded files (if applicable)
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
```bash
|
||||||
|
# Stop PocketBase
|
||||||
|
cp /path/to/backups/data-YYYYMMDD.db /path/to/pb_data/data.db
|
||||||
|
# Restart PocketBase
|
||||||
|
```
|
||||||
|
|
||||||
|
Backups are manual. Set up your own cron job or backup solution as needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
The following are **out of scope** for MVP:
|
||||||
|
|
||||||
|
| Limitation | Notes |
|
||||||
|
|------------|-------|
|
||||||
|
| Phase-only mode | Garmin connection required; no fallback without biometrics |
|
||||||
|
| Pregnancy/menopause | Cycle tracking assumes regular menstrual cycles |
|
||||||
|
| 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) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -821,9 +870,12 @@ phaseflow/
|
|||||||
│ │ │ ├── [userId]/[token].ics/route.ts
|
│ │ │ ├── [userId]/[token].ics/route.ts
|
||||||
│ │ │ └── regenerate-token/route.ts
|
│ │ │ └── regenerate-token/route.ts
|
||||||
│ │ ├── overrides/route.ts
|
│ │ ├── overrides/route.ts
|
||||||
|
│ │ ├── health/route.ts
|
||||||
│ │ └── cron/
|
│ │ └── cron/
|
||||||
│ │ ├── garmin-sync/route.ts
|
│ │ ├── garmin-sync/route.ts
|
||||||
│ │ └── notifications/route.ts
|
│ │ └── notifications/route.ts
|
||||||
|
│ │ └── metrics/
|
||||||
|
│ │ └── route.ts # Prometheus metrics
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── dashboard/
|
│ │ ├── dashboard/
|
||||||
│ │ │ ├── decision-card.tsx
|
│ │ │ ├── decision-card.tsx
|
||||||
@@ -843,7 +895,9 @@ phaseflow/
|
|||||||
│ │ ├── nutrition.ts
|
│ │ ├── nutrition.ts
|
||||||
│ │ ├── email.ts
|
│ │ ├── email.ts
|
||||||
│ │ ├── ics.ts # ICS feed generation
|
│ │ ├── ics.ts # ICS feed generation
|
||||||
│ │ └── encryption.ts
|
│ │ ├── encryption.ts
|
||||||
|
│ │ ├── logger.ts # Structured JSON logging
|
||||||
|
│ │ └── metrics.ts # Prometheus metrics
|
||||||
│ └── types/
|
│ └── types/
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── flake.nix
|
├── flake.nix
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## Job to Be Done
|
## Job to Be Done
|
||||||
|
|
||||||
When I access PhaseFlow, I want to securely log in with my email, so that my personal health data remains private.
|
When I access PhaseFlow, I want to securely log in with my identity provider, so that my personal health data remains private.
|
||||||
|
|
||||||
## Auth Provider
|
## Auth Provider
|
||||||
|
|
||||||
Using PocketBase for authentication and data storage.
|
Using PocketBase for authentication and data storage, with OIDC (Pocket-ID) as the primary identity provider.
|
||||||
|
|
||||||
**Connection:**
|
**Connection:**
|
||||||
- `POCKETBASE_URL` environment variable
|
- `POCKETBASE_URL` environment variable
|
||||||
@@ -14,12 +14,30 @@ Using PocketBase for authentication and data storage.
|
|||||||
|
|
||||||
## Login Flow
|
## Login Flow
|
||||||
|
|
||||||
### Email/Password Authentication
|
### OIDC Authentication (Pocket-ID)
|
||||||
|
|
||||||
1. User enters email and password on `/login`
|
1. User clicks "Sign In" on `/login`
|
||||||
2. App calls PocketBase `authWithPassword`
|
2. App redirects to Pocket-ID authorization endpoint
|
||||||
3. On success, PocketBase sets auth cookie
|
3. User authenticates with Pocket-ID (handles MFA if configured)
|
||||||
4. User redirected to dashboard
|
4. Pocket-ID redirects back with authorization code
|
||||||
|
5. PocketBase exchanges code for tokens
|
||||||
|
6. User redirected to dashboard
|
||||||
|
|
||||||
|
### OIDC Configuration
|
||||||
|
|
||||||
|
Configure in PocketBase Admin UI: **Settings → Auth providers → OpenID Connect**
|
||||||
|
|
||||||
|
**Required Settings:**
|
||||||
|
- `Client ID` - From Pocket-ID application
|
||||||
|
- `Client Secret` - From Pocket-ID application
|
||||||
|
- `Issuer URL` - Your Pocket-ID instance URL (e.g., `https://id.yourdomain.com`)
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
```env
|
||||||
|
POCKETBASE_OIDC_CLIENT_ID=phaseflow
|
||||||
|
POCKETBASE_OIDC_CLIENT_SECRET=xxx
|
||||||
|
POCKETBASE_OIDC_ISSUER_URL=https://id.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
|
|
||||||
@@ -32,11 +50,8 @@ Using PocketBase for authentication and data storage.
|
|||||||
### `/login`
|
### `/login`
|
||||||
|
|
||||||
**Elements:**
|
**Elements:**
|
||||||
- Email input
|
- "Sign In with Pocket-ID" button
|
||||||
- Password input
|
|
||||||
- "Sign In" button
|
|
||||||
- Error message display
|
- Error message display
|
||||||
- Link to password reset (future)
|
|
||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
- Redirect to `/` on successful login
|
- Redirect to `/` on successful login
|
||||||
@@ -130,18 +145,37 @@ User profile management:
|
|||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. Login completes in under 2 seconds
|
1. OIDC login flow completes in under 5 seconds (including redirect)
|
||||||
2. Session persists across browser refreshes
|
2. Session persists across browser refreshes
|
||||||
3. Unauthorized access redirects to login
|
3. Unauthorized access redirects to login
|
||||||
4. User data isolated by authentication
|
4. User data isolated by authentication
|
||||||
|
|
||||||
## Acceptance Tests
|
## Acceptance Tests
|
||||||
|
|
||||||
- [ ] Valid credentials authenticate successfully
|
- [ ] OIDC redirect initiates correctly
|
||||||
- [ ] Invalid credentials show error message
|
- [ ] Successful OIDC callback creates/updates user
|
||||||
- [ ] Session persists after page refresh
|
- [ ] Session persists after page refresh
|
||||||
- [ ] Protected routes redirect when not authenticated
|
- [ ] Protected routes redirect when not authenticated
|
||||||
- [ ] GET `/api/user` returns current user data
|
- [ ] GET `/api/user` returns current user data
|
||||||
- [ ] PATCH `/api/user` updates user record
|
- [ ] PATCH `/api/user` updates user record
|
||||||
- [ ] Logout clears session completely
|
- [ ] Logout clears session completely
|
||||||
- [ ] Auth cookie is HttpOnly and Secure
|
- [ ] Auth cookie is HttpOnly and Secure
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Open Registration
|
||||||
|
|
||||||
|
When open registration is enabled:
|
||||||
|
- Add `/signup` page with OIDC provider selection
|
||||||
|
- New users created automatically on first OIDC login
|
||||||
|
- Admin approval workflow (optional)
|
||||||
|
|
||||||
|
### Additional OAuth2 Providers
|
||||||
|
|
||||||
|
PocketBase supports multiple OAuth2 providers. Future options:
|
||||||
|
- Google
|
||||||
|
- GitHub
|
||||||
|
- Apple
|
||||||
|
- Other OIDC-compliant providers
|
||||||
|
|
||||||
|
Each provider configured separately in PocketBase Admin UI.
|
||||||
|
|||||||
@@ -55,6 +55,31 @@ Generates new `calendarToken`, invalidating previous subscriptions.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Period Logging Behavior
|
||||||
|
|
||||||
|
When a user logs a new period start date, the ICS feed updates as follows:
|
||||||
|
|
||||||
|
1. **Current cycle**: Phase events are corrected retroactively to reflect actual dates
|
||||||
|
2. **Original predictions preserved**: Previous predictions for the current cycle are kept as separate events with "(Predicted)" suffix, allowing users to compare predicted vs. actual
|
||||||
|
3. **Older cycles**: Events from previous cycles remain unchanged
|
||||||
|
4. **Future cycles**: Regenerated based on new period date and cycle length
|
||||||
|
|
||||||
|
**Example after logging period on Day 28 of a predicted 31-day cycle:**
|
||||||
|
```ics
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:🩸 Menstrual (Days 1-3)
|
||||||
|
DTSTART;VALUE=DATE:20240128
|
||||||
|
DESCRIPTION:Actual period start
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:🩸 Menstrual (Predicted)
|
||||||
|
DTSTART;VALUE=DATE:20240131
|
||||||
|
DESCRIPTION:Original prediction - period arrived 3 days early
|
||||||
|
END:VEVENT
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a built-in accuracy feedback loop for understanding cycle patterns.
|
||||||
|
|
||||||
## In-App Calendar
|
## In-App Calendar
|
||||||
|
|
||||||
### Month View (`/calendar`)
|
### Month View (`/calendar`)
|
||||||
@@ -107,6 +132,8 @@ Below calendar:
|
|||||||
- [ ] ICS contains events for next 90 days
|
- [ ] ICS contains events for next 90 days
|
||||||
- [ ] Invalid token returns 401
|
- [ ] Invalid token returns 401
|
||||||
- [ ] Regenerate token creates new unique token
|
- [ ] Regenerate token creates new unique token
|
||||||
|
- [ ] Period log updates current cycle events retroactively
|
||||||
|
- [ ] Period log preserves original predictions as "(Predicted)" events
|
||||||
- [ ] Month view renders all days in month
|
- [ ] Month view renders all days in month
|
||||||
- [ ] Day cells show correct phase colors
|
- [ ] Day cells show correct phase colors
|
||||||
- [ ] Today is visually highlighted
|
- [ ] Today is visually highlighted
|
||||||
|
|||||||
@@ -19,15 +19,29 @@ Default cycle length: 31 days (configurable per user).
|
|||||||
|
|
||||||
### Cycle Phases
|
### Cycle Phases
|
||||||
|
|
||||||
Based on a 31-day cycle:
|
Phase boundaries scale based on cycle length using a **fixed luteal, variable follicular** approach. The luteal phase (14 days) is biologically consistent; the follicular phase expands or contracts with cycle length.
|
||||||
|
|
||||||
|
**Phase Calculation Formula:**
|
||||||
|
|
||||||
|
Given `cycleLength` (user-configurable, default 31):
|
||||||
|
|
||||||
| Phase | Days | Weekly Limit | Training Type |
|
| Phase | Days | Weekly Limit | Training Type |
|
||||||
|-------|------|--------------|---------------|
|
|-------|------|--------------|---------------|
|
||||||
| MENSTRUAL | 1-3 | 30 min | Gentle rebounding only |
|
| MENSTRUAL | 1-3 | 30 min | Gentle rebounding only |
|
||||||
| FOLLICULAR | 4-14 | 120 min | Strength + rebounding |
|
| FOLLICULAR | 4 to (cycleLength - 16) | 120 min | Strength + rebounding |
|
||||||
| OVULATION | 15-16 | 80 min | Peak performance |
|
| OVULATION | (cycleLength - 15) to (cycleLength - 14) | 80 min | Peak performance |
|
||||||
| EARLY_LUTEAL | 17-24 | 100 min | Moderate training |
|
| EARLY_LUTEAL | (cycleLength - 13) to (cycleLength - 7) | 100 min | Moderate training |
|
||||||
| LATE_LUTEAL | 25-31 | 50 min | Gentle rebounding ONLY |
|
| LATE_LUTEAL | (cycleLength - 6) to cycleLength | 50 min | Gentle rebounding ONLY |
|
||||||
|
|
||||||
|
**Examples by Cycle Length:**
|
||||||
|
|
||||||
|
| Phase | 28-day | 31-day | 35-day |
|
||||||
|
|-------|--------|--------|--------|
|
||||||
|
| MENSTRUAL | 1-3 | 1-3 | 1-3 |
|
||||||
|
| FOLLICULAR | 4-12 | 4-15 | 4-19 |
|
||||||
|
| OVULATION | 13-14 | 16-17 | 20-21 |
|
||||||
|
| EARLY_LUTEAL | 15-21 | 18-24 | 22-28 |
|
||||||
|
| LATE_LUTEAL | 22-28 | 25-31 | 29-35 |
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -101,7 +115,18 @@ interface PeriodLog {
|
|||||||
Users with irregular cycles can adjust:
|
Users with irregular cycles can adjust:
|
||||||
- Default: 31 days
|
- Default: 31 days
|
||||||
- Range: 21-45 days
|
- Range: 21-45 days
|
||||||
- Affects phase day boundaries proportionally
|
- Affects phase day boundaries per formula above
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
The following scenarios are **out of scope** for MVP:
|
||||||
|
|
||||||
|
- **Pregnancy** - Cycle tracking pauses; app not designed for pregnancy
|
||||||
|
- **Menopause** - Irregular/absent cycles not supported
|
||||||
|
- **Hormonal birth control** - May suppress or alter natural cycle phases
|
||||||
|
- **Anovulatory cycles** - App assumes ovulation occurs; no detection for skipped ovulation
|
||||||
|
|
||||||
|
Users in these situations should consult healthcare providers for personalized guidance.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
@@ -115,6 +140,8 @@ Users with irregular cycles can adjust:
|
|||||||
- [ ] `getCycleDay` returns 1 on period start date
|
- [ ] `getCycleDay` returns 1 on period start date
|
||||||
- [ ] `getCycleDay` handles cycle rollover correctly
|
- [ ] `getCycleDay` handles cycle rollover correctly
|
||||||
- [ ] `getPhase` returns correct phase for each day range
|
- [ ] `getPhase` returns correct phase for each day range
|
||||||
|
- [ ] `getPhase` scales correctly for 28-day cycle
|
||||||
|
- [ ] `getPhase` scales correctly for 35-day cycle
|
||||||
- [ ] POST `/api/cycle/period` updates user record
|
- [ ] POST `/api/cycle/period` updates user record
|
||||||
- [ ] GET `/api/cycle/current` returns accurate phase info
|
- [ ] GET `/api/cycle/current` returns accurate phase info
|
||||||
- [ ] Days beyond cycle length default to LATE_LUTEAL
|
- [ ] Days beyond cycle length default to LATE_LUTEAL
|
||||||
|
|||||||
@@ -77,12 +77,68 @@ Visual cycle overview for the current month.
|
|||||||
- Period days marked distinctly
|
- Period days marked distinctly
|
||||||
- Quick navigation to previous/next month
|
- Quick navigation to previous/next month
|
||||||
|
|
||||||
|
## UI States
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
|
||||||
|
Use skeleton loaders during data fetching:
|
||||||
|
- Decision card: Shimmer placeholder for status and reason
|
||||||
|
- Data panel: Skeleton rows for each metric
|
||||||
|
- Mini calendar: Placeholder grid
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Toast notifications for errors with actionable feedback:
|
||||||
|
- Network errors: "Unable to fetch data. Retry?"
|
||||||
|
- Garmin sync failed: "Garmin data unavailable. Using last known values."
|
||||||
|
- Save errors: "Failed to save. Try again."
|
||||||
|
|
||||||
|
Toast position: Bottom-right, auto-dismiss after 5 seconds (errors persist until dismissed).
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Auto-detect system preference via CSS `prefers-color-scheme`:
|
||||||
|
- No manual toggle
|
||||||
|
- Respect OS-level dark mode setting
|
||||||
|
- Ensure all phase colors maintain contrast in both modes
|
||||||
|
|
||||||
|
## Onboarding
|
||||||
|
|
||||||
|
First-time users see inline prompts/banners for missing setup:
|
||||||
|
|
||||||
|
| Missing State | Banner Message | Action |
|
||||||
|
|---------------|----------------|--------|
|
||||||
|
| Garmin not connected | "Connect your Garmin to get started" | Link to /settings/garmin |
|
||||||
|
| Last period not set | "Set your last period date for accurate tracking" | Opens date picker |
|
||||||
|
| Notification time not set | "Set your preferred notification time" | Link to /settings |
|
||||||
|
|
||||||
|
Banners appear at top of dashboard, dismissable once action completed.
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
Mobile-responsive layout:
|
||||||
|
- Single-column layout on mobile (<768px)
|
||||||
|
- All critical metrics visible without scrolling
|
||||||
|
- Touch-friendly toggle buttons (min 44x44px tap targets)
|
||||||
|
- Collapsible sections for secondary content
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Basic accessibility requirements:
|
||||||
|
- Semantic HTML (proper heading hierarchy, landmarks)
|
||||||
|
- Keyboard navigation for all interactive elements
|
||||||
|
- Focus indicators visible
|
||||||
|
- Color contrast minimum 4.5:1 for text
|
||||||
|
- Phase colors supplemented with icons/text (not color-only)
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. User can determine training status within 3 seconds of opening app
|
1. User can determine training status within 3 seconds of opening app
|
||||||
2. All biometric data visible without scrolling on mobile
|
2. All biometric data visible without scrolling on mobile
|
||||||
3. Override toggles accessible but not accidentally activated
|
3. Override toggles accessible but not accidentally activated
|
||||||
4. Phase colors consistent across all components
|
4. Phase colors consistent across all components
|
||||||
|
5. Dashboard usable with keyboard only
|
||||||
|
6. Loading states display within 100ms of navigation
|
||||||
|
|
||||||
## Acceptance Tests
|
## Acceptance Tests
|
||||||
|
|
||||||
@@ -92,3 +148,9 @@ Visual cycle overview for the current month.
|
|||||||
- [ ] Nutrition panel shows correct seeds for cycle day
|
- [ ] Nutrition panel shows correct seeds for cycle day
|
||||||
- [ ] Override toggles update user record immediately
|
- [ ] Override toggles update user record immediately
|
||||||
- [ ] Mini calendar renders current month with phase colors
|
- [ ] Mini calendar renders current month with phase colors
|
||||||
|
- [ ] Skeleton loaders appear during data fetch
|
||||||
|
- [ ] Toast notifications display on error
|
||||||
|
- [ ] Dark mode activates based on system preference
|
||||||
|
- [ ] Onboarding banner shows when Garmin not connected
|
||||||
|
- [ ] Dashboard renders correctly on mobile viewport
|
||||||
|
- [ ] All interactive elements keyboard-accessible
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Garmin Integration Specification
|
# Garmin Integration Specification
|
||||||
|
|
||||||
|
> **Required**: Garmin connection is mandatory. PhaseFlow cannot function without biometric data from Garmin. The app's core value proposition is combining cycle phases with real physiological data—phase-only mode is not supported.
|
||||||
|
|
||||||
## Job to Be Done
|
## Job to Be Done
|
||||||
|
|
||||||
When I connect my Garmin account, I want the app to automatically sync my biometrics daily, so that training decisions are based on real physiological data.
|
When I connect my Garmin account, I want the app to automatically sync my biometrics daily, so that training decisions are based on real physiological data.
|
||||||
|
|||||||
@@ -81,9 +81,28 @@ Protected by `CRON_SECRET` header.
|
|||||||
|
|
||||||
### Timezone Handling
|
### Timezone Handling
|
||||||
|
|
||||||
Users store preferred timezone (e.g., `America/New_York`).
|
Users are batched into hourly timezone windows for efficient scheduling.
|
||||||
|
|
||||||
Cron runs every hour. Check if current hour in user's timezone matches their `notificationTime`.
|
**Implementation:**
|
||||||
|
1. Cron job runs 24 times daily (once per hour, on the hour)
|
||||||
|
2. Each run queries users whose `notificationTime` hour matches the current UTC hour adjusted for their timezone
|
||||||
|
3. Users are processed in batches within their timezone window
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- User A: timezone `America/New_York`, notificationTime `07:00`
|
||||||
|
- User B: timezone `America/Los_Angeles`, notificationTime `07:00`
|
||||||
|
- User C: timezone `Europe/London`, notificationTime `07:00`
|
||||||
|
|
||||||
|
When cron runs at 12:00 UTC:
|
||||||
|
- User A receives notification (12:00 UTC = 07:00 EST)
|
||||||
|
- User B skipped (12:00 UTC = 04:00 PST)
|
||||||
|
- User C skipped (12:00 UTC = 12:00 GMT)
|
||||||
|
|
||||||
|
**Query Logic:**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE EXTRACT(HOUR FROM NOW() AT TIME ZONE timezone) = CAST(SUBSTRING(notificationTime, 1, 2) AS INT)
|
||||||
|
```
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
|
|||||||
138
specs/observability.md
Normal file
138
specs/observability.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Observability Specification
|
||||||
|
|
||||||
|
## Job to Be Done
|
||||||
|
|
||||||
|
When the app is running in production, I want visibility into its health and behavior, so that I can detect and diagnose issues quickly.
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
### GET `/api/health`
|
||||||
|
|
||||||
|
Returns application health status for monitoring and load balancer checks.
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timestamp": "2024-01-10T12:00:00Z",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (503 Service Unavailable):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "unhealthy",
|
||||||
|
"timestamp": "2024-01-10T12:00:00Z",
|
||||||
|
"error": "PocketBase connection failed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checks Performed:**
|
||||||
|
- PocketBase connectivity
|
||||||
|
- Basic app startup complete
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
- Nomad health checks
|
||||||
|
- Uptime monitoring (e.g., Uptime Kuma)
|
||||||
|
- Load balancer health probes
|
||||||
|
|
||||||
|
## Prometheus Metrics
|
||||||
|
|
||||||
|
### GET `/metrics`
|
||||||
|
|
||||||
|
Returns Prometheus-format metrics for scraping.
|
||||||
|
|
||||||
|
**Standard Node.js Metrics:**
|
||||||
|
- `nodejs_heap_size_total_bytes`
|
||||||
|
- `nodejs_heap_size_used_bytes`
|
||||||
|
- `nodejs_eventloop_lag_seconds`
|
||||||
|
- `http_request_duration_seconds` (histogram)
|
||||||
|
- `http_requests_total` (counter)
|
||||||
|
|
||||||
|
**Custom Application Metrics:**
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Description |
|
||||||
|
|--------|------|--------|-------------|
|
||||||
|
| `phaseflow_garmin_sync_total` | counter | `status` (success/failure) | Garmin sync attempts |
|
||||||
|
| `phaseflow_garmin_sync_duration_seconds` | histogram | - | Garmin sync duration |
|
||||||
|
| `phaseflow_email_sent_total` | counter | `type` (daily/warning) | Emails sent |
|
||||||
|
| `phaseflow_decision_engine_calls_total` | counter | `decision` (REST/GENTLE/...) | Decision engine invocations |
|
||||||
|
| `phaseflow_active_users` | gauge | - | Users with activity in last 24h |
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
Use `prom-client` npm package for metrics collection.
|
||||||
|
|
||||||
|
## Structured Logging
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
JSON-structured logs for all significant events:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2024-01-10T12:00:00.000Z",
|
||||||
|
"level": "info",
|
||||||
|
"message": "Garmin sync completed",
|
||||||
|
"userId": "user123",
|
||||||
|
"duration_ms": 1250,
|
||||||
|
"metrics": {
|
||||||
|
"bodyBattery": 95,
|
||||||
|
"hrvStatus": "Balanced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Levels
|
||||||
|
|
||||||
|
| Level | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `error` | Failures requiring attention (sync failures, email errors) |
|
||||||
|
| `warn` | Degraded behavior (using cached data, retries) |
|
||||||
|
| `info` | Normal operations (sync complete, email sent, decision made) |
|
||||||
|
|
||||||
|
### Key Events to Log
|
||||||
|
|
||||||
|
| Event | Level | Fields |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Auth success | info | userId |
|
||||||
|
| Auth failure | warn | reason, ip |
|
||||||
|
| Garmin sync start | info | userId |
|
||||||
|
| Garmin sync complete | info | userId, duration_ms, metrics |
|
||||||
|
| Garmin sync failure | error | userId, error, attempt |
|
||||||
|
| Email sent | info | userId, type, recipient |
|
||||||
|
| Email failed | error | userId, type, error |
|
||||||
|
| Decision calculated | info | userId, decision, reason |
|
||||||
|
| Period logged | info | userId, date |
|
||||||
|
| Override toggled | info | userId, override, enabled |
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
Use structured logger (e.g., `pino`) configured for JSON output:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
formatters: {
|
||||||
|
level: (label) => ({ level: label }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. Health endpoint responds in under 100ms
|
||||||
|
2. Metrics endpoint scrapable by Prometheus
|
||||||
|
3. All key events logged with consistent structure
|
||||||
|
4. Logs parseable by log aggregators (Loki, ELK, etc.)
|
||||||
|
|
||||||
|
## Acceptance Tests
|
||||||
|
|
||||||
|
- [ ] GET `/api/health` returns 200 when healthy
|
||||||
|
- [ ] GET `/api/health` returns 503 when PocketBase unreachable
|
||||||
|
- [ ] GET `/metrics` returns valid Prometheus format
|
||||||
|
- [ ] Custom metrics increment on relevant events
|
||||||
|
- [ ] Logs output valid JSON to stdout
|
||||||
|
- [ ] Error logs include stack traces
|
||||||
204
specs/testing.md
Normal file
204
specs/testing.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Testing Specification
|
||||||
|
|
||||||
|
## Job to Be Done
|
||||||
|
|
||||||
|
When I make changes to the codebase, I want automated tests to catch regressions, so that I can deploy with confidence.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are not required for MVP (authorized skip).
|
||||||
|
|
||||||
|
### Test Types
|
||||||
|
|
||||||
|
| Type | Scope | Tools | Location |
|
||||||
|
|------|-------|-------|----------|
|
||||||
|
| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` |
|
||||||
|
| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` |
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
**Vitest** - Fast, Vite-native test runner with TypeScript support.
|
||||||
|
|
||||||
|
**Configuration (`vitest.config.ts`):**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
Test pure functions in isolation without external dependencies.
|
||||||
|
|
||||||
|
### Priority Targets
|
||||||
|
|
||||||
|
| Module | Functions | Priority |
|
||||||
|
|--------|-----------|----------|
|
||||||
|
| `src/lib/cycle.ts` | `getCycleDay`, `getPhase`, `getPhaseConfig` | High |
|
||||||
|
| `src/lib/decision-engine.ts` | `getTrainingDecision` | High |
|
||||||
|
| `src/lib/nutrition.ts` | `getNutritionGuidance`, `getSeeds`, `getMacros` | Medium |
|
||||||
|
| `src/lib/ics.ts` | `generatePhaseEvents`, `generateCalendarFeed` | Medium |
|
||||||
|
| `src/lib/encryption.ts` | `encrypt`, `decrypt` | Medium |
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/cycle.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getCycleDay, getPhase } from './cycle';
|
||||||
|
|
||||||
|
describe('getCycleDay', () => {
|
||||||
|
it('returns 1 on period start date', () => {
|
||||||
|
const lastPeriod = new Date('2024-01-01');
|
||||||
|
const today = new Date('2024-01-01');
|
||||||
|
expect(getCycleDay(lastPeriod, 31, today)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cycle rollover', () => {
|
||||||
|
const lastPeriod = new Date('2024-01-01');
|
||||||
|
const today = new Date('2024-02-01'); // Day 32
|
||||||
|
expect(getCycleDay(lastPeriod, 31, today)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPhase', () => {
|
||||||
|
it('returns MENSTRUAL for days 1-3', () => {
|
||||||
|
expect(getPhase(1, 31)).toBe('MENSTRUAL');
|
||||||
|
expect(getPhase(3, 31)).toBe('MENSTRUAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales correctly for 28-day cycle', () => {
|
||||||
|
// LATE_LUTEAL should be days 22-28 for 28-day cycle
|
||||||
|
expect(getPhase(22, 28)).toBe('LATE_LUTEAL');
|
||||||
|
expect(getPhase(21, 28)).toBe('EARLY_LUTEAL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Tests
|
||||||
|
|
||||||
|
Test API routes and PocketBase interactions.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Use a test PocketBase instance or PocketBase's testing utilities.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/test/setup.ts
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
export const testPb = new PocketBase(process.env.TEST_POCKETBASE_URL);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Setup test data
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priority Targets
|
||||||
|
|
||||||
|
| Route | Tests |
|
||||||
|
|-------|-------|
|
||||||
|
| `GET /api/today` | Returns decision with valid auth |
|
||||||
|
| `GET /api/cycle/current` | Returns correct phase info |
|
||||||
|
| `POST /api/cycle/period` | Updates user record |
|
||||||
|
| `GET /api/user` | Returns authenticated user |
|
||||||
|
| `PATCH /api/user` | Updates user fields |
|
||||||
|
| `GET /api/health` | Returns health status |
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/api/today/route.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GET } from './route';
|
||||||
|
|
||||||
|
describe('GET /api/today', () => {
|
||||||
|
it('returns 401 without auth', async () => {
|
||||||
|
const request = new Request('http://localhost/api/today');
|
||||||
|
const response = await GET(request);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns decision with valid auth', async () => {
|
||||||
|
// Setup authenticated request
|
||||||
|
const request = new Request('http://localhost/api/today', {
|
||||||
|
headers: { Cookie: 'pb_auth=...' },
|
||||||
|
});
|
||||||
|
const response = await GET(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data).toHaveProperty('decision');
|
||||||
|
expect(data).toHaveProperty('cycleDay');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
Tests colocated with source files:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
lib/
|
||||||
|
cycle.ts
|
||||||
|
cycle.test.ts
|
||||||
|
decision-engine.ts
|
||||||
|
decision-engine.test.ts
|
||||||
|
app/
|
||||||
|
api/
|
||||||
|
today/
|
||||||
|
route.ts
|
||||||
|
route.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run in watch mode
|
||||||
|
npm run test:watch
|
||||||
|
|
||||||
|
# Run specific file
|
||||||
|
npm test -- src/lib/cycle.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Expectations
|
||||||
|
|
||||||
|
No strict coverage thresholds for MVP, but aim for:
|
||||||
|
- 80%+ coverage on `src/lib/` (core logic)
|
||||||
|
- Key API routes tested for auth and happy path
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. All tests pass in CI before merge
|
||||||
|
2. Core decision engine logic has comprehensive tests
|
||||||
|
3. Phase scaling tested for multiple cycle lengths
|
||||||
|
4. API auth tested for protected routes
|
||||||
|
|
||||||
|
## Acceptance Tests
|
||||||
|
|
||||||
|
- [ ] `npm test` runs without errors
|
||||||
|
- [ ] Unit tests cover decision engine logic
|
||||||
|
- [ ] Unit tests cover cycle phase calculations
|
||||||
|
- [ ] Integration tests verify API authentication
|
||||||
|
- [ ] Tests run in CI pipeline
|
||||||
Reference in New Issue
Block a user