# Baby Sleep Tracker — Full Application Specification
## Overview
A self-hosted web application for tracking infant sleep sessions. It runs on a Raspberry Pi 4 (ARM64) via Docker Compose and consists of a FastAPI backend (SQLite), a React + Vite frontend, and end-to-end Playwright tests.
---
## Tech Stack
| Layer | Choice | Rationale |
|---|---|---|
| Frontend | React 19 + Vite 6 + TypeScript | Latest stable; fast HMR; excellent ecosystem |
| Styling | Tailwind CSS v4 | Utility-first; tiny output; works well with Vite |
| Charts | Recharts | Composable; zero-dep; works in React 19 |
| State / data fetching | TanStack Query v5 | Server-state management; caching; optimistic updates |
| Backend | Python 3.12 + FastAPI | Async; auto-generated OpenAPI; lightweight |
| ORM | SQLModel (wraps SQLAlchemy + Pydantic) | Single model definition for DB + API schemas |
| Database | SQLite (via aiosqlite for async) | Zero-ops; file-based; perfect for Pi |
| Testing — backend | pytest + httpx (async) | Fast; integrates with FastAPI's TestClient |
| Testing — frontend | Vitest + React Testing Library | Co-located unit/component tests |
| Testing — E2E | Playwright | Cross-browser; works in CI and dev |
| Container | Docker (multi-stage, linux/arm64) | Slim images for Pi 4 |
| Orchestration | Docker Compose v2 | Single-command startup |
---
## Repository Layout
```
baby-sleep-tracker/
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI app factory + lifespan
│ │ ├── config.py # Pydantic Settings (env vars)
│ │ ├── database.py # Async engine + session factory
│ │ ├── models.py # SQLModel table definitions
│ │ ├── schemas.py # Request / response Pydantic models
│ │ ├── crud.py # All DB operations (pure functions)
│ │ ├── routers/
│ │ │ ├── __init__.py
│ │ │ ├── sessions.py # /api/sessions
│ │ │ └── stats.py # /api/stats
│ │ └── utils/
│ │ ├── __init__.py
│ │ └── time_utils.py # Day/night boundary helpers
│ ├── tests/
│ │ ├── conftest.py # In-memory SQLite fixtures
│ │ ├── test_sessions.py
│ │ ├── test_stats.py
│ │ └── test_time_utils.py
│ ├── Dockerfile
│ ├── pyproject.toml
│ └── requirements.txt
│
├── frontend/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx # Tab routing (Tracker / Stats)
│ │ ├── api/
│ │ │ └── client.ts # Typed fetch wrappers (no extra lib)
│ │ ├── components/
│ │ │ ├── tracker/
│ │ │ │ ├── SleepButton.tsx
│ │ │ │ ├── TimeAdjuster.tsx
│ │ │ │ └── StatusBanner.tsx
│ │ │ └── stats/
│ │ │ ├── DailySummary.tsx
│ │ │ ├── SleepTimetable.tsx
│ │ │ ├── WakeupPlot.tsx
│ │ │ ├── BedtimePlot.tsx
│ │ │ └── IntervalPicker.tsx
│ │ ├── hooks/
│ │ │ ├── useSleepSession.ts
│ │ │ └── useStats.ts
│ │ ├── types/
│ │ │ └── index.ts # Shared TypeScript interfaces
│ │ └── utils/
│ │ ├── time.ts # Format helpers
│ │ └── chartHelpers.ts
│ ├── tests/
│ │ ├── unit/
│ │ │ ├── SleepButton.test.tsx
│ │ │ └── time.test.ts
│ │ └── e2e/ # Playwright tests
│ │ ├── tracker.spec.ts
│ │ └── stats.spec.ts
│ ├── playwright.config.ts
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ ├── package.json
│ └── Dockerfile
│
├── docker-compose.yml
└── README.md
```
---
## Backend Specification
### Data Model
#### Table: `sleep_session`
| Column | Type | Notes |
|---|---|---|
| id | INTEGER | Primary key, auto-increment |
| start_time | DATETIME | UTC, not null |
| end_time | DATETIME | UTC, nullable — null means session is ongoing |
| notes | TEXT | Optional free-text |
| created_at | DATETIME | UTC, server default now() |
| updated_at | DATETIME | UTC, auto-update on write |
No other tables are required. Settings (day/night boundary, plot reference times) are stored client-side in `localStorage` and passed as query parameters where needed.
---
### API Endpoints
#### `POST /api/sessions`
Start a new sleep session. Rejects if there is already an open session.
**Request body**
```json
{
"start_time": "2024-01-15T21:30:00Z" // ISO-8601, required
}
```
**Response 201**
```json
{
"id": 1,
"start_time": "2024-01-15T21:30:00Z",
"end_time": null,
"notes": null,
"created_at": "...",
"updated_at": "..."
}
```
**Error 409** — session already open.
---
#### `PATCH /api/sessions/{id}/end`
End an existing session.
**Request body**
```json
{
"end_time": "2024-01-16T06:45:00Z" // must be after start_time
}
```
**Response 200** — full session object.
**Error 400** — end_time before start_time.
**Error 404** — session not found.
**Error 409** — session already ended.
---
#### `GET /api/sessions/active`
Returns the currently open session or `null`.
**Response 200**
```json
{
"session": { ... } | null
}
```
---
#### `GET /api/sessions`
List sessions, most recent first.
**Query params**
- `from_date` — ISO-8601 date (inclusive)
- `to_date` — ISO-8601 date (inclusive)
- `limit` — default 200
**Response 200**
```json
{
"sessions": [ ... ],
"total": 42
}
```
---
#### `PUT /api/sessions/{id}`
Update start_time, end_time, or notes on any session.
**Request body** — all fields optional.
---
#### `DELETE /api/sessions/{id}`
Hard-delete a session.
---
#### `GET /api/stats/daily-summary`
Returns total sleep per day, split into day/night, for the given interval.
**Query params**
- `from_date` — required ISO-8601 date
- `to_date` — required ISO-8601 date
- `day_start` — HH:MM, default `07:00`
- `day_end` — HH:MM, default `20:00`
**Response 200**
```json
{
"days": [
{
"date": "2024-01-15",
"total_minutes": 720,
"day_minutes": 120,
"night_minutes": 600
}
]
}
```
The backend clips each session into day vs night segments using the provided boundary times. Sessions crossing midnight are handled correctly.
---
#### `GET /api/stats/timetable`
Returns sleep coverage per 15-minute slot for each day in the interval.
**Query params** — same as daily-summary.
**Response 200**
```json
{
"slots": [
{
"date": "2024-01-15",
"slot_start": "00:00",
"slot_end": "00:15",
"sleeping": true
},
...
]
}
```
---
#### `GET /api/stats/wake-sleep-times`
Returns first wakeup and last sleep-start time for each day, expressed as minutes after a reference time.
**Query params**
- `from_date`, `to_date` — required
- `wakeup_ref` — HH:MM, default `04:00`
- `bedtime_ref` — HH:MM, default `19:00`
**Response 200**
```json
{
"days": [
{
"date": "2024-01-15",
"wakeup_minutes": 195,
"bedtime_minutes": 75
}
]
}
```
`wakeup_minutes` = minutes after `wakeup_ref` of the first session end (i.e. first time baby woke up).
`bedtime_minutes` = minutes after `bedtime_ref` of the last session start (i.e. last time baby fell asleep).
Either value is `null` if no qualifying session exists for that day.
---
### Configuration (`config.py`)
Read from environment variables with sane defaults:
```
DATABASE_URL=sqlite+aiosqlite:///./data/sleep.db
CORS_ORIGINS=http://localhost:5173
LOG_LEVEL=info
```
---
### Backend Tests (`pytest`)
All tests use an in-memory SQLite database created fresh per test via a `pytest` fixture.
**`test_sessions.py`**
- Create session → 201
- Create second session while one is open → 409
- End session with valid end_time → 200
- End session with end_time before start_time → 400
- End already-ended session → 409
- List sessions respects date filters
- DELETE removes session
**`test_stats.py`**
- Daily summary: session entirely in day → only day_minutes populated
- Daily summary: session spanning day/night boundary → correctly split
- Daily summary: session spanning midnight → attributed to correct calendar day
- Timetable: 15-minute slots correctly marked
- Wake/sleep times: first wakeup and last bedtime correctly computed
- All stats return empty arrays for intervals with no data
**`test_time_utils.py`**
- Day/night split logic unit tested in isolation
- Minutes-after-reference calculations
- Edge cases: midnight crossing, DST handling (pass explicit tzinfo)
---
## Frontend Specification
### Global Settings (persisted in `localStorage`)
| Key | Type | Default |
|---|---|---|
| `dayStart` | `"HH:MM"` | `"07:00"` |
| `dayEnd` | `"HH:MM"` | `"20:00"` |
| `wakeupRef` | `"HH:MM"` | `"04:00"` |
| `bedtimeRef` | `"HH:MM"` | `"19:00"` |
| `statsInterval` | `7 \| 14 \| 30 \| 90` | `7` |
---
### Tab 1 — Tracker
#### Layout
- Full-height centered column, comfortable on a phone.
- Top: `StatusBanner` — shows current state.
- Middle: `TimeAdjuster` — editable datetime field pre-filled to "now".
- Bottom: `SleepButton` — primary CTA.
#### `StatusBanner`
- **No active session**: "Baby is awake 👶" with elapsed time since last sleep ended (if known).
- **Active session**: "Baby is sleeping 😴" + elapsed time since session start, updated every second.
#### `TimeAdjuster`
- `` pre-filled to current local time.
- Resets to "now" each time the state changes (start → stop or stop → start), but the user can freely edit before confirming.
- Small "Reset to now" link beside the field.
#### `SleepButton`
- **Awake state**: Large button labeled **"Start Sleep"** in a calm night-blue.
- **Sleeping state**: Large button labeled **"Stop Sleep"** in a warm amber.
- On click: reads the time from `TimeAdjuster`, calls the appropriate API, then updates UI via TanStack Query invalidation.
- Button is disabled while a mutation is in flight (shows spinner).
---
### Tab 2 — Stats
#### `IntervalPicker`
Segmented control always visible at the top: **7d · 14d · 30d · 90d**. Updates all charts simultaneously.
#### `DailySummary`
A stacked horizontal bar chart (Recharts `BarChart` with `layout="vertical"`):
- Y-axis: dates
- Each bar has two segments: day sleep (warm yellow) and night sleep (navy)
- Total label at end of bar
Below the chart: three summary numbers for the selected interval — average total sleep, average day sleep, average night sleep.
A small gear icon opens an inline settings popover to change `dayStart` / `dayEnd`.
#### `SleepTimetable`
A grid where:
- Rows = days (newest at top)
- Columns = 15-minute slots across 24 hours (96 columns)
- Each cell is a small coloured square: sleeping = navy, awake = light grey
- X-axis labels: 00:00, 03:00, 06:00 … 21:00
- Scrollable horizontally on mobile
#### `WakeupPlot` and `BedtimePlot`
Side-by-side (stacked on mobile) `LineChart` components:
- X-axis: days ago (0 = today, increasing to the left)
- Y-axis: minutes after reference time, formatted as clock time (e.g. 195 min after 04:00 → "07:15")
- Dots shown at each data point; `null` days leave a gap
- Small settings popover to change `wakeupRef` / `bedtimeRef`
---
### Frontend Tests
#### Unit / component (`Vitest` + React Testing Library)
- `SleepButton`: renders "Start Sleep" when no active session; renders "Stop Sleep" when session active; calls correct mutation on click; shows spinner during loading.
- `TimeAdjuster`: defaults to current time; "Reset to now" resets the field; emits onChange on edit.
- `time.ts` utils: `formatMinutesAfter`, `toLocalDatetimeInputValue`, `clampToDay` etc.
- `chartHelpers.ts`: `buildTimetableSlots`, `computeWakeupMinutes`, `computeBedtimeMinutes`.
#### E2E (`Playwright`)
**`tracker.spec.ts`**
1. Page loads — "Start Sleep" button is visible.
2. Click "Start Sleep" — button changes to "Stop Sleep", status banner updates.
3. Adjust start time to 5 minutes ago — verify API receives adjusted time.
4. Click "Stop Sleep" — button returns to "Start Sleep".
5. Reload page — UI correctly reflects state from API (no active session).
**`stats.spec.ts`**
1. Stats tab is reachable via tab navigation.
2. Interval picker changes interval (mocked API).
3. Timetable renders correct number of rows for selected interval.
4. Wakeup and bedtime plots render SVG lines.
5. Settings popover for day/night boundary saves to localStorage and re-fetches stats.
Playwright is configured to run against the Vite dev server (`webServer` option in `playwright.config.ts`) so tests can run during development with `npx playwright test --ui`.
---
## Docker
### `backend/Dockerfile`
```dockerfile
FROM python:3.12-slim AS base
WORKDIR /app
FROM base AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM base
COPY --from=builder /install /usr/local
COPY app/ ./app/
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
Build with `--platform linux/arm64`.
### `frontend/Dockerfile`
```dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
`nginx.conf` serves `index.html` for all routes (SPA fallback) and proxies `/api` to the backend container.
Build with `--platform linux/arm64`.
---
## `docker-compose.yml`
```yaml
services:
backend:
build:
context: ./backend
platforms: [linux/arm64]
volumes:
- sleep_data:/app/data
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/sleep.db
- CORS_ORIGINS=http://localhost
restart: unless-stopped
frontend:
build:
context: ./frontend
platforms: [linux/arm64]
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
volumes:
sleep_data:
```
---
## Development Workflow
```bash
# Backend
cd backend
pip install -e ".[dev]"
uvicorn app.main:app --reload
# Frontend
cd frontend
npm install
npm run dev # Vite dev server on :5173
# Backend tests
pytest -v
# Frontend unit tests
npm run test # Vitest
# E2E tests (requires both servers running)
npx playwright test
npx playwright test --ui # interactive mode during dev
```
`vite.config.ts` proxies `/api` → `http://localhost:8000` in dev mode so the frontend can call the backend without CORS issues during development.
---
## Implementation Notes for Claude Code
1. **Time zones**: store everything in UTC in the database. The frontend always sends ISO-8601 with offset. The backend's stats helpers accept a `tz` query param (IANA tz name, e.g. `Europe/Stockholm`) so day boundaries are computed in local time. Default to `UTC` if omitted.
2. **Active session guard**: the `POST /api/sessions` endpoint must check for an existing open session in a single atomic DB query (not two round-trips) to avoid race conditions.
3. **SQLModel async**: use `AsyncSession` throughout. Never mix sync and async SQLAlchemy calls.
4. **TanStack Query**: `useSleepSession` hook polls `GET /api/sessions/active` every 30 seconds so the status banner stays accurate if the app is open in multiple tabs.
5. **Recharts responsiveness**: wrap all charts in `` so they adapt to mobile widths.
6. **Playwright `webServer`**: configure `playwright.config.ts` to start both Vite dev server and backend automatically before running tests, then tear them down after. Use `process.env.CI` to skip auto-start in CI pipelines where servers are started externally.
7. **Error boundaries**: wrap each chart/stat card in a React error boundary so one failing component does not blank the entire Stats tab.
8. **Settings persistence**: all user-configurable thresholds live in a React context backed by `localStorage`. Pass them as query params to the stats API so the backend performs the splits server-side (avoids sending raw session data to the client for large intervals).
9. **ARM Docker builds**: if building on an x86 machine, use `docker buildx` with QEMU emulation: `docker buildx build --platform linux/arm64 .`. Document this in the README.
10. **Slim images**: do not install dev dependencies in the production images. Backend uses the multi-stage pattern above; frontend discards all `node_modules` after `npm run build`.