Compare commits

...

10 commits

Author SHA1 Message Date
traxys
8cc79d2331 Correctly calculate same remaining main account
This used to include the total savings
2024-05-29 20:13:15 +02:00
traxys
4d65995af1 Fix typo in same remaining 2024-05-29 20:01:19 +02:00
traxys
27e40f6b25 Update flake.nix 2024-05-13 23:04:12 +02:00
traxys
3fd54ff8ea Add desktop file 2024-01-27 12:12:04 +01:00
traxys
e67eb6200b Refactor saves 2023-12-09 22:03:51 +01:00
traxys
283cf7a4a5 Avoid using HashMap for coherent iteration order 2023-12-09 19:05:26 +01:00
traxys
afccfcfb56 Add a section comparing earnings 2023-12-09 18:09:55 +01:00
traxys
09f35f84ea Add a section for comparing remaining amounts 2023-12-09 18:05:38 +01:00
traxys
7545d7768f Reduce boilerplate in compare sections 2023-12-09 17:43:21 +01:00
traxys
e9ff7f7dcc Add a total spendings section 2023-12-09 17:28:21 +01:00
4 changed files with 357 additions and 196 deletions

View file

@ -5,14 +5,17 @@
inputs.naersk.url = "github:nix-community/naersk";
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
outputs = {
outputs =
{
self,
nixpkgs,
flake-utils,
naersk,
rust-overlay,
}:
flake-utils.lib.eachDefaultSystem (system: let
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
@ -22,21 +25,42 @@
cargo = rust;
rustc = rust;
};
libPath = with pkgs; lib.makeLibraryPath [libxkbcommon wayland vulkan-loader];
in {
libPath =
with pkgs;
lib.makeLibraryPath [
libxkbcommon
wayland
vulkan-loader
];
in
{
devShell = pkgs.mkShell {
nativeBuildInputs = [rust pkgs.cargo-watch];
nativeBuildInputs = [ rust ] ++ (with pkgs; [ cargo-watch ]);
RUST_PATH = "${rust}";
RUST_DOC_PATH = "${rust}/share/doc/rust/html/std/index.html";
LD_LIBRARY_PATH = libPath;
};
defaultPackage = naersk'.buildPackage {
packages = rec {
glaurung = default;
default = naersk'.buildPackage {
src = ./.;
nativeBuildInputs = [pkgs.makeWrapper];
nativeBuildInputs = with pkgs; [
makeWrapper
copyDesktopItems
];
postInstall = ''
wrapProgram "$out/bin/glaurung" --prefix LD_LIBRARY_PATH : "${libPath}"
'';
desktopItems = [
(pkgs.makeDesktopItem {
name = "glaurung";
exec = "glaurung";
desktopName = "Glaurung";
})
];
};
};
});
}
);
}

View file

