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

+158
View File
@@ -0,0 +1,158 @@
//! The live loader. A background thread owns the read-only [`Snapshot`],
//! watches the data directory, and on every change applies the new journal
//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint.
//! A change made by the agent in another window shows up within a frame or
//! two. Filesystem events are the trigger; a slow poll backs them up in case
//! an event is missed (network drives, odd editors).
use crate::books::Books;
use chrono::NaiveDate;
use ledger_config::BooksSettings;
use ledger_store::Snapshot;
use notify::{RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::{Duration, SystemTime};
/// Where the books come from.
#[derive(Clone, Debug)]
pub struct Source {
/// The `books-data` directory (journal and blobs).
pub data_dir: PathBuf,
/// The company's `wanderbooks.toml`, if any.
pub books_toml: Option<PathBuf>,
/// Fix "today" (tests, screenshots); otherwise the local date.
pub today: Option<NaiveDate>,
}
pub enum Update {
Books(Arc<Books>),
Error(String),
}
pub struct Loader {
rx: Receiver<Update>,
}
impl Loader {
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
let (tx, rx) = channel();
std::thread::Builder::new()
.name("books-loader".into())
.spawn(move || run(source, ctx, tx))
.expect("spawn loader");
Loader { rx }
}
/// The newest update, if any arrived since the last call.
pub fn poll(&self) -> Option<Update> {
let mut last = None;
while let Ok(u) = self.rx.try_recv() {
last = Some(u);
}
last
}
/// Block until the first update (tests, headless).
pub fn wait(&self, timeout: Duration) -> Option<Update> {
self.rx.recv_timeout(timeout).ok()
}
}
/// Load once, synchronously (tests and one-shot tools).
pub fn load_once(source: &Source) -> Result<Books, String> {
let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?;
Books::build(&snap, settings(source)?, today(source), None)
}
fn today(source: &Source) -> NaiveDate {
source
.today
.unwrap_or_else(|| chrono::Local::now().date_naive())
}
fn settings(source: &Source) -> Result<BooksSettings, String> {
match &source.books_toml {
Some(p) if p.exists() => ledger_config::load(p),
_ => Ok(BooksSettings::default()),
}
}
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
p.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
}
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
let send = |u: Update| {
let _ = tx.send(u);
ctx.request_repaint();
};
let mut snap = loop {
match Snapshot::load(&source.data_dir) {
Ok(s) => break s,
Err(e) => {
send(Update::Error(format!(
"Can't read the books in {}: {e}",
source.data_dir.display()
)));
std::thread::sleep(Duration::from_secs(2));
}
}
};
let build = |snap: &Snapshot, prev: Option<u64>| -> Update {
match settings(&source).and_then(|s| Books::build(snap, s, today(&source), prev)) {
Ok(b) => Update::Books(Arc::new(b)),
Err(e) => Update::Error(e),
}
};
send(build(&snap, None));
let (ev_tx, ev_rx) = channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = ev_tx.send(());
}
})
.ok();
if let Some(w) = watcher.as_mut() {
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
if let Some(t) = &source.books_toml
&& let Some(dir) = t.parent()
{
let _ = w.watch(dir, RecursiveMode::NonRecursive);
}
}
let mut toml_mtime = mtime(&source.books_toml);
let mut day = today(&source);
loop {
match ev_rx.recv_timeout(Duration::from_secs(3)) {
Ok(()) => {
// Let a burst of writes settle, then take them all at once.
std::thread::sleep(Duration::from_millis(30));
while ev_rx.try_recv().is_ok() {}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
std::thread::sleep(Duration::from_secs(3));
}
}
let before = snap.journal_seq();
let grew = match snap.refresh() {
Ok(n) => n > 0,
Err(e) => {
send(Update::Error(format!("Reading the journal failed: {e}")));
continue;
}
};
let toml_now = mtime(&source.books_toml);
let new_day = today(&source);
if grew || toml_now != toml_mtime || new_day != day {
toml_mtime = toml_now;
day = new_day;
send(build(&snap, grew.then_some(before)));
}
}
}