Form Validation UX und minimalistisches Dashboard Redesign

Heute habe ich zwei zusammenhängende UX-Verbesserungen an BudgetBuddy vorgenommen: Ein durchgängiges Formular-Validierungs-System und ein radikal vereinfachtes Dashboard. Beide Änderungen verfolgen dasselbe Ziel – dem Benutzer genau die Information zu geben, die er braucht, und nichts mehr.

Ausgangslage

BudgetBuddy hatte zwei UX-Probleme, die auf den ersten Blick unabhängig voneinander aussahen:

Problem 1: Unsichtbare Formularvalidierung

Wenn ein Benutzer auf einen “Speichern”-Button klickte, der deaktiviert war, passierte… nichts. Der Button sah fast genauso aus wie ein aktiver Button (nur minimal ausgegraut), und es gab keinerlei Feedback darüber, warum der Button nicht funktionierte. War etwas kaputt? Fehlte ein Feld? Welches Feld?

Problem 2: Dashboard-Überladung

Das Dashboard zeigte drei Statistik-Karten (“Letzter Sync”, “Total Importiert”, “Sync Sessions”) und eine Historie der letzten fünf Syncs. Das klingt nach nützlichen Informationen – war es aber nicht. Keine dieser Zahlen war anklickbar oder führte irgendwohin. “42 Transaktionen importiert” ist eine Zahl ohne Kontext. Die Historie konnte man nicht anklicken, um Details zu sehen.

Benutzer-Feedback war eindeutig: “Ich schaue mir das Dashboard nie an. Ich klicke direkt auf ‘Sync starten’.”

Herausforderung 1: Validierungsfeedback ohne Redundanz

Das Problem

Ein typisches Formular in BudgetBuddy (z.B. die Comdirect-Einstellungen) hat mehrere Pflichtfelder:

  • Client ID
  • Client Secret
  • Benutzerkennung
  • PIN
  • Account-ID

Wenn eines fehlt, sollte der Speichern-Button deaktiviert sein. Aber welches fehlt?

Die naive Lösung wäre, unter jedem Feld eine Fehlermeldung anzuzeigen (“Dieses Feld ist erforderlich”). Das führt aber zu visueller Überfrachtung – fünf rote Fehlermeldungen sehen aus wie ein Katastrophen-Bildschirm.

Optionen, die ich betrachtet habe

Option 1: Fehlermeldung pro Feld

// Jedes Feld zeigt seinen eigenen Fehler
Input.group {
    Label = "Client ID"
    Error = if String.IsNullOrEmpty model.ClientId then Some "Erforderlich" else None
    ...
}

Pro:

  • Direkte Zuordnung von Fehler zu Feld
  • Bekanntes Pattern aus Web-Formularen

Contra:

  • Bei 5 leeren Feldern = 5 Fehlermeldungen = visuelles Chaos
  • Benutzer wird erschlagen, bevor er überhaupt angefangen hat
  • Redundant: Der Benutzer sieht, dass das Feld leer ist

Option 2: Toast-Benachrichtigung beim Klick

// Beim Klick auf deaktivierten Button erscheint Toast
| SaveClicked when not isValid ->
    model, Cmd.none, ShowToast "Bitte füllen Sie alle Pflichtfelder aus"

Pro:

  • Keine permanente visuelle Störung
  • Klares Signal, dass etwas fehlt

Contra:

  • Sagt nicht, welche Felder fehlen
  • Button ist deaktiviert, also kommt der Klick nicht an
  • Toast verschwindet, Information ist weg

Option 3: Zusammengefasste Meldung unter dem Button (gewählt)

// Eine Meldung listet alle fehlenden Felder auf
Html.div [
    prop.text $"Bitte ausfüllen: {missingFields}"
]

Pro:

  • Eine zentrale Stelle für Validierungsfeedback
  • Listet konkret die fehlenden Felder auf
  • Verschwindet automatisch, wenn alles ausgefüllt ist
  • Fokussiert auf den Ort, wo der Benutzer hinschauen will (den Button)