@ -1,4 +1,4 @@
use std::{cmp::Ordering, collections::BTreeMap, iter};
use std::{borrow::Cow, cmp::Ordering, collections::BTreeMap, iter};
use enum_map::{Enum, EnumMap};
use iced::{
@ -13,12 +13,15 @@ use itertools::Itertools;
use crate::{
CompareLoad, CompareSide, Report, ReportDate, Spendings, TEXT_EMPH2, TEXT_H1, TEXT_H2,
TEXT_NORMAL,
};
pub(crate) struct Compare<'a, F> {
pub left: Option<(CompareLoad, Box<dyn Spendings>)>,
pub right: Option<(CompareLoad, Box<dyn Spendings>)>,
pub archive: &'a BTreeMap<ReportDate, Report>,
pub person_1: &'a str,
pub person_2: &'a str,
pub on_load: F,
}
@ -38,7 +41,9 @@ pub(crate) enum CompareMsg {
pub(crate) enum Section {
Recurring,
Variable,
Total,
Accounts,
Remaining,
Earnings,
}
impl Section {
@ -46,7 +51,9 @@ impl Section {
match self {
Section::Recurring => "Recurring",
Section::Variable => "Variable",
Section::Total => "Total",
Section::Accounts => "Accounts",
Section::Remaining => "Remaining",
Section::Earnings => "Earnings",
}
}
}
@ -116,64 +123,155 @@ where
];
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 = |section: Section| {
fn text_row<'a, T, M>(size: u16, r: [Option<T>; 4]) -> [iced::Element<'a, M>; 4]
where
T: Into<iced::Element<'a, M>>,
{
let [a, b, c, d] = r;
let map = |e: Option<T>| {
e.map(Into::into)
.unwrap_or_else(|| text("").size(size).into())
};
[map(a), map(b), map(c), map(d)]
}
let mk_section_header = |section: Section| {
let status = state.collapsed[section];
text_row(
TEXT_EMPH2,
[
Some(
row![
text(section.name()).size(TEXT_EMPH2),
horizontal_space(Length::Fill),
toggler(None, !status, move |b| CompareMsg::SetCollapse(section, !b)),
toggler(None, !status, move |b| CompareMsg::SetCollapse(section, !b))
.width(Length::Shrink),
]
.align_items(Alignment::Center)
.into(),
.align_items(Alignment::Center),
),
Some(text("").size(TEXT_EMPH2).into()),
Some(text("").size(TEXT_EMPH2).into()),
Some(text("").size(TEXT_EMPH2).into()),
]
None,
None,
None,
],
)
};
let left = self.left.as_ref().map(|(_, r)| &**r);
let right = self.right.as_ref().map(|(_, r)| &**r);
fn item_compare<'a, I, M>(
fn compare_row<'b, S, M>(
size: u16,
always: Option<&str>,
left: Option<(&S, f64)>,
right: Option<(&S, f64)>,
) -> Vec<[iced::Element<'b, M>; 4]>
where
S: AsRef<str> + ?Sized,
{
let float_text = |f: f64| Some(text(format!("{f:.2}")).size(size));
match (left, right) {
(None, None) => match always {
None => vec![],
Some(l) => vec![text_row(size, [Some(text(l).size(size)), None, None, None])],
},
(None, Some((k, v))) => {
vec![text_row(
size,
[Some(text(k.as_ref()).size(size)), None, None, float_text(v)],
)]
}
(Some((k, v)), None) => {
vec![text_row(
size,
[Some(text(k.as_ref()).size(size)), float_text(v), None, None],
)]
}
(Some((lk, lv)), Some((rk, rv))) if lk.as_ref() != rk.as_ref() => {
vec![
text_row(
size,
[
Some(text(lk.as_ref()).size(size)),
float_text(lv),
None,
None,
],
),
text_row(
size,
[
Some(text(rk.as_ref()).size(size)),
None,
None,
float_text(rv),
],
),
]
}
(Some((k, lv)), Some((_, rv))) => {
if rv == 0. || lv == 0. {
vec![text_row(
size,
[
Some(text(k.as_ref()).size(size)),
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 => "+ ",
};
vec![text_row(
size,
[
Some(text(k.as_ref()).size(size)),
float_text(lv),
Some(text(format!("{sign}{}%", difference.abs())).size(size)),
float_text(rv),
],
)]
}
}
}
}
fn item_compare<'a, 's, I, S, M>(
collapse: bool,
left: Option<I>,
right: Option<I>,
) -> Vec<[Option<iced::Element<'a, M>>; 4]>
) -> Vec<[iced::Element<'a, M>; 4]>
where
I: IntoIterator<Item = (&'a str, f64)>,
I: IntoIterator<Item = (S, f64)>,
S: Into<Cow<'s, str>>,
{
if collapse {
return Vec::new();
}
let to_btree = |i: I| i.into_iter().collect::<BTreeMap<_, _>>();
let float_text = |f: f64| Some(text(format!("{f:.2}")).into());
let entry_ref = |(s, v): (S, f64)| (s.into(), v);
let to_btree = |i: I| i.into_iter().map(entry_ref).collect::<BTreeMap<_, _>>();
let float_text = |f: f64| Some(text(format!("{f:.2}")).size(TEXT_NORMAL));
let (left, right) = match (left, right) {
(None, None) => return Vec::new(),
@ -181,15 +279,27 @@ where
(Some(u), None) => {
return u
.into_iter()
.sorted_by_key(|(k, _)| *k)
.map(|(k, v)| [Some(text(k).into()), float_text(v), None, None])
.map(entry_ref)
.sorted_by(|(ka, _), (kb, _)| ka.cmp(kb))
.map(|(k, v)| {
text_row(
TEXT_NORMAL,
[Some(text(k).size(TEXT_NORMAL)), float_text(v), None, None],
)
})
.collect();
}
(None, Some(u)) => {
return u
.into_iter()
.sorted_by_key(|(k, _)| *k)
.map(|(k, v)| [Some(text(k).into()), None, None, float_text(v)])
.map(entry_ref)
.sorted_by(|(ka, _), (kb, _)| ka.cmp(kb))
.map(|(k, v)| {
text_row(
TEXT_NORMAL,
[Some(text(k).size(TEXT_NORMAL)), None, None, float_text(v)],
)
})
.collect();
}
};
@ -202,16 +312,16 @@ where
Right,
}
type Peek<'a> = Option<&'a (&'a &'a str, &'a f64)>;
type Peek<'a> = Option<&'a (&'a Cow<'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) {
(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) {
Ordering::Greater => match right.contains_key(*l) {
false => None,
true => Some(Side::Right),
},
@ -220,50 +330,22 @@ where
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),
type Item<'a> = Option<(&'a Cow<'a, str>, &'a f64)>;
let mut insert_row = |l: Item, r: Item| {
let mut inserted = false;
compare.extend(
compare_row(
TEXT_NORMAL,
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
}
l.map(|(a, b)| (a, *b)),
r.map(|(a, b)| (a, *b)),
)
.into_iter()
.inspect(|_| {
inserted = true;
}),
);
inserted
};
loop {
@ -288,38 +370,60 @@ where
compare
}
fn mk_total<'a>(
archive: &BTreeMap<ReportDate, Report>,
side: Option<&'a dyn Spendings>,
) -> Option<impl Iterator<Item = (&'a str, f64)>> {
let mk_total = |side: Option<&dyn Spendings>| {
let side = side?;
let (main_account, _) = side.main_account(archive);
Some(std::iter::once(("Main", main_account)).chain(side.savings()))
Some(("Total", side.total_spendings()))
};
macro_rules! mk_section {
{$section:expr, $map:expr} => {
itertools::chain![
iter::once(mk_section_header($section)),
item_compare(
state.collapsed[$section],
left.map($map),
right.map($map)
)
]
};
}
scrollable(table::<_, _, iced::Element<_>, _>(
properties,
itertools::chain![
iter::once(headings),
iter::once(mk_section(Section::Recurring)),
item_compare(
state.collapsed[Section::Recurring],
left.map(|r| r.recurring()),
right.map(|r| r.recurring())
),
iter::once(mk_section(Section::Variable)),
item_compare(
state.collapsed[Section::Variable],
left.map(|r| r.variable()),
right.map(|r| r.variable())
),
iter::once(mk_section(Section::Total)),
item_compare(
state.collapsed[Section::Variable],
mk_total(self.archive, left),
mk_total(self.archive, right),
mk_section!(Section::Recurring, |r| r.recurring()),
mk_section!(Section::Variable, |r| r.variable()),
compare_row(TEXT_EMPH2, Some("Total"), mk_total(left), mk_total(right)),
mk_section!(Section::Accounts, |s| {
let (main_account, _) = s.main_account(self.archive);
std::iter::once(("Main", main_account)).chain(s.savings())
}),
mk_section!(Section::Earnings, |s| {
let earnings = s.earnings();
[
(self.person_1, earnings.person_1),
(self.person_2, earnings.person_2),
]
}),
mk_section!(Section::Remaining, move |s| {
let contrib = s.contributions(self.archive);
let remaining_1 = format!("Equal {}", self.person_1);
let remaining_2 = format!("Equal {}", self.person_2);
[
(
Cow::Borrowed("Same"),
contrib.same_remaining.remaining.person_1,
),
(remaining_1.into(), contrib.proportional.remaining.person_1),
(remaining_2.into(), contrib.proportional.remaining.person_2),
]
}),
],
))
.into()
@ -336,7 +440,7 @@ pub struct ColumnProperties {
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>>,
J: IntoIterator<Item = T>,
T: Into<iced::Element<'a, M>>,
M: 'a,
{
@ -350,7 +454,7 @@ where
col.push(horizontal_rule(5.).into());
}
let inner = item.map(Into::into).unwrap_or_else(|| text("").into());
let inner = item.into();
col.push(inner);
});
columns

View file

@ -1,4 +1,4 @@
use std::{collections::BTreeMap, fs::OpenOptions, num::NonZeroU8, path::PathBuf};
use std::{collections::BTreeMap, num::NonZeroU8};
use iced::{
theme,
@ -10,10 +10,11 @@ use iced::{
};
use iced_aw::{card, modal, number_input, selection_list};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use crate::{
calc::calc_parser, BoxIter, Config, ContributionPoint, Message, Report, ReportDate, SaveFile,
Spendings, LIST_RULE, SECTION_RULE, TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2,
calc::calc_parser, BoxIter, Config, ContributionPoint, Message, Report, ReportDate, Spendings,
LIST_RULE, SECTION_RULE, TEXT_EMPH1, TEXT_EMPH2, TEXT_H1, TEXT_H2,
};
struct AddFixed<F> {
@ -778,7 +779,6 @@ pub struct EditState {
recurring: BTreeMap<String, f64>,
variable: BTreeMap<String, (String, Option<f64>)>,
savings: BTreeMap<String, f64>,
save_file: PathBuf,
earnings_1: f64,
earnings_2: f64,
average: NonZeroU8,
@ -850,15 +850,22 @@ macro_rules! msg2 {
};
}
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct EditSaveFile {
#[serde(flatten)]
pub current: Report,
#[serde(default)]
pub archive: BTreeMap<ReportDate, Report>,
}
impl EditState {
pub fn new(save_file: PathBuf) -> Self {
pub fn new() -> Self {
Self {
recurring: Default::default(),
savings: Default::default(),
variable: Default::default(),
earnings_1: Default::default(),
earnings_2: Default::default(),
save_file,
average: NonZeroU8::MIN,
date: None,
}
@ -910,8 +917,8 @@ impl EditState {
}
}
pub(super) fn save(&self, archive: BTreeMap<ReportDate, Report>, pretty: bool) {
let mut save = SaveFile {
pub(super) fn save(&self, archive: BTreeMap<ReportDate, Report>) -> EditSaveFile {
let mut save = EditSaveFile {
current: Default::default(),
archive,
};
@ -923,20 +930,7 @@ impl EditState {
None => save.current = self.report(),
}
let tmp_path = format!(".glaurung-{}", std::process::id());
let save_file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)
.expect("Can't open temp save file");
let to_writer = match pretty {
true => serde_json::to_writer_pretty,
false => serde_json::to_writer,
};
to_writer(save_file, &save).expect("could not write save file");
std::fs::rename(tmp_path, &self.save_file).expect("could not save data");
save
}
pub(super) fn update(&mut self, message: EditMessage) {
@ -1020,7 +1014,7 @@ impl EditState {
)),
];
for (account, contribution) in &contributions.proportional.savings {
for (account, contribution) in &contributions.same_remaining.savings {
same_remaining = same_remaining
.push(text(account).size(TEXT_EMPH2))
.push(text(format!(

View file

@ -1,9 +1,9 @@
use std::{
collections::{BTreeMap, HashMap},
fs::File,
collections::BTreeMap,
fs::{File, OpenOptions},
io::BufReader,
num::NonZeroU8,
ops::{Mul, MulAssign, Sub, SubAssign},
ops::{AddAssign, Mul, MulAssign, Sub, SubAssign},
path::PathBuf,
};
@ -11,7 +11,7 @@ use anyhow::anyhow;
use calc::calc_parser;
use compare::Compare;
use directories::ProjectDirs;
use edit::{EditMessage, EditState};
use edit::{EditMessage, EditSaveFile, EditState};
use either::Either;
use iced::{
font, subscription,
@ -29,6 +29,7 @@ mod edit;
const TEXT_H1: u16 = 30;
const TEXT_H2: u16 = 25;
const TEXT_NORMAL: u16 = 15;
const TEXT_EMPH1: u16 = 17;
const TEXT_EMPH2: u16 = 20;
@ -53,6 +54,13 @@ impl ContributionPoint {
}
}
impl AddAssign<ContributionPoint> for ContributionPoint {
fn add_assign(&mut self, rhs: ContributionPoint) {
self.person_1 += rhs.person_1;
self.person_2 += rhs.person_2;
}
}
impl SubAssign<ContributionPoint> for ContributionPoint {
fn sub_assign(&mut self, rhs: ContributionPoint) {
self.person_1 -= rhs.person_1;
@ -155,6 +163,10 @@ pub(crate) trait Spendings {
remaining -= main;
let mut total_depositing = main_account;
let mut total_savings = ContributionPoint {
person_1: 0.,
person_2: 0.,
};
let (savings_same_percent, saving_same_remaining) = self
.savings()
@ -165,6 +177,12 @@ pub(crate) trait Spendings {
total_depositing += amount;
remaining -= contributions;
let savings = ContributionPoint {
person_1: amount / 2.,
person_2: amount / 2.,
};
total_savings += savings;
(
(account.to_string(), contributions),
(
@ -191,7 +209,7 @@ pub(crate) trait Spendings {
savings: savings_same_percent,
},
same_remaining: Contribution {
main: self.earnings() - remaining_per_person,
main: self.earnings() - remaining_per_person - total_savings,
remaining: remaining_per_person,
savings: saving_same_remaining,
},
@ -202,13 +220,13 @@ pub(crate) trait Spendings {
}
#[derive(Serialize, Deserialize, Clone)]
struct Report {
pub(crate) struct Report {
#[serde(default)]
recurring: BTreeMap<String, f64>,
#[serde(default)]
savings: BTreeMap<String, f64>,
#[serde(default)]
variable: HashMap<String, String>,
variable: BTreeMap<String, String>,
#[serde(default)]
earnings_1: f64,
#[serde(default)]
@ -282,7 +300,7 @@ impl Spendings for (ReportDate, Report) {
}
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Debug, Hash)]
struct ReportDate {
pub(crate) struct ReportDate {
pub year: u64,
pub month: u8,
}
@ -361,9 +379,7 @@ impl<'de> Deserialize<'de> for ReportDate {
#[derive(Serialize, Deserialize, Default)]
struct SaveFile {
#[serde(flatten)]
current: Report,
#[serde(default)]
archive: BTreeMap<ReportDate, Report>,
edit: EditSaveFile,
}
#[derive(Default)]
@ -416,6 +432,8 @@ struct Glaurung {
edit: EditState,
view: CurrentView,
save_file: PathBuf,
compare_left: Option<CompareLoad>,
compare_right: Option<CompareLoad>,
@ -435,6 +453,26 @@ impl Glaurung {
.map(|rep| (e, Box::new((r, rep.clone())) as Box<dyn Spendings>)),
}
}
fn save(&self) {
let edit = self.edit.save(self.archive.clone());
let save = SaveFile { edit };
let tmp_path = format!(".glaurung-{}", std::process::id());
let save_file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)
.expect("Can't open temp save file");
let to_writer = match self.config.pretty_save {
true => serde_json::to_writer_pretty,
false => serde_json::to_writer,
};
to_writer(save_file, &save).expect("could not write save file");
std::fs::rename(tmp_path, &self.save_file).expect("could not save data");
}
}
impl Application for Glaurung {
@ -446,14 +484,15 @@ impl Application for Glaurung {
fn new(config: Self::Flags) -> (Self, Command<Message>) {
let mut this = Self {
config: config.config,
edit: EditState::new(config.save_file),
archive: config.save.archive,
edit: EditState::new(),
save_file: config.save_file,
archive: config.save.edit.archive,
view: CurrentView::Edit,
compare_left: None,
compare_right: None,
};
this.edit.load(None, config.save.current);
this.edit.load(None, config.save.edit.current);
(
this,
@ -480,14 +519,12 @@ impl Application for Glaurung {
Message::FontLoaded(r) => r.expect("could not load font"),
Message::Event(ev) => {
if let Event::Window(window::Event::CloseRequested) = ev {
self.edit
.save(self.archive.clone(), self.config.pretty_save);
self.save();
return window::close();
}
}
Message::Load(d) => {
self.edit
.save(self.archive.clone(), self.config.pretty_save);
self.save();
self.edit
.load(Some(d), self.archive.get(&d).cloned().unwrap_or_default());
}
@ -520,6 +557,8 @@ impl Application for Glaurung {
right: self.compare_load(self.compare_right),
archive: &self.archive,
on_load: Message::CompareLoad,
person_1: &self.config.person_1,
person_2: &self.config.person_2,
}),
};