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

+184
View File
@@ -0,0 +1,184 @@
//! Formatting money, dates, and counts. Amounts are exact decimals in and
//! exact strings out: nothing here rounds except `compact`, which says so.
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Thousands separators, two decimals, a true minus sign: `−1,234.56`.
pub fn money(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero();
let s = group(d.abs().round_dp(2));
if neg { format!("\u{2212}{s}") } else { s }
}
/// Accounting style: negatives in parentheses, `(1,234.56)`.
pub fn accounting(d: Decimal) -> String {
if d.is_sign_negative() && !d.is_zero() {
format!("({})", group(d.abs().round_dp(2)))
} else {
group(d.round_dp(2))
}
}
/// With the currency sign: `$1,234.56`, `−$12.00`.
pub fn dollars(d: Decimal) -> String {
let neg = d.is_sign_negative() && !d.is_zero();
let s = format!("${}", group(d.abs().round_dp(2)));
if neg { format!("\u{2212}{s}") } else { s }
}
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
pub fn compact(d: Decimal) -> String {
let f: f64 = d.try_into().unwrap_or(0.0);
let (neg, a) = (f < 0.0, f.abs());
let s = if a >= 1_000_000.0 {
format!("${:.1}M", a / 1_000_000.0)
} else if a >= 10_000.0 {
format!("${:.0}k", a / 1_000.0)
} else if a >= 1_000.0 {
format!("${:.1}k", a / 1_000.0)
} else {
format!("${a:.0}")
};
if neg { format!("\u{2212}{s}") } else { s }
}
fn group(d: Decimal) -> String {
let s = format!("{:.2}", d);
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
let mut out = String::new();
for (i, c) in int.chars().enumerate() {
if i > 0 && (int.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
format!("{out}.{frac}")
}
/// A percent change from `prev` to `now`, if `prev` isn't zero.
pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> {
if prev.is_zero() {
return None;
}
let p: f64 = prev.try_into().ok()?;
let n: f64 = now.try_into().ok()?;
Some((n - p) / p.abs() * 100.0)
}
/// `Oct 12, 2026`.
pub fn date(d: NaiveDate) -> String {
d.format("%b %-d, %Y").to_string()
}
/// `Oct 12`.
pub fn date_short(d: NaiveDate) -> String {
d.format("%b %-d").to_string()
}
/// `Oct 2026`.
pub fn month(d: NaiveDate) -> String {
d.format("%b %Y").to_string()
}
/// `1 receipt`, `3 receipts`.
pub fn count(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// An RFC 3339 timestamp from the journal, shown as local-ish UTC text:
/// `Oct 11, 2026 18:04 UTC`.
pub fn timestamp(ts: &str) -> String {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|t| t.format("%b %-d, %Y %H:%M UTC").to_string())
.unwrap_or_else(|_| ts.to_string())
}
/// Bytes as `12.3 KB`.
pub fn bytes(n: u64) -> String {
let f = n as f64;
if f >= 1_048_576.0 {
format!("{:.1} MB", f / 1_048_576.0)
} else if f >= 1024.0 {
format!("{:.1} KB", f / 1024.0)
} else {
format!("{n} B")
}
}
/// `snake_case_key` as `Snake case key`, for settings shown as labels,
/// keeping acronyms upper case (`ein` → `EIN`).
pub fn label(key: &str) -> String {
const ACRONYMS: [&str; 9] = [
"ein", "duns", "irs", "cpa", "llc", "tin", "ssn", "id", "url",
];
let words: Vec<String> = key
.split(['_', ' '])
.filter(|w| !w.is_empty())
.enumerate()
.map(|(i, w)| {
if ACRONYMS.contains(&w.to_lowercase().as_str()) {
w.to_uppercase()
} else if i == 0 {
let mut c = w.chars();
c.next()
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
.unwrap_or_default()
} else {
w.to_string()
}
})
.collect();
words.join(" ")
}
/// A `CamelCase` enum name as words: `PrepaidExpense` → `Prepaid expense`.
pub fn words(debug: &str) -> String {
let mut out = String::new();
for (i, c) in debug.chars().enumerate() {
if c.is_uppercase() && i > 0 {
out.push(' ');
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
/// The tax return a form enum stands for.
pub fn tax_form(f: ledger_core::TaxForm) -> &'static str {
match f {
ledger_core::TaxForm::ScheduleC => "Schedule C (Form 1040)",
ledger_core::TaxForm::Form1065 => "Form 1065 (partnership)",
ledger_core::TaxForm::Form1120S => "Form 1120-S (S corporation)",
ledger_core::TaxForm::Form1120 => "Form 1120 (C corporation)",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
#[test]
fn money_formats() {
assert_eq!(money(dec("1234567.5")), "1,234,567.50");
assert_eq!(money(dec("-12")), "\u{2212}12.00");
assert_eq!(money(dec("0")), "0.00");
assert_eq!(money(dec("-0.00")), "0.00");
assert_eq!(accounting(dec("-207.68")), "(207.68)");
assert_eq!(dollars(dec("999.999")), "$1,000.00");
assert_eq!(compact(dec("1234")), "$1.2k");
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
assert_eq!(compact(dec("950.4")), "$950");
assert_eq!(label("state_entity_number"), "State entity number");
assert_eq!(label("ein"), "EIN");
assert_eq!(label("irs_name_control"), "IRS name control");
assert_eq!(words("PrepaidExpense"), "Prepaid expense");
}
}