WOPlanner is a Progressive Web App (PWA) for tracking strength training workouts. Built with Rust, Axum, HTMX, and SQLite, it focuses on mobile-first design and simplicity.
Primary Goal: Learning Rust and modern web development patterns while building a useful fitness tracking tool.
Key Philosophy: Simple, type-safe, mobile-first, no complex JavaScript.
- Language: Rust
- Web Framework: Axum 0.8.4
- Database: SQLite with SQLx
- Sessions: tower-sessions with SQLite backend
- Template Engine: Askama
- Error Handling: anyhow
- Interactivity: HTMX 1.9.12
- Styling: Bulma 1.0.4, lokal unter
static/css/bulma.min.css - Design System: Rack (Notizen
adr-002-design-rack,arch-woplanner-styling) - Icons: Inline-SVG-Sprite in
templates/icons.html, keine Emoji
- Build Tool: Cargo
- Dev Environment: Nix Flakes
- Hot Reload: cargo-watch
src/
├── main.rs # Entry point, router composition
├── database.rs # Connection pool & migrations
├── handlers/ # HTTP request handlers (one per domain)
└── models/ # Data structures (one per domain)
templates/ # Askama HTML templates
├── base.html # Base layout with navigation
└── feature/ # Feature-specific templates
migrations/ # SQL migration files (chronological, schema only)
seeds/ # Sample data for development and demos
static/ # CSS, PWA manifest, icons
Problem: Askama has very limited filter support
Forbidden Patterns:
{{ value | round }} ❌ No round filter
{{ value | min(100) }} ❌ No min/max
{{ list | sum }} ❌ No aggregate filters
{% set_global var = x %} ❌ No variable mutationCorrect Patterns:
{{ value as i32 }} ✅ Type casting
{% if value > 100 %} ✅ Inline conditionals
{% for item in list %} ✅ Simple loops
{% match option %} ✅ Pattern matchingRule: Compute in Rust, display in templates. Keep template logic minimal.
async fn get_current_user(session: &Session, pool: &SqlitePool) -> Option<User> {
if let Ok(Some(user_id)) = session.get::<String>("current_user_id").await {
sqlx::query_as!(User, "SELECT * FROM users WHERE id = ?", user_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
} else {
None
}
}use axum::http::{HeaderMap, HeaderValue};
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", HeaderValue::from_static("/path"));
(headers, Html("Message".to_string())).into_response()Der verbindliche Stil heißt Rack. Farb- und Größentokens stehen in der
Notiz arch-woplanner-styling, die zehn Struktur-Gesetze in
adr-002-design-rack. static/css/style.css ist die einzige Umsetzung.
Vor jeder UI-Änderung ./scripts/check-design.sh laufen lassen — es prüft
die Gesetze L1, L2, L3, L4 und L6 im Headless-Chromium gegen die laufende App
und die Klassennamen in beide Richtungen. CSS scheitert lautlos: eine Regel mit
undefiniertem Custom Property wird verworfen, ein erfundener Klassenname
erzeugt keinen Fehler. Was das Skript nicht prüft, ist nicht bewiesen.
| L1 | Eine Fläche. --wo-raised nur für .wo-dock und .wo-sheet, sonst Haarlinien. Keine Fläche in einer Fläche. |
| L2 | .wo-page ist der einzige Layout-Container. Kein Bulma .columns im Markup — dessen negative Außenränder erzeugen 12px horizontales Scrollen. Mehrspaltig: .wo-cols. |
| L3 | Genau eine h1 pro Seite, 20px, kein Hero. 56px (--wo-fs-num-lg) ist Daten vorbehalten. |
| L4 | .wo-row ist die Grundeinheit; die ganze Zeile ist das Tap-Ziel, min. 56px. Kein Bedienelement unter 44px. |
| L5 | Ein Akzent pro Bildschirm. Zahlen sind weiß. --wo-pr nur für Personal Records, --wo-ok nur für Abgeschlossenes. |
| L6 | Icons aus templates/icons.html (<use href="#i-…"/>), niemals Emoji im Chrome. |
| L7 | Anlege-Formulare in <dialog class="wo-sheet">, ausgelöst über einen „+"-Knopf. Listenseiten zeigen Listen. |
| L8 | Komponentenklassen statt Utility-Kombinationen. Kein wo-color-x-size-y — Farbe und Größe sind zwei Klassen. |
| L9 | Fehlende Werte weglassen, nicht als "", 0m oder - rendern. Daten in Rust formatieren, nie roh durchreichen. |
| L10 | Jedes Gesetz hat eine Prüfung in scripts/check-design.sh. |
.wo-page /* der einzige Layout-Container: Rand, Breite, Dock-Abstand */
.wo-cols /* 2 Spalten (Grid), .wo-cols--3 für drei */
.wo-section /* Gruppe von Zeilen, getrennt durch Abstand statt Box */
.wo-row /* Listeneintrag, als <a> die ganze Zeile tappbar */
.wo-row__main /* Titel + Meta links .wo-row__value: Wert/Chevron rechts */
.wo-row--button /* dieselbe Zeile, wenn sie per HTMX abschickt */
.wo-empty /* Leerzustand: zentriert, mit Luft, ohne Fläche */
.wo-note / .wo-prose /* Nutzernotiz (kursiv, Linie links) / Fließtext */
.wo-divider-top /* Trenner über einem Nachtrag */
.wo-sheet /* <dialog> als Bottom-Sheet */
.wo-icon /* 20px, stroke: currentColor */
.wo-tap /* freistehender Link, trotzdem 44px hoch */
.wo-label .wo-meta .wo-title .wo-num /* Typo */
.wo-btn .wo-btn-primary .wo-btn-inline .wo-input .wo-alert .wo-dock- Neue Klassennamen vor der Verwendung gegen
bulma.min.cssundstyle.cssprüfen — erfundene Namen sind der häufigste Fehler hier, und./scripts/check-classes.shfindet sie in beide Richtungen. - Spacing: Nur die sechs Tokens
--wo-s1…--wo-s6. - Bewegung: Nur Opazität beim Drücken. Keine Blur-, Schatten- oder Verlaufseffekte.
Ein Plan verlässt die App über GET /workouts/{id}/export und kommt über
POST /workouts/import (Feld yaml) zurück — im UI über den zweiten Knopf
neben „+" auf /workouts. Zweck: Pläne außerhalb der App erarbeiten, etwa von
einem Sprachmodell, und ohne Abtippen importieren.
Wenn du einen Plan erzeugen sollst, halte dich exakt an diese Form:
version: 1 # Pflicht
name: Push Day # Pflicht, max. 100 Zeichen
description: Brust, Schultern, Trizeps # optional
schedule: # optional, Standard: manual
type: rotation # manual | rotation | weekly | disabled
day: null # 0 = Sonntag … 6 = Samstag, nur bei weekly
exercises: # 1 bis 50
- name: Bench Press # Pflicht
instructions: | # Pflicht, wenn die Übung neu ist
Auf die Bank legen, Hantel zur Brust führen,
explosiv nach oben drücken.
video_url: https://www.youtube.com/watch?v=xyz # optional
sets: 4 # 1 bis 50, Standard 3
weight: 80.0 # optional; fehlt = Körpergewicht
notes: Aufwärmsatz mit 60 kg # optionalRegeln, die beim Erzeugen zählen:
- Keine IDs, keine Zeitstempel, kein Nutzerbezug. Ein Plan ist eine Vorlage.
- Keine
position. Die Reihenfolge der Liste ist die Reihenfolge im Plan. - Übungen werden über den Namen zugeordnet (getrimmt, ohne Groß-/Kleinschreibung). Existiert die Übung, wird sie verwendet und ihre vorhandene Anleitung nicht überschrieben. Existiert sie nicht, ist
instructionsPflicht. - Der Leser versteht nur Block-Stil. Kein
{a: 1}, keine Anker, keine Tabs, kein---. Unbekannte Felder sind ein Fehler, kein stilles Ignorieren. - Fehlermeldungen nennen die Stelle:
exercise 2 'Lunge': sets must be between 1 and 50.
Vollständige Begründung: Notiz concept-workout-yaml.
Implementierung: src/workout_yaml.rs (Format plus Parser, ohne Fremdbibliothek —
crates.io ist in dieser Umgebung nicht erreichbar).
/* Safe area support — steckt in .wo-dock */
padding: var(--wo-s2) var(--wo-s2) calc(var(--wo-s2) + env(safe-area-inset-bottom));user-scalable=no ist verboten (WCAG 1.4.4). Korrekt:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">iOS zoomt bei Feldern unter 16px hinein. .wo-input setzt deshalb
font-size: var(--wo-fs-title) (20px) — kein !important nötig und keins
erlaubt: in style.css ist genau ein !important zugelassen, im
prefers-reduced-motion-Block.
.wo-input { font-size: var(--wo-fs-title); }users -- User accounts
├── workouts -- Workout templates
│ └── workout_exercises -- Exercises in workout
│ └── exercises -- Exercise library
├── active_workouts -- Currently training
│ └── completed_sets -- Sets in active session
└── completed_workouts -- Finished trainings- One User → Many Workouts
- One Workout → Many WorkoutExercises → Many Exercises
- One User → One ActiveWorkout (at most)
- One ActiveWorkout → Many CompletedSets
- One ActiveWorkout → One CompletedWorkout (after finish)
- Database: Create migration in
migrations/ - Models: Define structs in
src/models/feature.rs - Handler: Create
src/handlers/feature.rswith router - Templates: Add templates in
templates/feature/ - Integration:
- Add to
src/handlers/mod.rs - Merge router in
src/main.rs - Update navigation (dock or dashboard)
- Add to
- Testing: Test on mobile PWA (iOS + Android)
-- migrations/YYYYMMDD_description.sql
-- Migrations should ONLY contain schema changes (tables, indexes, constraints)
-- NO sample data in migrations!
CREATE TABLE IF NOT EXISTS table_name (
id TEXT PRIMARY KEY NOT NULL,
field TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_name ON table(field);-- seeds/XX_description.sql
-- Seeds are for development and demos only
-- Use INSERT OR IGNORE for idempotency
INSERT OR IGNORE INTO table_name (id, field, created_at) VALUES
('sample-id-001', 'Sample Value', '2024-12-03T10:00:00Z');Important:
- Seeds run automatically in development (
SEED_DATABASE=true, default) - Disable for production:
SEED_DATABASE=false - Seeds must be registered in
src/database.rs
// Fetch one
let model = sqlx::query_as!(
Model,
"SELECT * FROM table WHERE id = ?",
id
).fetch_one(&pool).await?;
// Fetch optional
let model = sqlx::query_as!(
Model,
"SELECT * FROM table WHERE id = ?",
id
).fetch_optional(&pool).await?;
// Fetch many
let models = sqlx::query_as!(
Model,
"SELECT * FROM table ORDER BY created_at DESC"
).fetch_all(&pool).await?;- Run
cargo fmtbefore committing - Address
cargo clippywarnings - Use descriptive variable names
- Add comments for complex logic
- Keep functions focused and small
- Use database indexes for frequent queries
- Add
LIMITto unbounded queries - Compute aggregates in SQL when possible
- Keep templates simple (logic in Rust)
- Never trust user input
- Use SQLx parameterized queries (prevents SQL injection)
- Validate form data before processing
- Keep sessions secure (HTTPOnly, Secure in production)
- Mobile-first responsive design
- Touch-friendly (large targets)
- Immediate visual feedback
- Loading states for async operations
- Error messages that help users
- Rust Book: https://doc.rust-lang.org/book/
- Axum Docs: https://docs.rs/axum/
- SQLx Docs: https://docs.rs/sqlx/
- HTMX Docs: https://htmx.org/docs/
- Askama Docs: https://docs.rs/askama/
- PWA Guide: https://web.dev/progressive-web-apps/
See PROJECT_STATUS.md for detailed kabai board configuration.
- User management with sessions
- Exercise library (CRUD)
- Workout planning with scheduling
- Live training with guided flow
- Dashboard with real stats
- PWA manifest and iOS fixes
- Training history with progression tracking
Project Status & kabai Configuration
See
PROJECT_STATUS.mdfor detailed kabai board configuration, status definitions, and allowed workflow transitions.
- Exercise progression charts
- Body measurements tracking
- Rest timer between sets
- Personal records (PRs) detection
- Workout templates
- Full offline support