Initial commit
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user