Key architectural choices and the reasoning behind them.
Screen ownership, navigation, user tasks, surface roles, and motion policy are defined in PulseBase UX Structure. This document owns the technical and visual implementation decisions that support that structure.
Removed in Phase 4.
Grafana requires a separate login and an admin to manually provision accounts — a non-starter
for a self-service, multi-user app where users register themselves. Replaced by a custom /dashboard
in FastAPI that serves HTML and loads data via fetch() + Chart.js. No build step, no
framework, no extra service.
Initially considered for SSO. Dropped because it adds operational complexity (another service, config files, LDAP or file-based users) that isn't justified for a single self-hosted app with self-service registration. FastAPI handles auth directly with bcrypt + signed session cookies, email verification, and account lockout.
JWT adds stateless token management complexity that's unnecessary here. Starlette's
SessionMiddleware uses a signed, httpOnly, secure cookie — simpler, just as safe for
this use case, and sessions can be invalidated server-side by clearing the session store.
passlib is incompatible with bcrypt >= 4.0 and raises deprecation warnings. Using
bcrypt directly avoids the compatibility layer entirely.
bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
bcrypt.checkpw(password.encode(), hashed.encode())All queries are written as prepared statements in the db/ layer (api/src/db/, ml-service/src/db/). No SQLAlchemy, no Tortoise,
no Prisma. Reasons:
- Full control over SQL (important for time-series queries with TimescaleDB-specific syntax)
- asyncpg is the fastest PostgreSQL async driver for Python
- The query surface is small and well-defined — an ORM would add more complexity than it removes
- Prepared statements prevent SQL injection by design
When a user links their Garmin account, the password is used once to fetch a session
token, then explicitly deleted from memory (del garmin_password). The token is
serialized as a filename-agnostic JSON blob, Fernet-encrypted, and stored as BYTEA
in the user_tokens table. The sync-service loads and decrypts it on every run,
using a tmpdir for garth's file-based auth — the decrypted bytes never leave RAM beyond
the OS-managed tmpdir.
FastAPI uses Starlette's Jinja2 integration. Since Starlette 1.0, the TemplateResponse
signature changed — request is the first positional argument, not part of the context
dict:
# Correct (Starlette 1.0+)
templates.TemplateResponse(request, "page.html", {"key": "value"})
# Old form — raises deprecation warning
templates.TemplateResponse("page.html", {"request": request, "key": "value"})Database schema changes are managed via Flyway versioned migrations (V1__, V2__, ...).
Flyway runs automatically on container startup and exits. This means:
- Schema is always in sync with the codebase
- Migrations are versioned and repeatable
- No manual SQL on the database — ever
HTTPS uses Caddy in both deployment modes — chosen over Traefik for the simplest automatic Let's Encrypt setup (matches the dev-best-practices recommendation and the existing gateway).
- Home (default,
make up): TLS terminated by the separatehomelab-gatewayrepo running Caddy on a sharedproxyDocker network. PulseBase joins it — no host ports exposed. One Caddy serves all self-hosted services;*.home.labresolves via CoreDNS + Tailscale Split DNS; reachable only inside the Tailnet. - Public SaaS (
make up-public): a bundled Caddy terminates TLS via Let's Encrypt on a public domain (docker-compose.public.yml, ports 80/443). Used when running a public instance without the home gateway.
The earlier "Traefik standalone" fallback was never implemented and is replaced by the
bundled-Caddy public path. Security headers (HSTS, CSP, …) are emitted by the app
(api/src/main.py), so Caddy does not duplicate them.
Deployment is a manual make up (docker compose up -d --build) — no container registry,
no auto-deploy job in CI. With a single server and a single developer, the setup cost of a
CD pipeline (registry, SSH secret, rollback mechanics) outweighs the DORA benefit. The
deferral, the sketched target architecture (ghcr.io → SSH pull → /ready health gate →
rollback on the previous tag) and the trigger to build it are recorded in
ADR-0002 (supersedes the CICD-M4 keyword).
Templates use Tailwind CSS for layout, spacing, and color utilities. A companion
api/src/static/style.css handles only the classes that JavaScript generates dynamically
at runtime (.card, .badge-*, .metric-tile-*, .toast, etc.) — these cannot be
purged by the CLI because they are constructed in JS via string concatenation.
Build:
Tailwind CLI standalone binary (no Node.js) generates api/src/static/tailwind.min.css
from input.css + api/tailwind.config.js. Run after any template changes:
make tailwind-buildThe output (tailwind.min.css, ~27 KB) is committed and served as a static file.
No CDN, no runtime script, no build step in Docker.
Light / Dark theme:
theme-init.jsruns before the stylesheet to set.darkon<html>fromlocalStorage(keypb-theme) — prevents FOUCtailwind.config.jssetsdarkMode: 'class'; all templates usedark:variants- Toggle switch in Settings page writes to
localStorageand toggles the class live
Motion & Reduced Motion:
- Motion is tokenized in
:rootlike spacing/type:--dur-fast|base|slow,--ease-out,--ease-spring(single source of truth for timings/easing). Wave 16 PR-D consolidated the ~18 scattered hardcodedtransitiontimings onto--dur-fast/--dur-base; only the deliberately-slow.4seffect-fill and the skeleton-pulse keep literal values. - A global
@media (prefers-reduced-motion: reduce)guard neutralizes all animations and transitions (WCAG 2.3.3) — the one deliberate!importantexception (accessibility override) - The hero readiness ring (
dashboard-hero.js) animates draw-in + score count-up with cubic ease-out. Because it is driven by JSsetAttribute(not a CSS animation), the CSS guard does not cover it — an explicitwindow.matchMedia('(prefers-reduced-motion: reduce)')check sets the end state instantly instead of animating - Dashboard cards fade up with a staggered
card-inentrance (nth-childdelays); reduced-motion is handled automatically by the global guard
Loading states — Skeleton Screens:
- Hero card (
#bento-hero) and the activities table render skeleton placeholders instead of aLade…text — they mirror the final layout by reusing the same layout classes (.hero-grid,.hero-signals-row, …) so there is no layout shift when real content arrives - The skeleton is replaced automatically:
buildHeroCard()/renderActivitiesTable()overwrite the containerinnerHTML, so no JS wiring of the placeholders is needed - 300 ms rule (CSS-only, no JS timer): the
.skeletonwrapper staysopacity: 0and fades in viaanimation-delay: 300ms. If data arrives sooner, the placeholder is replaced before it ever shows — no flash for fast loads (NN/g / LogRocket guidance) - Placeholders carry
aria-hidden="true"(decorative); reduced-motion turns off the pulse - Chart cards are intentionally excluded:
.chart-wrapalready reserves a fixed height (240/280 px), so there is no shift to fix
Sport type display:
subActivityType.typeKey is read before activityType.typeKey so activities like
Krafttraining, Yoga, Indoor Cycling get their specific label instead of "Other".
German display labels are mapped in dashboard.js and activity.js (SPORT_LABEL).
The dashboard uses a "Dark Instrument Panel" aesthetic — precision-focused, information-dense, inspired by WHOOP and Bloomberg Terminal. Replaced the earlier glassmorphism style.
- Slate background (
#0f172a/#1e293b) with subtle emerald radial gradient accents - Cards: flat with
rgba(255,255,255,0.04–0.08)fill +1pxborder — no blur - Accent colors: emerald green (#22c55e), amber (#f59e0b), red (#ef4444) for score-based color coding — only three semantic colors used throughout the dashboard
Phase: Implemented (replaced the 4 separate Bento cards).
Four separate cards (Readiness, Heute, Energie, ML Status) were merged into a single "Tagesstatus" card using a WHOOP-style 3-tier hierarchy:
Tier 1 (left) — Readiness Arc:
SVG partial arc (240°, opens at bottom — Oura/WHOOP style). Score counts up from 0 via
requestAnimationFrame (600ms), arc fill animated via stroke-dasharray. Arc color
determined by score: ≥75 green, ≥50 amber, <50 red.
Tier 2 (right) — Energie-Triptychon + Vitals: Three energy rows (Physisch / Autonom / Kognitiv) with color-coded dots, scores, sub-labels (Form/Fitness, σ-deviation, sleep debt hours), and arrow links. A 2×2 vitals strip below shows steps, sleep score, HRV avg, resting HR.
Tier 3 (bottom) — ML Status Chips: Compact badge pills for: HRV status (BALANCED/UNBALANCED/LOW/POOR), Z-score anomaly, RF readiness forecast, Body Battery pattern, training effect. Only rendered when data exists.
Layout: Two-column grid (ring | right-panel) on desktop, stacked on mobile ≤600px.
Phase: Implemented.
The 7T/14T/30T/90T/365T buttons set the window size. The ← → nav bar (between tabs and
chart panels) shifts that window into the past via a currentOffset integer (0 = current
period, 1 = one period back, etc.).
getEndDate() computes today - offset × days as an ISO date string.
All 6 data endpoints (/api/activities, /api/daily, /api/sleep, /api/hrv/trend,
/api/weekly, /api/ml-history) accept end_date as an optional query parameter.
The DB layer uses a date-range WHERE date >= end_date - days AND date <= end_date
instead of the previous WHERE date >= NOW() - interval.
Switching time range (7T→30T etc.) resets offset to 0.
The forward button (→) is disabled when offset=0.
The ML inference and training logic runs in a dedicated ml-service container rather than
inside the API or sync-service. Reasons:
- Dependency isolation: scikit-learn, scipy, numpy, and joblib don't belong in the API image — they'd triple its size and have no business there
- Independent schedule: inference is triggered after every Garmin sync (via
ml_requestedflag) and runs on a daily fallback schedule (7:00); training runs weekly on Sundays — decoupled from the API - Failure isolation: a broken model or training run doesn't affect the API or sync
The ML service writes results to ml_predictions in the shared TimescaleDB. The API reads
from that table via /api/ml-insights — the ML service and the API never talk directly.
The RF readiness model is trained against the rule-based readiness score (the weighted HRV/sleep/body-battery/stress formula), not a user-provided label. This means:
- Training data is always available — no labeling effort required
- The model learns to predict what the rule-based formula would output given tomorrow's inputs
- As more data accumulates, the RF can generalize across partial inputs better than the hard-coded formula does (which excludes missing components entirely)
Z-score was chosen over more complex methods because:
- Resting HR is approximately normally distributed for a single individual over time
- The 30-day rolling baseline is short enough to track fitness changes, long enough to be stable
- Threshold of 1.5 σ is intentionally sensitive (early warning), not a strict outlier test
- Interpretable: the dashboard can show "1.3 σ above baseline" without further explanation
Changed: May 2026. ml-service/src/models/body_battery.py v2.
The original body_battery_custom used a linear Banister fitness-fatigue accumulation:
score = clamp(prev + recovery − drains, 5, 100). At rest, drains ≈ 0 and recovery
was constant — so the score hit 100 and stayed there indefinitely. Users saw flat plateaus
at 100 for multi-day rest periods, which contradicted reality.
Why fresh-state wins:
- Banister FFM has fundamental statistical flaws at the individual level (Scientific Reports, 2025, doi:10.1038/s41598-025-88153-7)
- No wearable manufacturer publishes a clinically validated composite score formula (Wearable Composite Health Scores Require Validation, 2025)
- Fresh-state
score = 0.30 × prev + 0.70 × fresh − drainsavoids the plateau while preserving multi-day continuity via the 30% inertia term
Sleep phases are primary, duration secondary (60/40 split):
- Deep sleep (SWS) and REM quality are stronger recovery signals than total hours (Walker 2017; Dijk & Czeisler 1995)
- HRV vs. personal 30-day baseline as recovery indicator (Plews et al. 2013)
Backfill: make backfill-battery deletes all body_battery_custom predictions
and re-runs the backfill script against full history.
Implemented: May 2026. /help, /metrics/{name}. Data: api/src/data/evidence_catalog.json (loaded via thin api/src/evidence_catalog.py).
Every health metric needs to communicate what it measures, what it is and is not intended for, and the quality of its evidence. EN 62366 (medical device usability engineering) provided the framework for thinking about this, even though PulseBase is not a regulated device.
Three-layer disclosure architecture:
-
Metrics overview (
/metrics) — Evidence Badges on every tile:- 🟢 M Meta-Analysis / clinical guideline standard
- 🟡 R Replicated (multiple independent studies)
- 🔵 E Eigenmodell (PulseBase-specific, literature-based)
Source:
GET /api/evidence→evidence_catalog.json(21 entries). Each entry haslevel,metric_type,time_horizon,intended_use,not_for,limitations,sources.
-
Metric detail pages (
/metrics/{name}) — simplified: value + chart + 1-sentence summary + score-dependent recommendation. No formula block, no ELI5 card, no sources card. Deep-link to the full help article via? Hilfe-Artikel. -
Help page (
/help) — full methodology for each metric: evidence level, formula, science background, sources, intended use, limitations. Client-side searchable (20 articles, 6 categories). Deep-linkable via#metric-key(e.g./help#energy_autonomic).
Why Progressive Disclosure instead of all-in-one detail pages:
- Practitioners need the recommendation fast; researchers need the formula — different needs
- EN 62366: intended use and not-for statements prevent misinterpretation of health data
- Avoids cognitive overload: 5-block detail pages discouraged exploration of the methodology
Regular PostgreSQL tables would work for daily summaries and activities. Intraday data (body battery, stress every ~5 min = 288 rows/day/user) benefits from TimescaleDB:
- Hypertables partition data by time automatically
- Compression policy reduces storage by ~10x after the retention window
- Time-bucket aggregations (
time_bucket()) are native and fast
Regular tables (activities, daily_summary, sleep_sessions, hrv_daily) don't need
hypertables — they're too small and queried differently.