//! Home: the state of the business in one sentence, the numbers behind //! it, what needs doing, and what just happened. use super::{columns, page}; use crate::app::Shell; use crate::books::{Books, Severity}; use crate::charts::{self, BarGroup}; use crate::fmt; use crate::nav::{ReportKind, Route}; use crate::theme::{self, pal}; use crate::widgets::{self, Kpi}; use egui::{RichText, Ui}; use egui_phosphor::regular as icon; use ledger_core::analysis::{Interval, pnl_by_interval}; use ledger_core::reports::profit_and_loss_for_period; use rust_decimal::Decimal; pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { page(ui, "overview", |ui| { let k = &b.kpis; widgets::page_header(ui, b.company_name(), &headline(b)); kpis(ui, b, shell); let _ = k; columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i { 0 => { let (more, _) = widgets::titled_card( ui, "Cash over time", |ui| { widgets::link( ui, RichText::new("Cash details") .font(theme::regular(12.5)) .color(pal(ui.ctx()).accent), ) .clicked() }, |ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0), ); if more { shell.go(Route::Cash); } } _ => attention(ui, b, shell), }); columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { 0 => income_vs_spending(ui, b, shell), _ => where_it_went(ui, b, shell), }); recent(ui, b, shell); }); } /// The answer, in words. fn headline(b: &Books) -> String { let k = &b.kpis; let Some(first) = k.first_entry else { return "No transactions yet. Once your agent records some, this page fills in on its own." .into(); }; let mut s = format!( "You have {} in cash and owe {}.", fmt::dollars(k.cash), fmt::dollars(k.liabilities) ); if let Some(burn) = k.monthly_burn { if burn > Decimal::ZERO && k.cash > Decimal::ZERO { let months = (k.cash / burn).round_dp(1); s.push_str(&format!( " Lately you spend {} a month more than you earn: about {} months of runway.", fmt::dollars(burn), months )); } } else if k.net_ytd > Decimal::ZERO { s.push_str(&format!( " You've earned {} more than you've spent this year.", fmt::dollars(k.net_ytd) )); } else if k.net_ytd < Decimal::ZERO { s.push_str(&format!( " You've spent {} more than you've earned this year.", fmt::dollars(-k.net_ytd) )); } if (b.today - first).num_days() < 60 { s.push_str(&format!( " Your books start {}; trends fill in as the months go by.", fmt::date(first) )); } s } fn change( now: Decimal, prev: Decimal, up_is_good: bool, what: &str, ) -> Option<(String, egui::Color32)> { let pct = fmt::pct_change(prev, now)?; let good = (pct >= 0.0) == up_is_good; let p = &theme::LIGHT; let arrow = if pct >= 0.0 { "▲" } else { "▼" }; Some(( format!("{arrow} {:.0}% vs {what}", pct.abs()), if pct.abs() < 0.5 { p.muted } else if good { p.positive } else { p.negative }, )) } fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let k = &b.kpis; let p = pal(ui.ctx()); // Last 12 months of month-end cash for the sparkline. let (start, end) = b.trailing_months(12); let start = k.first_entry.map_or(start, |f| { start.max(ledger_core::analysis::interval_start(f, Interval::Month)) }); let month_ends: Vec = ledger_core::analysis::intervals(start, end, Interval::Month) .into_iter() .map(|(_, e)| e) .collect(); let cash_spark: Vec = ledger_core::analysis::balance_series(&b.ledger, &b.cash_accounts, &month_ends) .into_iter() .map(|d| d.try_into().unwrap_or(0.0)) .collect(); let runway = match k.monthly_burn { Some(burn) if burn > Decimal::ZERO && k.cash > Decimal::ZERO => ( format!("{} mo", (k.cash / burn).round_dp(1)), Some((format!("burning {}/mo", fmt::compact(burn)), p.warning)), ), _ if k.first_entry.is_none() => ("—".to_string(), None), _ if k.first_entry.is_some_and(|f| f >= b.month_start()) => ( "Too early".to_string(), Some(("needs a full month of books".to_string(), p.muted)), ), _ => ( "Not burning".to_string(), Some(("earning ≥ spending".to_string(), p.positive)), ), }; let ytd = b.fiscal_ytd(); let tiles: Vec<(Kpi, Route)> = vec![ ( Kpi { label: "Cash on hand", value: fmt::dollars(k.cash), sub: change(k.cash, k.cash_30d_ago, true, "30 days ago") .map(|(t, c)| (t, color_for(c, p))), spark: Some(&cash_spark), how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.", }, Route::Cash, ), ( Kpi { label: "Owed by the business", value: fmt::dollars(k.liabilities), sub: Some(("bills, cards, owners, taxes".into(), p.muted)), spark: None, how: "Total liabilities on today's balance sheet.", }, Route::Reports(ReportKind::BalanceSheet), ), ( Kpi { label: "Spending this month", value: fmt::dollars(k.expenses_mtd), sub: change( k.expenses_mtd, k.expenses_prev_month, false, "same days last month", ) .map(|(t, c)| (t, color_for(c, p))), spark: None, how: "Expenses dated this month (accrual basis: when incurred, not when paid).", }, Route::Profit, ), ( Kpi { label: "Revenue this month", value: fmt::dollars(k.revenue_mtd), sub: change( k.revenue_mtd, k.revenue_prev_month, true, "same days last month", ) .map(|(t, c)| (t, color_for(c, p))), spark: None, how: "Revenue dated this month.", }, Route::Profit, ), ( Kpi { label: "Net income, year to date", value: fmt::dollars(k.net_ytd), sub: Some((format!("since {}", fmt::date_short(ytd.0)), p.muted)), spark: None, how: "Revenue minus expenses for the fiscal year so far.", }, Route::Reports(ReportKind::ProfitAndLoss), ), ( Kpi { label: "Runway", value: runway.0, sub: runway.1, spark: None, how: "Cash on hand divided by the average monthly net loss over the last three full months the books cover.", }, Route::Cash, ), ]; let n = if ui.available_width() > 1180.0 { 6 } else { 3 }; for row in tiles.chunks(n) { let weights = vec![1.0; n]; columns(ui, 0.0, &weights, |i, ui| { if let Some((tile, route)) = row.get(i) { let r = widgets::kpi( ui, Kpi { label: tile.label, value: tile.value.clone(), sub: tile.sub.clone(), spark: tile.spark, how: tile.how, }, ); if r.interact(egui::Sense::click()).clicked() { shell.go(route.clone()); } } }); } } /// `change` picks from the light palette; map to the current one. fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 { let l = &theme::LIGHT; if c == l.positive { p.positive } else if c == l.negative { p.negative } else { p.muted } } fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); widgets::titled_card( ui, "Needs attention", |ui| { if !b.attention.is_empty() { widgets::pill( ui, &b.attention.len().to_string(), p.warning, p.warning_soft, ); } }, |ui| { if b.attention.is_empty() { widgets::empty( ui, icon::CHECK_CIRCLE, "All clear", "Nothing is missing or overdue.", ); return; } egui::ScrollArea::vertical() .id_salt("attention") .max_height(260.0) .show(ui, |ui| { for (i, a) in b.attention.iter().enumerate() { if i > 0 { ui.add_space(4.0); widgets::rule(ui); ui.add_space(4.0); } let (glyph, color) = match a.severity { Severity::Problem => (icon::WARNING_CIRCLE, p.negative), Severity::Todo => (icon::CIRCLE_DASHED, p.warning), Severity::Info => (icon::INFO, p.muted), }; ui.horizontal_top(|ui| { ui.label(RichText::new(glyph).size(16.0).color(color)); ui.vertical(|ui| { let t = RichText::new(&a.title).font(theme::semibold(13.0)); let r = widgets::link(ui, t); if r.clicked() && let Some(to) = &a.target { shell.go(to.clone()); } ui.label( RichText::new(&a.detail) .font(theme::regular(12.0)) .color(p.muted), ); widgets::copy_prompt(ui, &a.prompt); }); }); } }); }, ); } fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let (start, end) = b.trailing_months(12); let start = b.kpis.first_entry.map_or(start, |f| { start.max(ledger_core::analysis::interval_start(f, Interval::Month)) }); let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default(); widgets::titled_card( ui, "Income and spending by month", |_| {}, |ui| { if months.is_empty() { widgets::empty(ui, icon::CHART_BAR, "No months yet", ""); return; } let groups: Vec = months .iter() .map(|m| BarGroup { label: m.start.format("%b").to_string(), values: vec![m.revenue, m.expenses, m.net()], }) .collect(); let clicked = charts::bar_chart( ui, &groups, &[ ("Income", p.series[1]), ("Spending", p.series[2]), ("Net", p.series[0]), ], 220.0, ); if let Some(i) = clicked { shell.state.reports.range = crate::pages::reports::Range::Custom(months[i].start, months[i].end); shell.go(Route::Reports(ReportKind::ProfitAndLoss)); } }, ); } fn where_it_went(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let (start, end) = b.fiscal_ytd(); widgets::titled_card( ui, "Where the money went, this year", |ui| { ui.label( RichText::new(format!( "{} – {}", fmt::date_short(start), fmt::date_short(end) )) .font(theme::regular(12.0)) .color(p.muted), ); }, |ui| { let Ok(pl) = profit_and_loss_for_period(&b.ledger, start, end, None) else { return; }; let mut rows: Vec<(String, Decimal, String)> = pl .expenses .iter() .map(|l| (l.name.clone(), l.amount, l.code.clone())) .collect(); rows.sort_by_key(|r| std::cmp::Reverse(r.1)); rows.truncate(8); if rows.is_empty() { widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No spending yet", ""); return; } let bars: Vec<(String, Decimal)> = rows.iter().map(|r| (r.0.clone(), r.1)).collect(); if let Some(i) = charts::hbars(ui, &bars, p.series[2]) && let Some(a) = b.ledger.chart().find_by_code(&rows[i].2) { shell.go(Route::Account(a.id)); } }, ); } fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let since_update = shell.since_update; let mut go = None; let (full, _) = widgets::titled_card( ui, "Recent activity", |ui| { widgets::link( ui, RichText::new("Full journal") .font(theme::regular(12.5)) .color(p.accent), ) .clicked() }, |ui| { let mut shown = 0; for (i, line) in b.journal.iter().enumerate().rev() { let Some(ev) = super::journal::describe(b, line) else { continue; }; if shown == 12 { break; } shown += 1; let fresh = line.seq > b.fresh_from_seq && since_update < 8.0; let row = egui::Frame::new() .fill(if fresh { p.new_highlight } else { egui::Color32::TRANSPARENT }) .corner_radius(egui::CornerRadius::same(6)) .inner_margin(egui::Margin::symmetric(6, 3)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { ui.label(RichText::new(ev.icon).size(15.0).color(p.muted)); let r = widgets::link( ui, RichText::new(&ev.text).font(theme::regular(13.0)), ); if r.clicked() { go = Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq))); } ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( RichText::new(fmt::timestamp(&line.ts)) .font(theme::regular(11.5)) .color(p.faint), ); }, ); }); }); let _ = (row, i); } if shown == 0 { widgets::empty(ui, icon::CLOCK, "Nothing yet", ""); } }, ); if full { go = Some(Route::Journal); } if let Some(r) = go { shell.go(r); } }