Files
toollist/documentation.md
T
Oliver Walter 5f43c19878 initial
2026-06-06 15:37:42 +02:00

12 KiB
Raw Blame History

Toollist Design-Dokumentation

Dieses Dokument hält die Hintergründe und Abwägungen fest, die während der Planungsphase getroffen wurden. Es dient als Nachschlagewerk, wenn zukünftige Erweiterungen Fragen über den ursprünglichen Kontext aufwerfen.


1. Kontext & Ziel

Das Tool entstand aus einer einfachen Textdatei (toollist_notes.txt), in der Werkzeuge für eine Haus-Renovierung grob notiert waren ohne einheitliche Struktur, ohne Status-Tracking, ohne Möglichkeit für mehrere Personen gleichzeitig darauf zuzugreifen.

Kernziel: Eine einfache, im Heimnetz zugängliche Web-App, mit der mehrere Personen gemeinsam verwalten können, welche Werkzeuge für die Renovierung benötigt werden, wie sie beschafft werden und ob sie bereits organisiert sind.


2. Technologie-Entscheidungen

2.1 Warum Full-Stack Rust mit Leptos?

Das Projekt startete als Rust-Crate (Cargo.toml war bereits vorhanden). Für ein Web-Tool mit mehreren Nutzern wurden drei Optionen verglichen:

Option Beschreibung Verworfen weil
CLI Befehle im Terminal Kein Mehrbenutzerzugriff, keine Übersicht
TUI Interaktive Terminal-UI Kein Netzwerkzugriff ohne zusätzlichen Server
Web Browser-basiert Gewählt

Innerhalb der Web-Option:

Option Beschreibung Entscheidung
Axum + Vanilla JS Rust-Backend, JS-Frontend Zwei Sprachen, mehr Aufwand
Axum + React/Svelte Beste UX, zwei Codebasen Zu aufwändig für ein internes Tool
Leptos SSR Full-Stack Rust, WASM Gewählt alles in einer Sprache

Leptos SSR + Hydration wurde gewählt, weil:

  • Alles bleibt in Rust (Typsicherheit zwischen Server und Client)
  • Server-seitiges Rendering für den initialen Load
  • Die Reaktivität (Signale, Ressourcen) ersetzt ein separates State-Management

2.2 Warum TOML als Datenspeicher?

Es wurden drei Optionen diskutiert:

Option Vorteile Nachteile
TOML-Datei Menschenlesbar, versionierbar mit Git, kein DB-Server Kein Query-Layer, Concurrent Writes brauchen Locking
JSON-Datei Besser für komplexe Typen, weit verbreitet Weniger lesbar als TOML
SQLite Besser für Abfragen, bewährt für gleichzeitigen Zugriff Overhead für ein kleines internes Tool

TOML wurde gewählt, da die Datei direkt lesbar und editierbar sein soll, das Projekt nur für wenige Nutzer im Heimnetz ausgelegt ist (kein Hochlast-Concurrent-Access), und die Werkzeug-Liste manuell wartbar bleiben soll.

Bekannte Einschränkung: Bei gleichzeitigen Schreibzugriffen mehrerer Nutzer gibt es kein Locking. Für den Heimnetz-Einsatz mit wenigen Personen ist das akzeptabel. Bei Bedarf kann ein tokio::sync::Mutex in den Axum-State eingebaut werden.

2.3 Warum kein Login / keine Auth?

Bewusste Entscheidung: Die App ist ausschließlich im lokalen Heimnetz erreichbar und wird von einer kleinen, bekannten Personengruppe genutzt. Eine Nutzerverwaltung würde den Aufwand signifikant erhöhen, ohne einen echten Mehrwert für diesen Kontext zu liefern.

Das Feld verantwortlich (wer kümmert sich um ein Werkzeug) ist ein Freitextfeld kein Verweis auf einen eingeloggten Nutzer.


3. Datenmodell Designentscheidungen

3.1 Werkzeug-Felder

Kernfelder (aus der ursprünglichen Anforderung):

Feld Entscheidung Begründung
name String Freitext, keine Normalisierung nötig
anzahl Anzahl-Struct Entweder konkrete Zahl oder „Mehrere" (z.B. bei Eimern, Handschuhen)
beschaffung Flache Beschaffung-Struct Kontextabhängige Zusatzfelder je nach Art (s.u.)
verwendung Vec<String> Mehrere Verwendungszwecke möglich
tags Vec<String> Flexible Kategorisierung ohne fixe Taxonomie

Zusatzfelder (im Design-Gespräch ergänzt):

Feld Entscheidung Begründung
status Enum Offen/Organisiert/VorOrt Wichtigster Fortschrittsindikator auf einen Blick
prioritaet Enum Hoch/Mittel/Niedrig Fokussierung auf kritische Werkzeuge
notizen Option<String> Freitext für alles was sonst nirgends passt
verantwortlich Option<String> Optional wer beschafft das Werkzeug
erstellt_am String (ISO-8601) Nützlich für Sortierung und Nachvollziehbarkeit

