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

+482
View File
@@ -0,0 +1,482 @@
//! The journal: every event, in order, as recorded. The audit trail an
//! accountant wants and the raw truth everything else is built from.
use super::page;
use crate::app::Shell;
use crate::books::{Books, entry_refs};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::JournalLine;
use serde_json::Value;
#[derive(Default)]
pub struct State {
pub query: String,
pub hide_steps: bool,
}
pub struct Event {
pub icon: &'static str,
pub text: String,
pub route: Option<Route>,
}
fn s<'a>(p: &'a Value, k: &str) -> &'a str {
p.get(k).and_then(|v| v.as_str()).unwrap_or("")
}
fn amount(p: &Value) -> String {
p.get("amount")
.and_then(|v| v.as_str())
.and_then(|a| a.parse().ok())
.map(fmt::dollars)
.unwrap_or_default()
}
fn entry_text(b: &Books, id: u64) -> String {
b.entry(id)
.map(|e| format!("#{} {} ({})", e.id, e.description, fmt::dollars(e.amount)))
.unwrap_or_else(|| format!("#{id}"))
}
fn attachment_name(b: &Books, id: u64) -> String {
b.attachments
.iter()
.find(|a| a.id == id)
.map(|a| a.file_name.clone())
.unwrap_or_else(|| format!("attachment {id}"))
}
/// A sentence for a meaningful event; `None` for the intermediate steps of
/// building an entry (created, line added), which the posted event covers.
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
let p = &line.payload;
let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
let entry_id = p.get("entry_id").and_then(|v| v.as_u64());
let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0);
let ev = |icon, text: String, route: Option<Route>| Some(Event { icon, text, route });
match line.line_type.as_str() {
"entry.created" | "entry.line_added" | "entry.line_class_set" => None,
"store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None),
"entry.posted" => ev(
icon::CHECK_CIRCLE,
format!("Posted {}", entry_text(b, id)),
Some(Route::Entry(id)),
),
"entry.reversed" => ev(
icon::ARROW_COUNTER_CLOCKWISE,
format!(
"Reversed #{} with #{id}",
p.get("reverses").and_then(|v| v.as_u64()).unwrap_or(0)
),
Some(Route::Entry(id)),
),
"receipt.attached" => ev(
icon::PAPERCLIP,
match entry_id {
Some(e) => format!("Attached {} to #{e}", s(p, "file_name")),
None => format!("Filed receipt {}", s(p, "file_name")),
},
Some(Route::Document(att)),
),
"document.filed" => ev(
icon::FILE_ARROW_UP,
format!(
"Filed {} ({})",
s(p, "file_name"),
fmt::label(s(p, "category"))
),
Some(Route::Document(att)),
),
"receipt.linked" => ev(
icon::LINK,
format!(
"Linked {} to #{}",
attachment_name(b, att),
entry_id.unwrap_or(0)
),
entry_id.map(Route::Entry),
),
"receipt.unlinked" => ev(
icon::LINK_BREAK,
format!(
"Unlinked {} from #{}: {}",
attachment_name(b, att),
entry_id.unwrap_or(0),
s(p, "reason")
),
entry_id.map(Route::Entry),
),
"attachment.withdrawn" => ev(
icon::ARCHIVE,
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
Some(Route::Document(att)),
),
"account.added" => ev(
icon::PLUS_CIRCLE,
format!("Added account {} {}", s(p, "code"), s(p, "name")),
Some(Route::Account(id)),
),
"account.tax_set" => ev(
icon::TAG,
format!(
"Tax category of {}: {}",
b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)),
p.get("tax_category")
.and_then(|v| v.as_str())
.unwrap_or("none")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
"vendor.added" => ev(
icon::STOREFRONT,
format!("Added vendor {}", s(p, "name")),
Some(Route::Company),
),
"customer.added" => ev(
icon::USER_PLUS,
format!("Added customer {}", s(p, "name")),
Some(Route::Company),
),
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
"vendor.1099_set" => ev(
icon::IDENTIFICATION_CARD,
"Updated a vendor's 1099 details".into(),
Some(Route::Company),
),
"entry.vendor_set" => ev(
icon::STOREFRONT,
format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
"invoice.created" => ev(
icon::INVOICE,
format!("Invoice {id} created"),
entry_id.map(Route::Entry),
),
"bill.created" => ev(
icon::RECEIPT,
format!("Bill {id} recorded"),
entry_id.map(Route::Entry),
),
"invoice.payment_received" => ev(
icon::HAND_COINS,
format!("Payment received, {}", amount(p)),
entry_id.map(Route::Entry),
),
"bill.paid" => ev(
icon::MONEY,
format!("Bill paid, {}", amount(p)),
entry_id.map(Route::Entry),
),
"period.created" => ev(
icon::CALENDAR_PLUS,
format!("Period {} created", s(p, "name")),
Some(Route::Company),
),
"period.closed" => ev(
icon::LOCK,
"Period closed".into(),
p.get("closing_entry")
.and_then(|v| v.as_u64())
.map(Route::Entry),
),
"asset.registered" => ev(
icon::PACKAGE,
format!("Registered asset {}", s(p, "description")),
Some(Route::Company),
),
"asset.depreciation_posted" => ev(
icon::TREND_DOWN,
"Depreciation posted".into(),
entry_id.map(Route::Entry),
),
"asset.disposed" => ev(
icon::TRASH,
"Asset disposed".into(),
entry_id.map(Route::Entry),
),
"schedule.registered" => ev(
icon::CALENDAR,
format!("Schedule: {}", s(p, "description")),
Some(Route::Company),
),
"schedule.posted" => ev(
icon::CALENDAR_CHECK,
"Amortization posted".into(),
p.get("entry")
.and_then(|e| e.get("id"))
.and_then(|v| v.as_u64())
.map(Route::Entry),
),
"recurring.registered" => ev(
icon::REPEAT,
format!("Recurring: {}", s(p, "description")),
Some(Route::Company),
),
"recurring.generated" => ev(
icon::REPEAT,
"Recurring draft created".into(),
entry_id.map(Route::Entry),
),
"fx.rate_set" => ev(
icon::CURRENCY_CIRCLE_DOLLAR,
format!("{} rate set", s(p, "currency")),
None,
),
"payroll.run" => ev(
icon::USERS,
"Payroll run".into(),
entry_id.map(Route::Entry),
),
"line.cleared" => ev(
icon::CHECK_SQUARE,
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
other => ev(
icon::DOT,
other.to_string(),
entry_refs(line).first().copied().map(Route::Entry),
),
}
}
/// Like [`describe`], but every event gets a sentence.
pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
if let Some(e) = describe(b, line) {
return e;
}
let p = &line.payload;
let id = p
.get("id")
.and_then(|v| v.as_u64())
.or_else(|| p.get("entry_id").and_then(|v| v.as_u64()))
.unwrap_or(0);
match line.line_type.as_str() {
"entry.created" => Event {
icon: icon::PENCIL_SIMPLE,
text: format!("Drafted #{id} {}", s(p, "description")),
route: Some(Route::Entry(id)),
},
"entry.line_added" => {
let amount = p
.get("amount")
.and_then(|a| a.get("amount").or(Some(a)))
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok())
.map(fmt::money)
.unwrap_or_default();
Event {
icon: icon::PLUS,
text: format!(
"Line on #{id}: {} {} {amount}",
s(p, "side"),
b.account_label(
p.get("account_id")
.or_else(|| p.get("account"))
.and_then(|v| v.as_u64())
.unwrap_or(0)
),
),
route: Some(Route::Entry(id)),
}
}
"entry.line_class_set" => {
let class = p
.get("class_id")
.and_then(|v| v.as_u64())
.and_then(|c| b.ledger.get_class(c as u32))
.map(|c| c.name.clone())
.unwrap_or_else(|| "no class".into());
Event {
icon: icon::TAG,
text: format!(
"Line {} of #{id} tagged {class}",
p.get("line_index")
.and_then(|v| v.as_u64())
.map_or(0, |i| i + 1)
),
route: Some(Route::Entry(id)),
}
}
t => Event {
icon: icon::DOT,
text: t.to_string(),
route: None,
},
}
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
if let Some(seq) = selected
&& let Some(line) = b.journal.get(seq.saturating_sub(1) as usize)
{
super::split(
ui,
"journal",
480.0,
shell,
|ui, shell| list(ui, b, selected, shell),
|ui, shell| detail(ui, b, line, shell),
);
} else {
list(ui, b, None, shell);
}
}
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "journal", |ui| {
widgets::page_header(
ui,
"Journal",
&format!(
"Every change to the books, in order: {} events, each hash-chained to the one before. Nothing is ever edited or deleted; corrections are new events.",
b.journal.len()
),
);
let st = &mut shell.state.journal;
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
.desired_width(320.0),
);
ui.checkbox(&mut st.hide_steps, "Hide entry-building steps");
});
ui.add_space(10.0);
let q = st.query.to_lowercase();
let hide = st.hide_steps;
let rows: Vec<(&JournalLine, Event)> = b
.journal
.iter()
.rev()
.filter(|l| !(hide && describe(b, l).is_none()))
.map(|l| (l, describe_any(b, l)))
.filter(|(l, e)| {
q.is_empty() || e.text.to_lowercase().contains(&q) || l.line_type.contains(&q)
})
.collect();
widgets::card(ui, |ui| {
let row_h = 34.0;
egui::ScrollArea::vertical()
.id_salt("journal_rows")
.max_height(ui.available_height().max(400.0))
.show_rows(ui, row_h, rows.len(), |ui, range| {
for (line, ev) in &rows[range] {
let (rect, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
let on = selected == Some(line.seq);
let fresh = line.seq > b.fresh_from_seq && shell.since_update < 8.0;
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.selected);
} else if fresh {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(rect);
let y = rect.center().y;
painter.text(
egui::pos2(rect.left() + 8.0, y),
egui::Align2::LEFT_CENTER,
format!("{}", line.seq),
theme::regular(12.0),
p.faint,
);
painter.text(
egui::pos2(rect.left() + 56.0, y),
egui::Align2::LEFT_CENTER,
ev.icon,
egui::FontId::proportional(15.0),
p.muted,
);
painter.text(
egui::pos2(rect.left() + 80.0, y),
egui::Align2::LEFT_CENTER,
&ev.text,
theme::regular(13.0),
p.text,
);
painter.text(
egui::pos2(rect.right() - 8.0, y),
egui::Align2::RIGHT_CENTER,
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
);
if resp.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
}
});
});
});
}
fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let ev = describe_any(b, line);
egui::ScrollArea::vertical()
.id_salt("event_detail")
.show(ui, |ui| {
ui.label(
RichText::new(format!("Event {}", line.seq))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(RichText::new(&ev.text).font(theme::semibold(17.0)));
ui.add_space(10.0);
let fact = |ui: &mut Ui, k: &str, v: &str| {
ui.horizontal(|ui| {
ui.add_sized(
vec2(90.0, 18.0),
egui::Label::new(
RichText::new(k).font(theme::regular(12.5)).color(p.muted),
),
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(12.5))).wrap());
});
};
fact(ui, "Type", &line.line_type);
fact(ui, "Recorded", &fmt::timestamp(&line.ts));
fact(ui, "Previous", &line.prev);
let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes());
fact(ui, "This hash", &own);
if let Some(r) = &ev.route {
ui.add_space(6.0);
if ui
.button(format!(
"{} Open {}",
icon::ARROW_SQUARE_OUT,
crate::palette::describe(b, r)
))
.clicked()
{
shell.go(r.clone());
}
}
ui.add_space(12.0);
ui.label(RichText::new("Payload").font(theme::semibold(13.0)));
ui.add_space(4.0);
let json = serde_json::to_string_pretty(&line.payload).unwrap_or_default();
egui::Frame::new()
.fill(p.hover)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(json).monospace().size(12.0)).wrap());
});
});
}