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
This commit is contained in:
52 files changed
+14050
-113
No files matched your search
@@ -0,0 +1,481 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user