From 5feb5cf771d473ff9f55be40169ca5db2bafd268 Mon Sep 17 00:00:00 2001 From: Guillermo Ramos Date: Sun, 9 Mar 2025 16:07:47 +0100 Subject: Updates in front (RO) --- Makefile | 3 + front/src/Main.elm | 427 +++++++++++++++++++++++++++++++++++------------------ src/bin/web.rs | 13 +- src/lib.rs | 48 +++--- 4 files changed, 324 insertions(+), 167 deletions(-) diff --git a/Makefile b/Makefile index ad7cba4..3e5d45b 100644 --- a/Makefile +++ b/Makefile @@ -5,4 +5,7 @@ build: serve: build target/release/web +cli: build + target/release/cli + .PHONY: build serve diff --git a/front/src/Main.elm b/front/src/Main.elm index baa76d3..bc3f763 100644 --- a/front/src/Main.elm +++ b/front/src/Main.elm @@ -2,6 +2,7 @@ module Main exposing (main) import Browser exposing (Document, UrlRequest(..)) import Browser.Navigation as Nav +import Dict exposing (Dict) import Html exposing ( Attribute @@ -11,6 +12,7 @@ import Html , div , hr , input + , p , pre , span , table @@ -37,8 +39,8 @@ import Html.Attributes ) import Html.Events exposing (onClick, onInput) import Http -import Json.Decode exposing (Decoder, field, float, int, list, map2, map3, map4) -import Json.Encode as Encode exposing (Value, object) +import Json.Decode as JD +import Json.Encode as JE import Platform.Cmd exposing (batch) import Round import Set exposing (Set) @@ -141,18 +143,18 @@ main = type alias Quota = - { period : Int + { month : Int , payed : Capital , pending_principal : Float } -quotaDecoder : Decoder Quota +quotaDecoder : JD.Decoder Quota quotaDecoder = - map3 Quota - (field "period" int) - (field "payed" capitalDecoder) - (field "pending_principal" float) + JD.map3 Quota + (JD.field "period" JD.int) + (JD.field "payed" capitalDecoder) + (JD.field "pending_principal" JD.float) type alias Capital = @@ -161,11 +163,11 @@ type alias Capital = } -capitalDecoder : Decoder Capital +capitalDecoder : JD.Decoder Capital capitalDecoder = - map2 Capital - (field "principal" float) - (field "interest" float) + JD.map2 Capital + (JD.field "principal" JD.float) + (JD.field "interest" JD.float) capitalSumView : Model -> Capital -> Html Msg @@ -188,20 +190,22 @@ capitalSumView { t, settings } { principal, interest } = type alias MortgageSim = - { history : List Quota + { updates : SimUpdates + , history : List Quota , topay : Capital , payed : Capital , payed_amortized : Float } -simDecoder : Decoder MortgageSim +simDecoder : JD.Decoder MortgageSim simDecoder = - map4 MortgageSim - (field "history" (list quotaDecoder)) - (field "topay" capitalDecoder) - (field "payed" capitalDecoder) - (field "payed_amortized" float) + JD.map5 MortgageSim + (JD.field "updates" simUpdatesDecoder) + (JD.field "history" (JD.list quotaDecoder)) + (JD.field "topay" capitalDecoder) + (JD.field "payed" capitalDecoder) + (JD.field "payed_amortized" JD.float) type alias RawSpecs = @@ -265,41 +269,179 @@ modelToUrl { settings, rawSpecs } = (settingsToQS settings ++ rawSpecsToQS rawSpecs) -type alias MortgageSpecs = +type SimUpdate + = Amortize Float + + +simUpdateEncode : SimUpdate -> JE.Value +simUpdateEncode mupdate = + case mupdate of + Amortize f -> + JE.object [ ( "Amortize", JE.float f ) ] + + +simUpdateDecoder : JD.Decoder SimUpdate +simUpdateDecoder = + JD.oneOf + [ JD.field "Amortize" <| JD.map Amortize JD.float + ] + + +simUpdateFlatten : List SimUpdate -> List SimUpdate +simUpdateFlatten us = + let + { amortized } = + List.foldr + (\upd acc -> + case upd of + Amortize f -> + { acc | amortized = acc.amortized + f } + ) + { amortized = 0 } + us + in + [ Amortize amortized ] + + +type alias PeriodicUpdate = + { period : Int + , from : Maybe Int + , to : Maybe Int + , upd : SimUpdate + } + + +periodicUpdateInMonth : Int -> PeriodicUpdate -> Bool +periodicUpdateInMonth month { period, from, to } = + let + base = + Maybe.withDefault 0 from + in + modBy period month == base && base <= month && Maybe.withDefault (month + 1) to > month + + +periodicUpdateEncode : PeriodicUpdate -> JE.Value +periodicUpdateEncode { period, from, to, upd } = + let + toJInt x = + case x of + Nothing -> + JE.null + + Just val -> + JE.int val + in + JE.object + [ ( "period", JE.int period ) + , ( "from", toJInt from ) + , ( "to", toJInt to ) + , ( "update", simUpdateEncode upd ) + ] + + +periodicUpdateDecoder : JD.Decoder PeriodicUpdate +periodicUpdateDecoder = + JD.map4 PeriodicUpdate + (JD.field "period" JD.int) + (JD.field "from" (JD.nullable JD.int)) + (JD.field "to" (JD.nullable JD.int)) + (JD.field "update" simUpdateDecoder) + + +type alias SimUpdates = + { periodically : List PeriodicUpdate + , byMonth : List ( Int, List SimUpdate ) + } + + +updatesInMonth : SimUpdates -> Int -> SimUpdates +updatesInMonth { periodically, byMonth } month = + let + newPeriodically = + List.filter (periodicUpdateInMonth month) periodically + + newByMonth = + List.filter (\( m, updates ) -> m == month) byMonth + in + { periodically = newPeriodically, byMonth = newByMonth } + + +simUpdatesEncode : SimUpdates -> JE.Value +simUpdatesEncode { periodically, byMonth } = + JE.object + [ ( "periodically", JE.list periodicUpdateEncode periodically ) + , ( "by_month" + , JE.object <| + List.map + (\( m, us ) -> + ( String.fromInt m, JE.list simUpdateEncode us ) + ) + byMonth + ) + ] + + +simUpdatesDecoder : JD.Decoder SimUpdates +simUpdatesDecoder = + JD.map2 SimUpdates + (JD.field "periodically" (JD.list periodicUpdateDecoder)) + (JD.field "by_month" + (JD.keyValuePairs (JD.list simUpdateDecoder) + |> JD.map + (\l -> + List.map (\( k, v ) -> ( Maybe.withDefault 0 (String.toInt k), v )) l + ) + ) + ) + + +type alias SimSpecs = { principal : Float , i1 : Float , years : Int + , updates : SimUpdates } -parseMortgageSpecs : RawSpecs -> Maybe MortgageSpecs -parseMortgageSpecs { total, rate, i1, years } = +parseSimSpecs : RawSpecs -> Maybe SimSpecs +parseSimSpecs { total, rate, i1, years } = case ( List.map String.toFloat [ total, i1 ] , List.map String.toInt [ rate, years ] ) of ( [ Just totalValueF, Just i1F ], [ Just rateI, Just yearsI ] ) -> - Just { principal = totalValueF * toFloat rateI / 100, i1 = i1F, years = yearsI } + let + updates = + { periodically = [], byMonth = [] } + in + Just + { principal = totalValueF * toFloat rateI / 100 + , i1 = i1F + , years = yearsI + , updates = updates + } _ -> Nothing -mortgageSpecsToUrl : MortgageSpecs -> String -mortgageSpecsToUrl { principal, i1, years } = - UB.absolute [ "api", "simulate" ] - [ UB.string "principal" (String.fromFloat principal) - , UB.string "i1" (String.fromFloat (i1 / 100)) - , UB.int "years" years +simSpecsEncode : SimSpecs -> JE.Value +simSpecsEncode { principal, i1, years, updates } = + JE.object + [ ( "principal", JE.float principal ) + , ( "i1", JE.float <| i1 / 100 ) + , ( "years", JE.int years ) + , ( "updates", simUpdatesEncode updates ) ] -runMortgageSim : Model -> MortgageSpecs -> Cmd Msg -runMortgageSim m mortgageSpecs = - Http.get - { url = mortgageSpecsToUrl mortgageSpecs - , expect = Http.expectJson (GotMortgageSim m) simDecoder +runSim : Model -> SimSpecs -> Cmd Msg +runSim m simSpecs = + Http.post + { url = UB.absolute [ "api", "simulate" ] [] + , body = Http.jsonBody <| simSpecsEncode simSpecs + , expect = Http.expectJson (GotSim m) simDecoder } @@ -541,8 +683,8 @@ type Msg | ChangedUrl Url | UpdateSpecs SpecField String | UpdateSettings SettingsChange - | RunMortgageSim MortgageSpecs - | GotMortgageSim Model (Result Http.Error MortgageSim) + | RunSim SimSpecs + | GotSim Model (Result Http.Error MortgageSim) | SetExpandedYears (Set Int) @@ -553,7 +695,7 @@ update msg m = Debug.log "UPDATE!" msg in case msg of - GotMortgageSim old_m (Ok msim) -> + GotSim old_m (Ok msim) -> ( { m | simulation = Just ( old_m.rawSpecs, msim ) @@ -561,15 +703,12 @@ update msg m = , Cmd.none ) - GotMortgageSim _ (Err err) -> + GotSim _ (Err err) -> ( { m | error = errorToString err }, Cmd.none ) - RunMortgageSim specs -> + RunSim specs -> ( m - , batch - [ runMortgageSim m specs - , Nav.pushUrl m.navKey (modelToUrl m) - ] + , batch [ runSim m specs, Nav.pushUrl m.navKey (modelToUrl m) ] ) SetUrl (Internal url) -> @@ -781,11 +920,18 @@ amountView currency amount = case String.split "." amountStr of int :: float :: [] -> let + floatPart = + case float of + "00" -> + [] + + _ -> + [ text <| String.fromChar (currencySep currency).decimal + , span [ class "text-sm" ] [ text float ] + ] + els = - [ text (insertThousandsSep currency int) - , text <| String.fromChar (currencySep currency).decimal - , span [ class "text-sm" ] [ text float ] - ] + [ text (insertThousandsSep currency int) ] ++ floatPart in span [] <| case currencyOrder currency of @@ -806,17 +952,17 @@ specsView { t, settings, rawSpecs } = rawSpecs simButAttrs = - case parseMortgageSpecs rawSpecs of + case parseSimSpecs rawSpecs of Nothing -> [ disabled True ] Just specs -> - [ onClick (RunMortgageSim specs) ] + [ onClick (RunSim specs) ] in div [] [ div [] [ input - [ class "min-w-full mb-2 py-1 px-3 text-xl font-bold lime-100" + [ class "min-w-full mb-2 py-1 px-3 text-xl font-bold" , placeholder (t "Title...") , value title , onInput (UpdateSpecs Title) @@ -891,92 +1037,59 @@ specsView { t, settings, rawSpecs } = ] -historyView : Model -> List Quota -> Html Msg -historyView m quotas = - let - titles = - [ "Year", "Month", "Quota", "Pending" ] - - head = - thead [ class "bg-lime-100" ] - [ tr [] - (List.map - (\txt -> - th [ class "px-3 py-1 border border-gray-300" ] - [ text <| m.t txt ] - ) - titles - ) - ] - in - div [ class "pt-4 flex flex-col items-center" ] - [ table [ class "border border-collapse bg-gray-50 border-gray-400" ] - [ head - , tbody [] - (List.map (quotaView m) - quotas - ) - ] - ] +monthToYear : Int -> Int +monthToYear month = + ((month - 1) // 12) + 1 -periodToYear : Int -> Int -periodToYear period = - ((period - 1) // 12) + 1 +simUpdateView : List (Attribute Msg) -> Model -> SimUpdate -> Html Msg +simUpdateView attrs m upd = + case upd of + Amortize f -> + span attrs [ text "+", amountView m.settings.currency f ] -quotaView : Model -> Quota -> Html Msg -quotaView m { period, payed, pending_principal } = +quotaView : Model -> MortgageSim -> Quota -> Html Msg +quotaView m { updates } { month, payed, pending_principal } = let + monthUpdates = + updatesInMonth updates month + year = - periodToYear period + monthToYear month - yearExpanded = + monthInExpandedYear = Set.member year m.expandedYears - in - if modBy 12 (period - 1) == 0 then - tr [] - (List.map (\t -> td [ class "px-3 py-1 border border-gray-300" ] [ t ]) - [ div [] - [ span - (clickableAttrs - (SetExpandedYears - ((if yearExpanded then - Set.remove - - else - Set.insert - ) - year - m.expandedYears - ) - ) - ) - [ text - (if yearExpanded then - "− " - else - "+ " - ) - ] + ( toggleYearIcon, newExpandedYears ) = + if monthInExpandedYear then + ( "− ", Set.remove year m.expandedYears ) + + else + ( "+ ", Set.insert year m.expandedYears ) + + ( yearField, updatesField ) = + if modBy 12 (month - 1) == 0 then + ( div [] + [ span (clickableAttrs (SetExpandedYears newExpandedYears)) [ text toggleYearIcon ] , text (String.fromInt year) ] - , text - (String.fromInt period) - , capitalSumView m payed - , amountView m.settings.currency pending_principal - ] - ) + , text "..." + ) - else if yearExpanded then + else + ( text "" + , div [] (List.map (simUpdateView [ class "bg-lime-200" ] m << .upd) <| monthUpdates.periodically) + ) + in + if modBy 12 (month - 1) == 0 || monthInExpandedYear then tr [] (List.map (\t -> td [ class "px-3 py-1 border border-gray-300" ] [ t ]) - [ text "" - , text - (String.fromInt period) + [ yearField + , text (String.fromInt month) , capitalSumView m payed , amountView m.settings.currency pending_principal + , updatesField ] ) @@ -984,8 +1097,34 @@ quotaView m { period, payed, pending_principal } = text "" +mortgageView : Model -> MortgageSim -> Html Msg +mortgageView m sim = + let + titles = + [ "Year", "Month", "Quota", "Pending", "Updates" ] + + head = + thead [ class "bg-lime-100" ] + [ tr [] + (List.map + (\txt -> + th [ class "px-3 py-1 border border-gray-300" ] + [ text <| m.t txt ] + ) + titles + ) + ] + in + div [ class "pt-4 flex flex-col items-center" ] + [ table [ class "border border-collapse bg-gray-50 border-gray-400" ] + [ head + , tbody [] (List.map (quotaView m sim) sim.history) + ] + ] + + simView : Model -> ( RawSpecs, MortgageSim ) -> Html Msg -simView m ( rawSpecs, { history, topay, payed } ) = +simView m ( rawSpecs, sim ) = let currency = m.settings.currency @@ -1007,6 +1146,26 @@ simView m ( rawSpecs, { history, topay, payed } ) = fee = total * parseFloat rawSpecs.fee / 100 + + overview financed = + div [] + [ pre [] + [ text <| t "Financed (mortgage): " + , capitalSumView m financed + ] + , pre [] + [ text <| t "Total to pay: " + , span [ class "font-bold" ] + [ amountView currency + (financed.principal + + financed.interest + + initial + + vat + + fee + ) + ] + ] + ] in div [] [ hr [ class "my-5" ] [] @@ -1023,25 +1182,11 @@ simView m ( rawSpecs, { history, topay, payed } ) = , text <| t "VAT: " , amountView currency vat ] - , pre [] - [ text <| t "Financed (mortgage): " - , capitalSumView m topay - ] - , pre [] - [ text <| t "Total to pay: " - , span [ class "font-bold" ] - [ amountView currency - (topay.principal - + topay.interest - + initial - + vat - + fee - ) - ] - ] - - -- , div [] [ text "payed: ", capitalSumView m payed ] - , historyView m history + , p [ class "text-lg font-bold" ] [ text "-- without early payments --" ] + , overview sim.topay + , p [ class "text-lg font-bold" ] [ text "-- with early payments --" ] + , overview sim.payed + , mortgageView m sim ] @@ -1058,7 +1203,7 @@ view m = Just sim -> simView m sim - , text m.error + , span [ class "text-rose-600" ] [ text m.error ] ] ] ] diff --git a/src/bin/web.rs b/src/bin/web.rs index 4d2938f..eba283c 100644 --- a/src/bin/web.rs +++ b/src/bin/web.rs @@ -4,8 +4,7 @@ use serde::Deserialize; use std::fs; use axum::{ response::{Html, Json}, - extract::Query, - routing::get, + routing::{get, post}, Router, }; @@ -40,11 +39,13 @@ struct SimSpecs { principal: f64, i1: f64, years: u32, + updates: SimUpdates } -async fn api_simulate_get<'a>(Query(specs): Query) -> Json> { - let mut sim = Simulation::new(specs. principal, specs.i1, specs.years); - let updates: SimUpdates = SimUpdates::default(); +async fn api_simulate_post(Json(specs): Json) -> Json { + let mut sim = Simulation::new(specs.principal, specs.i1, specs.years); + let updates: SimUpdates = specs.updates; + // let updates: SimUpdates = SimUpdates::default(); sim.run(updates); Json(sim) } @@ -55,7 +56,7 @@ async fn main() { let app = Router::new() .route("/", get(root_get)) .route("/main.js", get(main_get)) - .route("/api/simulate", get(api_simulate_get)); + .route("/api/simulate", post(api_simulate_post)); // run our app with hyper, listening globally on port 3000 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 423f607..1f4a977 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt; use std::ops::AddAssign; +use serde::Deserialize; use serde::Serialize; #[derive(Clone, Copy, Serialize)] @@ -25,16 +26,16 @@ impl AddAssign for Capital { } #[derive(Serialize)] -pub struct Simulation<'a> { +pub struct Simulation { st: SimState, - updates: SimUpdates<'a>, + updates: SimUpdates, history: Vec, topay: Capital, payed: Capital, payed_amortized: f64, } -impl<'a> Simulation<'a> { +impl Simulation { pub fn new(principal: f64, i1: f64, years: u32) -> Self { let pending_quotas = years * 12; let i12 = i1 / 12.0; @@ -66,7 +67,7 @@ impl<'a> Simulation<'a> { } } - pub fn run(&mut self, updates: SimUpdates<'a>) { + pub fn run(&mut self, updates: SimUpdates) { self.updates = updates; let mut st = self.st.clone(); while st.pending_quotas > 0 && st.principal > 0. { @@ -209,25 +210,32 @@ impl fmt::Display for Quota { } } -#[derive(Debug, Default, Serialize)] -pub struct SimUpdates<'a> { - periodically: Vec<(u32, Vec<&'a SimUpdate>)>, - by_month: HashMap>, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeriodicUpdate { + period: u32, + from: Option, + to: Option, + update: SimUpdate, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SimUpdates { + periodically: Vec, + by_month: HashMap>, } -impl<'a> SimUpdates<'a> { - fn get(&self, month: u32) -> Vec<&'a SimUpdate> { +impl SimUpdates { + fn get(&self, month: u32) -> Vec { let SimUpdates { periodically, by_month, } = self; let mut ret = vec![]; - for (m, updates) in periodically.iter() { - if month % m == 0 { - for update in updates.iter() { - ret.push(*update); - } + for PeriodicUpdate{period, from, to, update} in periodically.iter() { + let base = from.unwrap_or(0); + if month % period == base && base <= month && to.unwrap_or(month+1) > month { + ret.push(*update); } } if let Some(updates) = by_month.get(&month) { @@ -239,7 +247,7 @@ impl<'a> SimUpdates<'a> { ret } - pub fn and(mut self, other: SimUpdates<'a>) -> Self { + pub fn and(mut self, other: SimUpdates) -> Self { for p in other.periodically.iter() { self.periodically.push(p.clone()); } @@ -250,7 +258,7 @@ impl<'a> SimUpdates<'a> { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub enum SimUpdate { Amortize(f64), } @@ -269,18 +277,18 @@ impl fmt::Display for SimUpdate { impl SimUpdate { pub fn every(&self, months: u32) -> SimUpdates { let mut updates = SimUpdates::default(); - updates.periodically.push((months, vec![self])); + updates.periodically.push(PeriodicUpdate{period: months, from: None, to: None, update: *self}); updates } pub fn at(&self, month: u32) -> SimUpdates { let mut updates = SimUpdates::default(); - updates.by_month.insert(month, vec![self]); + updates.by_month.insert(month, vec![*self]); updates } } -fn flatten_amortizations(updates: Vec<&SimUpdate>) -> Vec { +fn flatten_amortizations(updates: Vec) -> Vec { let mut amortized = 0.; let mut result = vec![]; for update in updates { -- cgit v1.2.3