egui/wgpu app for owners and their accountant: Overview, Cash, Profit, Transactions (with rendered receipts and journal history), Reports (statements with interval columns, comparisons, drill-down), Accounts, Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark. Follows the journal live (~30 ms from write to screen). --demo opens a deterministic demo company, also used for screenshot tests. Engine: ledger-store Snapshot (read-only, incremental, tolerant of a half-written tail); ledger-core analysis (intervals, balance series, P&L by interval, cash flows); ledger-config extracted from ledger-mcp with the full company profile and cash_accounts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
482 lines
17 KiB
Rust
482 lines
17 KiB
Rust
//! 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<chrono::NaiveDate> =
|
||
ledger_core::analysis::intervals(start, end, Interval::Month)
|
||
.into_iter()
|
||
.map(|(_, e)| e)
|
||
.collect();
|
||
let cash_spark: Vec<f64> =
|
||
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<BarGroup> = 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);
|
||
}
|
||
}
|