Contra:

  • Benutzer muss zum Button scrollen, um den Fehler zu sehen
  • Bei sehr vielen Feldern wird die Liste lang

Ich habe mich für Option 3 entschieden, weil BudgetBuddy-Formulare selten mehr als 5-6 Pflichtfelder haben und der Button typischerweise sichtbar ist.

Die Lösung: Form.fs Design System Component

Ich habe ein neues Modul Form.fs im Design System erstellt:

module Client.DesignSystem.Form

open Feliz
open Client.DesignSystem.Button
open Client.DesignSystem.Icons
open Client.DesignSystem.Loading

// Prüft, ob ein Wert als "ausgefüllt" gilt
let private isRequiredValid (value: string) =
    not (System.String.IsNullOrWhiteSpace value)

// Gibt die Namen aller fehlenden Felder zurück
let private getMissingFields (fields: (string * string) list) =
    fields
    |> List.filter (fun (_, value) -> not (isRequiredValid value))
    |> List.map fst

let submitButton (text: string) (onClick: unit -> unit) (isLoading: bool) (requiredFields: (string * string) list) =
    let missingFields = getMissingFields requiredFields
    let isDisabled = isLoading || not (List.isEmpty missingFields)

    Html.div [
        prop.className "space-y-2"
        prop.children [
            Button.view {
                Button.defaultProps with
                    Text = text
                    OnClick = onClick
                    Variant = Button.Primary
                    IsLoading = isLoading
                    IsDisabled = isDisabled
                    Icon = Some (Icons.check SM Icons.Primary)
            }

            // Validierungsmeldung nur wenn deaktiviert UND Felder fehlen
            if isDisabled && not isLoading && not (List.isEmpty missingFields) then
                Html.div [
                    prop.className "flex items-center gap-2 text-sm text-neon-orange"
                    prop.children [
                        Icons.warning SM NeonOrange
                        Html.span [
                            let fields = String.concat ", " missingFields
                            prop.text $"Bitte ausfüllen: {fields}"
                        ]
                    ]
                ]
        ]
    ]

Architekturentscheidung: Warum eine separate Komponente?

  1. Konsistenz: Alle Formulare in der App verwenden jetzt dasselbe Pattern
  2. Deklarativ: Der Aufrufer übergibt nur eine Liste von (Feldname, Wert) – die Logik steckt in der Komponente
  3. Testbar: Die Validierungslogik ist pure und leicht zu testen
  4. Erweiterbar: Später können wir weitere Validierungsregeln hinzufügen (Min-Länge, Pattern, etc.)

Button-Styling für Disabled-State

Ein weiteres Problem war, dass deaktivierte Buttons kaum von aktiven zu unterscheiden waren. Ich habe in Button.fs den disabled-State angepasst:

// Vorher: Nur cursor-not-allowed
"disabled:cursor-not-allowed"

// Nachher: Deutlich sichtbar deaktiviert
"disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"

Die opacity-50 macht den Button halbtransparent, und shadow-none entfernt den charakteristischen Neon-Glow-Effekt. Jetzt ist sofort erkennbar, dass der Button nicht klickbar ist.

Required-Marker konsistent machen

Das letzte Puzzleteil war, alle Pflichtfelder mit einem roten Sternchen zu markieren. Das Input.group-Pattern hatte bereits Required: bool, aber es wurde nicht überall genutzt. Ich habe eine Convenience-Funktion hinzugefügt:

let groupRequired label children =
    group {
        Label = label
        Required = true
        Error = None
        HelpText = None
        Children = children
    }

Das Label-Rendering zeigt jetzt automatisch das Sternchen:

Html.label [
    prop.children [
        Html.text props.Label
        if props.Required then
            Html.span [
                prop.className "text-neon-red ml-0.5"
                prop.text "*"
            ]
    ]
]

Herausforderung 2: Das Dashboard-Dilemma

Das Problem

