12 KiB
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::Mutexin 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::Uuidmit demv4-Feature benötigtgetrandom, das für WASM diejs-Feature-Flag braucht.chrono::Utc::now()benötigt für WASM diewasmbind-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
anzahl→Anzahl::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 |