Milestone 7: Frontend-Implementierung – Von der leeren Leinwand zur vollständigen Elmish-App

Einleitung

Nach sechs Milestones mit Backend-Entwicklung – Domain-Typen, Persistence-Layer, YNAB-Integration, Comdirect OAuth-Flow, Rules Engine und 29 API-Endpoints – war es Zeit, dem Ganzen ein Gesicht zu geben. Milestone 7 hatte ein klares Ziel: Eine vollständige Elmish-Frontend-Anwendung mit vier Hauptseiten (Dashboard, Sync Flow, Rules, Settings) und kompletter State-Verwaltung.

Die Herausforderung dabei: Wie strukturiert man eine F#-Frontend-Anwendung so, dass sie wartbar, typsicher und gleichzeitig benutzerfreundlich ist? Wie verbindet man das MVU-Pattern (Model-View-Update) mit einer komplexen Domäne, die asynchrone Bank-Authentifizierung, Transaktions-Kategorisierung und Multi-Step-Workflows umfasst?

Was auf den ersten Blick nach “nur Frontend bauen” aussah, entpuppte sich als Session voller Überraschungen: Von Fable.Remoting-Einschränkungen über Dapper-Inkompatibilitäten bis hin zu subtilen F#-Syntax-Fallen. In diesem Blogpost dokumentiere ich meinen Weg durch diese Implementierung – inklusive der Stolpersteine, Architekturentscheidungen und Lessons Learned.


Ausgangslage

Das Backend war bereits vollständig implementiert:

  • Shared Domain-Typen in src/Shared/Domain.fs (Money, TransactionId, Rule, SyncSession, etc.)
  • API-Contracts in src/Shared/Api.fs (SettingsApi, YnabApi, RulesApi, SyncApi)
  • 29 Backend-Endpoints in src/Server/Api.fs
  • Persistence-Layer mit SQLite + verschlüsselten Settings
  • Rules Engine für automatische Kategorisierung
  • 121 Tests (alle grün)

Das Frontend bestand aus einer minimalen Placeholder-Implementierung:

// Vorher: Minimale View.fs
let view (model: Model) (dispatch: Msg -> unit) =
    Html.div [
        prop.className "p-4"
        prop.children [
            Html.h1 [ prop.text "BudgetBuddy" ]
            Html.p [ prop.text "Welcome to BudgetBuddy!" ]
        ]
    ]

Die API-Definition sah schön hierarchisch aus:

type AppApi = {
    Settings: SettingsApi
    Ynab: YnabApi
    Rules: RulesApi
    Sync: SyncApi
}

Meine Aufgabe: Von dieser leeren Leinwand zu einer vollständigen App mit Navigation, Toast-Benachrichtigungen, Settings-Management, Rules-Tabelle und dem kompletten Sync-Workflow. Was könnte schon schiefgehen?


Herausforderung 1: Fable.Remoting’s Record-Constraint verstehen

Das Problem

Beim ersten Versuch, den Server mit dem neuen Frontend zu starten, kam dieser Fehler:

Unhandled exception. System.Exception: The type 'SettingsApi' of the record
field 'Settings' for record type 'AppApi' is not valid. It must either be
Async<'t>, Task<'t> or a function that returns either
(i.e. 'u -> Async<'t>)

Verschachtelte API-Record-Typen werden von Fable.Remoting nicht unterstützt.

Warum diese Einschränkung?

Fable.Remoting verwendet Reflection, um aus einem F#-Record-Typ automatisch HTTP-Endpoints zu generieren. Dafür hat es eine strikte Anforderung: Jedes Feld im API-Record muss entweder:

  1. Async<'T> sein
  2. Task<'T> sein
  3. Eine Funktion sein, die Async<'T> oder Task<'T> zurückgibt

Ein verschachtelter Record-Typ wie SettingsApi erfüllt keine dieser Bedingungen – er ist selbst ein Record, kein Async-Rückgabewert.