Bewusst nicht aufgenommen (beim MVP):

  • Bauabschnitt als eigenes Feld → wird über Tags abgebildet (z.B. tag: estrich, tag: elektro)
  • Vollständiges Changelog / Audit-Log → zu viel Overhead für diesen Kontext

3.2 Warum Anzahl als Struct statt Enum?

Ursprünglich war ein Enum geplant:

// Erstentwurf (verworfen)
enum Anzahl {
    Zahl(u32),
    Mehrere,
}

Problem: TOML serialisiert gemischte Enum-Varianten (eine als Zahl, eine als String) je nach Variante unterschiedlich, was zu Serialisierungsfehlern führen kann insbesondere innerhalb von [[tools]]-Arrays, wo alle Felder als Inline-Tables dargestellt werden.

Lösung: Eine flache Struct mit optionalem Zahlenfeld:

struct Anzahl {
    zahl: Option<u32>, // None = "Mehrere"
}

TOML-Darstellung ist damit eindeutig:

# Konkrete Zahl:
[tools.anzahl]
zahl = 2

# "Mehrere":
[tools.anzahl]
# (kein zahl-Feld → None)

3.3 Warum Beschaffung als flache Struct statt Enum?

Ursprünglicher Plan:

// Erstentwurf (verworfen)
enum Beschaffung {
    Unbekannt,
    InBesitz,
    Leihen { von: String },
    Mieten { wo: String, preis: Option<f64> },
    Kaufen { wo: String, preis: Option<f64> },
}

Problem: Rust-Enums mit Struct-Varianten und TOML-Serialisierung können sich je nach verwendetem Serde-Tag-Modus (untagged, tag, extern) unterschiedlich verhalten und sind fehleranfällig, besonders mit Option-Feldern in Varianten.

Lösung: Flache Struct mit einem BeschaffungsArt-Enum für den Typ und separaten optionalen Feldern:

struct Beschaffung {
    art: BeschaffungsArt,  // "Unbekannt" | "InBesitz" | "Leihen" | "Mieten" | "Kaufen"
    von: Option<String>,   // nur für Leihen
    wo: Option<String>,    // nur für Mieten/Kaufen
    preis: Option<f64>,    // nur für Mieten/Kaufen
}

Vorteil: Einfache, TOML-kompatible Serialisierung. Im Formular werden die Felder von, wo, preis je nach gewählter art dynamisch ein-/ausgeblendet.

Bekannter Trade-off: Nicht alle Felddkombinationen sind sinnvoll (z.B. von bei Kaufen). Die Validierung liegt beim Formular, nicht im Typ selbst.

3.4 Warum String für id und erstellt_am?

Für id wäre uuid::Uuid typsicherer. Für erstellt_am wäre chrono::DateTime<Utc> korrekter. Beide Typen haben jedoch WASM-Kompatibilitätsprobleme:

  • uuid::Uuid mit dem v4-Feature benötigt getrandom, das für WASM die js-Feature-Flag braucht.
  • chrono::Utc::now() benötigt für WASM die wasmbind-Feature-Flag.

Da beide Werte ausschließlich serverseitig generiert werden (in Server Functions) und der Client sie nur als opaque Strings liest, ist String die pragmatische Wahl:

// In Tool::neu()  nur SSR
#[cfg(feature = "ssr")]
impl Tool {
    pub fn neu(input: NewTool) -> Self {
        Tool {
            id: uuid::Uuid::new_v4().to_string(),
            erstellt_am: chrono::Utc::now().to_rfc3339(),
            // ...
        }
    }
}

uuid und chrono sind daher als optionale, SSR-only Dependencies deklariert.


4. Architektur-Entscheidungen (Leptos)

4.1 SSR-Rendering-Modus

Leptos unterstützt verschiedene Rendering-Modi:

Modus Beschreibung Entscheidung
CSR Nur WASM, kein Server-Rendering Kein Dateisystemzugriff möglich
SSR + Hydration Server rendert HTML, WASM übernimmt Gewählt
Islands Nur bestimmte Komponenten hydratisieren Komplexer, kein Vorteil hier

SSR ist notwendig, weil der Server die TOML-Datei lesen muss. Mit CSR müsste ein separater API-Server betrieben werden.

4.2 Server Functions statt REST-API

Leptos #[server]-Funktionen generieren automatisch:

  • Serverseitig: die echte Implementierung
  • Clientseitig: einen HTTP-Stub, der den Server aufruft

Das erspart eine manuelle REST-API mit separaten Routen, Serialisierung und Client-Code. Alle Typen werden geteilt wenn sich das Datenmodell ändert, bricht der Compiler sowohl Server- als auch Client-Code.