Das Dashboard war ein klassischer Fall von “Feature Creep”. Beim initialen Design dachte ich: “Ein Dashboard sollte Statistiken zeigen!” Also habe ich hinzugefügt:

  • Letzter Sync: Datum und Uhrzeit des letzten Syncs
  • Total Importiert: Gesamtzahl aller jemals importierten Transaktionen
  • Sync Sessions: Anzahl der durchgeführten Syncs
  • Quick Actions: Buttons für häufige Aktionen
  • Recent Activity: Die letzten 5 Sync-Sessions mit Zeitstempel

Das Problem: Keine dieser Informationen half dem Benutzer, irgendetwas zu tun. “23 Sync Sessions” – und dann? Was macht der Benutzer mit dieser Information?

Die radikale Lösung: Alles löschen

Ich habe das gesamte Dashboard auf einen einzigen Button reduziert: “Start Sync”.

Vorher:

+------------------+------------------+------------------+
| Letzter Sync     | Total Importiert | Sync Sessions    |
| 15.12.25 18:30   | 1,234            | 23               |
+------------------+------------------+------------------+
| Quick Actions                                          |
| [Start Sync] [Rules] [Settings]                        |
+------------------+------------------+------------------+
| Recent Activity                                        |
| - 15.12.25 18:30: 12 Transaktionen                    |
| - 14.12.25 09:15: 8 Transaktionen                     |
| - 13.12.25 20:00: 15 Transaktionen                    |
| ...                                                    |
+--------------------------------------------------------+

Nachher:





            [  Start Sync  ]

      Letzter Sync: 15.12.25 18:30
         12 Transaktionen


Warum das besser ist

1. Eine Aktion, eine Frage

Wenn ein Benutzer BudgetBuddy öffnet, will er genau eine Sache: Transaktionen synchronisieren. Das Dashboard beantwortet jetzt nur noch zwei Fragen:

  • “Was kann ich hier tun?” → Den großen Button drücken
  • “Wann habe ich das zuletzt gemacht?” → Die Info unter dem Button

2. Kognitive Last reduziert

Das alte Dashboard hatte ~15 visuelle Elemente (3 Karten × 4 Elemente + 5 History-Items). Das neue hat 3: Button, Datum, Summary. Der Benutzer muss nicht mehr filtern, was wichtig ist.

3. Mobile-First

Auf einem Handy war das alte Dashboard ein Alptraum – scrollen, um den “Start Sync” Button zu finden. Jetzt ist er das Erste, was man sieht.

Implementierung: Drastisch vereinfachte Types

Die Dashboard-Types wurden entsprechend verschlankt:

// Vorher
type Model = {
    CurrentSession: RemoteData<SyncSession option>
    RecentSessions: RemoteData<SyncSession list>  // Für Historie
    Settings: RemoteData<Settings option>
    Stats: RemoteData<DashboardStats>  // Total Imported, etc.
}

// Nachher
type Model = {
    CurrentSession: RemoteData<SyncSession option>
    LastSession: RemoteData<SyncSession option>  // Nur die letzte!
    Settings: RemoteData<Settings option>
}

Die Messages wurden ebenfalls vereinfacht:

type Msg =
    | LoadCurrentSession
    | CurrentSessionLoaded of Result<SyncSession option, string>
    | LoadLastSession  // Vorher: LoadRecentSessions
    | LastSessionLoaded of Result<SyncSession option, string>  // Vorher: List
    | LoadSettings
    | SettingsLoaded of Result<Settings option, string>

Der Hero-Button

Der große “Start Sync” Button ist ein Custom-Styling, kein Standard-Design-System-Button:

let syncButton (onNavigateToSync: unit -> unit) =
    Html.button [
        prop.className """
            group relative
            px-12 py-5
            rounded-xl
            bg-gradient-to-r from-neon-orange to-neon-orange/80
            text-base-100 font-bold text-lg md:text-xl font-display
            shadow-[0_0_30px_rgba(255,107,44,0.4)]
            hover:shadow-[0_0_50px_rgba(255,107,44,0.6)]
            hover:scale-105
            transition-all duration-300
        """
        prop.onClick (fun _ -> onNavigateToSync())
        prop.children [
            Html.div [
                prop.className "flex items-center gap-3"
                prop.children [
                    Icons.sync Icons.MD Icons.Primary
                    Html.span [ prop.text "Start Sync" ]
                ]
            ]
        ]
    ]