Optionen, die ich betrachtet habe

  1. Alles in einen flachen Record
    • Pro: Einfach, sofort kompatibel
    • Contra: 29 Methoden in einem Record = unübersichtlich
  2. Separate API-Endpoints registrieren (gewählt)
    • Pro: Behält logische Gruppierung, Framework-konform
    • Contra: Mehr Boilerplate im Server-Setup

Die Lösung: Vier separate Fable.Remoting-Handler

// Server/Api.fs - Nachher
let webApp() =
    choose [
        Remoting.createApi()
        |> Remoting.withRouteBuilder routeBuilder
        |> Remoting.fromValue settingsApi
        |> Remoting.buildHttpHandler

        Remoting.createApi()
        |> Remoting.withRouteBuilder routeBuilder
        |> Remoting.fromValue ynabApi
        |> Remoting.buildHttpHandler

        Remoting.createApi()
        |> Remoting.withRouteBuilder routeBuilder
        |> Remoting.fromValue rulesApi
        |> Remoting.buildHttpHandler

        Remoting.createApi()
        |> Remoting.withRouteBuilder routeBuilder
        |> Remoting.fromValue syncApi
        |> Remoting.buildHttpHandler
    ]

Architekturentscheidung: Warum choose von Giraffe?

Giraffe’s choose ist ein HTTP-Handler, der mehrere Handler nacheinander probiert, bis einer matched. Jeder Fable.Remoting-Handler bekommt seine eigenen Routen basierend auf dem Typ-Namen:

  • /api/SettingsApi/getSettings
  • /api/YnabApi/getBudgets
  • /api/RulesApi/getAllRules
  • /api/SyncApi/startSync

Herausforderung 2: Client-seitige API-Proxies anpassen

Das Problem

Der Client sollte ursprünglich einen einzelnen Proxy verwenden:

// Client/Api.fs - Vorher (geplant)
let api : AppApi =
    Remoting.createApi()
    |> Remoting.buildProxy<AppApi>

Mit dem verschachtelten AppApi wäre der Aufruf Api.api.Settings.getSettings() gewesen. Jetzt musste das auf separate Proxies umgestellt werden.

Die Lösung: Vier separate Proxies

// Client/Api.fs - Nachher
let settings : SettingsApi =
    Remoting.createApi()
    |> Remoting.withRouteBuilder (fun typeName methodName ->
        $"/api/{typeName}/{methodName}")
    |> Remoting.buildProxy<SettingsApi>

let ynab : YnabApi =
    Remoting.createApi()
    |> Remoting.withRouteBuilder (fun typeName methodName ->
        $"/api/{typeName}/{methodName}")
    |> Remoting.buildProxy<YnabApi>

let rules : RulesApi = // ...
let sync : SyncApi = // ...

Trade-off Diskussion:

Aspekt Verschachtelt (AppApi) Separate Proxies
Aufruf-Syntax Api.api.Settings.getSettings() Api.settings.getSettings()
Lesbarkeit Hierarchisch gruppiert Flach, aber klar
Typ-Sicherheit Gleich Gleich
Framework-Support ❌ Nicht unterstützt ✅ Voll unterstützt

Die flache Struktur ist sogar kürzer zu tippen: Api.settings statt Api.api.Settings.


Herausforderung 3: Dapper und anonyme F#-Typen

Das Problem

Nach dem API-Refactoring kam ein weiterer Fehler beim Laden der Settings:

Error loading settings: A parameterless default constructor or one matching
signature (System.String value, System.Int64 encrypted) is required for
<>f__AnonymousType97031723`2 materialization

Der Code in Persistence.fs:

let getSetting (key: string) : Async<string option> =
    async {
        use conn = getConnection()
        let! row =
            conn.QueryFirstOrDefaultAsync<{| value: string; encrypted: int |}>(
                "SELECT value, encrypted FROM settings WHERE key = @Key",
                {| Key = key |}
            ) |> Async.AwaitTask
        // ...
    }

Warum funktionieren anonyme Typen nicht?

Dapper verwendet Reflection, um Objekte zu materialisieren. Dafür braucht es:

  1. Einen parameterlosen Konstruktor ODER
  2. Einen Konstruktor, dessen Parameter zu den Spalten passen

F#’s anonyme Record-Typen ({| ... |}) haben keinen parameterlosen Konstruktor – sie werden vom Compiler als spezielle Klassen generiert.

Die Lösung: CLIMutable Record

module Settings =
    [<CLIMutable>]
    type SettingRow = {
        value: string
        encrypted: int
    }

    let getSetting (key: string) : Async<string option> =
        async {
            use conn = getConnection()
            let! row =
                conn.QueryFirstOrDefaultAsync<SettingRow>(
                    "SELECT value, encrypted FROM settings WHERE key = @Key",
                    {| Key = key |}
                ) |> Async.AwaitTask
            // ...
        }

Was macht [<CLIMutable>]?

Dieses Attribut weist den F#-Compiler an:

  1. Einen parameterlosen Konstruktor zu generieren
  2. Property-Setter zu generieren (normalerweise sind F#-Records immutable)

Das macht den Typ kompatibel mit ORMs wie Dapper, Entity Framework, etc.

Wichtig: Das Attribut ändert nicht die F#-Semantik – der Record bleibt im F#-Code immutable. Es fügt nur die CLR-Infrastruktur hinzu, die Dapper braucht.


Herausforderung 4: Projektstruktur und Datei-Organisation

Das Problem

Nachdem die API-Probleme gelöst waren, kam die eigentliche Frontend-Arbeit. Die erste Frage: Wie organisiere ich den Frontend-Code? Alles in eine View.fs zu packen würde schnell unübersichtlich. Aber zu viel Fragmentierung macht die Navigation schwierig.

Optionen, die ich betrachtet habe

  1. Alles in View.fs
    • Pro: Einfach, keine Import-Probleme
    • Contra: Bei 4 Seiten mit jeweils ~100-200 Zeilen schnell 800+ Zeilen in einer Datei
  2. Ein Modul pro Page in separaten Dateien (gewählt)
    • Pro: Klare Trennung, jede Seite ist eigenständig
    • Contra: Mehr Dateien, Compilation-Order in .fsproj wichtig
  3. Feature-basierte Ordner (Rules/, Sync/, Settings/)
    • Pro: Gruppiert zusammengehörige Logik
    • Contra: Für eine App dieser Größe Overkill

Die Lösung: Views-Ordner

Ich habe mich für Option 2 entschieden – einen Views/-Ordner mit einer Datei pro Seite:

src/Client/
├── Types.fs           # RemoteData, Page, Toast
├── Api.fs             # Fable.Remoting Proxy
├── State.fs           # Model, Msg, init, update
├── Views/
│   ├── DashboardView.fs
│   ├── SyncFlowView.fs
│   ├── RulesView.fs
│   └── SettingsView.fs
├── View.fs            # Main Layout, Navbar, Routing
└── App.fs             # Entry Point

Architekturentscheidung: Warum Views-Ordner statt Feature-Ordner?

  1. Klare Verantwortlichkeit: Jede View-Datei kümmert sich um eine Seite
  2. State bleibt zentral: Alle Messages und das Model bleiben in State.fs
  3. F# Compilation Order: Die .fsproj muss eine lineare Reihenfolge haben. Ein Views-Ordner passt gut zwischen State und View.

Die Reihenfolge in Client.fsproj:

<ItemGroup>
  <Compile Include="Types.fs" />
  <Compile Include="Api.fs" />
  <Compile Include="State.fs" />
  <Compile Include="Views/DashboardView.fs" />
  <Compile Include="Views/SyncFlowView.fs" />
  <Compile Include="Views/RulesView.fs" />
  <Compile Include="Views/SettingsView.fs" />
  <Compile Include="View.fs" />
  <Compile Include="App.fs" />
</ItemGroup>

Herausforderung 5: State-Design für komplexe Workflows

Das Problem

BudgetBuddy ist keine einfache CRUD-App. Der Sync-Flow allein hat 7 verschiedene Zustände:

  • AwaitingBankAuth → AwaitingTan → FetchingTransactions → ReviewingTransactions → ImportingToYnab → Completed / Failed

Dazu kommen Settings mit 6 verschiedenen Eingabefeldern, Rules mit CRUD-Operationen, und Toast-Benachrichtigungen die nach 5 Sekunden verschwinden sollen.

Wie modelliert man das alles in einem einzigen Model?

Optionen, die ich betrachtet habe

  1. Flaches Model mit vielen Feldern (gewählt)
    • Pro: Einfach zu verstehen, alle Daten an einem Ort
    • Contra: Kann groß werden
  2. Nested Models (SettingsModel, RulesModel, SyncModel)
    • Pro: Bessere Kapselung
    • Contra: In Elmish unüblich, verkompliziert Update-Funktionen
  3. Separate Elmish-Programme pro Feature
    • Pro: Maximale Isolation
    • Contra: Kommunikation zwischen Features schwierig

Die Lösung: Strukturiertes Flaches Model

Ich habe mich für ein flaches Model mit klarer Gruppierung entschieden:

type Model = {
    // Navigation
    CurrentPage: Page

    // Toast notifications
    Toasts: Toast list

    // Dashboard
    CurrentSession: RemoteData<SyncSession option>
    RecentSessions: RemoteData<SyncSession list>

    // Settings
    Settings: RemoteData<AppSettings>
    YnabBudgets: RemoteData<YnabBudgetWithAccounts list>

    // Settings form state (getrennt von geladenen Settings!)
    YnabTokenInput: string
    ComdirectClientIdInput: string
    ComdirectClientSecretInput: string
    ComdirectUsernameInput: string
    ComdirectPasswordInput: string
    ComdirectAccountIdInput: string
    SyncDaysInput: int

    // Rules
    Rules: RemoteData<Rule list>
    EditingRule: Rule option
    Categories: YnabCategory list

    // Sync Flow
    SyncTransactions: RemoteData<SyncTransaction list>
    SelectedTransactions: Set<TransactionId>
}

Wichtige Design-Entscheidung: Form-State vs. Loaded-State

Ich habe bewusst YnabTokenInput (was der User gerade eingibt) von Settings (was vom Server geladen wurde) getrennt. Warum?

  1. Undo wird möglich: User kann Eingabe verwerfen ohne neu zu laden
  2. Keine Race Conditions: Während User tippt, kann Backend-Daten laden
  3. Klare Semantik: Settings = “was gespeichert ist”, *Input = “was User gerade bearbeitet”

Herausforderung 6: RemoteData-Pattern überall

Das Problem

Fast alle Daten kommen asynchron vom Server. Ohne strukturierte Behandlung explodiert der Code mit null-Checks und Loading-States.

Die Lösung: RemoteData Discriminated Union

type RemoteData<'T> =
    | NotAsked    // Noch nicht angefragt
    | Loading     // Request läuft
    | Success of 'T
    | Failure of string

Warum vier Zustände statt Option?

Option<'T> (Some/None) unterscheidet nicht zwischen:

  • “Noch nie geladen” (NotAsked)
  • “Gerade am Laden” (Loading)
  • “Geladen aber leer” (Success [])
  • “Fehler beim Laden” (Failure)

Mit RemoteData kann ich in der View präzise reagieren:

match model.RecentSessions with
| NotAsked ->
    Html.div [ prop.text "Click to load history" ]
| Loading ->
    Html.div [
        prop.className "flex justify-center p-4"
        prop.children [
            Html.span [ prop.className "loading loading-spinner loading-lg" ]
        ]
    ]
| Success sessions when sessions.IsEmpty ->
    Html.div [ prop.text "No sync history yet." ]
| Success sessions ->
    historyTable sessions
| Failure error ->
    Html.div [
        prop.className "alert alert-error"
        prop.text $"Failed to load: {error}"
    ]

Das Pattern garantiert, dass ich jeden Zustand behandle – der Compiler warnt mich, wenn ich einen vergesse.


Herausforderung 7: Type Annotations für Feliz Event-Handler

Das Problem

Beim ersten Build bekam ich diesen kryptischen Fehler:

error FS0041: A unique overload for method 'onChange' could not be
determined based on type information prior to this program point.
Known type of argument: ('a -> unit)
Candidates:
 - static member prop.onChange: handler: (bool -> unit) -> IReactProperty
 - static member prop.onChange: handler: (string -> unit) -> IReactProperty
 - static member prop.onChange: handler: (int -> unit) -> IReactProperty
 ...

Der Code sah harmlos aus:

Html.input [
    prop.type'.checkbox
    prop.isChecked isSelected
    prop.onChange (fun _ -> dispatch (ToggleTransactionSelection tx.Id))
]

Warum passiert das?

Feliz bietet prop.onChange mit verschiedenen Signaturen an – für verschiedene Input-Typen. Der F#-Compiler kann nicht erraten, welche gemeint ist, wenn der Lambda-Parameter ignoriert wird (fun _ ->).

Die Lösung: Explizite Type Annotation

Html.input [
    prop.type'.checkbox
    prop.isChecked isSelected
    prop.onChange (fun (_: bool) -> dispatch (ToggleTransactionSelection tx.Id))
]

Lesson Learned: Bei Feliz-Event-Handlern, die den Parameter ignorieren, immer den Typ annotieren. Das gilt besonders für:

  • Checkboxes: bool
  • Text-Inputs: string
  • Number-Inputs: int oder float

Herausforderung 8: If-Then ohne Else in Listen

Das Problem

In der Transaction-Übersicht wollte ich Badges nur anzeigen, wenn die Anzahl > 0 ist:

// FEHLER: Kompiliert nicht!
Html.div [
    prop.children [
        Html.span [ prop.text $"Categorized: {categorized}" ]
        if uncategorized > 0 then
            Html.span [ prop.text $"Uncategorized: {uncategorized}" ]
        if skipped > 0 then
            Html.span [ prop.text $"Skipped: {skipped}" ]
    ]
]

Der Compiler beschwert sich: “Incomplete structured construct”.

Warum passiert das?

In F# ist if-then ohne else ein Statement, kein Expression. Aber in einer Liste (prop.children [...]) brauchen wir Expressions, die einen Wert zurückgeben.

Die Lösung: Immer ein Else mit Html.none

Html.div [
    prop.children [
        Html.span [ prop.text $"Categorized: {categorized}" ]
        if uncategorized > 0 then
            Html.span [ prop.text $"Uncategorized: {uncategorized}" ]
        else Html.none
        if skipped > 0 then
            Html.span [ prop.text $"Skipped: {skipped}" ]
        else Html.none
    ]
]

Html.none ist Feliz’s “leeres Element” – es rendert nichts, aber erfüllt die Typ-Anforderung.


Herausforderung 9: Let-Bindings in Match-Cases

Das Problem

Bei der Transaktions-Statistik brauchte ich mehrere Berechnungen innerhalb eines Match-Arms:

// FEHLER: Syntaxfehler!
match model.SyncTransactions with
| Success transactions ->
    let categorized = transactions |> List.filter (fun tx ->
        match tx.Status with
        | AutoCategorized | ManualCategorized -> tx.CategoryId.IsSome
        | _ -> false
    ) |> List.length  // <- Fehler hier!
    let uncategorized = transactions |> List.filter ...

Der Fehler: “Unexpected infix operator in binding”.

Warum passiert das?

Das Problem war die Formatierung der Pipeline. F# ist whitespace-sensitiv bei Pipelines innerhalb von Let-Bindings. Wenn der |> am Zeilenende der schließenden Klammer folgt, interpretiert der Parser es falsch.

Die Lösung: Explizite Zeilenumbrüche

match model.SyncTransactions with
| Success transactions ->
    let categorized =
        transactions
        |> List.filter (fun tx ->
            match tx.Status with
            | AutoCategorized | ManualCategorized -> tx.CategoryId.IsSome
            | _ -> false)
        |> List.length
    let uncategorized = transactions |> List.filter (fun tx -> tx.Status = Pending) |> List.length

    Html.div [
        // ... verwende categorized und uncategorized
    ]

Wichtig: Die schließende Klammer der Lambda-Funktion muss auf derselben Zeile sein wie der nächste Pipeline-Operator, oder das Let-Binding muss über mehrere Zeilen mit klarer Einrückung gehen.


Herausforderung 10: Toast-Benachrichtigungen mit Auto-Dismiss

Das Problem

Toasts sollten nach 5 Sekunden automatisch verschwinden. Aber in Elmish gibt es keine “Timer” – alles läuft über Messages.

Die Lösung: Cmd.OfAsync mit Sleep

let private addToast (message: string) (toastType: ToastType) (model: Model) : Model * Cmd<Msg> =
    let toast = { Id = Guid.NewGuid(); Message = message; Type = toastType }
    let dismissCmd =
        Cmd.OfAsync.perform
            (fun () -> async { do! Async.Sleep 5000 })
            ()
            (fun _ -> AutoDismissToast toast.Id)
    { model with Toasts = toast :: model.Toasts }, dismissCmd

Wie funktioniert das?

  1. Ein neuer Toast bekommt eine eindeutige GUID
  2. Wir starten einen Async-Workflow, der 5 Sekunden schläft
  3. Nach dem Sleep dispatcht Cmd.OfAsync.perform automatisch AutoDismissToast
  4. Im Update-Handler entfernen wir den Toast aus der Liste
| AutoDismissToast id ->
    { model with Toasts = model.Toasts |> List.filter (fun t -> t.Id <> id) }, Cmd.none

Warum GUID statt Index?

Wenn wir einen Index verwenden würden, könnte sich die Liste ändern während der Timer läuft. Mit einer GUID ist jeder Toast eindeutig identifizierbar, unabhängig von seiner Position.


Herausforderung 11: Error-Typen benutzerfreundlich darstellen

Das Problem

Das Backend gibt typisierte Fehler zurück:

type SettingsError =
    | YnabTokenInvalid of message: string
    | YnabConnectionFailed of httpStatus: int * message: string
    | ComdirectCredentialsInvalid of field: string * reason: string
    | EncryptionFailed of message: string
    | DatabaseError of operation: string * message: string

Aber YnabConnectionFailed (401, "Unauthorized") ist für den User nicht hilfreich.

Die Lösung: Error-zu-String-Converter

let private settingsErrorToString (error: SettingsError) : string =
    match error with
    | SettingsError.YnabTokenInvalid msg ->
        $"Invalid YNAB token: {msg}"
    | SettingsError.YnabConnectionFailed (status, msg) ->
        $"YNAB connection failed (HTTP {status}): {msg}"
    | SettingsError.ComdirectCredentialsInvalid (field, reason) ->
        $"Invalid Comdirect credentials ({field}): {reason}"
    | SettingsError.EncryptionFailed msg ->
        $"Encryption failed: {msg}"
    | SettingsError.DatabaseError (op, msg) ->
        $"Database error during {op}: {msg}"

Für jeden Error-Typ habe ich einen Converter geschrieben:

  • settingsErrorToString
  • ynabErrorToString
  • rulesErrorToString
  • syncErrorToString

Architekturentscheidung: Warum im Frontend, nicht im Backend?

  1. Internationalisierung: Später könnte das Frontend verschiedene Sprachen unterstützen
  2. Context: Das Frontend weiß besser, wie der User den Fehler sieht (Toast vs. Inline)
  3. Flexibilität: Verschiedene Views könnten denselben Fehler anders darstellen

Lessons Learned

Was ich anders machen würde

  1. Framework-Dokumentation früh lesen: Fable.Remoting’s Einschränkung bei verschachtelten Records ist dokumentiert, aber ich habe sie erst nach dem Fehler gefunden. Lesson: Bei neuen Frameworks die “Limitations” oder “Known Issues” Sektion zuerst lesen.

  2. Anonyme Typen sind nicht ORM-kompatibel: F#’s anonyme Records ({| ... |}) sind fantastisch für lokale Verwendung, aber sobald Reflection ins Spiel kommt (Dapper, JSON-Serialisierung, etc.), braucht man echte Typen mit [<CLIMutable>].

  3. Error-Typen früher definieren: Ich musste mehrfach zwischen Result<'T, string> und Result<'T, SettingsError> konvertieren. Hätte ich von Anfang an konsequent die typisierten Fehler verwendet, wäre der Code sauberer.

  4. Form-Validation im Frontend: Aktuell validiert nur das Backend. Für bessere UX sollte das Frontend bereits beim Tippen Feedback geben.

Was gut funktioniert hat

  1. RemoteData überall: Das Pattern hat sich bewährt. Kein einziger null-Pointer-Fehler, und die Views sind selbstdokumentierend.

  2. Zentrale State.fs: Alle Messages an einem Ort zu haben macht Debugging viel einfacher.

  3. Separate View-Dateien: Die Trennung nach Seiten ist intuitiv und die Dateien bleiben unter 300 Zeilen.

  4. Giraffe’s choose für multiple Handler: Ein eleganter Weg, mehrere Fable.Remoting-APIs zu kombinieren, ohne den hierarchischen AppApi-Typ zu brauchen.


Fazit

Was wurde erreicht

  • 4 vollständige Seiten: Dashboard, Sync Flow, Rules, Settings
  • ~730 Zeilen State.fs: 15+ Model-Felder, 50+ Message-Typen
  • ~400 Zeilen SyncFlowView.fs: Der komplexeste View mit TAN-Waiting, Transaktionsliste, Bulk-Operationen
  • Build: 0 Warnings, 0 Errors
  • Tests: 121/121 grün (Frontend-Änderungen haben Backend nicht gebrochen)

Dateien erstellt/geändert

Neu:

  • src/Client/Views/DashboardView.fs (150 Zeilen)
  • src/Client/Views/SyncFlowView.fs (400 Zeilen)
  • src/Client/Views/RulesView.fs (180 Zeilen)
  • src/Client/Views/SettingsView.fs (280 Zeilen)

Modifiziert:

  • src/Shared/Api.fsAppApi vereinfacht (keine Verschachtelung)
  • src/Server/Api.fs – Vier separate Remoting-Handler
  • src/Server/Persistence.fsSettingRow mit [<CLIMutable>]
  • src/Client/Types.fs – Page, Toast, ToastType hinzugefügt
  • src/Client/Api.fs – Vier separate Proxies
  • src/Client/State.fs – Komplett neu geschrieben (~730 Zeilen)
  • src/Client/View.fs – Layout, Navbar, Routing
  • src/Client/Client.fsproj – Views-Dateien + Fable.Elmish 4.2.0

Key Takeaways für Neulinge

1. Fable.Remoting-Records müssen flach sein

Jedes Feld muss eine Funktion sein, die Async<'T> zurückgibt. Verschachtelte Records sind nicht erlaubt. Verwende stattdessen mehrere separate APIs mit Giraffe’s choose.

2. [<CLIMutable>] ist der Freund von ORMs

Wenn du F#-Records mit Dapper, Entity Framework oder ähnlichen Tools verwendest, brauchst du dieses Attribut. Anonyme Typen ({| ... |}) funktionieren nicht.

3. Type Annotations bei Event-Handlern

Wenn der Feliz-Compiler über onChange meckert: Annotiere den Parameter-Typ explizit.

// Schlecht: prop.onChange (fun _ -> dispatch Msg)
// Gut:     prop.onChange (fun (_: bool) -> dispatch Msg)

4. RemoteData statt Option für API-Daten

Option<'T> unterscheidet nicht zwischen “noch nicht geladen” und “geladen, aber leer”. RemoteData<'T> mit NotAsked/Loading/Success/Failure deckt alle Fälle ab.

5. Immer else Html.none

In Feliz-Listen funktioniert if-then ohne else nicht. Gewöhne dir else Html.none an – es ist idiomatisch und der Compiler ist glücklich.


Geschrieben während der Implementierung von BudgetBuddy – einem Self-Hosted Tool für automatische Bank-zu-YNAB-Synchronisation.