565 lines
17 KiB
Markdown
565 lines
17 KiB
Markdown
# 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`
|
|
|
|
- `<input type="datetime-local">` 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 `<ResponsiveContainer width="100%" height={...}>` 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`.
|