17 KiB
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
{
"start_time": "2024-01-15T21:30:00Z" // ISO-8601, required
}
Response 201
{
"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
{
"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
{
"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
{
"sessions": [ ... ],
"total": 42
}
PATCH /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 dateto_date— required ISO-8601 dateday_start— HH:MM, default07:00day_end— HH:MM, default20:00
Response 200
{
"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
{
"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— requiredwakeup_ref— HH:MM, default04:00bedtime_ref— HH:MM, default19:00
Response 200
{
"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 = hours of the day (00:00 at top, 23:00 at bottom — 24 rows)
- Columns = days in the selected interval (newest on the right)
- Each cell is a small coloured square: sleeping = green (
bg-green-500), awake = light grey (bg-gray-50) - Column headers: short weekday name per day
- A red horizontal line marks the current time of day
- 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;
nulldays 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.tsutils:formatMinutesAfter,toLocalDatetimeInputValue,clampToDayetc.chartHelpers.ts:buildTimetableSlots,computeWakeupMinutes,computeBedtimeMinutes.
E2E (Playwright)
tracker.spec.ts
- Page loads — "Start Sleep" button is visible.
- Click "Start Sleep" — button changes to "Stop Sleep", status banner updates.
- Adjust start time to 5 minutes ago — verify API receives adjusted time.
- Click "Stop Sleep" — button returns to "Start Sleep".
- Reload page — UI correctly reflects state from API (no active session).
stats.spec.ts
- Stats tab is reachable via tab navigation.
- Interval picker changes interval (mocked API).
- Timetable renders correct number of rows for selected interval.
- Wakeup and bedtime plots render SVG lines.
- 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
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
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
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
# 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
-
Time zones: store everything in UTC in the database. The frontend always sends ISO-8601 with offset. The backend's stats helpers accept a
tzquery param (IANA tz name, e.g.Europe/Stockholm) so day boundaries are computed in local time. Default toUTCif omitted. -
Active session guard: the
POST /api/sessionsendpoint must check for an existing open session in a single atomic DB query (not two round-trips) to avoid race conditions. -
SQLModel async: use
AsyncSessionthroughout. Never mix sync and async SQLAlchemy calls. -
TanStack Query:
useSleepSessionhook pollsGET /api/sessions/activeevery 30 seconds so the status banner stays accurate if the app is open in multiple tabs. -
Recharts responsiveness: wrap all charts in
<ResponsiveContainer width="100%" height={...}>so they adapt to mobile widths. -
Playwright
webServer: configureplaywright.config.tsto start both Vite dev server and backend automatically before running tests, then tear them down after. Useprocess.env.CIto skip auto-start in CI pipelines where servers are started externally. -
Error boundaries: wrap each chart/stat card in a React error boundary so one failing component does not blank the entire Stats tab.
-
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). -
ARM Docker builds: if building on an x86 machine, use
docker buildxwith QEMU emulation:docker buildx build --platform linux/arm64 .. Document this in the README. -
Slim images: do not install dev dependencies in the production images. Backend uses the multi-stage pattern above; frontend discards all
node_modulesafternpm run build.