Warum kein Design-System-Button?

Das Frontend Architecture Review hat dies als Verbesserungspotenzial markiert. Technisch korrekt – aber ich habe mich bewusst dagegen entschieden:

  1. Einmalige Verwendung: Dieser Button erscheint nur hier. Ein Button.hero-Variant im Design System wäre Over-Engineering.
  2. Spezielle Proportionen: px-12 py-5 sind deutlich größer als alle anderen Buttons (normalerweise px-4 py-2)
  3. Neon-Glow-Effekt: Der orangefarbene Schatten ist Dashboard-spezifisch und passt nicht zu anderen Kontexten

Falls ich später mehr “Hero”-Buttons brauche, werde ich das Design System erweitern. Bis dahin: YAGNI.

Lessons Learned

1. UX > Features

Es ist verlockend, mehr Features hinzuzufügen. “Ein Dashboard braucht Statistiken!” Aber jedes Feature, das nicht hilft, schadet – weil es Aufmerksamkeit von den Features abzieht, die helfen.

2. Validierungsfeedback gehört zum Action-Button

Die Idee, die Validierungsmeldung unter den Button zu setzen, kam aus der Beobachtung: Wenn ein Benutzer auf einen deaktivierten Button klickt, schaut er auf den Button. Genau dort sollte die Erklärung sein.

3. Konsistenz durch das Design System

Ohne das Design System hätte ich die Required-Marker in jedem Formular einzeln implementieren müssen. Mit dem Design System war es eine Änderung in Input.fs, und alle Formulare profitierten.

4. Weniger ist mehr (aber es braucht Mut)

Das Löschen von funktionierendem Code fühlt sich falsch an. “Das habe ich doch implementiert!” Aber wenn es dem Benutzer nicht hilft, ist es Ballast. Das Dashboard-Redesign hat ~200 Zeilen Code gelöscht und das Produkt besser gemacht.

Fazit

Zwei scheinbar unabhängige UX-Verbesserungen, die dasselbe Prinzip verfolgen: Dem Benutzer genau das zeigen, was er braucht – nicht mehr und nicht weniger.

Geänderte Dateien:

Datei Änderung
src/Client/DesignSystem/Form.fs Neu: Validierungs-Component
src/Client/DesignSystem/Button.fs Disabled-Styling verbessert
src/Client/DesignSystem/Input.fs Required-Marker konsistent
src/Client/Components/Dashboard/Types.fs Model vereinfacht
src/Client/Components/Dashboard/State.fs LastSession statt RecentSessions
src/Client/Components/Dashboard/View.fs Komplett neu: nur Button + Info
src/Client/Components/Settings/View.fs Form.submitButton verwendet
src/Client/Components/Rules/View.fs Form.submitButton verwendet
src/Client/Components/SyncFlow/View.fs Form.submitButton verwendet

Ergebnis:

  • Build: ✅
  • Tests: 294/294 bestanden
  • Code gelöscht: ~200 Zeilen Dashboard-Komplexität
  • Code hinzugefügt: ~50 Zeilen Form-Validierung

Key Takeaways für Neulinge

  1. Validierungsfeedback ist kein Afterthought: Plane von Anfang an, wie du dem Benutzer Validierungsfehler zeigst. Eine konsistente Lösung im Design System spart später viel Arbeit.

  2. Hinterfrage jeden Datenpunkt: Bevor du eine Statistik anzeigst, frage: “Was macht der Benutzer mit dieser Information?” Wenn die Antwort “nichts” ist, zeige sie nicht an.

  3. Design System Components für wiederkehrende Patterns: Sobald du dasselbe Pattern zweimal schreibst, extrahiere es in eine wiederverwendbare Komponente. Das garantiert Konsistenz und macht Änderungen einfach.