aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGuillermo Ramos2025-03-09 16:07:47 +0100
committerGuillermo Ramos2025-03-14 11:52:04 +0100
commit5feb5cf771d473ff9f55be40169ca5db2bafd268 (patch)
treea77d32e59daae7a0e2f98919b85706b18debb090
parent60f037e68466f2ba2137285cfa8e88782dcd905f (diff)
downloadhiccup-5feb5cf771d473ff9f55be40169ca5db2bafd268.tar.gz
Updates in front (RO)
-rw-r--r--Makefile3
-rw-r--r--front/src/Main.elm427
-rw-r--r--src/bin/web.rs13
-rw-r--r--src/lib.rs48
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<SimSpecs>) -> Json<hiccup::Simulation<'a>> {
- let mut sim = Simulation::new(specs. principal, specs.i1, specs.years);
- let updates: SimUpdates = SimUpdates::default();
+async fn api_simulate_post(Json(specs): Json<SimSpecs>) -> Json<hiccup::Simulation> {
+ 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<Quota>,
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<u32, Vec<&'a SimUpdate>>,
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PeriodicUpdate {
+ period: u32,
+ from: Option<u32>,
+ to: Option<u32>,
+ update: SimUpdate,
+}
+
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct SimUpdates {
+ periodically: Vec<PeriodicUpdate>,
+ by_month: HashMap<u32, Vec<SimUpdate>>,
}
-impl<'a> SimUpdates<'a> {
- fn get(&self, month: u32) -> Vec<&'a SimUpdate> {
+impl SimUpdates {
+ fn get(&self, month: u32) -> Vec<SimUpdate> {
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<SimUpdate> {
+fn flatten_amortizations(updates: Vec<SimUpdate>) -> Vec<SimUpdate> {
let mut amortized = 0.;
let mut result = vec![];
for update in updates {