Files
wanderbooks/crates/ledger-gui/src/pages/overview.rs
T
swaitsandClaude Opus 5.5 105fd52694 feat: ledger-gui — a read-only, live desktop view of the books
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
2026-10-12 16:20:14 -07:00

482 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}