check / check (push) Canceled after 0s
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
561 lines
20 KiB
Rust
561 lines
20 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;
|
||
// 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);
|
||
}
|
||
}
|