rex_app/views/
summary_view.rs

1use anyhow::Result;
2use chrono::{Datelike, NaiveDate};
3use rex_db::ConnCache;
4use rex_db::models::{FetchNature, FullTx, TxType};
5use rex_shared::models::{Cent, Dollar};
6use std::collections::HashMap;
7
8use crate::utils::{get_percentages, month_year_to_unique};
9use crate::views::{
10    LargestMomvement, LargestType, PeakMonthlyMovement, PeakType, SummaryLargest, SummaryMethods,
11    SummaryNet, SummaryPeak,
12};
13
14/// Contains `FullTx` to generate summary data. Will always contain the exact number of txs from
15/// the month and year (or all txs) the summary was generated with
16pub struct SummaryView {
17    txs: Vec<FullTx>,
18    nature: FetchNature,
19}
20
21pub struct FullSummary {
22    methods: Vec<SummaryMethods>,
23    largest: Vec<SummaryLargest>,
24    peak: Vec<SummaryPeak>,
25    net: SummaryNet,
26}
27
28impl FullSummary {
29    #[must_use]
30    pub fn net_array(&self) -> Vec<Vec<String>> {
31        self.net.array()
32    }
33
34    pub fn peak_array(&self) -> Vec<Vec<String>> {
35        self.peak.iter().map(SummaryPeak::array).collect()
36    }
37
38    pub fn method_array(&self) -> Vec<Vec<String>> {
39        self.methods.iter().map(SummaryMethods::array).collect()
40    }
41
42    pub fn largest_array(&self) -> Vec<Vec<String>> {
43        self.largest.iter().map(SummaryLargest::array).collect()
44    }
45}
46
47type CacheTxs = HashMap<i32, Vec<FullTx>>;
48
49pub(crate) fn get_summary(
50    date: NaiveDate,
51    nature: FetchNature,
52    conn: &mut impl ConnCache,
53) -> Result<(SummaryView, Option<CacheTxs>)> {
54    let txs = FullTx::get_txs(date, nature, conn)?;
55
56    let mut create_map = false;
57    if let FetchNature::All = nature {
58        create_map = true;
59    }
60
61    if create_map {
62        let mut map = HashMap::with_capacity(txs.len());
63
64        for tx in &txs {
65            let unique_value = month_year_to_unique(date.month() as i32, date.year());
66
67            map.entry(unique_value)
68                .or_insert_with(Vec::new)
69                .push(tx.clone());
70        }
71
72        let summary_view = SummaryView { txs, nature };
73
74        return Ok((summary_view, Some(map)));
75    }
76
77    let summary_view = SummaryView { txs, nature };
78
79    Ok((summary_view, None))
80}
81
82impl SummaryView {
83    pub fn tags_array(
84        &self,
85        compare: Option<&SummaryView>,
86        conn: &impl ConnCache,
87    ) -> Vec<Vec<String>> {
88        let mut income_tags = HashMap::new();
89        let mut expense_tags = HashMap::new();
90
91        let mut total_income = Cent::new(0);
92        let mut total_expense = Cent::new(0);
93
94        let mut no_mom_yoy = false;
95
96        if let FetchNature::All = self.nature {
97            no_mom_yoy = true;
98        }
99
100        let mut compare_income_tags = HashMap::new();
101        let mut compare_expense_tags = HashMap::new();
102
103        if !no_mom_yoy && let Some(compare) = compare {
104            let (income_map, expense_map) = compare.get_tags_movement_map();
105            compare_income_tags = income_map;
106            compare_expense_tags = expense_map;
107        }
108
109        for tx in &self.txs {
110            for tag in &tx.tags {
111                match tx.tx_type {
112                    TxType::Income => {
113                        total_income += tx.amount;
114
115                        let value = income_tags.entry(tag.name.clone()).or_insert(Cent::new(0));
116                        *value += tx.amount;
117                    }
118                    TxType::Expense => {
119                        total_expense += tx.amount;
120
121                        let value = expense_tags.entry(tag.name.clone()).or_insert(Cent::new(0));
122                        *value += tx.amount;
123                    }
124                    TxType::Transfer => {}
125                }
126            }
127        }
128
129        let mut to_return = Vec::new();
130
131        for tag in conn.cache().tags.values() {
132            let mut no_push = true;
133
134            let mut to_push = vec![tag.name.clone()];
135
136            let mut income_percentage = 0.0;
137            let mut expense_percentage = 0.0;
138
139            let mut income_amount = Dollar::new(0.0);
140            let mut expense_amount = Dollar::new(0.0);
141
142            if let Some(income) = income_tags.get(&tag.name) {
143                income_percentage = (income.value() as f64 / total_income.value() as f64) * 100.0;
144                income_amount = income.dollar();
145
146                no_push = false;
147            }
148
149            if let Some(expense) = expense_tags.get(&tag.name) {
150                expense_percentage =
151                    (expense.value() as f64 / total_expense.value() as f64) * 100.0;
152                expense_amount = expense.dollar();
153
154                no_push = false;
155            }
156
157            if no_push {
158                continue;
159            }
160
161            to_push.push(format!("{income_amount:.2}"));
162            to_push.push(format!("{expense_amount:.2}"));
163
164            to_push.push(format!("{income_percentage:.2}"));
165            to_push.push(format!("{expense_percentage:.2}"));
166
167            if !no_mom_yoy && compare.is_some() {
168                let compare_income = compare_income_tags.get(&tag.name);
169
170                let compare_expense = compare_expense_tags.get(&tag.name);
171
172                if let Some(last_income) = compare_income {
173                    let last_earning = last_income.dollar();
174
175                    let earning_increased_percentage =
176                        ((income_amount - last_earning) / last_earning) * 100.0;
177
178                    if last_earning == 0.0 {
179                        to_push.push("∞".to_string());
180                    } else if earning_increased_percentage < 0.0 {
181                        to_push.push(format!("↓{:.2}", earning_increased_percentage.abs()));
182                    } else {
183                        to_push.push(format!("↑{earning_increased_percentage:.2}"));
184                    }
185                } else {
186                    to_push.push("∞".to_string());
187                }
188
189                if let Some(last_expense) = compare_expense {
190                    let last_expense = last_expense.dollar();
191                    let expense_increased_percentage =
192                        ((expense_amount - last_expense) / last_expense) * 100.0;
193
194                    if last_expense == 0.0 {
195                        to_push.push("∞".to_string());
196                    } else if expense_increased_percentage < 0.0 {
197                        to_push.push(format!("↓{:.2}", expense_increased_percentage.abs()));
198                    } else {
199                        to_push.push(format!("↑{expense_increased_percentage:.2}"));
200                    }
201                } else {
202                    to_push.push("∞".to_string());
203                }
204            }
205
206            to_return.push(to_push);
207        }
208
209        to_return
210    }
211
212    fn get_tags_movement_map(&self) -> (HashMap<String, Cent>, HashMap<String, Cent>) {
213        let mut income_tags = HashMap::new();
214        let mut expense_tags = HashMap::new();
215
216        for tx in &self.txs {
217            for tag in &tx.tags {
218                match tx.tx_type {
219                    TxType::Income => {
220                        let value = income_tags.entry(tag.name.clone()).or_insert(Cent::new(0));
221                        *value += tx.amount;
222                    }
223                    TxType::Expense => {
224                        let value = expense_tags.entry(tag.name.clone()).or_insert(Cent::new(0));
225                        *value += tx.amount;
226                    }
227                    TxType::Transfer => {}
228                }
229            }
230        }
231
232        (income_tags, expense_tags)
233    }
234
235    #[allow(private_interfaces)]
236    pub fn generate_summary(
237        &self,
238        last_summary: Option<&FullSummary>,
239        conn: &impl ConnCache,
240    ) -> FullSummary {
241        let mut no_mom_yoy = false;
242
243        if let FetchNature::All = self.nature {
244            no_mom_yoy = true;
245        }
246
247        let mut total_income = Cent::new(0);
248        let mut total_expense = Cent::new(0);
249
250        let mut total_month_checked = 0;
251
252        let mut biggest_earning = LargestMomvement::default();
253        let mut biggest_expense = LargestMomvement::default();
254
255        let mut method_earning = HashMap::new();
256        let mut method_expense = HashMap::new();
257
258        for method in conn.cache().get_methods() {
259            method_earning.insert(method.name.to_string(), Cent::new(0));
260            method_expense.insert(method.name.to_string(), Cent::new(0));
261        }
262
263        let mut ongoing_month = 0;
264
265        let mut ongoing_date = NaiveDate::default();
266
267        let mut peak_earning = PeakMonthlyMovement::default();
268        let mut peak_expense = PeakMonthlyMovement::default();
269
270        let mut last_peak_earning = PeakMonthlyMovement::default();
271        let mut last_peak_expense = PeakMonthlyMovement::default();
272
273        for tx in &self.txs {
274            ongoing_date = tx.date.date();
275
276            let time_unique = month_year_to_unique(tx.date.month() as i32, tx.date.year());
277
278            if ongoing_month == 0 {
279                ongoing_month = time_unique;
280            }
281
282            if time_unique != ongoing_month {
283                ongoing_month = time_unique;
284                total_month_checked += 1;
285
286                if last_peak_earning.amount > peak_earning.amount {
287                    peak_earning = last_peak_earning;
288                    last_peak_earning = PeakMonthlyMovement::new(tx.date.date());
289                }
290
291                if last_peak_expense.amount > peak_expense.amount {
292                    peak_expense = last_peak_expense;
293                    last_peak_expense = PeakMonthlyMovement::new(tx.date.date());
294                }
295            }
296
297            match tx.tx_type {
298                TxType::Income => {
299                    total_income += tx.amount;
300                    let amount = method_earning
301                        .entry(tx.from_method.name.clone())
302                        .or_insert(Cent::new(0));
303
304                    *amount += tx.amount;
305
306                    if biggest_earning.amount < tx.amount {
307                        biggest_earning.amount = tx.amount;
308                        biggest_earning.date = tx.date.date();
309                        biggest_earning.method.clone_from(&tx.from_method.name);
310                    }
311
312                    last_peak_earning.amount += tx.amount;
313                }
314                TxType::Expense => {
315                    total_expense += tx.amount;
316                    let amount = method_expense
317                        .entry(tx.from_method.name.clone())
318                        .or_insert(Cent::new(0));
319
320                    *amount += tx.amount;
321
322                    if biggest_expense.amount < tx.amount {
323                        biggest_expense.amount = tx.amount;
324                        biggest_expense.date = tx.date.date();
325                        biggest_expense.method.clone_from(&tx.from_method.name);
326                    }
327
328                    last_peak_expense.amount += tx.amount;
329                }
330                TxType::Transfer => {}
331            }
332        }
333
334        total_month_checked += 1;
335
336        if last_peak_earning.amount > peak_earning.amount {
337            peak_earning = last_peak_earning;
338            peak_earning.date = ongoing_date;
339        }
340
341        if last_peak_expense.amount > peak_expense.amount {
342            peak_expense = last_peak_expense;
343            peak_expense.date = ongoing_date;
344        }
345
346        let (income_percentage, expense_percentage) =
347            get_percentages(total_income.value() as f64, total_expense.value() as f64);
348
349        let mut average_income = if total_income == 0 {
350            Some(Dollar::new(0.0))
351        } else {
352            Some(total_income.dollar() / f64::from(total_month_checked))
353        };
354
355        let mut average_expense = if total_income == 0 {
356            Some(Dollar::new(0.0))
357        } else {
358            Some(total_expense.dollar() / f64::from(total_month_checked))
359        };
360
361        let mut method_data = Vec::new();
362
363        for (index, method) in conn.cache().get_methods().iter().enumerate() {
364            // For % calculations, it's safe to directly cast to f64 before calculations
365
366            let earning_percentage = if method_earning[&method.name].value() == 0 {
367                0.0
368            } else {
369                (method_earning[&method.name].value() as f64 / total_income.value() as f64) * 100.0
370            };
371
372            let expense_percentage = if method_expense[&method.name] == 0 {
373                0.0
374            } else {
375                (method_expense[&method.name].value() as f64 / total_expense.value() as f64) * 100.0
376            };
377
378            let mut average_earning = if method_earning[&method.name] == 0 {
379                Some(Dollar::new(0.0))
380            } else {
381                Some(method_earning[&method.name].dollar() / f64::from(total_month_checked))
382            };
383
384            let mut average_expense = if method_expense[&method.name] == 0 {
385                Some(Dollar::new(0.0))
386            } else {
387                Some(method_expense[&method.name].dollar() / f64::from(total_month_checked))
388            };
389
390            let mut mom_yoy_earning = None;
391            let mut mom_yoy_expense = None;
392
393            if let FetchNature::Monthly = self.nature {
394                average_earning = None;
395                average_expense = None;
396            }
397
398            if let Some(last_summary) = last_summary
399                && !no_mom_yoy
400            {
401                let comparison = &last_summary.methods;
402
403                let last_earning = comparison[index].total_earning;
404                let last_expense = comparison[index].total_expense;
405
406                let current_earning = method_earning[&method.name].dollar();
407                let current_expense = method_expense[&method.name].dollar();
408
409                let earning_increased_percentage =
410                    ((current_earning - last_earning) / last_earning) * 100.0;
411
412                if last_earning == 0.0 {
413                    mom_yoy_earning = Some("∞".to_string());
414                } else if earning_increased_percentage < 0.0 {
415                    mom_yoy_earning = Some(format!("↓{:.2}", earning_increased_percentage.abs()));
416                } else {
417                    mom_yoy_earning = Some(format!("↑{earning_increased_percentage:.2}"));
418                }
419
420                let expense_increased_percentage =
421                    ((current_expense - last_expense) / last_expense) * 100.0;
422
423                if last_expense == 0.0 {
424                    mom_yoy_expense = Some("∞".to_string());
425                } else if expense_increased_percentage < 0.0 {
426                    mom_yoy_expense = Some(format!("↓{:.2}", expense_increased_percentage.abs()));
427                } else {
428                    mom_yoy_expense = Some(format!("↑{expense_increased_percentage:.2}"));
429                }
430            }
431
432            if !no_mom_yoy && mom_yoy_expense.is_none() && mom_yoy_earning.is_none() {
433                mom_yoy_earning = Some("∞".to_string());
434                mom_yoy_expense = Some("∞".to_string());
435            }
436
437            let method_summary = SummaryMethods::new(
438                method.name.to_string(),
439                method_earning[&method.name].dollar(),
440                method_expense[&method.name].dollar(),
441                earning_percentage,
442                expense_percentage,
443                average_earning,
444                average_expense,
445                mom_yoy_earning,
446                mom_yoy_expense,
447            );
448
449            method_data.push(method_summary);
450        }
451
452        if let FetchNature::Monthly = self.nature {
453            average_income = None;
454            average_expense = None;
455        }
456
457        let mut net_mom_yoy_earning = None;
458        let mut net_mom_yoy_expense = None;
459
460        if let Some(last_summary) = last_summary
461            && !no_mom_yoy
462        {
463            let comparison = &last_summary.net;
464
465            let last_earning = comparison.total_income;
466            let last_expense = comparison.total_expense;
467
468            let earning_increased_percentage =
469                ((total_income.dollar() - last_earning) / last_earning) * 100.0;
470
471            if last_earning == 0.0 {
472                net_mom_yoy_earning = Some("∞".to_string());
473            } else if earning_increased_percentage < 0.0 {
474                net_mom_yoy_earning = Some(format!("↓{:.2}", earning_increased_percentage.abs()));
475            } else {
476                net_mom_yoy_earning = Some(format!("↑{earning_increased_percentage:.2}"));
477            }
478
479            let expense_increased_percentage =
480                ((total_expense.dollar() - last_expense) / last_expense) * 100.0;
481
482            if last_expense == 0.0 {
483                net_mom_yoy_expense = Some("∞".to_string());
484            } else if expense_increased_percentage < 0.0 {
485                net_mom_yoy_expense = Some(format!("↓{:.2}", expense_increased_percentage.abs()));
486            } else {
487                net_mom_yoy_expense = Some(format!("↑{expense_increased_percentage:.2}"));
488            }
489        }
490
491        if !no_mom_yoy && net_mom_yoy_expense.is_none() && net_mom_yoy_earning.is_none() {
492            net_mom_yoy_earning = Some("∞".to_string());
493            net_mom_yoy_expense = Some("∞".to_string());
494        }
495
496        let summary_net = SummaryNet::new(
497            total_income.dollar(),
498            total_expense.dollar(),
499            average_income,
500            average_expense,
501            income_percentage,
502            expense_percentage,
503            net_mom_yoy_earning,
504            net_mom_yoy_expense,
505        );
506
507        let summary_largest = vec![
508            SummaryLargest::new(
509                LargestType::Earning,
510                biggest_earning.method,
511                biggest_earning.amount.dollar(),
512                biggest_earning.date,
513            ),
514            SummaryLargest::new(
515                LargestType::Expense,
516                biggest_expense.method,
517                biggest_expense.amount.dollar(),
518                biggest_expense.date,
519            ),
520        ];
521
522        let summary_peak = vec![
523            SummaryPeak::new(
524                PeakType::Earning,
525                peak_earning.amount.dollar(),
526                peak_earning.date,
527            ),
528            SummaryPeak::new(
529                PeakType::Expense,
530                peak_expense.amount.dollar(),
531                peak_expense.date,
532            ),
533        ];
534
535        FullSummary {
536            methods: method_data,
537            net: summary_net,
538            largest: summary_largest,
539            peak: summary_peak,
540        }
541    }
542}