Files
wanderbooks/crates/ledger-gui/src/pages/overview.rs
T
swaitsandClaude Opus 5.5 ae927c3f73
check / check (push) Canceled after 0s
Attachments, statements, reconciliations, TIN vault, counterparty policy
MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 15:42:37 -06:00

561 lines
20 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;
// What needs doing gets the page's full width, under the numbers:
// long items wrap in their own room instead of squeezing the chart.
attention(ui, b, shell);
ui.add_space(16.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);
}
ui.add_space(16.0);
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,
),
];
super::grid(ui, tiles.len(), 150.0, 6, |i, ui| {
let (tile, route) = &tiles[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
}
}
/// "Needs attention", full width: a summary of how many problems, to-dos,
/// and notes there are, then each item as a row (its severity, the title
/// that opens where to look, what's wrong, and the prompt for the agent at
/// the right; stacked under the text when narrow). The first few show;
/// the rest wait behind a button that says how many.
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let count = |s: Severity| b.attention.iter().filter(|a| a.severity == s).count();
widgets::titled_card(
ui,
"Needs attention",
|ui| {
// Laid out right to left: problems end up first, reading on.
for (sev, one, many, fg, bg) in [
(Severity::Info, "note", "notes", p.muted, p.hover),
(Severity::Todo, "to do", "to do", p.warning, p.warning_soft),
(
Severity::Problem,
"problem",
"problems",
p.negative,
p.negative_soft,
),
] {
let n = count(sev);
if n > 0 {
widgets::pill(ui, &fmt::count(n, one, many), fg, bg);
}
}
},
|ui| {
if b.attention.is_empty() {
ui.horizontal(|ui| {
ui.label(
RichText::new(icon::CHECK_CIRCLE)
.size(18.0)
.color(p.positive),
);
ui.label(
RichText::new("All clear: nothing is missing or overdue.")
.font(theme::regular(13.5))
.color(p.muted),
);
});
return;
}
const FIRST: usize = 6;
let id = ui.id().with("attention-expanded");
let mut expanded: bool = ui.ctx().data(|d| d.get_temp(id)).unwrap_or(false);
let shown = if expanded {
b.attention.len()
} else {
b.attention.len().min(FIRST)
};
let wide = ui.available_width() >= 720.0;
for (i, a) in b.attention.iter().take(shown).enumerate() {
if i > 0 {
ui.add_space(6.0);
widgets::rule(ui);
ui.add_space(6.0);
}
let (glyph, color, word) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative, "Problem"),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning, "To do"),
Severity::Info => (icon::INFO, p.muted, "Note"),
};
let full = ui.available_width();
let button_w = if wide { 150.0 } else { 0.0 };
ui.horizontal_top(|ui| {
ui.label(RichText::new(glyph).size(18.0).color(color))
.on_hover_text(word);
let text_w = (full - 30.0 - button_w).max(120.0);
ui.allocate_ui_with_layout(
egui::vec2(text_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_width(text_w);
ui.spacing_mut().item_spacing.y = 2.0;
let t = RichText::new(&a.title).font(theme::semibold(13.5));
let r = ui.add(egui::Label::new(t).wrap().sense(egui::Sense::click()));
if a.target.is_some() && r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.add(
egui::Label::new(
RichText::new(&a.detail)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
if !wide {
ui.add_space(4.0);
widgets::copy_prompt(ui, &a.prompt);
}
},
);
if wide {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
widgets::copy_prompt(ui, &a.prompt);
});
}
});
}
let hidden = b.attention.len() - shown;
if hidden > 0 || expanded && b.attention.len() > FIRST {
ui.add_space(8.0);
let label = if hidden > 0 {
format!("{} Show {} more", icon::CARET_DOWN, hidden)
} else {
format!("{} Show fewer", icon::CARET_UP)
};
if ui
.add(
egui::Button::new(
RichText::new(label)
.font(theme::semibold(12.5))
.color(p.accent),
)
.frame(false),
)
.clicked()
{
expanded = !expanded;
ui.ctx().data_mut(|d| d.insert_temp(id, expanded));
}
}
},
);
}
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;
// One painted row: icon, the event (cut with an ellipsis
// before the timestamp column), the timestamp.
let (r, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 30.0),
egui::Sense::click(),
);
if fresh {
ui.painter()
.rect_filled(r, egui::CornerRadius::same(6), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(r, egui::CornerRadius::same(6), p.hover);
}
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let painter = ui.painter_at(r);
let y = r.center().y;
painter.text(
egui::pos2(r.left() + 8.0, y),
egui::Align2::LEFT_CENTER,
ev.icon,
egui::FontId::proportional(15.0),
p.muted,
);
let ts =
painter.layout_no_wrap(fmt::timestamp(&line.ts), theme::regular(11.5), p.faint);
let ts_w = ts.size().x;
painter.galley(
egui::pos2(r.right() - 8.0 - ts_w, y - ts.size().y / 2.0),
ts,
p.faint,
);
widgets::clipped_text(
&painter,
egui::pos2(r.left() + 32.0, y),
&ev.text,
theme::regular(13.0),
p.text,
r.width() - 32.0 - ts_w - 28.0,
);
if resp.clicked() {
go = Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
}
let _ = 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);
}
}