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:
2026-01-11 07:49:56 +00:00
parent 97a424e41d
commit 6a8d55c0b9
9 changed files with 596 additions and 29 deletions

68
spec.md
View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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
View 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