Files
2026-05-28 09:31:20 +02:00

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 date
  • to_date — required ISO-8601 date
  • day_start — HH:MM, default 07:00
  • day_end — HH:MM, default 20: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 — required
  • wakeup_ref — HH:MM, default 04:00
  • bedtime_ref — HH:MM, default 19: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; 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

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

  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.