4.3 Suspense-Grenze für Resource-Zugriffe

Problem: In Leptos 0.8 dürfen Resource-Zugriffe in Hydration-Mode ausschließlich innerhalb einer <Suspense>-Komponente erfolgen. Anderenfalls drohen Hydration-Mismatches und eine Warnung im Log.

Falsch (ursprünglicher Ansatz):

// Außerhalb Suspense → Warnung
let alle_tags = Signal::derive(move || {
    tools_resource.get()  // ← Resource-Zugriff außerhalb Suspense!
        .and_then(|r| r.ok())
        // ...
});

Richtig (finaler Ansatz):

<Suspense fallback=|| view! { <p>"Lädt…"</p> }>
    {move || {
        tools_resource.get().map(|result| {  // ← Zugriff korrekt innerhalb
            let tools = result.unwrap_or_default();
            // Tags und gefilterte Liste hier berechnen
            view! { <FilterBar ... /> /* Tabelle */ }
        })
    }}
</Suspense>

Konsequenz: FilterBar und Tabelle liegen beide innerhalb der Suspense-Grenze. Bei Filteränderungen (reaktive filter: RwSignal) re-rendert der Suspense-Block, ohne den Ladeindikator zu zeigen (Resource ist bereits geladen).

4.4 Reaktivität: Wie Updates die Liste neu laden

Nach jeder Mutation (erstellen, aktualisieren, löschen) muss die Liste neu geladen werden. Das geschieht über einen einfachen Versions-Zähler:

let version = RwSignal::new(0u32);
let neu_laden = move || version.update(|v| *v += 1);

// Resource hängt von version ab
let tools_resource = Resource::new(
    move || version.get(),  // ← neu_laden() erhöht version → Resource re-fetcht
    |_| tools_laden(),
);

Das ist bewusst einfach gehalten. Eine Alternative wäre ServerAction mit automatischem resource.refetch(), was aber mehr Boilerplate erfordert.


5. Komponentenstruktur

ToolList          (Hauptkomponente  hält den gesamten Zustand)
│
├── ToolForm      (Formular  erscheint wenn bearbeitungs_modus != None)
│   └── Callback  on_fertig / on_abbrechen → zurück zur Liste
│
└── <Suspense>
    └── FilterBar (bekommt filter: RwSignal + alle_tags: Vec<String>)
    └── <table>   (iteriert über gefilterte Tools)

Zustand-Management in ToolList:

Signal Typ Zweck
version RwSignal<u32> Löst Resource-Reload aus
bearbeitungs_modus RwSignal<Option<FormModus>> Steuert Formular-Sichtbarkeit
filter RwSignal<FilterZustand> Aktueller Filterzustand
csv_fehler/erfolg RwSignal<Option<String>> Import-Feedback

Warum kein globales State-Management (z.B. Context)?
Für eine Single-Page-App mit einer einzigen Hauptansicht ist lokaler Komponentenzustand ausreichend. Ein Context würde nur Sinn ergeben, wenn mehrere unabhängige Seiten/Routen den gleichen Zustand teilen müssten.


6. CSV-Import Design

Format

Bewusst minimal gehalten nur name und anzahl:

name,anzahl
Schubkarren,2
Nadelrolle,mehrere
Besen,

Begründung: Die bestehende Notizliste (toollist_notes.txt) ist nicht gut strukturiert genug für einen vollständigen Import. Ein minimales Format erlaubt das schnelle Anlegen vieler Einträge, die dann manuell nachbearbeitet werden können.

Fehlertoleranz

Der Import ist absichtlich tolerant:

  • Fehlende anzahlAnzahl::zahl(1)
  • "mehrere" (Groß-/Kleinschreibung egal) → Anzahl::mehrere()
  • Nicht-numerische Anzahl → Anzahl::mehrere()
  • Leerzeilen und Kommentarzeilen (#) → übersprungen
  • Kopfzeile (name,...) → automatisch erkannt und übersprungen

Alle importierten Einträge erhalten Standardwerte: Status = Offen, Priorität = Mittel, Beschaffung = Unbekannt.


7. Bekannte Einschränkungen & offene Punkte

Thema Aktueller Stand Mögliche Lösung
Concurrent Writes Kein Locking auf TOML-Datei tokio::sync::Mutex in Axum-State
Verwendungsliste Textarea (zeilenweise) Dynamische Liste mit +/- Buttons
Bauabschnitt Nur via Tags Eigenes Pflichtfeld für Bauphase
Detailansicht Alles in Tabellenzeile gequetscht Aufklappbare Zeile oder Modal
Sortierung Keine Klickbare Spaltenköpfe
Export Nicht vorhanden Druckansicht, CSV-Export
Deployment Nur Dev-Server (cargo leptos watch) Release-Build + Systemd-Service