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:
swaitsandClaude Opus 5.5 committed 2026-10-12 16:20:14 -07:00
1 parent b48addd0e0
commit 105fd52694
52 files changed
+14050 -113

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
//! Live updates: a change written to the books (by the MCP server, in
//! practice) reaches the GUI's view model on its own, without a restart,
//! and the changed entry is marked fresh.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::data::{Loader, Source, Update};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::time::{Duration, Instant};
#[test]
fn a_change_in_another_process_shows_up_live() {
let dir = std::env::temp_dir().join(format!("ledger-gui-live-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let data = dir.join("books-data");
let mut store = Store::create(&data, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
drop(store);
let ctx = egui::Context::default();
let loader = Loader::spawn(
Source {
data_dir: data.clone(),
books_toml: None,
today: NaiveDate::from_ymd_opt(2026, 10, 12),
},
ctx,
);
let first = match loader.wait(Duration::from_secs(10)) {
Some(Update::Books(b)) => b,
_ => panic!("no initial load"),
};
assert_eq!(first.kpis.cash, Decimal::ZERO);
// The writer (the MCP server's store) posts an entry.
let started = Instant::now();
let mut store = Store::open(&data).unwrap();
let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let e = store.create_entry(d, "Owner contribution").unwrap();
let m = Money::new(Decimal::from(2500), Currency::USD);
store.add_line(e, cash, Side::Debit, m).unwrap();
store.add_line(e, equity, Side::Credit, m).unwrap();
store.post_entry(e).unwrap();
drop(store);
// The GUI sees it, without being asked.
let deadline = Instant::now() + Duration::from_secs(10);
let mut seen = None;
while Instant::now() < deadline {
if let Some(Update::Books(b)) = loader.wait(Duration::from_millis(200))
&& b.kpis.cash == Decimal::from(2500)
{
seen = Some(b);
break;
}
}
let b = seen.expect("the posted entry never reached the GUI");
assert!(b.fresh.contains(&e), "the new entry is highlighted as new");
assert!(b.seq() > first.seq());
eprintln!("live update arrived in {:?}", started.elapsed());
let _ = std::fs::remove_dir_all(&dir);
}
+88
View File
@@ -0,0 +1,88 @@
//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir
//! (containing `books-data/` and `wanderbooks.toml`) and `SHOT_DIR` to
//! where the PNGs go; without them this test does nothing.
use egui_kittest::Harness;
use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::{App, Source, data};
use std::path::PathBuf;
#[test]
fn screenshots() {
let Ok(out) = std::env::var("SHOT_DIR") else {
return;
};
let fixed_today: Option<chrono::NaiveDate> = std::env::var("SHOT_TODAY")
.ok()
.and_then(|d| d.parse().ok());
let books = match std::env::var("SHOT_BOOKS") {
Ok(b) => PathBuf::from(b),
Err(_) => {
let d = fixed_today
.unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap());
let dir =
std::env::temp_dir().join(format!("ledger-gui-shots-demo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
ledger_gui::demo::build(&dir, d).unwrap();
dir
}
};
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
let source = Source {
data_dir: books.join("books-data"),
books_toml: Some(books.join("wanderbooks.toml")),
today: fixed_today.or_else(|| {
std::env::var("SHOT_BOOKS")
.is_err()
.then(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap())
}),
};
let only = std::env::var("SHOT_ONLY").ok();
let dark = std::env::var("SHOT_DARK").is_ok();
let first_entry = data::load_once(&source)
.unwrap()
.entries
.last()
.map(|e| e.id)
.unwrap_or(1);
let routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview),
("transactions", Route::Transactions),
(
"entry",
Route::Entry(
std::env::var("SHOT_ENTRY")
.ok()
.and_then(|e| e.parse().ok())
.unwrap_or(first_entry),
),
),
("cash", Route::Cash),
("profit", Route::Profit),
("pnl", Route::Reports(ReportKind::ProfitAndLoss)),
("balance", Route::Reports(ReportKind::BalanceSheet)),
("accounts", Route::Accounts),
("documents", Route::Documents),
("company", Route::Company),
("journal", Route::Journal),
];
for (name, route) in routes {
if only
.as_deref()
.is_some_and(|o| !o.split(',').any(|x| x == name))
{
continue;
}
let books = data::load_once(&source).unwrap();
let mut harness = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
harness.run_steps(4);
let ctx = harness.ctx.clone();
harness.state_mut().settle(&ctx);
harness.run_steps(4);
let img = harness.render().unwrap();
img.save(out.join(format!("{name}.png"))).unwrap();
}
}