Create a compare view
This commit is contained in:
parent
a599da6fbc
commit
51693b3894
3 changed files with 648 additions and 214 deletions
390
src/compare.rs
Normal file
390
src/compare.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
use std::{collections::BTreeMap, cmp::Ordering, iter};
|
||||
|
||||
use iced::{
|
||||
widget::{column, component, row, text, Row, horizontal_rule, Column, vertical_rule, container, button},
|
||||
Alignment, Length, Renderer,
|
||||
};
|
||||
use iced_aw::{selection_list, card, modal};
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{CompareLoad, CompareSide, ReportDate, Spendings, TEXT_H2, TEXT_H1, TEXT_EMPH2};
|
||||
|
||||
pub(crate) struct Compare<F> {
|
||||
pub left: Option<(CompareLoad, Box<dyn Spendings>)>,
|
||||
pub right: Option<(CompareLoad, Box<dyn Spendings>)>,
|
||||
pub reports: Vec<ReportDate>,
|
||||
pub on_load: F,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CompareState {}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum CompareMsg {
|
||||
LoadLeft(CompareLoad),
|
||||
LoadRight(CompareLoad),
|
||||
}
|
||||
|
||||
impl<M, F> iced::widget::Component<M, Renderer> for Compare<F>
|
||||
where
|
||||
F: FnMut(CompareSide, CompareLoad) -> M,
|
||||
{
|
||||
type State = CompareState;
|
||||
type Event = CompareMsg;
|
||||
|
||||
fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<M> {
|
||||
match event {
|
||||
CompareMsg::LoadLeft(d) => Some((self.on_load)(CompareSide::Left, d)),
|
||||
CompareMsg::LoadRight(d) => Some((self.on_load)(CompareSide::Right, d)),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, _state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> {
|
||||
let heading_txt = |side: &Option<(CompareLoad, _)>| {
|
||||
match side {
|
||||
Some((c, _)) => text(c.to_string()),
|
||||
None => text("None"),
|
||||
}
|
||||
.size(TEXT_H2)
|
||||
};
|
||||
|
||||
let heading_load = |on_submit: fn(_) -> _| {
|
||||
component(CompareLoadChoice {
|
||||
on_submit,
|
||||
choices: &self.reports,
|
||||
})
|
||||
};
|
||||
|
||||
let heading_left =
|
||||
row![heading_txt(&self.left), heading_load(CompareMsg::LoadLeft)].spacing(5);
|
||||
let heading_right = row![
|
||||
heading_txt(&self.right),
|
||||
heading_load(CompareMsg::LoadRight)
|
||||
]
|
||||
.spacing(5);
|
||||
|
||||
let properties = vec![
|
||||
ColumnProperties {
|
||||
width: Length::Shrink,
|
||||
max_width: 200.,
|
||||
align: Alignment::Start,
|
||||
},
|
||||
ColumnProperties {
|
||||
width: Length::Fill,
|
||||
max_width: 400.,
|
||||
align: Alignment::Center,
|
||||
},
|
||||
ColumnProperties {
|
||||
width: Length::Fill,
|
||||
max_width: 400.,
|
||||
align: Alignment::Center,
|
||||
},
|
||||
ColumnProperties {
|
||||
width: Length::Fill,
|
||||
max_width: 400.,
|
||||
align: Alignment::Center,
|
||||
},
|
||||
];
|
||||
|
||||
let headings = [
|
||||
Some(
|
||||
column![text("Item").size(TEXT_H1), text("").size(TEXT_H2)]
|
||||
.align_items(Alignment::Center)
|
||||
.into(),
|
||||
),
|
||||
Some(
|
||||
column![text("Left").size(TEXT_H1), heading_left]
|
||||
.align_items(Alignment::Center)
|
||||
.into(),
|
||||
),
|
||||
Some(
|
||||
column![text("Difference").size(TEXT_H1), text("").size(TEXT_H2)]
|
||||
.align_items(Alignment::Center)
|
||||
.into(),
|
||||
),
|
||||
Some(
|
||||
column![text("Right").size(TEXT_H1), heading_right]
|
||||
.align_items(Alignment::Center)
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
|
||||
let mk_section = |name| {
|
||||
[
|
||||
Some(text(name).size(TEXT_EMPH2).into()),
|
||||
Some(text("").size(TEXT_EMPH2).into()),
|
||||
Some(text("").size(TEXT_EMPH2).into()),
|
||||
Some(text("").size(TEXT_EMPH2).into()),
|
||||
]
|
||||
};
|
||||
|
||||
let left = self.left.as_ref().map(|(_, r)| r);
|
||||
let right = self.right.as_ref().map(|(_, r)| r);
|
||||
|
||||
fn item_compare<'a, I, M>(
|
||||
left: Option<I>,
|
||||
right: Option<I>,
|
||||
) -> Vec<[Option<iced::Element<'a, M>>; 4]>
|
||||
where
|
||||
I: IntoIterator<Item = (&'a str, f64)>,
|
||||
{
|
||||
let to_btree = |i: I| i.into_iter().collect::<BTreeMap<_, _>>();
|
||||
|
||||
let float_text = |f: f64| Some(text(format!("{f:.2}")).into());
|
||||
|
||||
let (left, right) = match (left, right) {
|
||||
(None, None) => return Vec::new(),
|
||||
(Some(l), Some(r)) => (to_btree(l), to_btree(r)),
|
||||
(Some(u), None) => {
|
||||
return u
|
||||
.into_iter()
|
||||
.map(|(k, v)| [Some(text(k).into()), float_text(v), None, None])
|
||||
.collect();
|
||||
}
|
||||
(None, Some(u)) => {
|
||||
return u
|
||||
.into_iter()
|
||||
.map(|(k, v)| [Some(text(k).into()), None, None, float_text(v)])
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
|
||||
let mut l_iter = left.iter().peekable();
|
||||
let mut r_iter = right.iter().peekable();
|
||||
|
||||
enum Side {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
type Peek<'a> = Option<&'a (&'a &'a str, &'a f64)>;
|
||||
let needs_to_wait = |l: Peek, r: Peek| match (l, r) {
|
||||
(_, None) | (None, _) => None,
|
||||
(Some((&l, _)), Some((&r, _))) => match l.cmp(r) {
|
||||
Ordering::Less => match left.contains_key(r) {
|
||||
false => None,
|
||||
true => Some(Side::Left),
|
||||
},
|
||||
Ordering::Equal => None,
|
||||
Ordering::Greater => match right.contains_key(l) {
|
||||
false => None,
|
||||
true => Some(Side::Right),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let mut compare = Vec::new();
|
||||
|
||||
type Item<'a> = Option<(&'a &'a str, &'a f64)>;
|
||||
let mut insert_row = |l: Item, r: Item| match (l, r) {
|
||||
(None, None) => false,
|
||||
(None, Some((k, v))) => {
|
||||
compare.push([Some(text(k).into()), None, None, float_text(*v)]);
|
||||
true
|
||||
}
|
||||
(Some((k, v)), None) => {
|
||||
compare.push([Some(text(k).into()), float_text(*v), None, None]);
|
||||
true
|
||||
}
|
||||
(Some((lk, lv)), Some((rk, rv))) if lk != rk => {
|
||||
compare.push([Some(text(lk).into()), float_text(*lv), None, None]);
|
||||
|
||||
compare.push([Some(text(rk).into()), None, None, float_text(*rv)]);
|
||||
|
||||
true
|
||||
}
|
||||
(Some((k, lv)), Some((_, rv))) => {
|
||||
if *rv == 0. || *lv == 0. {
|
||||
compare.push([
|
||||
Some(text(k).into()),
|
||||
float_text(*lv),
|
||||
None,
|
||||
float_text(*rv),
|
||||
]);
|
||||
} else {
|
||||
let difference = ((rv / lv - 1.) * 100.) as i32;
|
||||
let sign = match difference.cmp(&0) {
|
||||
Ordering::Less => "- ",
|
||||
Ordering::Equal => "",
|
||||
Ordering::Greater => "+ ",
|
||||
};
|
||||
|
||||
compare.push([
|
||||
Some(text(k).into()),
|
||||
float_text(*lv),
|
||||
Some(text(format!("{sign}{}%", difference.abs())).into()),
|
||||
float_text(*rv),
|
||||
]);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
match needs_to_wait(l_iter.peek(), r_iter.peek()) {
|
||||
Some(Side::Left) => {
|
||||
assert!(insert_row(l_iter.next(), None));
|
||||
}
|
||||
Some(Side::Right) => {
|
||||
assert!(insert_row(None, r_iter.next()));
|
||||
}
|
||||
None => {
|
||||
let l = l_iter.next();
|
||||
let r = r_iter.next();
|
||||
|
||||
if !insert_row(l, r) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compare
|
||||
}
|
||||
|
||||
table::<_, _, iced::Element<_>, _>(
|
||||
properties,
|
||||
itertools::chain![
|
||||
iter::once(headings),
|
||||
iter::once(mk_section("Recurring")),
|
||||
item_compare(left.map(|r| r.recurring()), right.map(|r| r.recurring())),
|
||||
iter::once(mk_section("Variable")),
|
||||
item_compare(left.map(|r| r.variable()), right.map(|r| r.variable())),
|
||||
],
|
||||
)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ColumnProperties {
|
||||
pub width: Length,
|
||||
pub max_width: f32,
|
||||
pub align: Alignment,
|
||||
}
|
||||
|
||||
#[allow(unstable_name_collisions)]
|
||||
pub fn table<'a, I, J, T, M>(props: Vec<ColumnProperties>, items: I) -> Row<'a, M>
|
||||
where
|
||||
I: IntoIterator<Item = J>,
|
||||
J: IntoIterator<Item = Option<T>>,
|
||||
T: Into<iced::Element<'a, M>>,
|
||||
M: 'a,
|
||||
{
|
||||
let columns = items.into_iter().fold(
|
||||
std::iter::repeat_with(Vec::new)
|
||||
.take(props.len())
|
||||
.collect_vec(),
|
||||
|mut columns, row| {
|
||||
columns.iter_mut().zip(row).for_each(|(col, item)| {
|
||||
if !col.is_empty() {
|
||||
col.push(horizontal_rule(5.).into());
|
||||
}
|
||||
|
||||
let inner = item.map(Into::into).unwrap_or_else(|| text("").into());
|
||||
col.push(inner);
|
||||
});
|
||||
columns
|
||||
},
|
||||
);
|
||||
|
||||
Row::with_children(
|
||||
columns
|
||||
.into_iter()
|
||||
.zip(props.iter())
|
||||
.map(|(col, prop)| {
|
||||
Column::with_children(col)
|
||||
.height(Length::Fill)
|
||||
.width(prop.width)
|
||||
.max_width(prop.max_width)
|
||||
.align_items(prop.align)
|
||||
.into()
|
||||
})
|
||||
.intersperse_with(|| {
|
||||
container(vertical_rule(0.))
|
||||
.height(Length::Fill)
|
||||
.width(Length::Shrink)
|
||||
.center_y()
|
||||
.into()
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
struct CompareLoadChoice<'a, F> {
|
||||
choices: &'a [ReportDate],
|
||||
on_submit: F,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CompareLoadChoiceState {
|
||||
modal: bool,
|
||||
selected: Option<CompareLoad>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum CompareLoadChoiceMsg {
|
||||
Open,
|
||||
Close,
|
||||
Submit,
|
||||
Select(CompareLoad),
|
||||
}
|
||||
|
||||
impl<'a, M, F> iced::widget::Component<M, Renderer> for CompareLoadChoice<'a, F>
|
||||
where
|
||||
F: FnMut(CompareLoad) -> M,
|
||||
{
|
||||
type State = CompareLoadChoiceState;
|
||||
type Event = CompareLoadChoiceMsg;
|
||||
|
||||
fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option<M> {
|
||||
match event {
|
||||
CompareLoadChoiceMsg::Open => state.modal = true,
|
||||
CompareLoadChoiceMsg::Close => state.modal = false,
|
||||
CompareLoadChoiceMsg::Submit => {
|
||||
if let Some(v) = state.selected.take() {
|
||||
state.modal = false;
|
||||
return Some((self.on_submit)(v));
|
||||
}
|
||||
}
|
||||
CompareLoadChoiceMsg::Select(v) => state.selected = Some(v),
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn view(&self, state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> {
|
||||
let underlay = button(text("Load")).on_press(CompareLoadChoiceMsg::Open);
|
||||
let overlay = match state.modal {
|
||||
true => Some(
|
||||
card(
|
||||
text("Load report"),
|
||||
column![
|
||||
button(text("Current Report"))
|
||||
.on_press(CompareLoadChoiceMsg::Select(CompareLoad::Current)),
|
||||
horizontal_rule(5.),
|
||||
text("Historic Reports"),
|
||||
selection_list(self.choices, |_, v| CompareLoadChoiceMsg::Select(
|
||||
CompareLoad::Report(v)
|
||||
))
|
||||
.height(Length::Shrink),
|
||||
horizontal_rule(5.),
|
||||
match state.selected {
|
||||
None => button(text("Load report".to_string())),
|
||||
Some(rep) => button(text(format!("Load {rep}")))
|
||||
.on_press(CompareLoadChoiceMsg::Submit),
|
||||
}
|
||||
],
|
||||
)
|
||||
.max_width(300.0)
|
||||
.on_close(CompareLoadChoiceMsg::Close),
|
||||
),
|
||||
false => None,
|
||||
};
|
||||
|
||||
modal(underlay, overlay)
|
||||
.backdrop(CompareLoadChoiceMsg::Close)
|
||||
.on_esc(CompareLoadChoiceMsg::Close)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
173
src/edit.rs
173
src/edit.rs
|
|
@ -1,12 +1,5 @@
|
|||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::OpenOptions,
|
||||
num::NonZeroU8,
|
||||
ops::{Mul, MulAssign, Sub, SubAssign},
|
||||
path::PathBuf,
|
||||
};
|
||||
use std::{collections::BTreeMap, fs::OpenOptions, num::NonZeroU8, path::PathBuf};
|
||||
|
||||
use either::Either;
|
||||
use iced::{
|
||||
theme,
|
||||
widget::{
|
||||
|
|
@ -19,8 +12,8 @@ use iced_aw::{card, modal, number_input, selection_list};
|
|||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
calc::calc_parser, Config, Message, Report, ReportDate, SaveFile, LIST_RULE, SECTION_RULE,
|
||||
TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2,
|
||||
calc::calc_parser, BoxIter, Config, ContributionPoint, Message, Report, ReportDate, SaveFile,
|
||||
Spendings, LIST_RULE, SECTION_RULE, TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2,
|
||||
};
|
||||
|
||||
struct AddFixed<F> {
|
||||
|
|
@ -780,165 +773,7 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
type BoxIter<'a, I> = Box<dyn Iterator<Item = I> + 'a>;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ContributionPoint {
|
||||
person_1: f64,
|
||||
person_2: f64,
|
||||
}
|
||||
|
||||
impl ContributionPoint {
|
||||
fn total(&self) -> f64 {
|
||||
self.person_1 + self.person_2
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<ContributionPoint> for ContributionPoint {
|
||||
fn sub_assign(&mut self, rhs: ContributionPoint) {
|
||||
self.person_1 -= rhs.person_1;
|
||||
self.person_2 -= rhs.person_2;
|
||||
}
|
||||
}
|
||||
|
||||
impl MulAssign<f64> for ContributionPoint {
|
||||
fn mul_assign(&mut self, rhs: f64) {
|
||||
self.person_1 *= rhs;
|
||||
self.person_2 *= rhs;
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for ContributionPoint {
|
||||
type Output = Self;
|
||||
|
||||
fn mul(self, rhs: f64) -> Self::Output {
|
||||
Self {
|
||||
person_1: self.person_1 * rhs,
|
||||
person_2: self.person_2 * rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<ContributionPoint> for ContributionPoint {
|
||||
type Output = Self;
|
||||
|
||||
fn sub(self, rhs: ContributionPoint) -> Self::Output {
|
||||
Self {
|
||||
person_1: self.person_1 - rhs.person_1,
|
||||
person_2: self.person_2 - rhs.person_2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Contribution {
|
||||
main: ContributionPoint,
|
||||
savings: BTreeMap<String, ContributionPoint>,
|
||||
remaining: ContributionPoint,
|
||||
}
|
||||
|
||||
pub(crate) struct PossibleContributions {
|
||||
proportional: Contribution,
|
||||
same_remaining: Contribution,
|
||||
missing_months: u8,
|
||||
main_account: f64,
|
||||
}
|
||||
|
||||
pub(crate) trait Spendings {
|
||||
fn recurring(&self) -> BoxIter<(&str, f64)>;
|
||||
fn variable(&self) -> BoxIter<(&str, f64)>;
|
||||
fn savings(&self) -> BoxIter<(&str, f64)>;
|
||||
fn earnings(&self) -> ContributionPoint;
|
||||
fn average(&self) -> NonZeroU8;
|
||||
fn date(&self) -> Option<ReportDate>;
|
||||
|
||||
fn total_spendings(&self) -> f64 {
|
||||
self.recurring()
|
||||
.chain(self.variable())
|
||||
.map(|(_, v)| v)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn contributions(&self, archive: &BTreeMap<ReportDate, Report>) -> PossibleContributions {
|
||||
let total_spendings = self.total_spendings();
|
||||
let total_earnings = self.earnings().total();
|
||||
|
||||
let (historic_spendings, count) = std::iter::once(total_spendings)
|
||||
.chain(
|
||||
match self.date() {
|
||||
Some(date) => {
|
||||
Either::Left(archive.iter().rev().skip_while(move |(d, _)| **d >= date))
|
||||
}
|
||||
None => Either::Right(archive.iter().rev()),
|
||||
}
|
||||
.scan(self.date(), |last, (current, report)| match last {
|
||||
Some(last) if *current + 1 < *last => None,
|
||||
_ => {
|
||||
*last = Some(*current);
|
||||
Some(report)
|
||||
}
|
||||
})
|
||||
.map(|report| report.spendings()),
|
||||
)
|
||||
.take(self.average().get() as _)
|
||||
.fold((0., 0), |(acc, count), spending| {
|
||||
(acc + spending, count + 1)
|
||||
});
|
||||
|
||||
let main_account = historic_spendings / count as f64;
|
||||
let main_factor = main_account / total_earnings;
|
||||
|
||||
let mut remaining = self.earnings();
|
||||
|
||||
let main = self.earnings() * main_factor;
|
||||
remaining -= main;
|
||||
|
||||
let mut total_depositing = main_account;
|
||||
|
||||
let (savings_same_percent, saving_same_remaining) = self
|
||||
.savings()
|
||||
.map(|(account, amount)| {
|
||||
let factor = amount / total_earnings;
|
||||
let contributions = self.earnings() * factor;
|
||||
|
||||
total_depositing += amount;
|
||||
remaining -= contributions;
|
||||
|
||||
(
|
||||
(account.to_string(), contributions),
|
||||
(
|
||||
account.to_string(),
|
||||
ContributionPoint {
|
||||
person_1: amount / 2.,
|
||||
person_2: amount / 2.,
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
let remaining_per_person = (total_earnings - total_depositing) / 2.;
|
||||
let remaining_per_person = ContributionPoint {
|
||||
person_1: remaining_per_person,
|
||||
person_2: remaining_per_person,
|
||||
};
|
||||
|
||||
PossibleContributions {
|
||||
proportional: Contribution {
|
||||
main,
|
||||
remaining,
|
||||
savings: savings_same_percent,
|
||||
},
|
||||
same_remaining: Contribution {
|
||||
main: self.earnings() - remaining_per_person,
|
||||
remaining: remaining_per_person,
|
||||
savings: saving_same_remaining,
|
||||
},
|
||||
main_account,
|
||||
missing_months: self.average().get().saturating_sub(count),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EditState {
|
||||
recurring: BTreeMap<String, f64>,
|
||||
variable: BTreeMap<String, (String, Option<f64>)>,
|
||||
|
|
|
|||
299
src/main.rs
299
src/main.rs
|
|
@ -3,15 +3,19 @@ use std::{
|
|||
fs::File,
|
||||
io::BufReader,
|
||||
num::NonZeroU8,
|
||||
ops::{Mul, MulAssign, Sub, SubAssign},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use calc::calc_parser;
|
||||
use compare::Compare;
|
||||
use directories::ProjectDirs;
|
||||
use edit::{EditMessage, EditState, Spendings};
|
||||
use edit::{EditMessage, EditState};
|
||||
use either::Either;
|
||||
use iced::{
|
||||
font, subscription,
|
||||
widget::{column, text},
|
||||
widget::{column, component},
|
||||
window, Application, Command, Event, Settings, Theme,
|
||||
};
|
||||
use iced_aw::{TabBar, TabBarStyles, TabLabel};
|
||||
|
|
@ -20,6 +24,7 @@ use serde::{Deserialize, Serialize};
|
|||
type Element<'a> = iced::Element<'a, Message>;
|
||||
|
||||
mod calc;
|
||||
mod compare;
|
||||
mod edit;
|
||||
|
||||
const TEXT_H1: u16 = 30;
|
||||
|
|
@ -34,6 +39,165 @@ fn one() -> NonZeroU8 {
|
|||
NonZeroU8::MIN
|
||||
}
|
||||
|
||||
type BoxIter<'a, I> = Box<dyn Iterator<Item = I> + 'a>;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ContributionPoint {
|
||||
person_1: f64,
|
||||
person_2: f64,
|
||||
}
|
||||
|
||||
impl ContributionPoint {
|
||||
fn total(&self) -> f64 {
|
||||
self.person_1 + self.person_2
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<ContributionPoint> for ContributionPoint {
|
||||
fn sub_assign(&mut self, rhs: ContributionPoint) {
|
||||
self.person_1 -= rhs.person_1;
|
||||
self.person_2 -= rhs.person_2;
|
||||
}
|
||||
}
|
||||
|
||||
impl MulAssign<f64> for ContributionPoint {
|
||||
fn mul_assign(&mut self, rhs: f64) {
|
||||
self.person_1 *= rhs;
|
||||
self.person_2 *= rhs;
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for ContributionPoint {
|
||||
type Output = Self;
|
||||
|
||||
fn mul(self, rhs: f64) -> Self::Output {
|
||||
Self {
|
||||
person_1: self.person_1 * rhs,
|
||||
person_2: self.person_2 * rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<ContributionPoint> for ContributionPoint {
|
||||
type Output = Self;
|
||||
|
||||
fn sub(self, rhs: ContributionPoint) -> Self::Output {
|
||||
Self {
|
||||
person_1: self.person_1 - rhs.person_1,
|
||||
person_2: self.person_2 - rhs.person_2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Contribution {
|
||||
main: ContributionPoint,
|
||||
savings: BTreeMap<String, ContributionPoint>,
|
||||
remaining: ContributionPoint,
|
||||
}
|
||||
|
||||
pub(crate) struct PossibleContributions {
|
||||
proportional: Contribution,
|
||||
same_remaining: Contribution,
|
||||
missing_months: u8,
|
||||
main_account: f64,
|
||||
}
|
||||
|
||||
pub(crate) trait Spendings {
|
||||
fn recurring(&self) -> BoxIter<(&str, f64)>;
|
||||
fn variable(&self) -> BoxIter<(&str, f64)>;
|
||||
fn savings(&self) -> BoxIter<(&str, f64)>;
|
||||
fn earnings(&self) -> ContributionPoint;
|
||||
fn average(&self) -> NonZeroU8;
|
||||
fn date(&self) -> Option<ReportDate>;
|
||||
|
||||
fn total_spendings(&self) -> f64 {
|
||||
self.recurring()
|
||||
.chain(self.variable())
|
||||
.map(|(_, v)| v)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn contributions(&self, archive: &BTreeMap<ReportDate, Report>) -> PossibleContributions {
|
||||
let total_spendings = self.total_spendings();
|
||||
let total_earnings = self.earnings().total();
|
||||
|
||||
let (historic_spendings, count) = std::iter::once(total_spendings)
|
||||
.chain(
|
||||
match self.date() {
|
||||
Some(date) => {
|
||||
Either::Left(archive.iter().rev().skip_while(move |(d, _)| **d >= date))
|
||||
}
|
||||
None => Either::Right(archive.iter().rev()),
|
||||
}
|
||||
.scan(self.date(), |last, (current, report)| match last {
|
||||
Some(last) if *current + 1 < *last => None,
|
||||
_ => {
|
||||
*last = Some(*current);
|
||||
Some(report)
|
||||
}
|
||||
})
|
||||
.map(|report| report.spendings()),
|
||||
)
|
||||
.take(self.average().get() as _)
|
||||
.fold((0., 0), |(acc, count), spending| {
|
||||
(acc + spending, count + 1)
|
||||
});
|
||||
|
||||
let main_account = historic_spendings / count as f64;
|
||||
let main_factor = main_account / total_earnings;
|
||||
|
||||
let mut remaining = self.earnings();
|
||||
|
||||
let main = self.earnings() * main_factor;
|
||||
remaining -= main;
|
||||
|
||||
let mut total_depositing = main_account;
|
||||
|
||||
let (savings_same_percent, saving_same_remaining) = self
|
||||
.savings()
|
||||
.map(|(account, amount)| {
|
||||
let factor = amount / total_earnings;
|
||||
let contributions = self.earnings() * factor;
|
||||
|
||||
total_depositing += amount;
|
||||
remaining -= contributions;
|
||||
|
||||
(
|
||||
(account.to_string(), contributions),
|
||||
(
|
||||
account.to_string(),
|
||||
ContributionPoint {
|
||||
person_1: amount / 2.,
|
||||
person_2: amount / 2.,
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
let remaining_per_person = (total_earnings - total_depositing) / 2.;
|
||||
let remaining_per_person = ContributionPoint {
|
||||
person_1: remaining_per_person,
|
||||
person_2: remaining_per_person,
|
||||
};
|
||||
|
||||
PossibleContributions {
|
||||
proportional: Contribution {
|
||||
main,
|
||||
remaining,
|
||||
savings: savings_same_percent,
|
||||
},
|
||||
same_remaining: Contribution {
|
||||
main: self.earnings() - remaining_per_person,
|
||||
remaining: remaining_per_person,
|
||||
savings: saving_same_remaining,
|
||||
},
|
||||
main_account,
|
||||
missing_months: self.average().get().saturating_sub(count),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct Report {
|
||||
#[serde(default)]
|
||||
|
|
@ -77,6 +241,43 @@ impl Default for Report {
|
|||
}
|
||||
}
|
||||
|
||||
impl Spendings for (ReportDate, Report) {
|
||||
fn recurring(&self) -> BoxIter<(&str, f64)> {
|
||||
Box::new(self.1.recurring.iter().map(|(s, v)| (s.as_str(), *v)))
|
||||
}
|
||||
|
||||
fn variable(&self) -> BoxIter<(&str, f64)> {
|
||||
Box::new(
|
||||
self.1
|
||||
.variable
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match calc_parser::calc(v) {
|
||||
Ok(v) => Some((k.as_str(), v)),
|
||||
Err(_) => None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn savings(&self) -> BoxIter<(&str, f64)> {
|
||||
Box::new(self.1.savings.iter().map(|(s, v)| (s.as_str(), *v)))
|
||||
}
|
||||
|
||||
fn earnings(&self) -> ContributionPoint {
|
||||
ContributionPoint {
|
||||
person_1: self.1.earnings_1,
|
||||
person_2: self.1.earnings_2,
|
||||
}
|
||||
}
|
||||
|
||||
fn average(&self) -> NonZeroU8 {
|
||||
self.1.average
|
||||
}
|
||||
|
||||
fn date(&self) -> Option<ReportDate> {
|
||||
Some(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug, Hash)]
|
||||
struct ReportDate {
|
||||
pub year: u64,
|
||||
|
|
@ -177,50 +378,29 @@ enum Message {
|
|||
InitFrom(ReportDate),
|
||||
Edit(EditMessage),
|
||||
ChangeTab(CurrentView),
|
||||
CompareLoad(CompareSide, CompareLoad),
|
||||
}
|
||||
|
||||
// enum CompareLoad {
|
||||
// Current,
|
||||
// Report(ReportDate),
|
||||
// }
|
||||
//
|
||||
// enum CompareSide {
|
||||
// Left,
|
||||
// Right,
|
||||
// }
|
||||
//
|
||||
// struct Compare<'a, F> {
|
||||
// left: Option<&'a dyn Spendings>,
|
||||
// right: Option<&'a dyn Spendings>,
|
||||
// on_load: F,
|
||||
// }
|
||||
//
|
||||
// #[derive(Default)]
|
||||
// struct CompareState {}
|
||||
//
|
||||
// enum CompareMsg {
|
||||
// LoadLeft(CompareLoad),
|
||||
// LoadRight(CompareLoad),
|
||||
// }
|
||||
//
|
||||
// impl<'a, M, F> iced::widget::Component<M, Renderer> for Compare<'a, F>
|
||||
// where
|
||||
// F: FnMut(CompareSide, CompareLoad) -> M,
|
||||
// {
|
||||
// type State = CompareState;
|
||||
// type Event = CompareMsg;
|
||||
//
|
||||
// fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<M> {
|
||||
// match event {
|
||||
// CompareMsg::LoadLeft(d) => Some((self.on_load)(CompareSide::Left, d)),
|
||||
// CompareMsg::LoadRight(d) => Some((self.on_load)(CompareSide::Right, d)),
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fn view(&self, _state: &Self::State) -> iced_aw::Element<'_, Self::Event, Renderer> {
|
||||
// text("todo").into()
|
||||
// }
|
||||
// }
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum CompareLoad {
|
||||
Current,
|
||||
Report(ReportDate),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CompareLoad {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CompareLoad::Current => write!(f, "current"),
|
||||
CompareLoad::Report(r) => write!(f, "{r}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum CompareSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)]
|
||||
enum CurrentView {
|
||||
|
|
@ -233,9 +413,27 @@ struct Glaurung {
|
|||
edit: EditState,
|
||||
view: CurrentView,
|
||||
|
||||
compare_left: Option<CompareLoad>,
|
||||
compare_right: Option<CompareLoad>,
|
||||
|
||||
archive: BTreeMap<ReportDate, Report>,
|
||||
}
|
||||
|
||||
impl Glaurung {
|
||||
fn compare_load(
|
||||
&self,
|
||||
element: Option<CompareLoad>,
|
||||
) -> Option<(CompareLoad, Box<dyn Spendings>)> {
|
||||
match element? {
|
||||
e @ CompareLoad::Current => Some((e, Box::new(self.edit.clone()))),
|
||||
e @ CompareLoad::Report(r) => self
|
||||
.archive
|
||||
.get(&r)
|
||||
.map(|rep| (e, Box::new((r, rep.clone())) as Box<dyn Spendings>)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Application for Glaurung {
|
||||
type Message = Message;
|
||||
type Theme = Theme;
|
||||
|
|
@ -248,6 +446,8 @@ impl Application for Glaurung {
|
|||
edit: EditState::new(config.save_file),
|
||||
archive: config.save.archive,
|
||||
view: CurrentView::Edit,
|
||||
compare_left: None,
|
||||
compare_right: None,
|
||||
};
|
||||
|
||||
this.edit.load(None, config.save.current);
|
||||
|
|
@ -295,6 +495,10 @@ impl Application for Glaurung {
|
|||
}
|
||||
Message::Edit(m) => self.edit.update(m),
|
||||
Message::ChangeTab(t) => self.view = t,
|
||||
Message::CompareLoad(side, load) => match side {
|
||||
CompareSide::Left => self.compare_left = Some(load),
|
||||
CompareSide::Right => self.compare_right = Some(load),
|
||||
},
|
||||
}
|
||||
|
||||
Command::none()
|
||||
|
|
@ -308,7 +512,12 @@ impl Application for Glaurung {
|
|||
.style(TabBarStyles::Dark);
|
||||
let content = match self.view {
|
||||
CurrentView::Edit => self.edit.view(&self.archive, &self.config),
|
||||
CurrentView::Compare => text("todo").into(),
|
||||
CurrentView::Compare => component(Compare {
|
||||
left: self.compare_load(self.compare_left),
|
||||
right: self.compare_load(self.compare_right),
|
||||
reports: self.archive.keys().copied().collect(),
|
||||
on_load: Message::CompareLoad,
|
||||
}),
|
||||
};
|
||||
|
||||
column![bar, content].into()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue