Files
wanderbooks/crates/ledger-gui/src/fmt.rs
T
swaitsandClaude Opus 5.5 105fd52694 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
2026-10-12 16:20:14 -07:00

185 lines
5.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}