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

+17
View File
@@ -0,0 +1,17 @@
[package]
name = "ledger-config"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Company settings for wanderbooks: the books dir's wanderbooks.toml."
[dependencies]
ledger-core = { path = "../ledger-core" }
chrono = "0.4"
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
toml = { version = "1", features = ["preserve_order"] }
[lints]
workspace = true
@@ -35,6 +35,9 @@ pub struct Company {
/// `[books] close_sweep`: equity accounts (draws, dividends,
/// distributions) a close zeroes into `close_into`.
pub close_sweep: Vec<String>,
/// Every simple `[company]` setting (name, EIN, formation date, ...),
/// in file order, as written: the full profile for display.
pub profile: Vec<(String, String)>,
}
/// One member of a multi-member LLC.
@@ -61,6 +64,7 @@ impl Default for Company {
tax_form: None,
close_into: None,
close_sweep: Vec::new(),
profile: Vec::new(),
}
}
}
@@ -89,6 +93,10 @@ pub struct BooksSettings {
pub company: Company,
/// `[books] receipt_threshold`, validated as a decimal string.
pub receipt_threshold: Option<String>,
/// `[books] cash_accounts`: account codes that hold cash (bank,
/// savings, cash on hand), for cash reports and dashboards. Empty:
/// callers decide (e.g. by account name).
pub cash_accounts: Vec<String>,
}
#[derive(Deserialize)]
@@ -124,6 +132,8 @@ struct BooksSection {
close_into: Option<String>,
#[serde(default)]
close_sweep: Vec<String>,
#[serde(default)]
cash_accounts: Vec<String>,
}
/// The return a federal tax classification files: "partnership" → 1065,
@@ -238,9 +248,27 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
}
None => None,
};
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
if let Some(toml::Value::Table(c)) = table.get("company") {
company.profile = c
.iter()
.filter_map(|(k, v)| {
let s = match v {
toml::Value::String(s) => s.clone(),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => f.to_string(),
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Datetime(d) => d.to_string(),
_ => return None,
};
Some((k.clone(), s))
})
.collect();
}
Ok(BooksSettings {
company,
receipt_threshold,
cash_accounts: raw.books.cash_accounts,
})
}
@@ -306,6 +334,23 @@ mod tests {
);
}
#[test]
fn keeps_the_whole_profile_in_order() {
let s = parse(
"[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\n",
)
.unwrap();
assert_eq!(
s.company.profile,
[
("name".to_string(), "Acme LLC".to_string()),
("ein".to_string(), "12-3456789".to_string()),
("formed".to_string(), "2026-10-04".to_string()),
]
);
assert_eq!(s.cash_accounts, ["1000", "1010"]);
}
#[test]
fn reads_members_and_basis() {
let s = parse(
+245
View File
@@ -0,0 +1,245 @@
//! Time series over the books, for dashboards and interval-column reports.
//!
//! Statements answer "what was true for this range"; these answer "how did
//! it change": a range split into months, quarters, or years; balances of a
//! set of accounts on a series of dates; profit and loss per interval. They
//! are built from the same posted entries and statements as everything
//! else, so a number on a chart always ties to a number on a statement.
use crate::reports::profit_and_loss_for_period;
use crate::{AccountId, AccountType, Ledger, LedgerError, Side};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use std::collections::HashSet;
/// How to split a date range into columns.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Interval {
/// Calendar months.
Month,
/// Calendar quarters.
Quarter,
/// Calendar years.
Year,
}
impl Interval {
fn months(self) -> u32 {
match self {
Interval::Month => 1,
Interval::Quarter => 3,
Interval::Year => 12,
}
}
}
/// The first day of the calendar interval containing `d`.
pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate {
let month0 = (d.month0() / interval.months()) * interval.months();
NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid")
}
/// `[start, end]` split on calendar interval boundaries, first and last
/// pieces clipped to the range. Empty when `end < start`.
pub fn intervals(
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Vec<(NaiveDate, NaiveDate)> {
let mut out = Vec::new();
let mut from = interval_start(start, interval);
while from <= end {
let next = from + Months::new(interval.months());
let to = (next - Days::new(1)).min(end);
out.push((from.max(start), to));
from = next;
}
out
}
/// The combined normal-side balance of `accounts` at the end of each date
/// in `dates` (which must be ascending). Assets and expenses count debits
/// as positive; liabilities, equity, and revenue count credits. Mixing
/// types is allowed but rarely meaningful.
pub fn balance_series(
ledger: &Ledger,
accounts: &[AccountId],
dates: &[NaiveDate],
) -> Vec<Decimal> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new();
for entry in ledger.posted_entries() {
for line in entry.lines() {
if !set.contains(&line.account) {
continue;
}
let Some(account) = ledger.chart().get(line.account) else {
continue;
};
let signed = if line.side == account.account_type.normal_side() {
line.amount.amount
} else {
-line.amount.amount
};
postings.push((entry.date(), signed));
}
}
postings.sort_by_key(|(d, _)| *d);
let mut out = Vec::with_capacity(dates.len());
let mut running = Decimal::ZERO;
let mut i = 0;
for date in dates {
while i < postings.len() && postings[i].0 <= *date {
running += postings[i].1;
i += 1;
}
out.push(running);
}
out
}
/// Every date `accounts` moved, with the combined balance at the end of
/// that day: the exact shape of a balance-over-time chart.
pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut dates: Vec<NaiveDate> = ledger
.posted_entries()
.filter(|e| e.lines().iter().any(|l| set.contains(&l.account)))
.map(|e| e.date())
.collect();
dates.sort();
dates.dedup();
let balances = balance_series(ledger, accounts, &dates);
dates.into_iter().zip(balances).collect()
}
/// One interval's profit and loss.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IntervalPnl {
/// First day.
pub start: NaiveDate,
/// Last day.
pub end: NaiveDate,
/// Total revenue.
pub revenue: Decimal,
/// Total expenses.
pub expenses: Decimal,
}
impl IntervalPnl {
/// Revenue minus expenses.
pub fn net(&self) -> Decimal {
self.revenue - self.expenses
}
}
/// Profit and loss per interval across `[start, end]` (closing entries
/// excluded, as on every period statement).
pub fn pnl_by_interval(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Result<Vec<IntervalPnl>, LedgerError> {
intervals(start, end, interval)
.into_iter()
.map(|(s, e)| {
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
Ok(IntervalPnl {
start: s,
end: e,
revenue: pl.total_revenue,
expenses: pl.total_expenses,
})
})
.collect()
}
/// Money in and out of `accounts` per interval: (inflows, outflows), each
/// positive. Each entry counts by its net debit (in) or credit (out) to the
/// accounts together: for cash, deposits and payments; a transfer between
/// two of them (checking to savings) is neither; with a credit card in the
/// set, a charge is money out and paying the card moves nothing.
pub fn flows_by_interval(
ledger: &Ledger,
accounts: &[AccountId],
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval)
.into_iter()
.map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO))
.collect();
for entry in ledger.posted_entries() {
let d = entry.date();
let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else {
continue;
};
// Debits in, credits out, for every account in the set: cash
// received is a debit to cash, and a charge on a card in the set is
// a credit to the card.
let mut net = Decimal::ZERO;
for line in entry.lines().iter().filter(|l| set.contains(&l.account)) {
match line.side {
Side::Debit => net += line.amount.amount,
Side::Credit => net -= line.amount.amount,
}
}
if net > Decimal::ZERO {
slot.2 += net;
} else {
slot.3 -= net;
}
}
out
}
/// Accounts of the given types, in chart order.
pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec<AccountId> {
ledger
.chart()
.all()
.filter(|a| types.contains(&a.account_type))
.map(|a| a.id)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn intervals_clip_to_the_range() {
assert_eq!(
intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month),
[
(d(2026, 1, 15), d(2026, 1, 31)),
(d(2026, 2, 1), d(2026, 2, 28)),
(d(2026, 3, 1), d(2026, 3, 10)),
]
);
assert_eq!(
intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter),
[
(d(2026, 2, 1), d(2026, 3, 31)),
(d(2026, 4, 1), d(2026, 6, 30)),
(d(2026, 7, 1), d(2026, 9, 30)),
(d(2026, 10, 1), d(2026, 12, 31)),
]
);
assert_eq!(
intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year),
[
(d(2025, 6, 1), d(2025, 12, 31)),
(d(2026, 1, 1), d(2026, 2, 1)),
]
);
assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty());
}
}
+1
View File
@@ -17,6 +17,7 @@
//! (`docs/oracle.md`) possible.
pub mod account;
pub mod analysis;
pub mod arap;
pub mod assets;
pub mod classes;
+81
View File
@@ -0,0 +1,81 @@
//! Time series. Numbers computed by hand.
//!
//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of
//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the
//! card 300. Mar 15: 800 rent from cash.
use chrono::NaiveDate;
use ledger_core::analysis::{
Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval,
};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
#[test]
fn balances_pnl_and_flows_over_time() {
let mut l = Ledger::new(Currency::USD);
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
let cash = a("1000", AccountType::Asset);
let card = a("2100", AccountType::Liability);
let equity = a("3000", AccountType::Equity);
let sales = a("4000", AccountType::Revenue);
let software = a("6300", AccountType::Expense);
let rent = a("6600", AccountType::Expense);
for (date, dr, cr, amt) in [
(d(1, 5), cash, equity, 5000),
(d(1, 20), software, card, 300),
(d(2, 3), cash, sales, 1200),
(d(2, 10), card, cash, 300),
(d(3, 15), rent, cash, 800),
] {
let e = l.create_entry(date, "x");
let m = Money::new(dec(amt), Currency::USD);
l.add_line(e, dr, Side::Debit, m).unwrap();
l.add_line(e, cr, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
}
assert_eq!(
balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]),
[dec(0), dec(5000), dec(5900), dec(5100)]
);
// The card is a liability: owed 300, then paid off.
assert_eq!(
balance_series(&l, &[card], &[d(1, 31), d(2, 28)]),
[dec(300), dec(0)]
);
assert_eq!(
balance_history(&l, &[cash]),
[
(d(1, 5), dec(5000)),
(d(2, 3), dec(6200)),
(d(2, 10), dec(5900)),
(d(3, 15), dec(5100))
]
);
let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap();
let nets: Vec<Decimal> = pnl.iter().map(|p| p.net()).collect();
assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]);
let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month);
let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect();
assert_eq!(
io,
[
(dec(5000), dec(0)),
(dec(1200), dec(300)),
(dec(0), dec(800))
]
);
// Cash and card together: a card charge is money out when it's made,
// and paying the card moves nothing out of the pair.
let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month);
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
}
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "ledger-gui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Read-only, live desktop view of wanderbooks books."
[[bin]]
name = "ledger-gui"
path = "src/main.rs"
[dependencies]
ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-config = { path = "../ledger-config" }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
egui = "0.36"
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
egui-phosphor = "0.14"
hayro = { version = "0.8", features = ["embed-fonts"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
notify = "8"
jiff = "0.2"
chrono = "0.4"
rust_decimal = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
chrono = "0.4"
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
[lints]
workspace = true
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+442
View File
@@ -0,0 +1,442 @@
//! The window: sidebar, top bar, and the current page.
use crate::books::Books;
use crate::data::{Loader, Source, Update};
use crate::nav::{Nav, Route, Section};
use crate::pages;
use crate::receipts::Receipts;
use crate::theme::{self, pal};
use egui::{
Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2,
};
use egui_phosphor::regular as icon;
use std::sync::Arc;
/// Per-page state that survives navigation.
#[derive(Default)]
pub struct PageState {
pub transactions: pages::transactions::State,
pub reports: pages::reports::State,
pub accounts: pages::accounts::State,
pub documents: pages::documents::State,
pub journal: pages::journal::State,
}
/// What a page can ask of the shell.
pub struct Shell<'a> {
pub receipts: &'a mut Receipts,
pub state: &'a mut PageState,
pub go: Option<Route>,
/// Seconds since the last live update arrived (for "new" highlights).
pub since_update: f64,
}
impl Shell<'_> {
pub fn go(&mut self, r: Route) {
self.go = Some(r);
}
}
pub struct App {
loader: Option<Loader>,
books: Option<Arc<Books>>,
error: Option<String>,
nav: Nav,
receipts: Receipts,
dark: bool,
state: PageState,
palette: crate::palette::Palette,
updated_at: f64,
}
impl App {
/// The live app over a books directory.
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
let dark = cc
.storage
.and_then(|s| s.get_string("dark"))
.map(|v| v == "true")
.unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode);
let mut app = Self::bare(&cc.egui_ctx, dark);
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
app
}
/// An app showing fixed books (tests and screenshots).
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
let mut app = Self::bare(ctx, dark);
app.books = Some(Arc::new(books));
app.nav = Nav::new(start);
app
}
fn bare(ctx: &egui::Context, dark: bool) -> Self {
theme::install_fonts(ctx);
theme::apply(ctx, dark);
egui_extras::install_image_loaders(ctx);
Self {
loader: None,
books: None,
error: None,
nav: Nav::new(Route::Overview),
receipts: Receipts::new(ctx),
dark,
state: PageState::default(),
palette: Default::default(),
updated_at: -1e9,
}
}
/// Navigate (tests).
pub fn goto(&mut self, r: Route) {
self.nav.go(r);
}
/// Block until every receipt on screen has rendered (screenshots).
pub fn settle(&mut self, ctx: &egui::Context) {
self.receipts.settle(ctx);
}
/// Page state (tests).
pub fn state_mut(&mut self) -> &mut PageState {
&mut self.state
}
fn handle_keys(&mut self, ui: &Ui) {
let (back, fwd, palette) = ui.input(|i| {
(
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|| i.pointer.button_pressed(egui::PointerButton::Extra1),
(i.modifiers.alt && i.key_pressed(Key::ArrowRight))
|| i.pointer.button_pressed(egui::PointerButton::Extra2),
i.modifiers.command && i.key_pressed(Key::K),
)
});
if back {
self.nav.back();
}
if fwd {
self.nav.forward();
}
if palette {
self.palette.open();
}
}
fn sidebar(&mut self, ui: &mut Ui) {
let p = pal(ui.ctx());
let books = self.books.clone();
egui::Panel::left("sidebar")
.exact_size(224.0)
.resizable(false)
.frame(
Frame::new()
.fill(p.sidebar)
.inner_margin(Margin::symmetric(12, 16)),
)
.show(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(8), p.accent);
let initial = books
.as_ref()
.and_then(|b| b.company_name().chars().next())
.unwrap_or('W');
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
initial,
theme::bold(15.0),
Color32::WHITE,
);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let name = books
.as_ref()
.map(|b| b.company_name().to_string())
.unwrap_or_else(|| "wanderbooks".into());
ui.add(
egui::Label::new(RichText::new(name).font(theme::semibold(13.5)))
.truncate(),
);
ui.label(
RichText::new("Books")
.font(theme::regular(11.5))
.color(p.muted),
);
});
});
ui.add_space(18.0);
let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
for s in Section::ALL {
let on = s == current;
let (rect, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.surface);
ui.painter().rect_stroke(
rect,
CornerRadius::same(8),
Stroke::new(1.0, p.border),
egui::StrokeKind::Inside,
);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
let color = if on { p.text } else { p.muted };
ui.painter().text(
rect.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
s.icon(),
egui::FontId::proportional(16.0),
if on { p.accent } else { p.muted },
);
ui.painter().text(
rect.left_center() + vec2(36.0, 0.0),
egui::Align2::LEFT_CENTER,
s.title(),
if on {
theme::semibold(13.5)
} else {
theme::regular(13.5)
},
color,
);
if s == Section::Overview && attention > 0 {
let badge = egui::Rect::from_center_size(
rect.right_center() - vec2(18.0, 0.0),
vec2(22.0, 18.0),
);
ui.painter()
.rect_filled(badge, CornerRadius::same(9), p.warning_soft);
ui.painter().text(
badge.center(),
egui::Align2::CENTER_CENTER,
attention.to_string(),
theme::semibold(11.0),
p.warning,
);
}
if resp.clicked() {
self.nav.go(s.home());
}
}
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let label = if self.dark {
format!("{} Light mode", icon::SUN)
} else {
format!("{} Dark mode", icon::MOON)
};
if ui
.add(
egui::Button::new(
RichText::new(label)
.font(theme::regular(12.5))
.color(p.muted),
)
.frame(false),
)
.clicked()
{
self.dark = !self.dark;
theme::apply(ui.ctx(), self.dark);
}
ui.add_space(6.0);
if let Some(b) = &books {
live_status(
ui,
b,
ui.input(|i| i.time) - self.updated_at,
self.loader.is_some(),
);
}
});
});
}
fn topbar(&mut self, ui: &mut Ui) {
let p = pal(ui.ctx());
egui::Panel::top("topbar")
.exact_size(52.0)
.frame(
Frame::new()
.fill(p.bg)
.inner_margin(Margin::symmetric(20, 10))
.stroke(Stroke::new(1.0, p.border)),
)
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
ui.add_enabled(
enabled,
egui::Button::new(RichText::new(glyph).size(16.0))
.frame(false)
.min_size(vec2(28.0, 28.0)),
)
.on_hover_text(tip)
.clicked()
};
if nav_btn(ui, icon::ARROW_LEFT, self.nav.can_back(), "Back (Alt+←)") {
self.nav.back();
}
if nav_btn(
ui,
icon::ARROW_RIGHT,
self.nav.can_forward(),
"Forward (Alt+→)",
) {
self.nav.forward();
}
ui.add_space(6.0);
let crumb = self
.books
.as_ref()
.map(|b| crate::palette::describe(b, &self.nav.current))
.unwrap_or_default();
ui.label(
RichText::new(self.nav.current.section().title())
.font(theme::regular(13.5))
.color(p.muted),
);
if !crumb.is_empty() {
ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint));
ui.add(
egui::Label::new(RichText::new(crumb).font(theme::semibold(13.5)))
.truncate(),
);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let search = egui::Button::new(
RichText::new(format!(
"{} Search everything… Ctrl K",
icon::MAGNIFYING_GLASS
))
.font(theme::regular(13.0))
.color(p.faint),
)
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(8))
.min_size(vec2(260.0, 30.0));
if ui.add(search).clicked() {
self.palette.open();
}
if let Some(b) = &self.books {
ui.label(
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
.font(theme::regular(12.5))
.color(p.muted),
);
}
});
});
});
}
}
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
let p = pal(ui.ctx());
Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover());
let pulse = (since_update < 2.0) as u8 as f32;
let color = if live { p.positive } else { p.faint };
ui.painter().circle_filled(r.center(), 4.0 + pulse, color);
ui.label(
RichText::new(if live { "Live" } else { "Snapshot" })
.font(theme::semibold(12.0)),
);
ui.label(
RichText::new(format!("· event {}", b.seq()))
.font(theme::regular(12.0))
.color(p.muted),
);
});
ui.label(
RichText::new(format!("{} Journal chain verified", icon::SEAL_CHECK))
.font(theme::regular(11.5))
.color(p.muted),
)
.on_hover_text(format!(
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
b.head,
b.data_dir.display()
));
});
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
if let Some(loader) = &self.loader {
match loader.poll() {
Some(Update::Books(b)) => {
if self.books.is_some() {
self.updated_at = ui.input(|i| i.time);
}
self.books = Some(b);
self.error = None;
}
Some(Update::Error(e)) => self.error = Some(e),
None => {}
}
}
self.receipts.pump(ui.ctx());
self.handle_keys(ui);
self.sidebar(ui);
self.topbar(ui);
let p = pal(ui.ctx());
let since_update = ui.input(|i| i.time) - self.updated_at;
if since_update < 6.0 {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
}
egui::CentralPanel::no_frame()
.frame(Frame::new().fill(p.bg))
.show(ui, |ui| {
if let Some(err) = &self.error {
Frame::new()
.fill(p.negative_soft)
.inner_margin(Margin::symmetric(20, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(
RichText::new(format!("{} {err}", icon::WARNING)).color(p.negative),
);
});
}
let Some(books) = self.books.clone() else {
ui.centered_and_justified(|ui| {
ui.label(RichText::new("Opening the books…").color(p.muted));
});
return;
};
let mut shell = Shell {
receipts: &mut self.receipts,
state: &mut self.state,
go: None,
since_update,
};
let route = self.nav.current.clone();
pages::show(ui, &books, &route, &mut shell);
if let Some(to) = shell.go.take() {
self.nav.go(to);
}
if let Some(to) = self.palette.show(ui.ctx(), &books) {
self.nav.go(to);
}
});
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string("dark", self.dark.to_string());
}
}
+265
View File
@@ -0,0 +1,265 @@
//! What the books need: drafts left unposted, missing receipts, schedules
//! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! owners. Each item says what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity};
use crate::fmt;
use crate::nav::Route;
use chrono::Datelike;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
pub fn find(b: &Books) -> Vec<Attention> {
let mut out = Vec::new();
let today = b.today;
for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) {
out.push(Attention {
severity: Severity::Todo,
title: format!("Draft not posted: {}", e.description),
detail: format!(
"#{} · {} · {}. Drafts appear in no report until posted.",
e.id,
fmt::date(e.date),
fmt::dollars(e.amount)
),
target: Some(Route::Entry(e.id)),
prompt: format!(
"In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.",
e.id,
e.description,
fmt::dollars(e.amount)
),
});
}
let missing: Vec<_> = b
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted && e.missing_receipt)
.collect();
if missing.len() > 3 {
let total: Decimal = missing.iter().map(|e| e.amount).sum();
let list: Vec<String> = missing
.iter()
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
.collect();
out.push(Attention {
severity: Severity::Problem,
title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")),
detail: format!(
"{} of spending over the {} threshold has no receipt attached.",
fmt::dollars(total),
fmt::dollars(b.receipt_threshold)
),
target: Some(Route::Transactions),
prompt: format!(
"In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.",
list.join("; ")
),
});
}
for e in missing.iter().filter(|_| missing.len() <= 3) {
out.push(Attention {
severity: Severity::Problem,
title: format!("No receipt: {}", e.description),
detail: format!(
"#{} · {} · {} is over the {} receipt threshold.",
e.id,
fmt::date(e.date),
fmt::dollars(e.amount),
fmt::dollars(b.receipt_threshold)
),
target: Some(Route::Entry(e.id)),
prompt: format!(
"In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.",
e.id,
e.description,
fmt::dollars(e.amount),
e.date
),
});
}
for r in b.ledger.recurring_entries() {
let due = r.due(today);
if !due.is_empty() {
out.push(Attention {
severity: Severity::Todo,
title: format!("Recurring \"{}\" is due", r.description),
detail: format!(
"{} since {}.",
fmt::count(due.len(), "occurrence", "occurrences"),
fmt::date(due[0])
),
target: Some(Route::Company),
prompt: format!(
"In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them."
),
});
}
}
for s in b.ledger.schedules() {
let due = s.due(today);
if !due.is_empty() {
let total: Decimal = due.iter().map(|(_, a)| *a).sum();
out.push(Attention {
severity: Severity::Todo,
title: format!("Amortization due: {}", s.description),
detail: format!(
"{} totaling {} not yet posted.",
fmt::count(due.len(), "month", "months"),
fmt::dollars(total)
),
target: Some(Route::Company),
prompt: format!(
"In wanderbooks, preview books.run_schedules through {today}, then post it if it's right."
),
});
}
}
for p in b.ledger.list_periods() {
if p.status == PeriodStatus::Open && p.end < today {
out.push(Attention {
severity: Severity::Todo,
title: format!("Period {} ended but is open", p.name),
detail: format!(
"{} – {}. Closing locks it and moves its earnings to equity.",
fmt::date(p.start),
fmt::date(p.end)
),
target: Some(Route::Company),
prompt: format!(
"In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.",
p.name
),
});
}
}
let (fy_start, _) = b.fiscal_ytd();
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
let uncategorized: Vec<String> = pl
.revenue
.iter()
.chain(&pl.expenses)
.filter(|l| {
b.ledger
.chart()
.find_by_code(&l.code)
.is_some_and(|a| a.tax_category.is_none())
})
.map(|l| format!("{} {}", l.code, l.name))
.collect();
if !uncategorized.is_empty() {
out.push(Attention {
severity: Severity::Info,
title: format!(
"{} without a tax category",
fmt::count(uncategorized.len(), "account", "accounts")
),
detail: format!(
"{}. The tax summary lists them as uncategorized.",
uncategorized.join(", ")
),
target: Some(Route::Accounts),
prompt: format!(
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
uncategorized.join(", ")
),
});
}
}
let mut years = vec![today.year()];
if today.month() == 1 {
years.insert(0, today.year() - 1);
}
for year in years {
let r = vendor_1099_report(&b.ledger, year, None);
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
out.push(Attention {
severity: Severity::Problem,
title: format!("{} needs a W-9", v.name),
detail: format!(
"Paid {} in {year}, over the {} 1099 threshold.",
fmt::dollars(v.paid),
fmt::dollars(r.threshold)
),
target: Some(Route::Company),
prompt: format!(
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
v.name
),
});
}
}
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
let late: Vec<Decimal> = rows
.iter()
.map(|r| r.days_30 + r.days_60 + r.days_90 + r.days_90_plus)
.filter(|d| !d.is_zero())
.collect();
(late.len(), late.iter().sum())
};
let (n, total) = overdue(b.ledger.ap_aging(today));
if n > 0 {
out.push(Attention {
severity: Severity::Problem,
title: format!("{} overdue to pay", fmt::dollars(total)),
detail: format!(
"{} with bills past due.",
fmt::count(n, "vendor", "vendors")
),
target: Some(Route::Reports(crate::nav::ReportKind::ApAging)),
prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(),
});
}
let (n, total) = overdue(b.ledger.ar_aging(today));
if n > 0 {
out.push(Attention {
severity: Severity::Problem,
title: format!("{} overdue to collect", fmt::dollars(total)),
detail: format!(
"{} with invoices past due.",
fmt::count(n, "customer", "customers")
),
target: Some(Route::Reports(crate::nav::ReportKind::ArAging)),
prompt:
"In wanderbooks, show me the A/R aging and draft reminders for past-due invoices."
.into(),
});
}
for a in b.ledger.chart().all().filter(|a| {
a.account_type == AccountType::Liability && {
let n = a.name.to_lowercase();
n.contains("due to") || n.contains("owed to")
}
}) {
let bal = b.balance_on(a.id, today);
if bal > Decimal::ZERO {
out.push(Attention {
severity: Severity::Info,
title: format!("{} owed on {}", fmt::dollars(bal), a.name),
detail: "Expenses someone paid personally, not yet reimbursed.".into(),
target: Some(Route::Account(a.id)),
prompt: format!(
"In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.",
fmt::dollars(bal),
a.code,
a.name
),
});
}
}
out.sort_by_key(|a| a.severity);
out
}
+519
View File
@@ -0,0 +1,519 @@
//! The view model: one immutable picture of the books, rebuilt whenever the
//! journal grows. Built off the UI thread from a [`Snapshot`]; the UI only
//! reads it. Every number here comes from `ledger-core`; this module only
//! indexes and arranges.
use chrono::{Datelike, NaiveDate};
use ledger_config::BooksSettings;
use ledger_core::analysis::{balance_history, balance_series};
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
use ledger_core::{AccountId, AccountType, EntryId, EntryStatus, Ledger, Side};
use ledger_store::{Attachment, JournalLine, Snapshot};
use rust_decimal::Decimal;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
/// One entry, indexed for listing and detail.
#[derive(Clone, Debug)]
pub struct EntryInfo {
pub id: EntryId,
pub date: NaiveDate,
pub description: String,
pub status: EntryStatus,
/// Total debits.
pub amount: Decimal,
/// Accounts touched, debits first: "6300 Software".
pub accounts: Vec<AccountId>,
/// Indexes into [`Books::attachments`] of linked, current receipts.
pub receipts: Vec<usize>,
/// How the entry came to be, in words.
pub origin: &'static str,
/// Indexes into [`Books::journal`] of every event that touched it.
pub events: Vec<usize>,
pub vendor: Option<String>,
pub reversal_of: Option<EntryId>,
pub reversed_by: Option<EntryId>,
/// Spending over the receipt threshold with no receipt.
pub missing_receipt: bool,
/// Net effect on cash accounts: positive in, negative out, zero if the
/// entry doesn't touch cash.
pub cash_effect: Decimal,
/// The customer or vendor, from invoices, bills, and vendor tags.
pub party: Option<String>,
/// Lower-cased text for search: description, accounts, vendor, amount.
pub haystack: String,
}
/// Something the books need, with how to fix it.
#[derive(Clone, Debug)]
pub struct Attention {
pub severity: Severity,
pub title: String,
pub detail: String,
pub target: Option<crate::nav::Route>,
/// A prompt to paste into the agent session that fixes it.
pub prompt: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Problem,
Todo,
Info,
}
/// The whole picture.
pub struct Books {
pub ledger: Ledger,
pub settings: BooksSettings,
pub data_dir: PathBuf,
pub attachments: Vec<Attachment>,
pub journal: Vec<JournalLine>,
pub head: String,
pub today: NaiveDate,
pub loaded_at: chrono::DateTime<chrono::Utc>,
/// Newest first (date, then id).
pub entries: Vec<EntryInfo>,
pub entry_index: HashMap<EntryId, usize>,
pub cash_accounts: Vec<AccountId>,
pub card_accounts: Vec<AccountId>,
/// Entries created or changed by the most recent refresh.
pub fresh: HashSet<EntryId>,
/// Journal seq before the most recent refresh.
pub fresh_from_seq: u64,
pub receipt_threshold: Decimal,
pub attention: Vec<Attention>,
pub kpis: Kpis,
}
/// The headline numbers.
#[derive(Clone, Debug, Default)]
pub struct Kpis {
pub cash: Decimal,
pub cash_30d_ago: Decimal,
pub liabilities: Decimal,
pub receivables: Decimal,
pub revenue_mtd: Decimal,
pub expenses_mtd: Decimal,
/// Last month over the same days as this month so far.
pub revenue_prev_month: Decimal,
pub expenses_prev_month: Decimal,
pub net_ytd: Decimal,
/// Average monthly net loss over the last three full months, if losing.
pub monthly_burn: Option<Decimal>,
pub first_entry: Option<NaiveDate>,
}
impl Books {
/// Build the picture from a snapshot. `prev` is the previous picture,
/// to mark what's new.
pub fn build(
snap: &Snapshot,
settings: BooksSettings,
today: NaiveDate,
prev_seq: Option<u64>,
) -> Result<Books, String> {
let ledger = snap.ledger().clone();
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
let journal = snap.journal().to_vec();
let receipt_threshold = settings
.receipt_threshold
.as_deref()
.and_then(|t| t.parse().ok())
.unwrap_or(Decimal::new(25, 0));
let cash_accounts = cash_accounts(&ledger, &settings);
let card_accounts: Vec<AccountId> = ledger
.chart()
.all()
.filter(|a| {
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
})
.map(|a| a.id)
.collect();
// Journal events per entry.
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
for (i, line) in journal.iter().enumerate() {
for id in entry_refs(line) {
events.entry(id).or_default().push(i);
}
}
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
for (i, a) in attachments.iter().enumerate() {
if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) {
receipts.entry(e).or_default().push(i);
}
}
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
let parties = parties(&ledger, &journal);
let mut fresh = HashSet::new();
let closings = ledger.closing_entry_ids();
let mut entries = Vec::new();
let mut reversed_by: HashMap<EntryId, EntryId> = HashMap::new();
for id in 1..ledger.next_entry_id() {
if let Some(e) = ledger.get_entry(id)
&& let Some(orig) = e.reversal_of()
{
reversed_by.insert(orig, id);
}
}
for id in 1..ledger.next_entry_id() {
let Some(e) = ledger.get_entry(id) else {
continue;
};
let evs = events.remove(&id).unwrap_or_default();
if evs.iter().any(|&i| journal[i].seq > fresh_from_seq) {
fresh.insert(id);
}
let origin = origin(&evs, &journal, closings.contains(&id));
let mut accounts: Vec<AccountId> = Vec::new();
for side in [Side::Debit, Side::Credit] {
for l in e.lines().iter().filter(|l| l.side == side) {
if !accounts.contains(&l.account) {
accounts.push(l.account);
}
}
}
let amount: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
let rec = receipts.remove(&id).unwrap_or_default();
let vendor = ledger
.entry_vendor(id)
.and_then(|v| ledger.get_vendor(v))
.map(|v| v.name.clone());
// Receipts prove spending: hand-made entries (and recurring
// drafts) that debit an expense or buy an asset from outside.
// Invoices, payments, transfers, draws, closes, and schedules
// carry their own evidence.
let spends = e.lines().iter().any(|l| {
l.side == Side::Debit
&& ledger.chart().get(l.account).is_some_and(|a| {
a.account_type == AccountType::Expense
|| (a.account_type == AccountType::Asset
&& !cash_accounts.contains(&a.id)
&& !a.name.to_lowercase().contains("receivable"))
})
});
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
&& spends
&& rec.is_empty()
&& amount > receipt_threshold;
let cash_effect: Decimal = e
.lines()
.iter()
.filter(|l| cash_accounts.contains(&l.account))
.map(|l| match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
})
.sum();
let party = vendor.clone().or_else(|| parties.get(&id).cloned());
let mut haystack = format!(
"{} {} #{} {}",
e.description(),
crate::fmt::money(amount),
id,
amount
);
for a in &accounts {
if let Some(acct) = ledger.chart().get(*a) {
haystack.push_str(&format!(" {} {}", acct.code, acct.name));
}
}
if let Some(v) = &party {
haystack.push(' ');
haystack.push_str(v);
}
entries.push(EntryInfo {
id,
date: e.date(),
description: e.description().to_string(),
status: e.status(),
amount,
accounts,
receipts: rec,
origin,
events: evs,
vendor,
reversal_of: e.reversal_of(),
reversed_by: reversed_by.get(&id).copied(),
missing_receipt,
cash_effect,
party,
haystack: haystack.to_lowercase(),
});
}
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id)));
let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
let mut books = Books {
ledger,
settings,
data_dir: snap.data_dir().to_path_buf(),
attachments,
journal,
head: snap.head_hash().to_string(),
today,
loaded_at: chrono::Utc::now(),
entries,
entry_index,
cash_accounts,
card_accounts,
fresh,
fresh_from_seq,
receipt_threshold,
attention: Vec::new(),
kpis: Kpis::default(),
};
books.kpis = books.compute_kpis();
books.attention = crate::attention::find(&books);
Ok(books)
}
pub fn seq(&self) -> u64 {
self.journal.len() as u64
}
pub fn entry(&self, id: EntryId) -> Option<&EntryInfo> {
self.entry_index.get(&id).map(|&i| &self.entries[i])
}
pub fn account_label(&self, id: AccountId) -> String {
self.ledger
.chart()
.get(id)
.map(|a| format!("{} {}", a.code, a.name))
.unwrap_or_else(|| format!("account {id}"))
}
pub fn account_name(&self, id: AccountId) -> String {
self.ledger
.chart()
.get(id)
.map(|a| a.name.clone())
.unwrap_or_else(|| format!("account {id}"))
}
pub fn company_name(&self) -> &str {
&self.settings.company.name
}
/// Cash balance at the end of each day it changed.
pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> {
balance_history(&self.ledger, &self.cash_accounts)
}
/// Normal-side balance of one account at the end of `date`.
pub fn balance_on(&self, account: AccountId, date: NaiveDate) -> Decimal {
balance_series(&self.ledger, &[account], &[date])[0]
}
/// First day of the current month.
pub fn month_start(&self) -> NaiveDate {
NaiveDate::from_ymd_opt(self.today.year(), self.today.month(), 1).expect("valid")
}
/// Range of the last `n` months, ending with the current one.
pub fn trailing_months(&self, n: u32) -> (NaiveDate, NaiveDate) {
let start = self.month_start() - chrono::Months::new(n.saturating_sub(1));
(start, self.today)
}
/// The fiscal year to date.
pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) {
let (start, _) = self.settings.company.fiscal_year_containing(self.today);
(start, self.today)
}
fn compute_kpis(&self) -> Kpis {
let l = &self.ledger;
let today = self.today;
let cash = balance_series(
l,
&self.cash_accounts,
&[today - chrono::Days::new(30), today],
);
let bs = balance_sheet_as_of(l, today).ok();
let receivables: Decimal = l
.chart()
.all()
.filter(|a| {
a.account_type == AccountType::Asset && a.name.to_lowercase().contains("receivable")
})
.map(|a| self.balance_on(a.id, today))
.sum();
let ms = self.month_start();
let prev_start = ms - chrono::Months::new(1);
let prev_end = ms - chrono::Days::new(1);
let pl = |s, e| profit_and_loss_for_period(l, s, e, None).ok();
let mtd = pl(ms, today);
// Same days of last month: a fair comparison mid-month.
let same_day = (prev_start + chrono::Days::new(u64::from(today.day() - 1))).min(prev_end);
let prev = pl(prev_start, same_day);
let (fy_start, _) = self.fiscal_ytd();
let ytd = pl(fy_start, today);
let three = pl(ms - chrono::Months::new(3), prev_end);
let first_entry = self
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted)
.map(|e| e.date)
.min();
let monthly_burn = three.and_then(|p| {
let loss = p.total_expenses - p.total_revenue;
// Only months the books cover count toward the average.
let months = match first_entry {
Some(f) if f > ms - chrono::Months::new(3) => {
let m = (prev_end.year() * 12 + prev_end.month() as i32)
- (f.year() * 12 + f.month() as i32)
+ 1;
m.max(0)
}
Some(_) => 3,
None => 0,
};
(loss > Decimal::ZERO && months > 0).then(|| (loss / Decimal::from(months)).round_dp(2))
});
Kpis {
cash: cash[1],
cash_30d_ago: cash[0],
liabilities: bs.as_ref().map(|b| b.total_liabilities).unwrap_or_default(),
receivables,
revenue_mtd: mtd.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
expenses_mtd: mtd.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
revenue_prev_month: prev.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
expenses_prev_month: prev.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
net_ytd: ytd
.map(|p| p.total_revenue - p.total_expenses)
.unwrap_or_default(),
monthly_burn,
first_entry,
}
}
/// Blob store path for an attachment.
pub fn blob_root(&self) -> PathBuf {
self.data_dir.join("blobs")
}
}
/// Customer or vendor names for entries created by invoices and bills and
/// their payments, from the journal.
fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String> {
let mut out = HashMap::new();
let customer = |id: Option<u64>| {
id.and_then(|c| ledger.get_customer(c))
.map(|c| c.name.clone())
};
let vendor = |id: Option<u64>| {
id.and_then(|v| ledger.get_vendor(v))
.map(|v| v.name.clone())
};
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
for line in journal {
let p = &line.payload;
let Some(entry) = u(p, "entry_id") else {
continue;
};
let name = match line.line_type.as_str() {
"invoice.created" => customer(u(p, "customer_id")),
"invoice.payment_received" => customer(
u(p, "invoice_id")
.and_then(|i| ledger.get_invoice(i))
.map(|i| i.customer_id),
),
"bill.created" => vendor(u(p, "vendor_id")),
"bill.paid" => vendor(
u(p, "bill_id")
.and_then(|b| ledger.get_bill(b))
.map(|b| b.vendor_id),
),
_ => None,
};
if let Some(n) = name {
out.insert(entry, n);
}
}
out
}
/// Entry ids a journal line refers to.
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
let p = &line.payload;
let mut out = Vec::new();
let mut push = |v: Option<&Value>| {
if let Some(id) = v.and_then(|v| v.as_u64())
&& !out.contains(&id)
{
out.push(id);
}
};
push(p.get("entry_id"));
push(p.get("closing_entry"));
push(p.get("entry").and_then(|e| e.get("id")));
if line.line_type.starts_with("entry.") {
push(p.get("id"));
}
out
}
fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static str {
if closing {
return "Period close";
}
let has = |t: &str| events.iter().any(|&i| journal[i].line_type == t);
if has("entry.reversed") {
"Reversal"
} else if has("schedule.posted") {
"Amortization"
} else if has("asset.depreciation_posted") {
"Depreciation"
} else if has("asset.disposed") {
"Asset disposal"
} else if has("bill.created") {
"Bill"
} else if has("bill.paid") {
"Bill payment"
} else if has("invoice.created") {
"Invoice"
} else if has("invoice.payment_received") {
"Customer payment"
} else if has("payroll.run") {
"Payroll"
} else if has("payroll.remitted") || has("payroll.taxes_remitted") {
"Payroll taxes"
} else if has("recurring.generated") {
"Recurring"
} else {
"Journal entry"
}
}
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
/// names say they hold cash.
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
if !settings.cash_accounts.is_empty() {
return settings
.cash_accounts
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
.collect();
}
ledger
.chart()
.all()
.filter(|a| {
let n = a.name.to_lowercase();
a.account_type == AccountType::Asset
&& !a.placeholder
&& ["cash", "bank", "checking", "savings", "money market"]
.iter()
.any(|w| n.contains(w))
})
.map(|a| a.id)
.collect()
}
+387
View File
@@ -0,0 +1,387 @@
//! Charts, painted directly: a balance line (step-shaped, because a
//! balance holds until the next transaction), grouped bars, ranked
//! horizontal bars, and sparklines. Every point answers hover with its
//! exact value; bars report clicks so they can drill down.
use crate::fmt;
use crate::theme::{self, pal};
use chrono::NaiveDate;
use egui::{
Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2,
};
use rust_decimal::Decimal;
fn f(d: Decimal) -> f64 {
d.try_into().unwrap_or(0.0)
}
/// "Nice" axis ticks covering [lo, hi].
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
let span = (hi - lo).max(1e-9);
let raw = span / n as f64;
let mag = 10f64.powf(raw.log10().floor());
let step = [1.0, 2.0, 2.5, 5.0, 10.0]
.iter()
.map(|m| m * mag)
.find(|s| span / s <= n as f64)
.unwrap_or(10.0 * mag);
let start = (lo / step).floor() * step;
let mut v = vec![start];
// Up to the first tick at or above the top, so the data never pokes
// past the last gridline.
while *v.last().expect("non-empty") < hi - step * 1e-9 {
let next = v.last().expect("non-empty") + step;
v.push(next);
}
v
}
fn money_label(v: f64) -> String {
fmt::compact(Decimal::from_f64_retain(v).unwrap_or_default())
}
/// A balance over time as a step line with a soft fill. `points` are
/// (date, balance at end of day), ascending; the line runs to `until`.
pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDate, height: f32) {
let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
let painter = ui.painter_at(rect);
if points.is_empty() {
painter.text(
rect.center(),
Align2::CENTER_CENTER,
"No cash activity yet",
theme::regular(12.5),
p.faint,
);
return;
}
let first = points[0].0;
let last = until.max(points[points.len() - 1].0);
let days = ((last - first).num_days().max(1)) as f64;
let vals: Vec<f64> = points.iter().map(|(_, v)| f(*v)).collect();
let lo = vals.iter().cloned().fold(0.0f64, f64::min);
let hi = vals.iter().cloned().fold(f64::MIN, f64::max).max(lo + 1.0);
let ts = ticks(lo, hi, 4);
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(8.0, 22.0));
let x =
|d: NaiveDate| plot.left() + ((d - first).num_days() as f64 / days) as f32 * plot.width();
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
for t in &ts {
let yy = y(*t);
painter.line_segment(
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
Stroke::new(1.0, p.border),
);
painter.text(
pos2(plot.left() - 8.0, yy),
Align2::RIGHT_CENTER,
money_label(*t),
theme::regular(11.0),
p.faint,
);
}
// X labels: first, last, and a few between.
let n_labels = 4;
for i in 0..=n_labels {
let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64);
let align = match i {
0 => Align2::LEFT_TOP,
i if i == n_labels => Align2::RIGHT_TOP,
_ => Align2::CENTER_TOP,
};
painter.text(
pos2(x(d), plot.bottom() + 6.0),
align,
fmt::date_short(d),
theme::regular(11.0),
p.faint,
);
}
// Step path.
let mut line: Vec<Pos2> = Vec::new();
for (i, (d, v)) in points.iter().enumerate() {
let px = x(*d);
if i > 0 {
line.push(pos2(px, line.last().unwrap().y));
}
line.push(pos2(px, y(f(*v))));
}
line.push(pos2(x(last), line.last().unwrap().y));
let base = y(0.0f64.clamp(lo, hi));
let mut fill = line.clone();
fill.push(pos2(x(last), base));
fill.push(pos2(x(first), base));
let soft = Color32::from_rgba_unmultiplied(p.accent.r(), p.accent.g(), p.accent.b(), 28);
// Fill: one mesh of strips under each flat step (the outline may be
// non-convex). A mesh has no anti-aliased edges, so strips meet without
// seams.
let mut mesh = egui::Mesh::default();
for w in line.windows(2) {
if (w[0].y - w[1].y).abs() < 0.01 && w[1].x > w[0].x {
mesh.add_colored_rect(
Rect::from_min_max(
pos2(w[0].x, w[0].y.min(base)),
pos2(w[1].x, w[0].y.max(base)),
),
soft,
);
}
}
painter.add(Shape::mesh(mesh));
let _ = fill;
painter.add(Shape::line(line.clone(), Stroke::new(2.0, p.accent)));
// Hover: the balance on the hovered day.
if let Some(hp) = resp.hover_pos()
&& plot.x_range().contains(hp.x)
{
let frac = ((hp.x - plot.left()) / plot.width()) as f64;
let day = first + chrono::Days::new((frac * days).round() as u64);
let bal = points
.iter()
.take_while(|(d, _)| *d <= day)
.last()
.map(|(_, v)| *v);
if let Some(b) = bal {
let hx = x(day);
painter.line_segment(
[pos2(hx, plot.top()), pos2(hx, plot.bottom())],
Stroke::new(1.0, p.faint),
);
painter.circle_filled(pos2(hx, y(f(b))), 4.0, p.accent);
tooltip(
&painter,
pos2(hx, plot.top()),
&fmt::date(day),
&fmt::dollars(b),
p.surface,
p.border,
p.text,
p.muted,
rect,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn tooltip(
painter: &Painter,
at: Pos2,
title: &str,
value: &str,
bg: Color32,
border: Color32,
fg: Color32,
muted: Color32,
bounds: Rect,
) {
let t1 = painter.layout_no_wrap(title.to_string(), theme::regular(11.5), muted);
let t2 = painter.layout_no_wrap(value.to_string(), theme::semibold(13.0), fg);
let w = t1.size().x.max(t2.size().x) + 16.0;
let h = t1.size().y + t2.size().y + 12.0;
let mut r = Rect::from_min_size(pos2(at.x + 8.0, at.y), vec2(w, h));
if r.right() > bounds.right() {
r = r.translate(vec2(-w - 16.0, 0.0));
}
painter.rect(
r,
CornerRadius::same(6),
bg,
Stroke::new(1.0, border),
egui::StrokeKind::Inside,
);
painter.galley(r.min + vec2(8.0, 6.0), t1.clone(), muted);
painter.galley(r.min + vec2(8.0, 6.0 + t1.size().y), t2, fg);
}
/// One group of bars on a [`bar_chart`].
pub struct BarGroup {
pub label: String,
pub values: Vec<Decimal>,
}
/// Grouped bars (e.g. income and spending per month), with a legend.
/// Returns the clicked group, if any.
pub fn bar_chart(
ui: &mut Ui,
groups: &[BarGroup],
series: &[(&str, Color32)],
height: f32,
) -> Option<usize> {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
for (name, color) in series {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(3), *color);
ui.label(
egui::RichText::new(*name)
.font(theme::regular(12.0))
.color(p.muted),
);
ui.add_space(8.0);
}
});
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
let painter = ui.painter_at(rect);
if groups.is_empty() {
return None;
}
let all: Vec<f64> = groups
.iter()
.flat_map(|g| g.values.iter().map(|v| f(*v)))
.collect();
let lo = all.iter().cloned().fold(0.0f64, f64::min);
let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0);
let ts = ticks(lo, hi, 4);
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0));
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
for t in &ts {
let yy = y(*t);
painter.line_segment(
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
Stroke::new(1.0, p.border),
);
painter.text(
pos2(plot.left() - 8.0, yy),
Align2::RIGHT_CENTER,
money_label(*t),
theme::regular(11.0),
p.faint,
);
}
let gw = plot.width() / groups.len() as f32;
let n = series.len().max(1) as f32;
let bw = ((gw * 0.7) / n).min(28.0);
let mut clicked = None;
let hover = resp.hover_pos();
for (gi, g) in groups.iter().enumerate() {
let gx = plot.left() + gw * gi as f32;
let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom()));
let hovered = hover.is_some_and(|h| grect.contains(h));
if hovered {
painter.rect_filled(grect, CornerRadius::same(6), p.hover);
}
let total_w = bw * n + 3.0 * (n - 1.0);
let start = gx + (gw - total_w) / 2.0;
for (si, v) in g.values.iter().enumerate() {
let color = series.get(si).map(|s| s.1).unwrap_or(p.accent);
let x0 = start + si as f32 * (bw + 3.0);
let (y0, y1) = (y(0.0), y(f(*v)));
let r = Rect::from_min_max(
pos2(x0, y0.min(y1)),
pos2(x0 + bw, y0.max(y1).max(y0.min(y1) + 1.0)),
);
painter.rect_filled(
r,
CornerRadius {
nw: 3,
ne: 3,
sw: 0,
se: 0,
},
color,
);
}
painter.text(
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
Align2::CENTER_TOP,
&g.label,
theme::regular(11.0),
p.faint,
);
if hovered {
let lines: Vec<String> = series
.iter()
.zip(&g.values)
.map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v)))
.collect();
tooltip(
&painter,
pos2(gx + gw / 2.0, plot.top()),
&g.label,
&lines.join("\n"),
p.surface,
p.border,
p.text,
p.muted,
rect,
);
if resp.clicked() {
clicked = Some(gi);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
}
clicked
}
/// Ranked horizontal bars with labels and exact amounts. Returns the
/// clicked row.
pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<usize> {
let p = pal(ui.ctx());
let max = rows
.iter()
.map(|(_, v)| f(*v).abs())
.fold(0.0, f64::max)
.max(1e-9);
let mut clicked = None;
for (i, (label, v)) in rows.iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let painter = ui.painter_at(rect);
if resp.hovered() {
painter.rect_filled(rect, CornerRadius::same(6), p.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
let amount = fmt::dollars(*v);
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
let label_w = (rect.width() * 0.38).min(220.0);
let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w);
painter.galley(
pos2(rect.left() + 6.0, rect.center().y - lg.size().y / 2.0),
lg,
p.text,
);
let bar_left = rect.left() + label_w + 14.0;
let bar_max = rect.right() - ag.size().x - 18.0 - bar_left;
let w = (f(*v).abs() / max) as f32 * bar_max.max(0.0);
let bar = Rect::from_min_size(
pos2(bar_left, rect.center().y - 6.0),
vec2(w.max(2.0), 12.0),
);
painter.rect_filled(bar, CornerRadius::same(3), color);
painter.galley(
pos2(
rect.right() - ag.size().x - 6.0,
rect.center().y - ag.size().y / 2.0,
),
ag,
p.text,
);
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// A tiny trend line.
pub fn sparkline(painter: &Painter, rect: Rect, values: &[f64], color: Color32) {
let lo = values.iter().cloned().fold(f64::MAX, f64::min);
let hi = values.iter().cloned().fold(f64::MIN, f64::max);
let span = (hi - lo).max(1e-9);
let n = values.len().max(2) - 1;
let pts: Vec<Pos2> = values
.iter()
.enumerate()
.map(|(i, v)| {
pos2(
rect.left() + rect.width() * i as f32 / n as f32,
rect.bottom() - ((v - lo) / span) as f32 * rect.height(),
)
})
.collect();
painter.add(Shape::line(pts, Stroke::new(1.5, color)));
}
+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)));
}
}
}
+796
View File
@@ -0,0 +1,796 @@
//! A demo company, built through the real engine: fifteen months of a
//! small design studio (single-member LLC) with client invoices, template
//! sales paid out by a platform, contractors (one without a W-9), rent on a
//! recurring schedule, a credit card, a laptop on depreciation, prepaid
//! insurance, owner draws, a closed year, PDF receipts, and a few loose
//! ends for the "needs attention" list. Deterministic for a given `today`.
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::{
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
};
use ledger_store::{Store, StoreError};
use rust_decimal::Decimal;
use std::path::Path;
/// The demo's `wanderbooks.toml`.
pub const BOOKS_TOML: &str = r#"[company]
name = "Juniper Studio LLC"
entity_type = "LLC, single-member"
tax_classification = "disregarded entity" # Schedule C
fiscal_year_start = "01-01"
formation_state = "OR"
formed = "2025-06-02"
ein = "00-0000000"
address = "1200 NW Everett St, Portland, OR 97209"
owner = "Alex Rivera"
[books]
accounting_basis = "accrual"
receipt_threshold = "25.00"
close_into = "3000"
close_sweep = ["3100"]
cash_accounts = ["1000", "1010"]
"#;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0 >> 33
}
/// Uniform in [lo, hi] dollars, to the cent.
fn amount(&mut self, lo: u64, hi: u64) -> Decimal {
let cents = lo * 100 + self.next() % ((hi - lo) * 100 + 1);
Decimal::new(cents as i64, 2)
}
fn pick(&mut self, n: u64) -> u64 {
self.next() % n
}
}
fn usd(d: Decimal) -> Money {
Money::new(d, Currency::USD)
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
fn day(month_start: NaiveDate, d: u32) -> NaiveDate {
let last = (month_start + Months::new(1) - Days::new(1)).day();
NaiveDate::from_ymd_opt(month_start.year(), month_start.month(), d.min(last)).expect("valid")
}
/// A one-page PDF receipt with the given lines (Helvetica).
pub fn receipt_pdf(title: &str, lines: &[String]) -> Vec<u8> {
let esc = |s: &str| {
s.replace('\\', "\\\\")
.replace('(', "\\(")
.replace(')', "\\)")
};
let mut content = String::new();
content.push_str("BT /F2 22 Tf 56 760 Td (");
content.push_str(&esc(title));
content.push_str(") Tj ET\n");
content.push_str("0.85 0.85 0.85 RG 56 742 m 556 742 l S\n");
let mut y = 712;
for l in lines {
let (font, size) = if l.starts_with("TOTAL") {
("F2", 13)
} else {
("F1", 11)
};
content.push_str(&format!(
"BT /{font} {size} Tf 56 {y} Td ({}) Tj ET\n",
esc(l)
));
y -= 20;
}
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in offsets {
pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
pdf
}
fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &str) -> Vec<u8> {
receipt_pdf(
vendor,
&[
format!("Receipt {}", date.format("%B %-d, %Y")),
format!(
"Order {:08}",
(date.num_days_from_ce() as u64 * 7919
+ (amount * dec(100)).try_into().unwrap_or(0u64))
% 100_000_000
),
String::new(),
item.to_string(),
format!("Amount: ${amount}"),
String::new(),
format!("TOTAL ${amount}"),
format!("Paid with {paid}"),
String::new(),
"Thank you for your business.".into(),
],
)
}
/// Build the demo books in `dir` (which must not already hold books): the
/// journal and blobs under `dir/books-data`, the settings in
/// `dir/wanderbooks.toml`.
pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
std::fs::create_dir_all(dir)?;
std::fs::write(dir.join("wanderbooks.toml"), BOOKS_TOML)?;
let mut s = Store::create(&dir.join("books-data"), Currency::USD)?;
let mut rng = Rng(0x5eed_1234);
let start =
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15);
let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid");
let acct =
|s: &mut Store, code: &str, name: &str, t, tax: Option<&str>| -> Result<u64, StoreError> {
let id = s.add_account(code, name, t, None, false)?;
if let Some(c) = tax {
s.set_account_tax(id, Some(c), None)?;
}
Ok(id)
};
use AccountType::*;
let checking = acct(&mut s, "1000", "Operating Checking", Asset, None)?;
let savings = acct(&mut s, "1010", "Savings", Asset, None)?;
let prepaid = acct(&mut s, "1300", "Prepaid Insurance", Asset, None)?;
let equipment = acct(&mut s, "1500", "Computer Equipment", Asset, None)?;
let accum = acct(&mut s, "1510", "Accumulated Depreciation", Asset, None)?;
let card = acct(&mut s, "2100", "Business Credit Card", Liability, None)?;
let capital = acct(&mut s, "3000", "Owner's Capital", Equity, None)?;
let draws = acct(&mut s, "3100", "Owner's Draws", Equity, None)?;
let _services = acct(
&mut s,
"4000",
"Design Services",
Revenue,
Some("gross_receipts"),
)?;
let templates = acct(
&mut s,
"4100",
"Template Sales",
Revenue,
Some("gross_receipts"),
)?;
let fees = acct(
&mut s,
"6000",
"Platform Fees",
Expense,
Some("commissions_fees"),
)?;
let contract = acct(
&mut s,
"6050",
"Contract Labor",
Expense,
Some("contract_labor"),
)?;
let software = acct(
&mut s,
"6100",
"Software & Subscriptions",
Expense,
Some("office_expense"),
)?;
let rent = acct(
&mut s,
"6200",
"Studio Rent",
Expense,
Some("rent_property"),
)?;
let insurance = acct(&mut s, "6300", "Insurance", Expense, Some("insurance"))?;
let advertising = acct(&mut s, "6400", "Advertising", Expense, Some("advertising"))?;
let meals = acct(&mut s, "6500", "Meals", Expense, Some("meals"))?;
let travel = acct(&mut s, "6600", "Travel", Expense, Some("travel"))?;
let supplies = acct(&mut s, "6700", "Office Supplies", Expense, Some("supplies"))?;
let depreciation = acct(
&mut s,
"6800",
"Depreciation",
Expense,
Some("depreciation"),
)?;
let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?;
let client_class = s.add_class("Client work")?;
let template_class = s.add_class("Templates")?;
let sam = s.add_vendor("Sam Okafor")?;
s.set_vendor_1099(sam, Some(Form1099::Nec), true)?;
let riley = s.add_vendor("Riley Chen")?;
s.set_vendor_1099(riley, Some(Form1099::Nec), false)?;
let landlord = s.add_vendor("Everett Lofts LLC")?;
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)?;
let customers: Vec<u64> = [
"Fern & Field Coffee",
"Bramble Outdoor Co.",
"Northwind Clinics",
"Tidepool Records",
"Larkspur Bakery",
]
.iter()
.map(|n| s.add_customer(n))
.collect::<Result<_, _>>()?;
// Formation documents.
s.file_document(
"formation",
"Articles of Organization.pdf",
"application/pdf",
&receipt_pdf(
"Articles of Organization",
&[
"Juniper Studio LLC".into(),
"Oregon Secretary of State".into(),
format!("Filed {}", start.format("%B %-d, %Y")),
],
),
Some("Filed with the state"),
)?;
s.file_document(
"tax",
"IRS EIN confirmation.pdf",
"application/pdf",
&receipt_pdf(
"EIN Assignment",
&[
"Juniper Studio LLC".into(),
"Employer Identification Number assigned".into(),
],
),
Some("CP 575"),
)?;
// Helper: post a two-line entry with an optional receipt and vendor.
#[allow(clippy::too_many_arguments)]
fn post(
s: &mut Store,
date: NaiveDate,
desc: &str,
dr: u64,
cr: u64,
amount: Decimal,
receipt: Option<(&str, Vec<u8>)>,
vendor: Option<u64>,
class: Option<u32>,
) -> Result<u64, StoreError> {
let e = s.create_entry(date, desc)?;
s.add_line(e, dr, Side::Debit, usd(amount))?;
s.add_line(e, cr, Side::Credit, usd(amount))?;
if let Some(c) = class {
s.set_line_class(e, 0, Some(c))?;
s.set_line_class(e, 1, Some(c))?;
}
if let Some((name, bytes)) = receipt {
s.attach_receipt(Some(e), name, "application/pdf", &bytes, None)?;
}
s.post_entry(e)?;
if vendor.is_some() {
s.set_entry_vendor(e, vendor)?;
}
Ok(e)
}
post(
&mut s,
day(start, 2),
"Owner contribution — startup capital",
checking,
capital,
dec(15000),
Some((
"bank-transfer.pdf",
receipt(
"Transfer confirmation",
day(start, 2),
"Transfer from Alex Rivera personal checking",
dec(15000),
"ACH",
),
)),
None,
None,
)?;
// Rent: a recurring entry, posted every month with its invoice; this
// month's is left as a draft.
s.register_recurring(NewRecurring {
description: "Studio rent — Everett Lofts".into(),
lines: vec![
(rent, Side::Debit, usd(dec(1800))),
(checking, Side::Credit, usd(dec(1800))),
],
frequency: Frequency::Monthly,
start: day(start, 1),
end: None,
})?;
// Laptop and its depreciation.
let laptop_date = day(start + Months::new(1), 9);
let laptop = dec(3199);
post(
&mut s,
laptop_date,
"MacBook Pro 16\" — studio workstation",
equipment,
card,
laptop,
Some((
"apple-store.pdf",
receipt(
"Apple Store",
laptop_date,
"MacBook Pro 16-inch, M-series",
laptop,
"Visa ending 4242",
),
)),
None,
None,
)?;
let asset = s.register_asset(NewAsset {
description: "MacBook Pro 16\"".into(),
purchase_date: laptop_date,
cost: usd(laptop),
salvage_value: usd(dec(400)),
useful_life_months: 36,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depreciation,
})?;
// Prepaid insurance, spread over a year.
let ins_date = day(start + Months::new(2), 15);
post(
&mut s,
ins_date,
"General liability insurance — annual premium",
prepaid,
checking,
dec(1440),
Some((
"hiscox-policy.pdf",
receipt(
"Hiscox",
ins_date,
"General liability, 12 months",
dec(1440),
"ACH",
),
)),
None,
None,
)?;
s.register_schedule(NewSchedule {
description: "General liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd(dec(1440)),
start: ins_date,
months: 12,
balance_account: prepaid,
pl_account: insurance,
})?;
let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new();
let mut card_balance = Decimal::ZERO;
let mut m = start;
let mut month_index = 0;
while m <= this_month {
let current = m == this_month;
let in_range = |d: NaiveDate| d <= today;
// Software on the card.
for (vendor, item, lo, hi, d) in [
("Figma", "Figma Professional — monthly", 45, 45, 3),
("Adobe", "Creative Cloud All Apps", 59, 60, 7),
("Google Workspace", "Business Starter — 2 users", 28, 28, 11),
] {
let date = day(m, d);
if !in_range(date) {
continue;
}
let amt = rng.amount(lo, hi);
post(
&mut s,
date,
&format!("{vendor} — {item}"),
software,
card,
amt,
Some((
&format!("{}-{}.pdf", vendor.to_lowercase().replace(' ', "-"), date),
receipt(vendor, date, item, amt, "Visa ending 4242"),
)),
None,
None,
)?;
card_balance += amt;
}
// Advertising some months.
if month_index % 3 == 1 {
let date = day(m, 14);
if in_range(date) {
let amt = rng.amount(150, 420);
post(
&mut s,
date,
"Instagram ads — portfolio campaign",
advertising,
card,
amt,
Some((
"meta-ads.pdf",
receipt("Meta Ads", date, "Sponsored posts", amt, "Visa ending 4242"),
)),
None,
None,
)?;
card_balance += amt;
}
}
// Meals and supplies.
let date = day(m, 17);
if in_range(date) {
let amt = rng.amount(28, 96);
let missing = current || month_index == 13;
post(
&mut s,
date,
"Client lunch — project kickoff",
meals,
card,
amt,
(!missing).then(|| {
(
"lunch.pdf",
receipt(
"Tasty n Alder",
date,
"Lunch for 2",
amt,
"Visa ending 4242",
),
)
}),
None,
None,
)?;
card_balance += amt;
}
if month_index % 4 == 2 {
let date = day(m, 21);
if in_range(date) {
let amt = rng.amount(40, 180);
post(
&mut s,
date,
"Paper, ink, and sketchbooks",
supplies,
card,
amt,
Some((
"blick.pdf",
receipt(
"Blick Art Materials",
date,
"Supplies",
amt,
"Visa ending 4242",
),
)),
None,
None,
)?;
card_balance += amt;
}
}
if month_index == 7 {
let date = day(m, 9);
if in_range(date) {
let amt = dec(1186) + rng.amount(0, 90);
post(
&mut s,
date,
"Flights and hotel — client workshop, Austin",
travel,
card,
amt,
Some((
"alaska-air.pdf",
receipt(
"Alaska Airlines",
date,
"PDX-AUS round trip + 2 nights",
amt,
"Visa ending 4242",
),
)),
None,
None,
)?;
card_balance += amt;
}
}
// Pay the card in full on the 25th.
let date = day(m, 25);
if in_range(date) && card_balance > Decimal::ZERO {
post(
&mut s,
date,
"Credit card payment",
card,
checking,
card_balance,
Some((
"card-statement.pdf",
receipt(
"Chase Ink",
date,
"Statement balance payment",
card_balance,
"ACH from Operating Checking",
),
)),
None,
None,
)?;
card_balance = Decimal::ZERO;
}
// Client invoices: two or three a month, net 30.
for k in 0..(2 + rng.pick(2)) {
let date = day(m, 5 + 9 * k as u32);
if !in_range(date) {
continue;
}
let cust = customers[rng.pick(customers.len() as u64) as usize];
let amt = (rng.amount(1800, 7800) / dec(50)).round() * dec(50);
let inv = s.create_invoice(
cust,
date,
date + Days::new(30),
vec![DocumentLine {
description: [
"Brand identity",
"Website design",
"Packaging",
"Illustration set",
"Design retainer",
][rng.pick(5) as usize]
.into(),
amount: amt,
}],
)?;
let entry = s.ledger().get_invoice(inv).map(|i| i.entry_id);
if let Some(e) = entry {
let _ = e;
}
open_invoices.push((inv, date, amt));
}
// Collect invoices 20-50 days out; leave the latest ones open and one overdue.
let mut still_open = Vec::new();
for (inv, date, amt) in open_invoices.drain(..) {
let paid_on = (date + Days::new(20 + rng.pick(30))).max(day(m, 2));
let overdue_one = month_index == 12 && inv % 2 == 0;
if paid_on <= today && paid_on < m + Months::new(1) && !overdue_one {
s.receive_payment(inv, paid_on, amt, checking)?;
} else {
still_open.push((inv, date, amt));
}
}
open_invoices = still_open;
// Template sales, paid out by the platform on the 1st for last month.
if month_index > 1 {
let date = day(m, 1);
if in_range(date) {
let gross = rng.amount(450, 1600);
let fee = (gross * Decimal::new(10, 2)).round_dp(2) + Decimal::new(30, 2);
let net = gross - fee;
let e = s.create_entry(date, "Gumroad payout — template sales")?;
s.add_line(e, checking, Side::Debit, usd(net))?;
s.add_line(e, fees, Side::Debit, usd(fee))?;
s.add_line(e, templates, Side::Credit, usd(gross))?;
for i in 0..3 {
s.set_line_class(e, i, Some(template_class))?;
}
s.attach_receipt(
Some(e),
"gumroad-payout.pdf",
"application/pdf",
&receipt_pdf(
"Gumroad payout",
&[
format!("Payout {}", date.format("%B %-d, %Y")),
format!("Gross sales: ${gross}"),
format!("Fees: ${fee}"),
format!("TOTAL ${net}"),
"Deposited to checking ending 0187".into(),
],
),
None,
)?;
s.post_entry(e)?;
}
}
// Contractors.
if month_index % 2 == 0 && month_index > 0 {
let date = day(m, 28);
if in_range(date) {
let amt = (rng.amount(900, 2200) / dec(25)).round() * dec(25);
post(
&mut s,
date,
"Sam Okafor — illustration (invoice)",
contract,
checking,
amt,
Some((
"sam-okafor-invoice.pdf",
receipt(
"Sam Okafor Illustration",
date,
"Illustration for client projects",
amt,
"ACH",
),
)),
Some(sam),
Some(client_class),
)?;
}
}
if month_index == 11 || month_index == 13 {
let date = day(m, 19);
if in_range(date) {
post(
&mut s,
date,
"Riley Chen — front-end development",
contract,
checking,
dec(1400),
Some((
"riley-chen.pdf",
receipt(
"Riley Chen",
date,
"Website build, milestone",
dec(1400),
"ACH",
),
)),
Some(riley),
Some(client_class),
)?;
}
}
// Owner draws and savings.
if month_index >= 3 {
let date = day(m, 28);
if in_range(date) {
post(
&mut s,
date,
"Owner draw",
draws,
checking,
dec(3500),
None,
None,
None,
)?;
}
}
if month_index % 3 == 2 {
let date = day(m, 26);
if in_range(date) {
post(
&mut s,
date,
"Transfer to savings",
savings,
checking,
dec(4000),
None,
None,
None,
)?;
}
}
let date = day(m, 30);
if in_range(date) && month_index % 5 == 4 {
post(
&mut s,
date,
"Wire fee",
bank_fees,
checking,
dec(30),
None,
None,
None,
)?;
}
// Month-end: rent occurrences, depreciation, amortization.
let month_end = m + Months::new(1) - Days::new(1);
let through = if current { today } else { month_end };
for (_, date, entry) in s.run_recurring(through)? {
if current {
continue; // this month's rent waits as a draft
}
s.attach_receipt(
Some(entry),
&format!("rent-{date}.pdf"),
"application/pdf",
&receipt(
"Everett Lofts LLC",
date,
"Studio 4B rent",
dec(1800),
"ACH",
),
None,
)?;
s.post_entry(entry)?;
s.set_entry_vendor(entry, Some(landlord))?;
}
if !current {
if m >= laptop_date.with_day(1).expect("valid") {
s.run_depreciation(asset, &m.format("%Y-%m").to_string())?;
}
s.run_schedules(month_end)?;
}
// Close each finished calendar year into capital, draws swept in.
if m.month() == 12 && !current {
let ys = NaiveDate::from_ymd_opt(m.year(), 1, 1).expect("valid");
let p = s.create_period(&m.year().to_string(), ys.max(start), month_end)?;
s.close_period_to(
p,
&CloseTo::Account {
into: capital,
sweep: vec![draws],
},
)?;
}
m = m + Months::new(1);
month_index += 1;
}
let _ = (bank_fees, client_class);
Ok(())
}
+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");
}
}
+21
View File
@@ -0,0 +1,21 @@
//! `ledger-gui`: a read-only, live view of wanderbooks books for the people
//! who own them and the accountant who checks them. See `plan/gui.md`.
#![allow(missing_docs)]
pub mod app;
mod attention;
pub mod books;
mod charts;
pub mod data;
pub mod demo;
mod fmt;
pub mod nav;
mod pages;
mod palette;
mod receipts;
mod theme;
mod widgets;
pub use app::App;
pub use data::Source;
+72
View File
@@ -0,0 +1,72 @@
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]`
//!
//! Opens the books read-only and follows them live. With no flags it looks
//! for `books-data/` and `wanderbooks.toml` in the current directory.
//! `--demo` builds a demo company in a temp directory and opens that.
use ledger_gui::{App, Source};
use std::path::PathBuf;
fn main() -> eframe::Result<()> {
let mut data_dir: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut today = None;
let mut demo = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--data-dir" => data_dir = args.next().map(PathBuf::from),
"--books-toml" => books_toml = args.next().map(PathBuf::from),
"--today" => today = args.next().and_then(|d| d.parse().ok()),
"--demo" => demo = true,
"-h" | "--help" => {
println!(
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]"
);
return Ok(());
}
other => {
eprintln!("unknown argument {other}");
std::process::exit(2);
}
}
}
if demo {
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
let _ = std::fs::remove_dir_all(&dir);
if let Err(e) = ledger_gui::demo::build(&dir, d) {
eprintln!("building the demo books failed: {e}");
std::process::exit(1);
}
eprintln!("demo books: {}", dir.display());
data_dir = Some(dir.join("books-data"));
books_toml = Some(dir.join("wanderbooks.toml"));
}
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
let books_toml = books_toml.or_else(|| {
let guess = data_dir
.parent()
.unwrap_or(&data_dir)
.join("wanderbooks.toml");
guess.exists().then_some(guess)
});
let source = Source {
data_dir,
books_toml,
today,
};
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("wanderbooks")
.with_app_id("wanderbooks")
.with_inner_size([1440.0, 920.0])
.with_min_inner_size([900.0, 600.0]),
..Default::default()
};
eframe::run_native(
"wanderbooks",
options,
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
)
}
+195
View File
@@ -0,0 +1,195 @@
//! Where you are and how you got there: a browser-style history.
use ledger_core::{AccountId, EntryId};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ReportKind {
ProfitAndLoss,
BalanceSheet,
CashFlow,
TrialBalance,
GeneralLedger,
MembersEquity,
TaxSummary,
ArAging,
ApAging,
Vendor1099,
}
impl ReportKind {
pub const ALL: [ReportKind; 10] = [
ReportKind::ProfitAndLoss,
ReportKind::BalanceSheet,
ReportKind::CashFlow,
ReportKind::MembersEquity,
ReportKind::TrialBalance,
ReportKind::GeneralLedger,
ReportKind::TaxSummary,
ReportKind::Vendor1099,
ReportKind::ArAging,
ReportKind::ApAging,
];
pub fn title(self) -> &'static str {
match self {
ReportKind::ProfitAndLoss => "Profit and loss",
ReportKind::BalanceSheet => "Balance sheet",
ReportKind::CashFlow => "Cash flow",
ReportKind::TrialBalance => "Trial balance",
ReportKind::GeneralLedger => "General ledger",
ReportKind::MembersEquity => "Owners' equity",
ReportKind::TaxSummary => "Tax summary",
ReportKind::ArAging => "Receivables aging",
ReportKind::ApAging => "Payables aging",
ReportKind::Vendor1099 => "1099 worksheet",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Route {
Overview,
Cash,
Profit,
Transactions,
Entry(EntryId),
Reports(ReportKind),
Accounts,
Account(AccountId),
Documents,
Document(u64),
Company,
Journal,
JournalEvent(u64),
}
impl Route {
/// The sidebar section this route belongs to.
pub fn section(&self) -> Section {
match self {
Route::Overview => Section::Overview,
Route::Cash => Section::Cash,
Route::Profit => Section::Profit,
Route::Transactions | Route::Entry(_) => Section::Transactions,
Route::Reports(_) => Section::Reports,
Route::Accounts | Route::Account(_) => Section::Accounts,
Route::Documents | Route::Document(_) => Section::Documents,
Route::Company => Section::Company,
Route::Journal | Route::JournalEvent(_) => Section::Journal,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Section {
Overview,
Cash,
Profit,
Transactions,
Reports,
Accounts,
Documents,
Company,
Journal,
}
impl Section {
pub const ALL: [Section; 9] = [
Section::Overview,
Section::Cash,
Section::Profit,
Section::Transactions,
Section::Reports,
Section::Accounts,
Section::Documents,
Section::Company,
Section::Journal,
];
pub fn title(self) -> &'static str {
match self {
Section::Overview => "Overview",
Section::Cash => "Cash",
Section::Profit => "Profit",
Section::Transactions => "Transactions",
Section::Reports => "Reports",
Section::Accounts => "Accounts",
Section::Documents => "Documents",
Section::Company => "Company",
Section::Journal => "Journal",
}
}
pub fn icon(self) -> &'static str {
use egui_phosphor::regular as i;
match self {
Section::Overview => i::SQUARES_FOUR,
Section::Cash => i::BANK,
Section::Profit => i::TREND_UP,
Section::Transactions => i::LIST_BULLETS,
Section::Reports => i::FILE_TEXT,
Section::Accounts => i::TREE_STRUCTURE,
Section::Documents => i::FILES,
Section::Company => i::BUILDINGS,
Section::Journal => i::SCROLL,
}
}
pub fn home(self) -> Route {
match self {
Section::Overview => Route::Overview,
Section::Cash => Route::Cash,
Section::Profit => Route::Profit,
Section::Transactions => Route::Transactions,
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
Section::Accounts => Route::Accounts,
Section::Documents => Route::Documents,
Section::Company => Route::Company,
Section::Journal => Route::Journal,
}
}
}
/// Back/forward history.
pub struct Nav {
back: Vec<Route>,
forward: Vec<Route>,
pub current: Route,
}
impl Nav {
pub fn new(start: Route) -> Self {
Self {
back: Vec::new(),
forward: Vec::new(),
current: start,
}
}
pub fn go(&mut self, to: Route) {
if to != self.current {
self.back.push(std::mem::replace(&mut self.current, to));
self.forward.clear();
}
}
pub fn can_back(&self) -> bool {
!self.back.is_empty()
}
pub fn can_forward(&self) -> bool {
!self.forward.is_empty()
}
pub fn back(&mut self) {
if let Some(r) = self.back.pop() {
self.forward.push(std::mem::replace(&mut self.current, r));
}
}
pub fn forward(&mut self) {
if let Some(r) = self.forward.pop() {
self.back.push(std::mem::replace(&mut self.current, r));
}
}
}
+350
View File
@@ -0,0 +1,350 @@
//! The chart of accounts with balances, and one account's register: every
//! posting with a running balance and the balance over time.
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::NaiveDate;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::AccountType;
use ledger_core::analysis::balance_history;
use ledger_core::reports::general_ledger;
use rust_decimal::Decimal;
#[derive(Default)]
pub struct State {
/// Limit the register to these dates (set by drill-downs).
pub range: Option<(NaiveDate, NaiveDate)>,
pub show_empty: bool,
}
pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "accounts", |ui| {
widgets::page_header(
ui,
"Accounts",
"Every account in the chart with today's balance. Click one for its register.",
);
ui.checkbox(
&mut shell.state.accounts.show_empty,
"Show accounts with no activity",
);
ui.add_space(10.0);
let used: std::collections::HashSet<u64> = b
.entries
.iter()
.flat_map(|e| e.accounts.iter().copied())
.collect();
for (t, title) in [
(AccountType::Asset, "Assets"),
(AccountType::Liability, "Liabilities"),
(AccountType::Equity, "Equity"),
(AccountType::Revenue, "Income"),
(AccountType::Expense, "Expenses"),
] {
let accounts: Vec<_> = b
.ledger
.chart()
.all()
.filter(|a| {
a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id))
})
.collect();
if accounts.is_empty() {
continue;
}
let mut go = None;
widgets::titled_card(
ui,
title,
|_| {},
|ui| {
for a in &accounts {
let bal = b.balance_on(a.id, b.today);
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
painter.text(
r.left_center() + vec2(8.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.code,
theme::regular(13.0),
p.muted,
);
painter.text(
r.left_center() + vec2(64.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.name,
theme::regular(13.5),
p.text,
);
let mut tags = Vec::new();
if b.cash_accounts.contains(&a.id) {
tags.push("cash".to_string());
}
if let Some(c) = &a.tax_category {
let pct = a
.deductible_percent
.map(|p| format!(" {}%", p.normalize()))
.unwrap_or_default();
tags.push(format!("tax: {}{pct}", c.replace('_', " ")));
} else if matches!(t, AccountType::Revenue | AccountType::Expense) {
tags.push("no tax category".into());
}
if a.placeholder {
tags.push("header".into());
}
painter.text(
r.left_center() + vec2(340.0, 0.0),
egui::Align2::LEFT_CENTER,
tags.join(" · "),
theme::regular(12.0),
p.faint,
);
painter.text(
r.right_center() - vec2(8.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
theme::semibold(13.5),
p.text,
);
if resp.clicked() {
go = Some(a.id);
}
}
},
);
if let Some(id) = go {
shell.state.accounts.range = None;
shell.go(Route::Account(id));
}
ui.add_space(12.0);
}
});
}
pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(a) = b.ledger.chart().get(id) else {
return;
};
let first = b.kpis.first_entry.unwrap_or(b.today);
let (s, e) = shell
.state
.accounts
.range
.unwrap_or((first.min(b.today), b.today));
page(ui, "account", |ui| {
ui.label(
RichText::new(format!("{:?} account · {}", a.account_type, a.code))
.font(theme::regular(13.0))
.color(p.muted),
);
ui.label(RichText::new(&a.name).font(theme::bold(26.0)));
ui.add_space(4.0);
let bal = b.balance_on(id, b.today);
ui.label(
RichText::new(format!("Balance today {}", fmt::dollars(bal)))
.font(theme::regular(15.0))
.color(p.muted),
);
ui.add_space(14.0);
let hist = balance_history(&b.ledger, &[id]);
if hist.len() > 1 && a.account_type.is_balance_sheet() {
widgets::titled_card(
ui,
"Balance over time",
|_| {},
|ui| crate::charts::balance_line(ui, &hist, b.today, 180.0),
);
ui.add_space(12.0);
}
ui.horizontal(|ui| {
ui.label(
RichText::new(format!(
"{} {} – {}",
icon::CALENDAR_BLANK,
fmt::date(s),
fmt::date(e)
))
.font(theme::semibold(13.5)),
);
if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() {
shell.state.accounts.range = None;
}
if ui
.button(format!("{} Find in transactions", icon::LIST_BULLETS))
.clicked()
{
shell.state.transactions.account = Some(id);
shell.go(Route::Transactions);
}
});
ui.add_space(8.0);
let Ok(gl) = general_ledger(&b.ledger, s, e) else {
return;
};
let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::EMPTY,
"No activity",
"Nothing posted to this account in this range.",
)
});
return;
};
let mut go = None;
widgets::card(ui, |ui| {
let w = ui.available_width();
let amt = 120.0;
let header = |ui: &mut Ui, vals: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[0],
theme::semibold(12.0),
p.muted,
);
ui.painter().text(
r.left_center() + vec2(90.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[1],
theme::semibold(12.0),
p.muted,
);
for (i, v) in vals[2..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*v,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
widgets::rule(ui);
let line = |ui: &mut Ui,
date: String,
desc: String,
sub: String,
d: Decimal,
c: Decimal,
bal: Decimal,
strong: bool|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
Sense::click(),
);
if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
let font = if strong {
theme::semibold(13.0)
} else {
theme::regular(13.0)
};
let y = if sub.is_empty() {
r.center().y
} else {
r.center().y - 7.0
};
painter.text(
egui::pos2(r.left() + 4.0, y),
egui::Align2::LEFT_CENTER,
date,
theme::regular(12.5),
p.muted,
);
painter.text(
egui::pos2(r.left() + 90.0, y),
egui::Align2::LEFT_CENTER,
desc,
font.clone(),
p.text,
);
if !sub.is_empty() {
painter.text(
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(11.5),
p.faint,
);
}
for (i, v) in [d, c].iter().enumerate() {
if !v.is_zero() {
painter.text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::money(*v),
font.clone(),
p.text,
);
}
}
painter.text(
egui::pos2(r.right() - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
font,
p.text,
);
resp
};
line(
ui,
fmt::date_short(s),
"Beginning balance".into(),
String::new(),
Decimal::ZERO,
Decimal::ZERO,
acct.beginning_balance,
true,
);
for post in acct.postings.iter().rev() {
let r = line(
ui,
fmt::date_short(post.date),
post.description.clone(),
format!("#{} · {}", post.entry_id, post.split),
post.debit,
post.credit,
post.balance,
false,
);
if r.clicked() {
go = Some(post.entry_id);
}
}
widgets::rule(ui);
line(
ui,
fmt::date_short(e),
"Ending balance".into(),
String::new(),
acct.total_debits,
acct.total_credits,
acct.ending_balance,
true,
);
});
if let Some(entry) = go {
shell.go(Route::Entry(entry));
}
});
}
+457
View File
@@ -0,0 +1,457 @@
//! Cash: what's in the bank, where it's been going, how long it lasts, and
//! what's coming in and out next.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Days, NaiveDate};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start};
use rust_decimal::Decimal;
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "cash", |ui| {
let k = &b.kpis;
let since = b.today - Days::new(30);
let flows = flows_by_interval(
&b.ledger,
&b.cash_accounts,
since + Days::new(1),
b.today,
Interval::Year,
);
let (cin, cout): (Decimal, Decimal) =
flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| {
(a.0 + f.2, a.1 + f.3)
});
widgets::page_header(
ui,
"Cash",
&format!(
"{} across {}. In the last 30 days {} came in and {} went out.",
fmt::dollars(k.cash),
fmt::count(b.cash_accounts.len(), "account", "accounts"),
fmt::dollars(cin),
fmt::dollars(cout)
),
);
if b.cash_accounts.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::BANK,
"No cash accounts found",
"Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.",
)
});
return;
}
// One tile per cash and card account.
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
let n = tiles.len().clamp(1, 4);
for row in tiles.chunks(n) {
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
let Some((id, is_card)) = row.get(i) else {
return;
};
let hist = balance_history(&b.ledger, &[*id]);
let spark: Vec<f64> = hist
.iter()
.rev()
.take(40)
.rev()
.map(|(_, v)| (*v).try_into().unwrap_or(0.0))
.collect();
let bal = b.balance_on(*id, b.today);
let ago = b.balance_on(*id, since);
let delta = bal - ago;
let r = widgets::kpi(
ui,
widgets::Kpi {
label: &b.account_label(*id),
value: fmt::dollars(bal),
sub: Some((
if *is_card {
"owed on the card".to_string()
} else if delta.is_zero() {
"no change in 30 days".to_string()
} else {
format!(
"{}{} in 30 days",
if delta > Decimal::ZERO { "+" } else { "" },
fmt::dollars(delta)
)
},
if *is_card || delta.is_zero() {
p.muted
} else if delta > Decimal::ZERO {
p.positive
} else {
p.negative
},
)),
spark: (spark.len() > 1).then_some(&spark[..]),
how: "",
},
);
if r.interact(Sense::click()).clicked() {
shell.state.accounts.range = None;
shell.go(Route::Account(*id));
}
});
}
let first = k.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Cash balance",
|_| {},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0),
);
}
_ => {
let months =
flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month);
widgets::titled_card(
ui,
"Money in and out by month",
|_| {},
|ui| {
let groups: Vec<BarGroup> = months
.iter()
.map(|(s, _, i, o)| BarGroup {
label: s.format("%b").to_string(),
values: vec![*i, *o],
})
.collect();
charts::bar_chart(
ui,
&groups,
&[("In", p.series[1]), ("Out", p.series[2])],
210.0,
);
},
);
}
});
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => runway(ui, b),
_ => coming_up(ui, b, shell),
});
biggest_outflows(ui, b, shell);
});
}
fn runway(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let ms = b.month_start();
let three = ms - chrono::Months::new(3);
let start = three.max(
b.kpis
.first_entry
.map_or(three, |f| interval_start(f, Interval::Month)),
);
let months = flows_by_interval(
&b.ledger,
&b.cash_accounts,
start,
ms - Days::new(1),
Interval::Month,
);
widgets::titled_card(
ui,
"Burn and runway",
|_| {},
|ui| {
if months.is_empty() {
ui.label(RichText::new("Needs at least one full month of books.").color(p.muted));
return;
}
let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::<Decimal>()
/ Decimal::from(months.len() as u64);
let net = net.round_dp(2);
let big = |ui: &mut Ui, label: &str, value: String, color| {
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(
RichText::new(value)
.font(theme::semibold(22.0))
.color(color),
);
});
};
ui.horizontal(|ui| {
big(
ui,
"Average monthly net cash flow",
format!(
"{}{}",
if net > Decimal::ZERO { "+" } else { "" },
fmt::dollars(net)
),
if net >= Decimal::ZERO {
p.positive
} else {
p.negative
},
);
ui.add_space(32.0);
if net < Decimal::ZERO {
let months_left = (b.kpis.cash / -net).round_dp(1);
let out = b.today
+ Days::new((months_left * Decimal::from(30)).try_into().unwrap_or(0u64));
big(ui, "Runway", format!("{months_left} months"), p.text);
ui.add_space(32.0);
big(ui, "Cash runs out around", fmt::month(out), p.warning);
} else {
big(ui, "Runway", "Cash is growing".into(), p.positive);
}
});
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).",
b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::<Vec<_>>().join(" and "),
fmt::count(months.len(), "full month", "full months")
))
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}
struct Upcoming {
date: NaiveDate,
label: String,
amount: Decimal,
route: Option<Route>,
}
fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let horizon = b.today + Days::new(45);
let mut items: Vec<Upcoming> = Vec::new();
for r in b.ledger.recurring_entries() {
let cash: Decimal = r
.lines
.iter()
.filter(|(a, _, _)| b.cash_accounts.contains(a))
.map(|(_, s, m)| {
if *s == ledger_core::Side::Debit {
m.amount
} else {
-m.amount
}
})
.sum();
for d in r
.due(horizon)
.into_iter()
.filter(|d| *d > b.today - Days::new(1))
{
items.push(Upcoming {
date: d,
label: format!("{} (recurring)", r.description),
amount: cash,
route: Some(Route::Company),
});
}
}
for id in 1..b.ledger.next_invoice_id() {
if let Some(inv) = b.ledger.get_invoice(id)
&& !inv.balance().is_zero()
{
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
let late = inv.due_date < b.today;
items.push(Upcoming {
date: inv.due_date,
label: format!(
"Invoice {id} from {who}{}",
if late { " — overdue" } else { "" }
),
amount: inv.balance(),
route: Some(Route::Entry(inv.entry_id)),
});
}
}
for id in 1..b.ledger.next_bill_id() {
if let Some(bill) = b.ledger.get_bill(id)
&& !bill.balance().is_zero()
{
let who = b
.ledger
.get_vendor(bill.vendor_id)
.map(|v| v.name.clone())
.unwrap_or_default();
items.push(Upcoming {
date: bill.due_date,
label: format!("Bill {id} to {who}"),
amount: -bill.balance(),
route: Some(Route::Entry(bill.entry_id)),
});
}
}
items.sort_by_key(|i| i.date);
let mut go = None;
widgets::titled_card(
ui,
"Coming up",
|ui| {
ui.label(
RichText::new("open invoices, bills, recurring")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if items.is_empty() {
widgets::empty(
ui,
icon::CALENDAR_CHECK,
"Nothing scheduled",
"No open invoices, bills, or recurring entries due soon.",
);
return;
}
let (inflow, outflow): (Decimal, Decimal) =
items.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, i| {
if i.amount > Decimal::ZERO {
(a.0 + i.amount, a.1)
} else {
(a.0, a.1 - i.amount)
}
});
ui.label(
RichText::new(format!(
"Expected in {} · expected out {}",
fmt::dollars(inflow),
fmt::dollars(outflow)
))
.font(theme::semibold(13.0)),
);
ui.add_space(6.0);
egui::ScrollArea::vertical()
.id_salt("upcoming")
.max_height(220.0)
.show(ui, |ui| {
for it in &items {
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let late = it.date < b.today;
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(it.date),
theme::regular(12.5),
if late { p.negative } else { p.muted },
);
ui.painter().text(
r.left_center() + vec2(70.0, 0.0),
egui::Align2::LEFT_CENTER,
&it.label,
theme::regular(13.0),
p.text,
);
let (txt, c) = if it.amount > Decimal::ZERO {
(format!("+{}", fmt::money(it.amount)), p.positive)
} else {
(fmt::money(it.amount), p.text)
};
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
txt,
theme::semibold(13.0),
c,
);
if resp.clicked() {
go = it.route.clone();
}
}
});
},
);
if let Some(r) = go {
shell.go(r);
}
}
fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let since = b.today - Days::new(30);
let mut out: Vec<&crate::books::EntryInfo> = b
.entries
.iter()
.filter(|e| e.date > since && e.cash_effect < Decimal::ZERO)
.collect();
out.sort_by_key(|e| e.cash_effect);
out.truncate(8);
let mut go = None;
widgets::titled_card(
ui,
"Biggest payments, last 30 days",
|_| {},
|ui| {
if out.is_empty() {
widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", "");
}
for e in &out {
let (r, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(e.date),
theme::regular(12.5),
p.muted,
);
ui.painter().text(
r.left_center() + vec2(70.0, 0.0),
egui::Align2::LEFT_CENTER,
&e.description,
theme::regular(13.0),
p.text,
);
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::money(e.cash_effect),
theme::semibold(13.0),
p.text,
);
if resp.clicked() {
go = Some(e.id);
}
}
},
);
if let Some(id) = go {
shell.go(Route::Entry(id));
}
}
+493
View File
@@ -0,0 +1,493 @@
//! The company: who it is, who owns it, and the standing machinery of the
//! books — periods, fixed assets, amortization schedules, recurring
//! entries, vendors and customers.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::assets::{accumulated_depreciation, book_value};
use ledger_core::{PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
k,
theme::regular(13.0),
p.muted,
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
});
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "company", |ui| {
let c = &b.settings.company;
widgets::page_header(
ui,
&c.name,
"The company's profile and the standing parts of its books.",
);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Profile",
|_| {},
|ui| {
if c.profile.is_empty() {
ui.label(
RichText::new("No [company] settings in wanderbooks.toml.")
.color(p.muted),
);
}
for (k, v) in &c.profile {
kv(ui, &fmt::label(k), v);
}
kv(ui, "Accounting basis", &c.basis);
kv(
ui,
"Tax return",
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
);
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
},
);
}
_ => {
widgets::titled_card(
ui,
"Owners",
|_| {},
|ui| {
if c.members.is_empty() {
// A single owner or a corporation: equity by account.
for a in b
.ledger
.chart()
.all()
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
{
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("{} {}", a.code, a.name))
.font(theme::regular(13.0)),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::accounting(
b.balance_on(a.id, b.today),
))
.font(theme::semibold(13.0)),
);
},
);
});
}
}
for m in &c.members {
let cap = b.ledger.chart().find_by_code(&m.capital_account);
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
widgets::pill(
ui,
&format!("{}%", (m.share * Decimal::from(100)).normalize()),
p.accent,
p.accent_soft,
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::dollars(bal))
.font(theme::semibold(13.5)),
);
},
);
});
if let Some(a) = cap {
ui.label(
RichText::new(format!("Capital account {} {}", a.code, a.name))
.font(theme::regular(12.0))
.color(p.muted),
);
}
ui.add_space(6.0);
}
},
);
ui.add_space(12.0);
widgets::titled_card(
ui,
"Periods",
|_| {},
|ui| {
let periods = b.ledger.list_periods();
if periods.is_empty() {
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
}
for per in periods {
ui.horizontal(|ui| {
let (g, color) = match per.status {
PeriodStatus::Closed => (icon::LOCK, p.muted),
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
};
ui.label(RichText::new(g).color(color));
ui.label(RichText::new(&per.name).font(theme::semibold(13.0)));
ui.label(
RichText::new(format!(
"{} – {}",
fmt::date(per.start),
fmt::date(per.end)
))
.color(p.muted),
);
if let Some(e) = per.closing_entry
&& widgets::link(
ui,
RichText::new(format!("closing entry #{e}"))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(e));
}
});
}
},
);
}
});
assets(ui, b, shell);
schedules(ui, b);
recurring(ui, b);
parties(ui, b);
});
}
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let assets: Vec<_> = b.ledger.registered_assets().collect();
if assets.is_empty() {
return;
}
let mut go = None;
widgets::titled_card(
ui,
"Fixed assets",
|_| {},
|ui| {
for a in &assets {
// What has actually been posted, so this ties to the balance
// sheet; the schedule may run ahead of the books.
let acc: Decimal = b
.journal
.iter()
.filter(|l| {
l.line_type == "asset.depreciation_posted"
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
})
.filter_map(|l| {
l.payload
.get("amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok())
})
.sum();
let bv = a.cost.amount - acc;
let scheduled = accumulated_depreciation(a, b.today);
let _ = book_value;
let dep = a.cost.amount - a.salvage_value.amount;
let frac: f32 = if dep.is_zero() {
1.0
} else {
(acc / dep).try_into().unwrap_or(0.0)
};
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(&a.description).font(theme::semibold(13.5)),
)
.clicked()
{
go = Some(a.cost_account);
}
ui.label(
RichText::new(format!(
"bought {} · {} months · {}",
fmt::date(a.purchase_date),
a.useful_life_months,
fmt::words(&format!("{:?}", a.status)).to_lowercase()
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("book value {}", fmt::dollars(bv)))
.font(theme::semibold(13.0)),
);
});
});
let behind = scheduled - acc;
let note = if behind > Decimal::ZERO {
format!(" · {} more scheduled through today", fmt::dollars(behind))
} else {
String::new()
};
progress(
ui,
frac,
&format!(
"{} of {} depreciated{note}",
fmt::dollars(acc),
fmt::dollars(dep)
),
);
ui.add_space(8.0);
}
},
);
if let Some(id) = go {
shell.go(Route::Account(id));
}
ui.add_space(16.0);
}
fn progress(ui: &mut Ui, frac: f32, label: &str) {
let p = pal(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
let mut done = r;
done.set_width(r.width() * frac.clamp(0.0, 1.0));
ui.painter()
.rect_filled(done, CornerRadius::same(4), p.accent);
ui.label(
RichText::new(label)
.font(theme::regular(11.5))
.color(p.faint),
);
}
fn schedules(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.schedules().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Amortization schedules",
|_| {},
|ui| {
for s in list {
let total = s.total.amount;
let remaining = s.remaining();
let frac: f32 = if total.is_zero() {
1.0
} else {
((total - remaining) / total).try_into().unwrap_or(0.0)
};
let due = s.due(b.today);
ui.horizontal(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {} months from {}",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::month(s.start)
))
.font(theme::regular(12.0))
.color(p.muted),
);
if !due.is_empty() {
widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft);
}
});
progress(
ui,
frac,
&format!(
"{} of {} recognized · {} left",
fmt::dollars(total - remaining),
fmt::dollars(total),
fmt::dollars(remaining)
),
);
ui.add_space(8.0);
}
},
);
ui.add_space(16.0);
}
fn recurring(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.recurring_entries().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Recurring entries",
|_| {},
|ui| {
for r in list {
let amount: Decimal = r
.lines
.iter()
.filter(|(_, s, _)| *s == ledger_core::Side::Debit)
.map(|(_, _, m)| m.amount)
.sum();
let next = (0..)
.map_while(|k| {
r.frequency
.nth(r.start, k)
.filter(|d| r.end.is_none_or(|e| *d <= e))
})
.find(|d| !r.generated.contains(d));
ui.horizontal(|ui| {
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {}",
fmt::words(&format!("{:?}", r.frequency)),
fmt::dollars(amount)
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let text = match next {
Some(d) if d <= b.today => format!("due {}", fmt::date(d)),
Some(d) => format!("next {}", fmt::date(d)),
None => "ended".into(),
};
ui.label(RichText::new(text).font(theme::regular(12.5)).color(
if next.is_some_and(|d| d <= b.today) {
p.warning
} else {
p.muted
},
));
});
});
ui.label(
RichText::new(format!(
"{} generated so far · drafts only; each one is posted with its receipt",
r.generated.len()
))
.font(theme::regular(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
},
);
ui.add_space(16.0);
}
fn parties(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Vendors",
|_| {},
|ui| {
let vendors = b.ledger.list_vendors();
if vendors.is_empty() {
ui.label(RichText::new("No vendors yet.").color(p.muted));
}
for v in vendors {
let paid = r1099
.vendors
.iter()
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
} else {
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
}
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !paid.is_zero() {
ui.label(
RichText::new(format!(
"{} paid in {year}",
fmt::dollars(paid)
))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
_ => {
widgets::titled_card(
ui,
"Customers",
|_| {},
|ui| {
let customers = b.ledger.list_customers();
if customers.is_empty() {
ui.label(RichText::new("No customers yet.").color(p.muted));
}
for c in customers {
let owed: Decimal = (1..b.ledger.next_invoice_id())
.filter_map(|i| b.ledger.get_invoice(i))
.filter(|i| i.customer_id == c.id)
.map(|i| i.balance())
.sum();
ui.horizontal(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !owed.is_zero() {
ui.label(
RichText::new(format!("owes {}", fmt::dollars(owed)))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
});
}
+302
View File
@@ -0,0 +1,302 @@
//! Every receipt and document the books hold, rendered: what it is, what
//! entry it proves, and whether it was withdrawn (and why).
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::receipts::State as Render;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::Attachment;
#[derive(Default)]
pub struct State {
pub category: Option<String>,
pub show_withdrawn: bool,
pub query: String,
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
match selected.and_then(|id| b.attachments.iter().find(|a| a.id == id)) {
Some(a) => super::split(
ui,
"documents",
(ui.available_width() * 0.5).max(460.0),
shell,
|ui, shell| grid(ui, b, Some(a.id), shell),
|ui, shell| detail(ui, b, a, shell),
),
None => grid(ui, b, None, shell),
}
}
fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
page(ui, "documents", |ui| {
let current = b
.attachments
.iter()
.filter(|a| a.withdrawn_at.is_none())
.count();
widgets::page_header(
ui,
"Documents",
&format!(
"{} on file: receipts that prove entries, and the company's records. Bytes are content-addressed and verified on every read.",
fmt::count(current, "document", "documents")
),
);
let mut cats: Vec<String> = b.attachments.iter().map(|a| a.category.clone()).collect();
cats.sort();
cats.dedup();
let st = &mut shell.state.documents;
ui.horizontal_wrapped(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
.desired_width(260.0),
);
if ui.selectable_label(st.category.is_none(), "All").clicked() {
st.category = None;
}
for c in &cats {
if ui
.selectable_label(st.category.as_deref() == Some(c.as_str()), fmt::label(c))
.clicked()
{
st.category = Some(c.clone());
}
}
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
});
ui.add_space(12.0);
let q = st.query.to_lowercase();
let docs: Vec<&Attachment> = b
.attachments
.iter()
.rev()
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
.filter(|a| {
q.is_empty()
|| a.file_name.to_lowercase().contains(&q)
|| a.description
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&q)
})
.collect();
if docs.is_empty() {
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
return;
}
let tile_w = 200.0;
let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0))
.floor()
.max(1.0) as usize;
let mut go = None;
for row in docs.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
for a in row {
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
go = Some(a.id);
}
}
});
ui.add_space(16.0);
}
if let Some(id) = go {
shell.go(Route::Document(id));
}
});
}
fn tile(
ui: &mut Ui,
b: &Books,
a: &Attachment,
w: f32,
on: bool,
shell: &mut Shell<'_>,
) -> egui::Response {
let p = pal(ui.ctx());
let h = 250.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
let fill = if on { p.selected } else { p.surface };
ui.painter().rect(
rect,
CornerRadius::same(10),
fill,
Stroke::new(1.0, if resp.hovered() { p.accent } else { p.border }),
egui::StrokeKind::Inside,
);
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
ui.painter()
.rect_filled(thumb, CornerRadius::same(6), p.hover);
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
Render::Ready(pages) => {
if let Some(tex) = pages.first() {
// Top of the first page, filling the width.
let size = tex.size_vec2();
let scale = thumb.width() / size.x;
let visible_h = (thumb.height() / scale).min(size.y);
let uv = egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, visible_h / size.y),
);
let img_rect =
egui::Rect::from_min_size(thumb.min, vec2(thumb.width(), visible_h * scale));
ui.painter()
.image(tex.id(), img_rect, uv, egui::Color32::WHITE);
}
}
Render::Loading => {
ui.painter().text(
thumb.center(),
egui::Align2::CENTER_CENTER,
"…",
theme::regular(16.0),
p.faint,
);
}
Render::Failed(_) => {
ui.painter().text(
thumb.center(),
egui::Align2::CENTER_CENTER,
icon::FILE,
egui::FontId::proportional(28.0),
p.faint,
);
}
}
// Title: what it proves, else what it is.
let title = a
.entry_id
.and_then(|e| b.entry(e))
.map(|e| e.description.clone())
.or_else(|| a.description.clone())
.unwrap_or_else(|| a.file_name.clone());
let max_chars = ((w - 20.0) / 7.2) as usize;
let title = if title.chars().count() > max_chars {
title.chars().take(max_chars - 1).collect::<String>() + "…"
} else {
title
};
ui.painter().text(
egui::pos2(rect.left() + 10.0, thumb.bottom() + 18.0),
egui::Align2::LEFT_CENTER,
title,
theme::semibold(12.5),
p.text,
);
let sub = match (a.entry_id, &a.withdrawn_at) {
(_, Some(_)) => format!("Withdrawn · {}", a.file_name),
(Some(e), None) => match b.entry(e) {
Some(x) => format!(
"#{e} · {} · {}",
fmt::date_short(x.date),
fmt::dollars(x.amount)
),
None => format!("#{e}"),
},
(None, None) => format!("{} · {}", fmt::label(&a.category), a.file_name),
};
let sub = if sub.chars().count() > max_chars + 4 {
sub.chars().take(max_chars + 3).collect::<String>() + "…"
} else {
sub
};
ui.painter().text(
egui::pos2(rect.left() + 10.0, thumb.bottom() + 40.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(11.5),
if a.withdrawn_at.is_some() {
p.warning
} else {
p.muted
},
);
resp
}
fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
egui::ScrollArea::vertical()
.id_salt(("doc", a.id))
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Document {}", a.id))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
.clicked()
{
shell.go(Route::Documents);
}
});
});
ui.label(RichText::new(&a.file_name).font(theme::bold(20.0)));
ui.add_space(8.0);
egui::Grid::new(("docfacts", a.id))
.num_columns(2)
.spacing(vec2(20.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
};
k(ui, "Category");
ui.label(fmt::label(&a.category));
ui.end_row();
k(ui, "Type");
ui.label(format!("{} · {}", a.mime_type, fmt::bytes(a.size_bytes)));
ui.end_row();
k(ui, "Filed");
ui.label(fmt::timestamp(&a.created_at));
ui.end_row();
k(ui, "SHA-256");
ui.add(
egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(),
);
ui.end_row();
if let Some(e) = a.entry_id {
k(ui, "Proves");
let label = b
.entry(e)
.map(|x| {
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
})
.unwrap_or_else(|| format!("#{e}"));
if widgets::link(ui, RichText::new(label).color(p.accent)).clicked() {
shell.go(Route::Entry(e));
}
ui.end_row();
}
});
ui.add_space(10.0);
super::entry::render(ui, b, a, shell, ui.available_width());
if let Some(d) = &a.description {
ui.add_space(6.0);
ui.label(RichText::new(d).color(p.muted));
}
if a.withdrawn_at.is_some() {
ui.add_space(6.0);
widgets::pill(
ui,
&format!("Withdrawn: {}", a.withdrawn_reason.as_deref().unwrap_or("")),
p.warning,
p.warning_soft,
);
}
});
}
+398
View File
@@ -0,0 +1,398 @@
//! One entry, completely: what it is, every line, the receipt itself, and
//! every journal event that touched it.
use crate::app::Shell;
use crate::books::{Books, EntryInfo};
use crate::fmt;
use crate::nav::Route;
use crate::receipts::State as Render;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{EntryStatus, Side};
use ledger_store::Attachment;
use rust_decimal::Decimal;
pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(entry) = b.ledger.get_entry(e.id) else {
return;
};
egui::ScrollArea::vertical()
.id_salt(("entry", e.id))
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Entry #{}", e.id))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
.on_hover_text("Close")
.clicked()
{
shell.go(Route::Transactions);
}
});
});
ui.label(RichText::new(&e.description).font(theme::bold(21.0)));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
match e.status {
EntryStatus::Posted => widgets::pill(ui, "Posted", p.positive, p.positive_soft),
EntryStatus::Draft => {
widgets::pill(ui, "Draft — not in any report", p.warning, p.warning_soft)
}
};
widgets::pill(ui, e.origin, p.muted, p.hover);
if let Some(v) = &e.vendor {
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
}
if e.missing_receipt {
widgets::pill(ui, "Missing receipt", p.negative, p.negative_soft);
}
});
if let Some(o) = e.reversal_of {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!("{} Reverses #{o}", icon::ARROW_COUNTER_CLOCKWISE))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(o));
}
}
if let Some(r) = e.reversed_by {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!(
"{} Reversed by #{r}",
icon::ARROW_COUNTER_CLOCKWISE
))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(r));
}
}
ui.add_space(16.0);
// Facts.
let created = e.events.first().map(|&i| fmt::timestamp(&b.journal[i].ts));
// Posted by entry.posted, or in one step by whatever created it
// (a schedule, a close, a bill).
let posted = e
.events
.iter()
.find(|&&i| b.journal[i].line_type == "entry.posted")
.or_else(|| e.events.first())
.map(|&i| fmt::timestamp(&b.journal[i].ts));
egui::Grid::new(("facts", e.id))
.num_columns(2)
.spacing(vec2(24.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
};
k(ui, "Date");
ui.label(fmt::date(e.date));
ui.end_row();
k(ui, "Amount");
ui.label(RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0)));
ui.end_row();
if let Some(c) = &created {
k(ui, "Recorded");
ui.label(c);
ui.end_row();
}
if e.status == EntryStatus::Posted
&& let Some(t) = &posted
{
k(ui, "Posted");
ui.label(t);
ui.end_row();
}
});
ui.add_space(16.0);
// Lines.
section(ui, "Lines");
let total_w = ui.available_width();
let amt_w = 104.0;
let class_names: std::collections::HashMap<u32, String> = b
.ledger
.list_classes()
.into_iter()
.map(|c| (c.id, c.name.clone()))
.collect();
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(10))
.inner_margin(Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(total_w - 26.0);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
"Account",
theme::regular(12.0),
p.muted,
);
widgets::amount_cell(ui, "Debit", amt_w, p.muted, false, false);
widgets::amount_cell(ui, "Credit", amt_w, p.muted, false, false);
});
let (mut dr, mut cr) = (Decimal::ZERO, Decimal::ZERO);
for line in entry.lines() {
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w);
ui.horizontal(|ui| {
let r = widgets::link(
ui,
RichText::new(b.account_label(line.account))
.font(theme::regular(13.5)),
);
if r.clicked() {
shell.go(Route::Account(line.account));
}
if let Some(c) = line.class_id.and_then(|c| class_names.get(&c))
{
ui.label(
RichText::new(format!("{} {c}", icon::TAG))
.font(theme::regular(11.5))
.color(p.muted),
);
}
if let (Some(cur), Some(fa)) =
(&line.foreign_currency, line.foreign_amount)
{
ui.label(
RichText::new(format!(
"{} {}",
fmt::money(fa),
cur.code()
))
.font(theme::regular(11.5))
.color(p.muted),
);
}
});
});
let a = fmt::money(line.amount.amount);
let (d, c) = match line.side {
Side::Debit => {
dr += line.amount.amount;
(a, String::new())
}
Side::Credit => {
cr += line.amount.amount;
(String::new(), a)
}
};
widgets::amount_cell(ui, &d, amt_w, p.text, false, false);
widgets::amount_cell(ui, &c, amt_w, p.text, false, false);
});
}
widgets::rule(ui);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
let balanced = dr == cr;
let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
if balanced {
format!("{} Balanced", icon::CHECK)
} else {
"Out of balance".into()
},
theme::regular(12.0),
if balanced { p.positive } else { p.negative },
);
widgets::amount_cell(ui, &fmt::money(dr), amt_w, p.text, true, false);
widgets::amount_cell(ui, &fmt::money(cr), amt_w, p.text, true, false);
});
});
ui.add_space(18.0);
// Receipts.
let all_for_entry: Vec<&Attachment> = b
.attachments
.iter()
.filter(|a| a.entry_id == Some(e.id))
.collect();
section(
ui,
if all_for_entry.len() == 1 {
"Receipt"
} else {
"Receipts"
},
);
if all_for_entry.is_empty() {
let msg = if e.missing_receipt {
format!(
"None attached, and this entry is over the {} receipt threshold.",
fmt::dollars(b.receipt_threshold)
)
} else {
"None attached.".to_string()
};
ui.label(RichText::new(msg).color(if e.missing_receipt {
p.negative
} else {
p.muted
}));
}
for a in all_for_entry {
attachment_view(ui, b, a, shell, true);
ui.add_space(12.0);
}
ui.add_space(12.0);
// History.
section(ui, "History");
for &i in &e.events {
let line = &b.journal[i];
let ev = super::journal::describe_any(b, line);
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5)));
if r.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts)))
.font(theme::regular(11.5))
.color(p.faint),
);
});
});
}
});
}
pub fn section(ui: &mut Ui, title: &str) {
let p = pal(ui.ctx());
ui.label(
RichText::new(title.to_uppercase())
.font(theme::semibold(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
/// A receipt or document: its name and facts, then the file itself.
pub fn attachment_view(
ui: &mut Ui,
b: &Books,
a: &Attachment,
shell: &mut Shell<'_>,
link_to_doc: bool,
) {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.label(
RichText::new(if a.mime_type == "application/pdf" {
icon::FILE_PDF
} else {
icon::FILE_IMAGE
})
.size(18.0)
.color(p.muted),
);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
let name = RichText::new(&a.file_name).font(theme::semibold(13.0));
if link_to_doc {
if widgets::link(ui, name).clicked() {
shell.go(Route::Document(a.id));
}
} else {
ui.label(name);
}
ui.label(
RichText::new(format!(
"{} · {} · filed {}",
fmt::label(&a.category),
fmt::bytes(a.size_bytes),
fmt::timestamp(&a.created_at)
))
.font(theme::regular(11.5))
.color(p.muted),
);
});
});
if let Some(when) = &a.withdrawn_at {
widgets::pill(
ui,
&format!(
"Withdrawn {}: {}",
fmt::timestamp(when),
a.withdrawn_reason.as_deref().unwrap_or("")
),
p.warning,
p.warning_soft,
);
}
if let Some(d) = &a.description {
ui.label(RichText::new(d).font(theme::regular(12.5)).color(p.muted));
}
ui.add_space(6.0);
render(ui, b, a, shell, ui.available_width());
}
/// The file itself, pages stacked, scaled to `width`.
pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, width: f32) {
let p = pal(ui.ctx());
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
Render::Loading => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("Rendering…").color(p.muted));
});
}
Render::Failed(err) => {
ui.label(
RichText::new(format!("{} Can't show this file: {err}", icon::WARNING))
.color(p.negative),
);
}
Render::Ready(pages) => {
for (i, tex) in pages.iter().enumerate() {
let size = tex.size_vec2();
let scale = (width / size.x).min(1.0);
let img = egui::Image::new(tex)
.fit_to_exact_size(size * scale)
.corner_radius(CornerRadius::same(4));
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(6))
.show(ui, |ui| {
ui.add(img);
});
if pages.len() > 1 {
ui.label(
RichText::new(format!("Page {} of {}", i + 1, pages.len()))
.font(theme::regular(11.0))
.color(p.faint),
);
}
ui.add_space(6.0);
}
}
}
}
+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());
});
});
}
+156
View File
@@ -0,0 +1,156 @@
//! One module per screen.
pub mod accounts;
pub mod cash;
pub mod company;
pub mod documents;
pub mod entry;
pub mod journal;
pub mod overview;
pub mod profit;
pub mod reports;
pub mod transactions;
use crate::app::Shell;
use crate::books::Books;
use crate::nav::Route;
use egui::{Margin, Ui};
/// Standard page body: scrollable, padded.
pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) {
egui::ScrollArea::vertical()
.id_salt(id)
.auto_shrink(false)
.show(ui, |ui| {
egui::Frame::new()
.inner_margin(Margin {
left: 32,
right: 32,
top: 24,
bottom: 40,
})
.show(ui, |ui| {
ui.set_width(ui.available_width());
add(ui)
});
});
}
pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
match route {
Route::Overview => overview::show(ui, b, shell),
Route::Cash => cash::show(ui, b, shell),
Route::Profit => profit::show(ui, b, shell),
Route::Transactions => transactions::show(ui, b, None, shell),
Route::Entry(id) => transactions::show(ui, b, Some(*id), shell),
Route::Reports(kind) => reports::show(ui, b, *kind, shell),
Route::Accounts => accounts::show_chart(ui, b, shell),
Route::Account(id) => accounts::show_account(ui, b, *id, shell),
Route::Documents => documents::show(ui, b, None, shell),
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
Route::Company => company::show(ui, b, shell),
Route::Journal => journal::show(ui, b, None, shell),
Route::JournalEvent(seq) => journal::show(ui, b, Some(*seq), shell),
}
}
/// Columns sized by `weights` when the page is at least `min_width`
/// wide; stacked otherwise. `add(i, ui)` fills column `i`.
pub fn columns(ui: &mut Ui, min_width: f32, weights: &[f32], mut add: impl FnMut(usize, &mut Ui)) {
let gap = 16.0;
if ui.available_width() < min_width {
for i in 0..weights.len() {
add(i, ui);
ui.add_space(gap);
}
return;
}
let total: f32 = weights.iter().sum();
let avail = ui.available_width() - gap * (weights.len() as f32 - 1.0);
let top = ui.cursor().min;
let mut x = top.x;
let mut bottom = top.y;
for (i, w) in weights.iter().enumerate() {
let width = avail * w / total;
let rect =
egui::Rect::from_min_size(egui::pos2(x, top.y), egui::vec2(width, f32::INFINITY));
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
child.set_width(width);
add(i, &mut child);
bottom = bottom.max(child.min_rect().bottom());
x += width + gap;
}
let used = egui::Rect::from_min_max(top, egui::pos2(top.x + ui.available_width(), bottom));
ui.advance_cursor_after_rect(used);
ui.add_space(gap);
}
/// A list on the left and a detail pane on the right, split by a
/// draggable divider. The split is remembered per `id`.
pub fn split<T: ?Sized>(
ui: &mut Ui,
id: &str,
default_detail: f32,
state: &mut T,
left: impl FnOnce(&mut Ui, &mut T),
right: impl FnOnce(&mut Ui, &mut T),
) {
let p = crate::theme::pal(ui.ctx());
let rect = ui.available_rect_before_wrap();
let key = egui::Id::new(("split", id));
let mut detail_w: f32 = ui
.ctx()
.data_mut(|d| d.get_persisted(key))
.unwrap_or(default_detail);
detail_w = detail_w.clamp(360.0, (rect.width() - 380.0).max(360.0));
let divider_x = rect.right() - detail_w;
let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom()));
let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max);
ui.painter().rect_filled(right_rect, 0.0, p.surface);
ui.painter().line_segment(
[
egui::pos2(divider_x, rect.top()),
egui::pos2(divider_x, rect.bottom()),
],
egui::Stroke::new(1.0, p.border),
);
let mut l = ui.new_child(
egui::UiBuilder::new()
.max_rect(left_rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
l.set_clip_rect(left_rect);
left(&mut l, state);
let inner = right_rect.shrink2(egui::vec2(24.0, 20.0));
let mut r = ui.new_child(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
r.set_clip_rect(right_rect);
right(&mut r, state);
let handle = egui::Rect::from_center_size(
egui::pos2(divider_x, rect.center().y),
egui::vec2(8.0, rect.height()),
);
let resp = ui.interact(handle, key.with("handle"), egui::Sense::drag());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
ui.painter().line_segment(
[
egui::pos2(divider_x, rect.top()),
egui::pos2(divider_x, rect.bottom()),
],
egui::Stroke::new(2.0, p.accent),
);
}
if resp.dragged() {
detail_w -= resp.drag_delta().x;
ui.ctx().data_mut(|d| d.insert_persisted(key, detail_w));
}
ui.allocate_rect(rect, egui::Sense::hover());
}
+481
View File
@@ -0,0 +1,481 @@
//! Home: the state of the business in one sentence, the numbers behind
//! it, what needs doing, and what just happened.
use super::{columns, page};
use crate::app::Shell;
use crate::books::{Books, Severity};
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets::{self, Kpi};
use egui::{RichText, Ui};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, pnl_by_interval};
use ledger_core::reports::profit_and_loss_for_period;
use rust_decimal::Decimal;
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
page(ui, "overview", |ui| {
let k = &b.kpis;
widgets::page_header(ui, b.company_name(), &headline(b));
kpis(ui, b, shell);
let _ = k;
columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i {
0 => {
let (more, _) = widgets::titled_card(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
}
_ => attention(ui, b, shell),
});
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => income_vs_spending(ui, b, shell),
_ => where_it_went(ui, b, shell),
});
recent(ui, b, shell);
});
}
/// The answer, in words.
fn headline(b: &Books) -> String {
let k = &b.kpis;
let Some(first) = k.first_entry else {
return "No transactions yet. Once your agent records some, this page fills in on its own."
.into();
};
let mut s = format!(
"You have {} in cash and owe {}.",
fmt::dollars(k.cash),
fmt::dollars(k.liabilities)
);
if let Some(burn) = k.monthly_burn {
if burn > Decimal::ZERO && k.cash > Decimal::ZERO {
let months = (k.cash / burn).round_dp(1);
s.push_str(&format!(
" Lately you spend {} a month more than you earn: about {} months of runway.",
fmt::dollars(burn),
months
));
}
} else if k.net_ytd > Decimal::ZERO {
s.push_str(&format!(
" You've earned {} more than you've spent this year.",
fmt::dollars(k.net_ytd)
));
} else if k.net_ytd < Decimal::ZERO {
s.push_str(&format!(
" You've spent {} more than you've earned this year.",
fmt::dollars(-k.net_ytd)
));
}
if (b.today - first).num_days() < 60 {
s.push_str(&format!(
" Your books start {}; trends fill in as the months go by.",
fmt::date(first)
));
}
s
}
fn change(
now: Decimal,
prev: Decimal,
up_is_good: bool,
what: &str,
) -> Option<(String, egui::Color32)> {
let pct = fmt::pct_change(prev, now)?;
let good = (pct >= 0.0) == up_is_good;
let p = &theme::LIGHT;
let arrow = if pct >= 0.0 { "▲" } else { "▼" };
Some((
format!("{arrow} {:.0}% vs {what}", pct.abs()),
if pct.abs() < 0.5 {
p.muted
} else if good {
p.positive
} else {
p.negative
},
))
}
fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let k = &b.kpis;
let p = pal(ui.ctx());
// Last 12 months of month-end cash for the sparkline.
let (start, end) = b.trailing_months(12);
let start = k.first_entry.map_or(start, |f| {
start.max(ledger_core::analysis::interval_start(f, Interval::Month))
});
let month_ends: Vec<chrono::NaiveDate> =
ledger_core::analysis::intervals(start, end, Interval::Month)
.into_iter()
.map(|(_, e)| e)
.collect();
let cash_spark: Vec<f64> =
ledger_core::analysis::balance_series(&b.ledger, &b.cash_accounts, &month_ends)
.into_iter()
.map(|d| d.try_into().unwrap_or(0.0))
.collect();
let runway = match k.monthly_burn {
Some(burn) if burn > Decimal::ZERO && k.cash > Decimal::ZERO => (
format!("{} mo", (k.cash / burn).round_dp(1)),
Some((format!("burning {}/mo", fmt::compact(burn)), p.warning)),
),
_ if k.first_entry.is_none() => ("—".to_string(), None),
_ if k.first_entry.is_some_and(|f| f >= b.month_start()) => (
"Too early".to_string(),
Some(("needs a full month of books".to_string(), p.muted)),
),
_ => (
"Not burning".to_string(),
Some(("earning ≥ spending".to_string(), p.positive)),
),
};
let ytd = b.fiscal_ytd();
let tiles: Vec<(Kpi, Route)> = vec![
(
Kpi {
label: "Cash on hand",
value: fmt::dollars(k.cash),
sub: change(k.cash, k.cash_30d_ago, true, "30 days ago")
.map(|(t, c)| (t, color_for(c, p))),
spark: Some(&cash_spark),
how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.",
},
Route::Cash,
),
(
Kpi {
label: "Owed by the business",
value: fmt::dollars(k.liabilities),
sub: Some(("bills, cards, owners, taxes".into(), p.muted)),
spark: None,
how: "Total liabilities on today's balance sheet.",
},
Route::Reports(ReportKind::BalanceSheet),
),
(
Kpi {
label: "Spending this month",
value: fmt::dollars(k.expenses_mtd),
sub: change(
k.expenses_mtd,
k.expenses_prev_month,
false,
"same days last month",
)
.map(|(t, c)| (t, color_for(c, p))),
spark: None,
how: "Expenses dated this month (accrual basis: when incurred, not when paid).",
},
Route::Profit,
),
(
Kpi {
label: "Revenue this month",
value: fmt::dollars(k.revenue_mtd),
sub: change(
k.revenue_mtd,
k.revenue_prev_month,
true,
"same days last month",
)
.map(|(t, c)| (t, color_for(c, p))),
spark: None,
how: "Revenue dated this month.",
},
Route::Profit,
),
(
Kpi {
label: "Net income, year to date",
value: fmt::dollars(k.net_ytd),
sub: Some((format!("since {}", fmt::date_short(ytd.0)), p.muted)),
spark: None,
how: "Revenue minus expenses for the fiscal year so far.",
},
Route::Reports(ReportKind::ProfitAndLoss),
),
(
Kpi {
label: "Runway",
value: runway.0,
sub: runway.1,
spark: None,
how: "Cash on hand divided by the average monthly net loss over the last three full months the books cover.",
},
Route::Cash,
),
];
let n = if ui.available_width() > 1180.0 { 6 } else { 3 };
for row in tiles.chunks(n) {
let weights = vec![1.0; n];
columns(ui, 0.0, &weights, |i, ui| {
if let Some((tile, route)) = row.get(i) {
let r = widgets::kpi(
ui,
Kpi {
label: tile.label,
value: tile.value.clone(),
sub: tile.sub.clone(),
spark: tile.spark,
how: tile.how,
},
);
if r.interact(egui::Sense::click()).clicked() {
shell.go(route.clone());
}
}
});
}
}
/// `change` picks from the light palette; map to the current one.
fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
let l = &theme::LIGHT;
if c == l.positive {
p.positive
} else if c == l.negative {
p.negative
} else {
p.muted
}
}
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
"Needs attention",
|ui| {
if !b.attention.is_empty() {
widgets::pill(
ui,
&b.attention.len().to_string(),
p.warning,
p.warning_soft,
);
}
},
|ui| {
if b.attention.is_empty() {
widgets::empty(
ui,
icon::CHECK_CIRCLE,
"All clear",
"Nothing is missing or overdue.",
);
return;
}
egui::ScrollArea::vertical()
.id_salt("attention")
.max_height(260.0)
.show(ui, |ui| {
for (i, a) in b.attention.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
widgets::rule(ui);
ui.add_space(4.0);
}
let (glyph, color) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning),
Severity::Info => (icon::INFO, p.muted),
};
ui.horizontal_top(|ui| {
ui.label(RichText::new(glyph).size(16.0).color(color));
ui.vertical(|ui| {
let t = RichText::new(&a.title).font(theme::semibold(13.0));
let r = widgets::link(ui, t);
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.label(
RichText::new(&a.detail)
.font(theme::regular(12.0))
.color(p.muted),
);
widgets::copy_prompt(ui, &a.prompt);
});
});
}
});
},
);
}
fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let (start, end) = b.trailing_months(12);
let start = b.kpis.first_entry.map_or(start, |f| {
start.max(ledger_core::analysis::interval_start(f, Interval::Month))
});
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
widgets::titled_card(
ui,
"Income and spending by month",
|_| {},
|ui| {
if months.is_empty() {
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
return;
}
let groups: Vec<BarGroup> = months
.iter()
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
})
.collect();
let clicked = charts::bar_chart(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
220.0,
);
if let Some(i) = clicked {
shell.state.reports.range =
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
},
);
}
fn where_it_went(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let (start, end) = b.fiscal_ytd();
widgets::titled_card(
ui,
"Where the money went, this year",
|ui| {
ui.label(
RichText::new(format!(
"{} – {}",
fmt::date_short(start),
fmt::date_short(end)
))
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
let Ok(pl) = profit_and_loss_for_period(&b.ledger, start, end, None) else {
return;
};
let mut rows: Vec<(String, Decimal, String)> = pl
.expenses
.iter()
.map(|l| (l.name.clone(), l.amount, l.code.clone()))
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
rows.truncate(8);
if rows.is_empty() {
widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No spending yet", "");
return;
}
let bars: Vec<(String, Decimal)> = rows.iter().map(|r| (r.0.clone(), r.1)).collect();
if let Some(i) = charts::hbars(ui, &bars, p.series[2])
&& let Some(a) = b.ledger.chart().find_by_code(&rows[i].2)
{
shell.go(Route::Account(a.id));
}
},
);
}
fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let since_update = shell.since_update;
let mut go = None;
let (full, _) = widgets::titled_card(
ui,
"Recent activity",
|ui| {
widgets::link(
ui,
RichText::new("Full journal")
.font(theme::regular(12.5))
.color(p.accent),
)
.clicked()
},
|ui| {
let mut shown = 0;
for (i, line) in b.journal.iter().enumerate().rev() {
let Some(ev) = super::journal::describe(b, line) else {
continue;
};
if shown == 12 {
break;
}
shown += 1;
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
let row = egui::Frame::new()
.fill(if fresh {
p.new_highlight
} else {
egui::Color32::TRANSPARENT
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).size(15.0).color(p.muted));
let r = widgets::link(
ui,
RichText::new(&ev.text).font(theme::regular(13.0)),
);
if r.clicked() {
go =
Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::timestamp(&line.ts))
.font(theme::regular(11.5))
.color(p.faint),
);
},
);
});
});
let _ = (row, i);
}
if shown == 0 {
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
}
},
);
if full {
go = Some(Route::Journal);
}
if let Some(r) = go {
shell.go(r);
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Profit: are we making money, how is it trending, and what moved.
//! Spending by category this month against the recent average, income by
//! customer and by class.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Days, Months};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, interval_start, pnl_by_interval};
use ledger_core::reports::profit_and_loss_for_period;
use rust_decimal::Decimal;
use std::collections::BTreeMap;
fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
(!whole.is_zero()).then(|| (part / whole * Decimal::from(100)).round_dp(1))
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "profit", |ui| {
let (ys, ye) = b.fiscal_ytd();
let ytd = profit_and_loss_for_period(&b.ledger, ys, ye, None).ok();
let (rev, exp) = ytd
.as_ref()
.map(|p| (p.total_revenue, p.total_expenses))
.unwrap_or_default();
let net = rev - exp;
let margin = pct(net, rev);
let sentence = match margin {
Some(m) => format!(
"This year: {} of income, {} of spending, {} {} — a {}% margin.",
fmt::dollars(rev),
fmt::dollars(exp),
fmt::dollars(net.abs()),
if net >= Decimal::ZERO {
"profit"
} else {
"loss"
},
m
),
None if exp.is_zero() => "No income or spending recorded this year yet.".into(),
None => format!(
"This year: {} of spending and no income yet.",
fmt::dollars(exp)
),
};
widgets::page_header(ui, "Profit", &sentence);
let first = b.kpis.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
widgets::titled_card(
ui,
"By month",
|_| {},
|ui| {
if months.is_empty() {
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
return;
}
let groups: Vec<BarGroup> = months
.iter()
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
})
.collect();
if let Some(i) = charts::bar_chart(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
230.0,
) {
shell.state.reports.range =
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
ui.add_space(10.0);
// The same numbers as a table, with the margin.
let w = ui.available_width();
let cw = 130.0;
let header = |ui: &mut Ui, cells: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
cells[0],
theme::semibold(12.0),
p.muted,
);
for (i, c) in cells[1..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Month", "Income", "Spending", "Net", "Margin"]);
for m in months.iter().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::month(m.start),
theme::regular(13.0),
p.text,
);
let vals = [
fmt::money(m.revenue),
fmt::money(m.expenses),
fmt::accounting(m.net()),
pct(m.net(), m.revenue)
.map(|x| format!("{x}%"))
.unwrap_or_else(|| "—".into()),
];
for (i, v) in vals.iter().enumerate() {
let color = if i == 2 && m.net() < Decimal::ZERO {
p.negative
} else {
p.text
};
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
v,
theme::regular(13.0),
color,
);
}
if resp.clicked() {
shell.state.reports.range =
crate::pages::reports::Range::Custom(m.start, m.end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
}
},
);
ui.add_space(16.0);
spending_changes(ui, b, shell);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => by_customer(ui, b),
_ => by_class(ui, b),
});
});
}
/// Each expense account: this month so far, last month, the three-month
/// average, and how far this month is running from it.
fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let ms = b.month_start();
let prev =
profit_and_loss_for_period(&b.ledger, ms - Months::new(1), ms - Days::new(1), None).ok();
let three =
profit_and_loss_for_period(&b.ledger, ms - Months::new(3), ms - Days::new(1), None).ok();
let now = profit_and_loss_for_period(&b.ledger, ms, b.today, None).ok();
let mut rows: BTreeMap<String, (String, Decimal, Decimal, Decimal)> = BTreeMap::new();
let mut add = |s: &Option<ledger_core::ProfitAndLossStatement>, slot: usize| {
for l in s.iter().flat_map(|s| &s.expenses) {
let e = rows.entry(l.code.clone()).or_insert((
l.name.clone(),
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
));
match slot {
0 => e.1 = l.amount,
1 => e.2 = l.amount,
_ => e.3 = l.amount / Decimal::from(3),
}
}
};
add(&now, 0);
add(&prev, 1);
add(&three, 2);
let mut rows: Vec<_> = rows.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1.3.max(r.1.1)));
let mut go = None;
widgets::titled_card(
ui,
"Spending by category",
|ui| {
ui.label(
RichText::new("this month so far vs. the last three months")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if rows.is_empty() {
widgets::empty(ui, icon::RECEIPT, "No spending yet", "");
return;
}
let w = ui.available_width();
let cw = 130.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
for (i, c) in ["This month", "Last month", "3-month avg"]
.iter()
.enumerate()
{
ui.painter().text(
egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
"Account",
theme::semibold(12.0),
p.muted,
);
for (code, (name, now, last, avg)) in &rows {
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{code} {name}"),
theme::regular(13.0),
p.text,
);
// Flag a category already running well above its average.
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
let vals = [(*now, hot), (*last, false), (*avg, false)];
for (i, (v, flag)) in vals.iter().enumerate() {
let x = r.right() - cw * (2 - i) as f32 - 4.0;
ui.painter().text(
egui::pos2(x, r.center().y),
egui::Align2::RIGHT_CENTER,
fmt::money(v.round_dp(2)),
theme::regular(13.0),
if *flag { p.warning } else { p.text },
);
if *flag {
ui.painter().text(
egui::pos2(x - 86.0, r.center().y),
egui::Align2::RIGHT_CENTER,
icon::ARROW_UP_RIGHT,
egui::FontId::proportional(13.0),
p.warning,
);
}
}
if resp.clicked() {
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
}
}
},
);
if let Some(id) = go {
shell.state.accounts.range = Some((ms - Months::new(3), b.today));
shell.go(Route::Account(id));
}
ui.add_space(16.0);
}
fn by_customer(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let (ys, ye) = b.fiscal_ytd();
let mut by: BTreeMap<String, Decimal> = BTreeMap::new();
for id in 1..b.ledger.next_invoice_id() {
if let Some(inv) = b.ledger.get_invoice(id)
&& inv.date >= ys
&& inv.date <= ye
{
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
*by.entry(who).or_default() += inv.total();
}
}
let mut rows: Vec<(String, Decimal)> = by.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
widgets::titled_card(
ui,
"Invoiced by customer, this year",
|_| {},
|ui| {
if rows.is_empty() {
widgets::empty(
ui,
icon::USERS,
"No invoices this year",
"Income recorded without invoices doesn't show a customer.",
);
return;
}
let total: Decimal = rows.iter().map(|r| r.1).sum();
if let Some((top, v)) = rows.first() {
ui.label(
RichText::new(format!(
"{top} is {}% of invoiced income.",
pct(*v, total).unwrap_or_default()
))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.add_space(4.0);
}
charts::hbars(ui, &rows, p.series[1]);
},
);
}
fn by_class(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let (ys, ye) = b.fiscal_ytd();
let classes = b.ledger.list_classes();
widgets::titled_card(
ui,
"Net income by class, this year",
|_| {},
|ui| {
if classes.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classes",
"Tag lines with classes (departments, products) to see profit by each.",
);
return;
}
let mut rows: Vec<(String, Decimal)> = classes
.iter()
.filter_map(|c| {
let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?;
let net = pl.total_revenue - pl.total_expenses;
(!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero())
.then(|| (c.name.clone(), net))
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
if rows.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classified income or spending this year",
"",
);
return;
}
charts::hbars(ui, &rows, p.series[0]);
ui.label(
RichText::new("Only lines tagged with a class count here.")
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}
File diff suppressed because it is too large. Load diff
+279
View File
@@ -0,0 +1,279 @@
//! Every entry, searchable and filterable, with the selected one open
//! beside the list: its lines, its receipt rendered, its history.
use super::page;
use crate::app::Shell;
use crate::books::{Books, EntryInfo};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{AccountId, EntryStatus};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Filter {
#[default]
All,
Posted,
Drafts,
NoReceipt,
WithReceipt,
}
#[derive(Default)]
pub struct State {
pub query: String,
pub filter: Filter,
pub account: Option<AccountId>,
}
pub fn matches(e: &EntryInfo, st: &State) -> bool {
let f = match st.filter {
Filter::All => true,
Filter::Posted => e.status == EntryStatus::Posted,
Filter::Drafts => e.status == EntryStatus::Draft,
Filter::NoReceipt => e.receipts.is_empty(),
Filter::WithReceipt => !e.receipts.is_empty(),
};
let a = st.account.is_none_or(|a| e.accounts.contains(&a));
let q = st.query.trim().to_lowercase();
let t = q.is_empty() || q.split_whitespace().all(|w| e.haystack.contains(w));
f && a && t
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
match selected.and_then(|id| b.entry(id)) {
Some(e) => {
let w = (ui.available_width() * 0.5).max(420.0);
super::split(
ui,
"transactions",
w,
shell,
|ui, shell| list(ui, b, selected, shell),
|ui, shell| super::entry::detail(ui, b, e, shell),
);
}
None => 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, "transactions", |ui| {
let total = b.entries.len();
widgets::page_header(
ui,
"Transactions",
&format!(
"{} recorded, newest first. Click one to see its lines, its receipt, and its history.",
fmt::count(total, "entry", "entries")
),
);
let st = &mut shell.state.transactions;
let narrow = ui.available_width() < 720.0;
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!(
"{} Search description, account, vendor, amount",
icon::MAGNIFYING_GLASS
))
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
);
if !narrow {
filters(ui, b, st);
}
});
if narrow {
ui.add_space(6.0);
ui.horizontal(|ui| filters(ui, b, st));
}
ui.add_space(12.0);
let rows: Vec<&EntryInfo> = b.entries.iter().filter(|e| matches(e, st)).collect();
if rows.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::MAGNIFYING_GLASS,
"No matching entries",
"Try a different search or filter.",
)
});
return;
}
widgets::card(ui, |ui| {
let mut last_month: Option<(i32, u32)> = None;
for e in rows {
let m = (
chrono::Datelike::year(&e.date),
chrono::Datelike::month(&e.date),
);
if last_month != Some(m) {
if last_month.is_some() {
ui.add_space(10.0);
}
last_month = Some(m);
ui.label(
RichText::new(fmt::month(e.date))
.font(theme::semibold(12.5))
.color(p.muted),
);
ui.add_space(2.0);
}
if row(ui, b, e, selected == Some(e.id), shell.since_update).clicked() {
shell.go(Route::Entry(e.id));
}
}
});
});
}
fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response {
let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
let fresh = b.fresh.contains(&e.id) && since_update < 8.0;
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.selected);
} else if fresh {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
let painter = ui.painter_at(rect);
let left = rect.left() + 10.0;
// Date block.
painter.text(
egui::pos2(left, rect.center().y - 8.0),
egui::Align2::LEFT_CENTER,
e.date.format("%b").to_string().to_uppercase(),
theme::semibold(10.5),
p.faint,
);
painter.text(
egui::pos2(left, rect.center().y + 8.0),
egui::Align2::LEFT_CENTER,
e.date.format("%-d").to_string(),
theme::semibold(16.0),
p.text,
);
// Description and accounts.
let text_left = left + 44.0;
let amount_w = 120.0;
let max_w = (rect.right() - amount_w - 70.0 - text_left).max(60.0);
let desc = painter.layout(e.description.clone(), theme::semibold(13.5), p.text, max_w);
let desc = if desc.rows.len() > 1 {
painter.layout_no_wrap(
truncate(&e.description, (max_w / 7.5) as usize),
theme::semibold(13.5),
p.text,
)
} else {
desc
};
painter.galley(egui::pos2(text_left, rect.center().y - 17.0), desc, p.text);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
let sub = truncate(&sub, (max_w / 6.3) as usize);
painter.text(
egui::pos2(text_left, rect.center().y + 10.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(12.0),
p.muted,
);
// Amount and badges.
let right = rect.right() - 10.0;
// Cash in reads green with a plus, cash out with a minus; entries that
// don't move cash show their size, muted.
let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO {
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
} else if e.cash_effect < rust_decimal::Decimal::ZERO {
(fmt::money(e.cash_effect), p.text)
} else {
(fmt::money(e.amount), p.muted)
};
painter.text(
egui::pos2(right, rect.center().y - 6.0),
egui::Align2::RIGHT_CENTER,
amount,
theme::semibold(14.0),
color,
);
let mut badges: Vec<(&str, egui::Color32)> = Vec::new();
if e.status == EntryStatus::Draft {
badges.push(("DRAFT", p.warning));
}
if e.reversed_by.is_some() {
badges.push(("REVERSED", p.muted));
}
if e.reversal_of.is_some() {
badges.push(("REVERSAL", p.muted));
}
let mut x = right;
for (text, color) in badges {
let g = painter.layout_no_wrap(text.to_string(), theme::semibold(10.0), color);
x -= g.size().x;
painter.galley(egui::pos2(x, rect.center().y + 6.0), g, color);
x -= 8.0;
}
let clip = if !e.receipts.is_empty() {
Some((icon::PAPERCLIP, p.muted))
} else if e.missing_receipt {
Some((icon::WARNING_CIRCLE, p.negative))
} else {
None
};
if let Some((g, c)) = clip {
painter.text(
egui::pos2(rect.right() - amount_w - 24.0, rect.center().y),
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(16.0),
c,
);
}
resp
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let t: String = s.chars().take(n.saturating_sub(1)).collect();
format!("{t}…")
}
}
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
widgets::segmented(
ui,
&mut st.filter,
&[
(Filter::All, "All"),
(Filter::Posted, "Posted"),
(Filter::Drafts, "Drafts"),
(Filter::WithReceipt, "With receipt"),
(Filter::NoReceipt, "No receipt"),
],
);
if let Some(a) = st.account {
ui.add_space(6.0);
let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X);
if ui
.button(label)
.on_hover_text("Clear the account filter")
.clicked()
{
st.account = None;
}
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
//! amount, or #id), vendors, and documents, ranked by a simple fuzzy match.
use crate::books::Books;
use crate::fmt;
use crate::nav::{ReportKind, Route, Section};
use crate::theme::{self, pal};
use egui::{CornerRadius, Key, Margin, RichText, Sense, Stroke, vec2};
use egui_phosphor::regular as icon;
#[derive(Default)]
pub struct Palette {
open: bool,
query: String,
selected: usize,
focus: bool,
}
struct Item {
icon: &'static str,
title: String,
detail: String,
route: Route,
score: i32,
}
impl Palette {
pub fn open(&mut self) {
self.open = true;
self.query.clear();
self.selected = 0;
self.focus = true;
}
/// Show the palette if open; returns where to go.
pub fn show(&mut self, ctx: &egui::Context, books: &Books) -> Option<Route> {
if !self.open {
return None;
}
let p = pal(ctx);
let items = search(books, &self.query);
let mut go = None;
let (up, down, enter, esc) = ctx.input(|i| {
(
i.key_pressed(Key::ArrowUp),
i.key_pressed(Key::ArrowDown),
i.key_pressed(Key::Enter),
i.key_pressed(Key::Escape),
)
});
if down {
self.selected = (self.selected + 1).min(items.len().saturating_sub(1));
}
if up {
self.selected = self.selected.saturating_sub(1);
}
if esc {
self.open = false;
return None;
}
if enter && let Some(it) = items.get(self.selected) {
self.open = false;
return Some(it.route.clone());
}
let modal = egui::Modal::new(egui::Id::new("palette"))
.frame(
egui::Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(14))
.inner_margin(Margin::same(10)),
)
.show(ctx, |ui| {
ui.set_width(620.0);
let edit = egui::TextEdit::singleline(&mut self.query)
.hint_text("Search entries, accounts, vendors, documents, reports…")
.font(theme::regular(16.0))
.frame(egui::Frame::NONE)
.desired_width(f32::INFINITY);
let r = ui.add(edit);
if self.focus {
r.request_focus();
self.focus = false;
}
if r.changed() {
self.selected = 0;
}
ui.add_space(6.0);
crate::widgets::rule(ui);
ui.add_space(4.0);
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
if items.is_empty() {
ui.label(RichText::new("Nothing matches.").color(p.muted));
}
for (i, it) in items.iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(
vec2(ui.available_width(), 40.0),
Sense::click(),
);
if i == self.selected || resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
if i == self.selected {
ui.scroll_to_rect(rect, None);
}
let painter = ui.painter_at(rect);
painter.text(
rect.left_center() + vec2(12.0, 0.0),
egui::Align2::LEFT_CENTER,
it.icon,
egui::FontId::proportional(16.0),
p.muted,
);
painter.text(
rect.left_center() + vec2(38.0, -7.0),
egui::Align2::LEFT_CENTER,
&it.title,
theme::regular(14.0),
p.text,
);
painter.text(
rect.left_center() + vec2(38.0, 9.0),
egui::Align2::LEFT_CENTER,
&it.detail,
theme::regular(11.5),
p.faint,
);
if resp.clicked() {
go = Some(it.route.clone());
}
}
});
});
if modal.should_close() {
self.open = false;
}
if go.is_some() {
self.open = false;
}
go
}
}
/// Subsequence match: every query character in order, rewarding
/// contiguous runs and word starts. None if no match.
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
if query.is_empty() {
return Some(0);
}
let hay: Vec<char> = hay.to_lowercase().chars().collect();
let mut score = 0;
let mut hi = 0;
let mut prev_match = false;
for qc in query.to_lowercase().chars() {
if qc == ' ' {
prev_match = false;
continue;
}
let mut found = false;
while hi < hay.len() {
let c = hay[hi];
hi += 1;
if c == qc {
score += if prev_match { 6 } else { 1 };
if hi == 1 || !hay[hi - 2].is_alphanumeric() {
score += 4;
}
prev_match = true;
found = true;
break;
}
prev_match = false;
}
if !found {
return None;
}
}
Some(score)
}
fn search(b: &Books, query: &str) -> Vec<Item> {
let q = query.trim();
let mut out: Vec<Item> = Vec::new();
let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| {
if let Some(score) = fuzzy(hay, q) {
out.push(Item {
icon,
title,
detail,
route,
score,
});
}
};
for s in Section::ALL {
add(
s.icon(),
s.title().into(),
"Page".into(),
s.home(),
s.title(),
);
}
for r in ReportKind::ALL {
add(
icon::FILE_TEXT,
r.title().into(),
"Report".into(),
Route::Reports(r),
&format!("report {}", r.title()),
);
}
if !q.is_empty() {
for a in b.ledger.chart().all() {
add(
icon::TREE_STRUCTURE,
format!("{} {}", a.code, a.name),
format!("{:?} account", a.account_type),
Route::Account(a.id),
&format!("{} {}", a.code, a.name),
);
}
let id_query = q.strip_prefix('#').and_then(|s| s.parse::<u64>().ok());
for e in &b.entries {
let exact = id_query == Some(e.id);
let hay = &e.haystack;
let score = if exact {
Some(1000)
} else {
fuzzy(hay, q).filter(|_| q.len() >= 2)
};
if let Some(score) = score {
// Prefer substring hits for entries: fuzzy over long text is noisy.
let bonus = if hay.contains(&q.to_lowercase()) {
50
} else {
0
};
if bonus == 0 && !exact && q.len() < 4 {
continue;
}
out.push(Item {
icon: icon::RECEIPT,
title: format!("#{} {}", e.id, e.description),
detail: format!("{} · {}", fmt::date(e.date), fmt::dollars(e.amount)),
route: Route::Entry(e.id),
score: score + bonus,
});
}
}
for v in b.ledger.list_vendors() {
if let Some(score) = fuzzy(&v.name, q) {
out.push(Item {
icon: icon::STOREFRONT,
title: v.name.clone(),
detail: "Vendor".into(),
route: Route::Company,
score,
});
}
}
for a in &b.attachments {
let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or(""));
if let Some(score) = fuzzy(&hay, q) {
out.push(Item {
icon: icon::FILE,
title: a.file_name.clone(),
detail: format!(
"{} · {}",
fmt::label(&a.category),
a.description.as_deref().unwrap_or("")
),
route: Route::Document(a.id),
score,
});
}
}
}
out.sort_by_key(|i| std::cmp::Reverse(i.score));
out.truncate(40);
out
}
/// A short description of a route for the breadcrumb.
pub fn describe(b: &Books, r: &Route) -> String {
match r {
Route::Entry(id) => b
.entry(*id)
.map(|e| format!("#{} {}", e.id, e.description))
.unwrap_or_default(),
Route::Reports(k) => k.title().into(),
Route::Account(id) => b.account_label(*id),
Route::Document(id) => b
.attachments
.iter()
.find(|a| a.id == *id)
.map(|a| a.file_name.clone())
.unwrap_or_default(),
Route::JournalEvent(seq) => format!("Event {seq}"),
_ => String::new(),
}
}
+177
View File
@@ -0,0 +1,177 @@
//! Rendering receipts and documents to textures, off the UI thread.
//!
//! PDFs are rasterized with `hayro` (pure Rust), images decoded with
//! `image`. Results are cached by blob hash: the same bytes never render
//! twice, and a hash is the content, so the cache never goes stale. Bytes
//! are read through the verifying blob store: a corrupt blob shows as an
//! error, never as a picture.
use egui::{ColorImage, Context, TextureHandle, TextureOptions};
use ledger_store::BlobStore;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
pub enum State {
Loading,
Ready(Vec<TextureHandle>),
Failed(String),
}
struct Job {
sha: String,
mime: String,
root: PathBuf,
}
struct Done {
sha: String,
result: Result<Vec<ColorImage>, String>,
}
pub struct Receipts {
cache: HashMap<String, State>,
jobs: Sender<Job>,
done: Receiver<Done>,
}
impl Receipts {
pub fn new(ctx: &Context) -> Self {
let (jobs, job_rx) = channel::<Job>();
let (done_tx, done) = channel();
let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));
let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4));
for i in 0..workers {
let ctx = ctx.clone();
let job_rx = job_rx.clone();
let done_tx = done_tx.clone();
std::thread::Builder::new()
.name(format!("receipt-render-{i}"))
.spawn(move || {
loop {
let job = match job_rx.lock() {
Ok(rx) => rx.recv(),
Err(_) => return,
};
let Ok(job) = job else {
return;
};
let result = render(&job);
let _ = done_tx.send(Done {
sha: job.sha,
result,
});
ctx.request_repaint();
}
})
.expect("spawn renderer");
}
Self {
cache: HashMap::new(),
jobs,
done,
}
}
/// The rendering of a blob, starting it if needed.
pub fn get(&mut self, sha: &str, mime: &str, root: PathBuf) -> &State {
if !self.cache.contains_key(sha) {
self.cache.insert(sha.to_string(), State::Loading);
let _ = self.jobs.send(Job {
sha: sha.to_string(),
mime: mime.to_string(),
root,
});
}
&self.cache[sha]
}
/// Turn finished renders into textures. Call once per frame.
pub fn pump(&mut self, ctx: &Context) {
while let Ok(done) = self.done.try_recv() {
self.accept(ctx, done);
}
}
fn accept(&mut self, ctx: &Context, done: Done) {
let state = match done.result {
Ok(images) => State::Ready(
images
.into_iter()
.enumerate()
.map(|(i, img)| {
ctx.load_texture(
format!("blob-{}-{i}", done.sha),
img,
TextureOptions::LINEAR,
)
})
.collect(),
),
Err(e) => State::Failed(e),
};
self.cache.insert(done.sha, state);
}
/// Wait until nothing is rendering (tests and screenshots).
pub fn settle(&mut self, ctx: &Context) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while self.cache.values().any(|s| matches!(s, State::Loading))
&& std::time::Instant::now() < deadline
{
if let Ok(done) = self
.done
.recv_timeout(std::time::Duration::from_millis(100))
{
self.accept(ctx, done);
}
}
}
}
fn render(job: &Job) -> Result<Vec<ColorImage>, String> {
let bytes = BlobStore::open_read_only(&job.root)
.get(&job.sha)
.map_err(|e| e.to_string())?;
let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF");
if is_pdf {
render_pdf(bytes)
} else if job.mime.starts_with("image/") {
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
Ok(vec![to_color_image(img.to_rgba8())])
} else {
Err(format!("{} can't be previewed", job.mime))
}
}
fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
let cache = RenderCache::new();
let interp = InterpreterSettings::default();
let settings = RenderSettings::default();
let px = PixmapSettings {
x_scale: 2.0,
y_scale: 2.0,
bg_color: WHITE,
};
let mut out = Vec::new();
for page in pdf.pages().iter().take(30) {
let pixmap = render(page, &cache, &interp, &settings, &px);
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
out.push(to_color_image(img.to_rgba8()));
}
if out.is_empty() {
return Err("the PDF has no pages".into());
}
Ok(out)
}
fn to_color_image(img: image::RgbaImage) -> ColorImage {
let size = [img.width() as usize, img.height() as usize];
ColorImage::from_rgba_unmultiplied(size, img.as_raw())
}
+228
View File
@@ -0,0 +1,228 @@
//! Look and feel: one palette per mode, Inter with tabular figures, and
//! the spacing the whole app shares.
use egui::{
Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke,
TextStyle, Visuals,
};
use std::sync::Arc;
/// Colors for one mode. Every color in the app comes from here.
#[derive(Clone, Copy, Debug)]
pub struct Palette {
pub bg: Color32,
pub surface: Color32,
pub sidebar: Color32,
pub hover: Color32,
pub selected: Color32,
pub border: Color32,
pub text: Color32,
pub muted: Color32,
pub faint: Color32,
pub accent: Color32,
pub accent_soft: Color32,
pub positive: Color32,
pub negative: Color32,
pub warning: Color32,
pub warning_soft: Color32,
pub negative_soft: Color32,
pub positive_soft: Color32,
pub new_highlight: Color32,
/// Chart series, in order.
pub series: [Color32; 6],
}
pub const LIGHT: Palette = Palette {
bg: Color32::from_rgb(0xF6, 0xF6, 0xF4),
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
sidebar: Color32::from_rgb(0xEF, 0xEF, 0xEC),
hover: Color32::from_rgb(0xF2, 0xF2, 0xF0),
selected: Color32::from_rgb(0xE8, 0xE9, 0xFB),
border: Color32::from_rgb(0xE4, 0xE4, 0xE0),
text: Color32::from_rgb(0x1B, 0x1B, 0x1F),
muted: Color32::from_rgb(0x67, 0x67, 0x70),
faint: Color32::from_rgb(0x9C, 0x9C, 0xA4),
accent: Color32::from_rgb(0x4F, 0x46, 0xE5),
accent_soft: Color32::from_rgb(0xEE, 0xEE, 0xFD),
positive: Color32::from_rgb(0x15, 0x80, 0x3D),
negative: Color32::from_rgb(0xB9, 0x1C, 0x1C),
warning: Color32::from_rgb(0xB4, 0x53, 0x09),
warning_soft: Color32::from_rgb(0xFE, 0xF3, 0xE2),
negative_soft: Color32::from_rgb(0xFD, 0xEC, 0xEC),
positive_soft: Color32::from_rgb(0xE8, 0xF5, 0xEC),
new_highlight: Color32::from_rgb(0xFF, 0xF7, 0xD6),
series: [
Color32::from_rgb(0x4F, 0x46, 0xE5),
Color32::from_rgb(0x0D, 0x94, 0x88),
Color32::from_rgb(0xD9, 0x77, 0x06),
Color32::from_rgb(0xDB, 0x27, 0x77),
Color32::from_rgb(0x25, 0x63, 0xEB),
Color32::from_rgb(0x64, 0x74, 0x8B),
],
};
pub const DARK: Palette = Palette {
bg: Color32::from_rgb(0x11, 0x12, 0x14),
surface: Color32::from_rgb(0x19, 0x1A, 0x1D),
sidebar: Color32::from_rgb(0x15, 0x16, 0x18),
hover: Color32::from_rgb(0x22, 0x23, 0x27),
selected: Color32::from_rgb(0x26, 0x27, 0x45),
border: Color32::from_rgb(0x2A, 0x2B, 0x30),
text: Color32::from_rgb(0xEC, 0xEC, 0xEE),
muted: Color32::from_rgb(0xA3, 0xA3, 0xAC),
faint: Color32::from_rgb(0x6E, 0x6E, 0x78),
accent: Color32::from_rgb(0x8B, 0x93, 0xFA),
accent_soft: Color32::from_rgb(0x24, 0x25, 0x45),
positive: Color32::from_rgb(0x4A, 0xDE, 0x80),
negative: Color32::from_rgb(0xF8, 0x71, 0x71),
warning: Color32::from_rgb(0xFB, 0xBF, 0x24),
warning_soft: Color32::from_rgb(0x33, 0x29, 0x16),
negative_soft: Color32::from_rgb(0x3A, 0x1C, 0x1E),
positive_soft: Color32::from_rgb(0x16, 0x30, 0x22),
new_highlight: Color32::from_rgb(0x36, 0x31, 0x18),
series: [
Color32::from_rgb(0x8B, 0x93, 0xFA),
Color32::from_rgb(0x2D, 0xD4, 0xBF),
Color32::from_rgb(0xFB, 0xBF, 0x24),
Color32::from_rgb(0xF4, 0x72, 0xB6),
Color32::from_rgb(0x60, 0xA5, 0xFA),
Color32::from_rgb(0x94, 0xA3, 0xB8),
],
};
/// The palette for the current mode.
pub fn pal(ctx: &Context) -> &'static Palette {
if ctx.global_style().visuals.dark_mode {
&DARK
} else {
&LIGHT
}
}
pub const SEMIBOLD: &str = "semibold";
pub const BOLD: &str = "bold";
pub fn regular(size: f32) -> FontId {
FontId::new(size, FontFamily::Proportional)
}
pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(SEMIBOLD.into()))
}
pub fn bold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(BOLD.into()))
}
/// Install fonts: Inter with tabular figures frozen in, so every number in
/// the app lines up digit for digit. Phosphor icons ride along as a
/// fallback in every family.
pub fn install_fonts(ctx: &Context) {
let mut fonts = FontDefinitions::default();
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
fonts
.font_data
.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
};
add(
&mut fonts,
"InterTab-Regular",
include_bytes!("../assets/fonts/InterTab-Regular.ttf"),
);
add(
&mut fonts,
"InterTab-SemiBold",
include_bytes!("../assets/fonts/InterTab-SemiBold.ttf"),
);
add(
&mut fonts,
"InterTab-Bold",
include_bytes!("../assets/fonts/InterTab-Bold.ttf"),
);
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
let fallbacks: Vec<String> = fonts
.families
.get(&FontFamily::Proportional)
.cloned()
.unwrap_or_default();
// Text first, icons as a fallback. The bundled Inter copies have their
// private-use glyphs removed, so Phosphor's icons (which live there)
// are never shadowed.
let family = |primary: &str| {
let mut v = vec![primary.to_owned()];
v.extend(fallbacks.iter().cloned());
v
};
fonts
.families
.insert(FontFamily::Proportional, family("InterTab-Regular"));
fonts.families.insert(
FontFamily::Name(SEMIBOLD.into()),
family("InterTab-SemiBold"),
);
fonts
.families
.insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold"));
ctx.set_fonts(fonts);
}
/// Apply the app's style for light or dark mode.
pub fn apply(ctx: &Context, dark: bool) {
let p = if dark { &DARK } else { &LIGHT };
let mut visuals = if dark {
Visuals::dark()
} else {
Visuals::light()
};
visuals.panel_fill = p.bg;
visuals.window_fill = p.surface;
visuals.extreme_bg_color = p.surface;
visuals.faint_bg_color = p.hover;
visuals.override_text_color = Some(p.text);
visuals.hyperlink_color = p.accent;
visuals.selection.bg_fill = p.selected;
visuals.selection.stroke = Stroke::new(1.0, p.accent);
visuals.window_stroke = Stroke::new(1.0, p.border);
visuals.window_corner_radius = CornerRadius::same(12);
visuals.menu_corner_radius = CornerRadius::same(10);
for w in [
&mut visuals.widgets.noninteractive,
&mut visuals.widgets.inactive,
&mut visuals.widgets.hovered,
&mut visuals.widgets.active,
&mut visuals.widgets.open,
] {
w.corner_radius = CornerRadius::same(8);
}
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
visuals.widgets.inactive.bg_fill = p.surface;
visuals.widgets.inactive.weak_bg_fill = p.surface;
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, p.text);
visuals.widgets.hovered.bg_fill = p.hover;
visuals.widgets.hovered.weak_bg_fill = p.hover;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, p.border);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, p.text);
visuals.widgets.active.bg_fill = p.selected;
visuals.widgets.active.weak_bg_fill = p.selected;
visuals.widgets.active.fg_stroke = Stroke::new(1.0, p.text);
ctx.set_visuals(visuals);
ctx.all_styles_mut(|style| {
style.text_styles = [
(TextStyle::Heading, semibold(20.0)),
(TextStyle::Body, regular(14.0)),
(TextStyle::Button, regular(14.0)),
(TextStyle::Small, regular(12.0)),
(TextStyle::Monospace, FontId::monospace(12.5)),
]
.into();
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(10.0, 5.0);
style.spacing.interact_size.y = 28.0;
style.spacing.window_margin = Margin::same(16);
style.spacing.scroll.bar_width = 8.0;
style.spacing.scroll.floating = true;
style.interaction.selectable_labels = false;
});
}
+291
View File
@@ -0,0 +1,291 @@
//! Small building blocks shared by every screen.
use crate::theme::{self, pal};
use egui::{
Align, Color32, CornerRadius, Frame, Layout, Margin, Response, RichText, Sense, Stroke, Ui,
vec2,
};
/// A white (or dark) rounded surface with a hairline border.
pub fn card<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let p = pal(ui.ctx());
Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(12))
.inner_margin(Margin::same(16))
.show(ui, |ui| {
ui.set_width(ui.available_width());
add(ui)
})
.inner
}
/// A card with a title row and a right-aligned extra (a link, a pill).
/// Returns what `extra` and `add` return.
pub fn titled_card<E, R>(
ui: &mut Ui,
title: &str,
extra: impl FnOnce(&mut Ui) -> E,
add: impl FnOnce(&mut Ui) -> R,
) -> (E, R) {
card(ui, |ui| {
let e = ui
.horizontal(|ui| {
ui.label(RichText::new(title).font(theme::semibold(14.0)));
ui.with_layout(Layout::right_to_left(Align::Center), extra)
.inner
})
.inner;
ui.add_space(8.0);
(e, add(ui))
})
}
/// Page title and the sentence under it.
pub fn page_header(ui: &mut Ui, title: &str, subtitle: &str) {
let p = pal(ui.ctx());
ui.label(RichText::new(title).font(theme::bold(26.0)));
if !subtitle.is_empty() {
ui.add_space(2.0);
let w = ui.available_width().min(860.0);
ui.allocate_ui(vec2(w, 0.0), |ui| {
ui.set_max_width(w);
ui.label(
RichText::new(subtitle)
.font(theme::regular(15.0))
.color(p.muted),
);
});
}
ui.add_space(16.0);
}
/// A rounded tag.
pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response {
Frame::new()
.fill(bg)
.corner_radius(CornerRadius::same(255))
.inner_margin(Margin::symmetric(8, 2))
.show(ui, |ui| {
ui.label(RichText::new(text).font(theme::semibold(11.5)).color(fg))
})
.response
}
/// Text that looks like plain text until you hover it, then like a link.
pub fn link(ui: &mut Ui, text: RichText) -> Response {
let p = pal(ui.ctx());
let r = ui.add(egui::Label::new(text.clone()).sense(Sense::click()));
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
let rect = r.rect;
ui.painter().line_segment(
[
rect.left_bottom() + vec2(0.0, 1.0),
rect.right_bottom() + vec2(0.0, 1.0),
],
Stroke::new(1.0, p.accent),
);
}
r
}
/// An amount, right-aligned in a fixed-width cell, clickable when `drill`.
pub fn amount_cell(
ui: &mut Ui,
text: &str,
width: f32,
color: Color32,
strong: bool,
drill: bool,
) -> Response {
let font = if strong {
theme::semibold(14.0)
} else {
theme::regular(14.0)
};
let (rect, resp) = ui.allocate_exact_size(
vec2(width, 22.0),
if drill {
Sense::click()
} else {
Sense::hover()
},
);
let p = pal(ui.ctx());
let hovered = drill && resp.hovered();
let galley = ui.painter().layout_no_wrap(
text.to_string(),
font,
if hovered { p.accent } else { color },
);
let pos = egui::pos2(
rect.right() - galley.size().x,
rect.center().y - galley.size().y / 2.0,
);
if hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
ui.painter().line_segment(
[
egui::pos2(pos.x, rect.center().y + galley.size().y / 2.0),
egui::pos2(rect.right(), rect.center().y + galley.size().y / 2.0),
],
Stroke::new(1.0, p.accent),
);
}
ui.painter().galley(pos, galley, color);
resp
}
/// A big number with a label, a change line, and an optional sparkline.
pub struct Kpi<'a> {
pub label: &'a str,
pub value: String,
pub sub: Option<(String, Color32)>,
pub spark: Option<&'a [f64]>,
pub how: &'a str,
}
pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response {
let p = pal(ui.ctx());
let resp = Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(12))
.inner_margin(Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(104.0);
ui.label(
RichText::new(k.label)
.font(theme::regular(12.5))
.color(p.muted),
);
ui.add_space(4.0);
ui.label(RichText::new(&k.value).font(theme::semibold(24.0)));
if let Some((text, color)) = &k.sub {
ui.add_space(2.0);
ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color));
}
if let Some(values) = k.spark
&& values.len() > 1
{
ui.add_space(6.0);
let (rect, _) =
ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
crate::charts::sparkline(ui.painter(), rect, values, p.accent);
}
})
.response;
if !k.how.is_empty() {
resp.on_hover_text(k.how)
} else {
resp
}
}
/// A friendly placeholder when there's nothing to show yet.
pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) {
let p = pal(ui.ctx());
ui.vertical_centered(|ui| {
ui.add_space(18.0);
ui.label(RichText::new(icon).size(28.0).color(p.faint));
ui.add_space(4.0);
ui.label(
RichText::new(title)
.font(theme::semibold(14.0))
.color(p.muted),
);
ui.label(
RichText::new(text)
.font(theme::regular(12.5))
.color(p.faint),
);
ui.add_space(18.0);
});
}
/// "Copy prompt": puts an agent prompt on the clipboard.
pub fn copy_prompt(ui: &mut Ui, prompt: &str) {
let p = pal(ui.ctx());
let id = ui.id().with(("copied", prompt));
let copied_at: Option<f64> = ui.ctx().data(|d| d.get_temp(id));
let now = ui.input(|i| i.time);
let recently = copied_at.is_some_and(|t| now - t < 1.5);
let label = if recently {
format!("{} Copied", egui_phosphor::regular::CHECK)
} else {
format!("{} Ask the agent", egui_phosphor::regular::SPARKLE)
};
let r = ui
.add(
egui::Button::new(
RichText::new(label)
.font(theme::regular(12.0))
.color(p.accent),
)
.fill(p.accent_soft)
.stroke(Stroke::NONE)
.corner_radius(CornerRadius::same(6)),
)
.on_hover_text(format!(
"Copies this prompt for your agent session:\n\n{prompt}"
));
if r.clicked() {
ui.ctx().copy_text(prompt.to_string());
ui.ctx().data_mut(|d| d.insert_temp(id, now));
}
if recently {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(300));
}
}
/// A horizontal rule in the border color.
pub fn rule(ui: &mut Ui) {
let p = pal(ui.ctx());
let w = ui.available_width();
let (rect, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
ui.painter().rect_filled(rect, 0.0, p.border);
}
/// Selectable segmented control.
pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool {
let p = pal(ui.ctx());
let mut changed = false;
Frame::new()
.fill(p.hover)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(2))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.horizontal(|ui| {
for (v, label) in options {
let on = *value == *v;
let b = egui::Button::new(
RichText::new(*label)
.font(if on {
theme::semibold(12.5)
} else {
theme::regular(12.5)
})
.color(if on { p.text } else { p.muted }),
)
.fill(if on { p.surface } else { Color32::TRANSPARENT })
.stroke(if on {
Stroke::new(1.0, p.border)
} else {
Stroke::NONE
})
.corner_radius(CornerRadius::same(6))
.min_size(vec2(0.0, 24.0));
if ui.add(b).clicked() && !on {
*value = *v;
changed = true;
}
}
});
});
changed
}
+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();
}
}
+1
View File
@@ -14,6 +14,7 @@ path = "src/main.rs"
ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
ledger-import = { path = "../ledger-import" }
ledger-config = { path = "../ledger-config" }
base64 = "0.22"
mime_guess = "2"
rust_decimal = "1"
+1 -1
View File
@@ -9,7 +9,7 @@
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir.
mod company;
use ledger_config as company;
mod policy;
mod server;
mod types;
+1 -1
View File
@@ -1,10 +1,10 @@
//! The MCP server: narrow typed tools over the store, with the receipt
//! policy enforced server-side.
use crate::company::Company;
use crate::policy::ReceiptPolicy;
use crate::types::*;
use chrono::NaiveDate;
use ledger_config::Company;
use ledger_core::{
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
+8
View File
@@ -25,6 +25,14 @@ impl BlobStore {
})
}
/// Open an existing blob store for reading, creating nothing. A
/// missing directory just means no blobs yet.
pub fn open_read_only(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
}
}
fn path_for(&self, sha_hex: &str) -> PathBuf {
self.root
.join(&sha_hex[0..2])
+2
View File
@@ -22,11 +22,13 @@ pub mod blob;
pub mod error;
pub mod journal;
pub mod schema;
pub mod snapshot;
pub mod store;
pub use blob::BlobStore;
pub use error::StoreError;
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use snapshot::Snapshot;
pub use store::{
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
PayrollLineInput, Store,
+175
View File
@@ -0,0 +1,175 @@
//! [`Snapshot`]: a read-only view of the books, for viewers.
//!
//! A snapshot never writes to the data directory. It reads the journal,
//! verifies the hash chain, replays it into its own [`Ledger`], and projects
//! it into a private in-memory SQLite database with exactly the code the
//! writer uses, so attachments and events read the same as through
//! [`crate::Store`]. Blobs are read (and verified) straight from the blob
//! store.
//!
//! [`Snapshot::refresh`] follows a live journal: it reads only the bytes
//! appended since the last read, verifies they chain onto what it has, and
//! applies them. A line the writer is still in the middle of appending
//! (no newline yet) is left for the next refresh, never treated as
//! corruption. If the file shrank or no longer chains, the snapshot reloads
//! from scratch.
use crate::blob::BlobStore;
use crate::error::StoreError;
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
use crate::store::{
Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments,
read_attachment_bytes, replay_line_into_ledger,
};
use ledger_core::{Currency, EntryId, Ledger};
use rusqlite::Connection;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
/// A read-only, live-refreshable view of a data directory.
pub struct Snapshot {
data_dir: PathBuf,
conn: Connection,
blobs: BlobStore,
ledger: Ledger,
base_currency: Currency,
lines: Vec<JournalLine>,
/// Bytes of the journal consumed (complete lines only).
offset: u64,
/// Hash of the last line read: what the next line must chain onto.
head: String,
}
impl Snapshot {
/// Load the books in `data_dir`, read-only.
pub fn load(data_dir: &Path) -> Result<Self, StoreError> {
let journal_path = data_dir.join(JOURNAL_FILE);
if !journal_path.exists() {
return Err(StoreError::NotFound(data_dir.display().to_string()));
}
let conn = Connection::open_in_memory()?;
Store::migrate(&conn)?;
let mut snap = Self {
data_dir: data_dir.to_path_buf(),
conn,
blobs: BlobStore::open_read_only(&data_dir.join(BLOBS_DIR)),
ledger: Ledger::new(Currency::USD),
base_currency: Currency::USD,
lines: Vec::new(),
offset: 0,
head: GENESIS_PREV.to_string(),
};
snap.read_new()?;
Ok(snap)
}
/// Apply whatever the writer appended since the last read. Returns the
/// number of new journal lines (0 when nothing changed).
pub fn refresh(&mut self) -> Result<usize, StoreError> {
let len = std::fs::metadata(self.data_dir.join(JOURNAL_FILE))?.len();
if len < self.offset {
// Rewritten underneath us (e.g. restored from a backup).
*self = Self::load(&self.data_dir)?;
return Ok(self.lines.len());
}
match self.read_new() {
Ok(n) => Ok(n),
Err(StoreError::JournalCorrupt(_)) => {
// No longer chains onto what we have: start over, and let a
// real corruption surface from the full load.
*self = Self::load(&self.data_dir)?;
Ok(self.lines.len())
}
Err(e) => Err(e),
}
}
fn read_new(&mut self) -> Result<usize, StoreError> {
let mut file = std::fs::File::open(self.data_dir.join(JOURNAL_FILE))?;
file.seek(SeekFrom::Start(self.offset))?;
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
// Complete lines only: a torn tail is a write still in progress.
let Some(last_newline) = buf.iter().rposition(|&b| b == b'\n') else {
return Ok(0);
};
let complete = &buf[..=last_newline];
let mut added = 0;
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
let line: JournalLine = serde_json::from_slice(raw).map_err(|e| {
StoreError::JournalCorrupt(format!(
"seq {}: unparseable line: {e}",
self.lines.len() + 1
))
})?;
let want_seq = self.lines.len() as u64 + 1;
if line.seq != want_seq {
return Err(StoreError::JournalCorrupt(format!(
"seq gap: expected {want_seq}, found {}",
line.seq
)));
}
if line.prev != self.head {
return Err(StoreError::JournalCorrupt(format!(
"hash chain broken at seq {}",
line.seq
)));
}
if line.seq == 1 {
let code = line.payload["base_currency"].as_str().unwrap_or("USD");
self.base_currency = Currency::new(code)
.map_err(|_| StoreError::JournalCorrupt(format!("bad base_currency {code}")))?;
self.ledger = Ledger::new(self.base_currency);
}
replay_line_into_ledger(&mut self.ledger, &line, self.base_currency)?;
apply_line_to_sqlite(&self.conn, &line)?;
self.head = sha256_hex(line.to_canonical().as_bytes());
self.lines.push(line);
added += 1;
}
self.offset += complete.len() as u64;
Ok(added)
}
/// The replayed ledger: the source for every report.
pub fn ledger(&self) -> &Ledger {
&self.ledger
}
/// The books' base currency.
pub fn base_currency(&self) -> Currency {
self.base_currency
}
/// The data directory.
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
/// Every journal line read so far, in order: the complete history,
/// with timestamps and hashes.
pub fn journal(&self) -> &[JournalLine] {
&self.lines
}
/// Sequence number of the last journal line read.
pub fn journal_seq(&self) -> u64 {
self.lines.len() as u64
}
/// Hash of the last journal line: the head of the verified chain.
pub fn head_hash(&self) -> &str {
&self.head
}
/// Attachments (receipts and documents, withdrawn ones included),
/// optionally only one entry's.
pub fn attachments(&self, entry_id: Option<EntryId>) -> Result<Vec<Attachment>, StoreError> {
query_attachments(&self.conn, entry_id)
}
/// An attachment's bytes, verified against its hash.
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
}
}
+50 -30
View File
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
pub const JOURNAL_FILE: &str = "journal.jsonl";
/// File names inside the data directory.
pub const DB_FILE: &str = "wanderbooks.db";
const BLOBS_DIR: &str = "blobs";
pub(crate) const BLOBS_DIR: &str = "blobs";
/// One row of an entry listing.
#[derive(Clone, Debug)]
@@ -283,7 +283,7 @@ impl Store {
matches!(check, Ok(s) if s == "ok")
}
fn migrate(conn: &Connection) -> Result<(), StoreError> {
pub(crate) fn migrate(conn: &Connection) -> Result<(), StoreError> {
let mut v = Self::current_version(conn)?;
if v > schema::SCHEMA_VERSION {
return Err(StoreError::SchemaTooNew(v, schema::SCHEMA_VERSION));
@@ -2106,36 +2106,12 @@ impl Store {
&self,
entry_id: Option<EntryId>,
) -> Result<Vec<Attachment>, StoreError> {
let mut sql = String::from(
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
FROM attachments a \
JOIN blobs b ON b.sha256 = a.blob_sha",
);
if entry_id.is_some() {
sql.push_str(" WHERE a.entry_id = ?");
}
sql.push_str(" ORDER BY a.id");
let mut stmt = self.conn.prepare(&sql)?;
let rows: Vec<Attachment> = match entry_id {
Some(eid) => stmt
.query_map(params![eid as i64], row_to_attachment)?
.collect::<Result<_, _>>()?,
None => stmt
.query_map([], row_to_attachment)?
.collect::<Result<_, _>>()?,
};
Ok(rows)
query_attachments(&self.conn, entry_id)
}
/// Read attachment bytes, verifying the SHA-256.
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
let sha: String = self.conn.query_row(
"SELECT blob_sha FROM attachments WHERE id = ?",
params![attachment_id as i64],
|r| r.get(0),
)?;
self.blobs.get(&sha)
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
}
// ------------------------------------------------------------------
@@ -2795,7 +2771,7 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
/// Replay one journal line into a ledger, asserting recorded ids. Used by
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
/// is the actor.
fn replay_line_into_ledger(
pub(crate) fn replay_line_into_ledger(
ledger: &mut Ledger,
line: &JournalLine,
base_currency: Currency,
@@ -3376,7 +3352,10 @@ fn check_replayed_entry(
Ok(())
}
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
pub(crate) fn apply_line_to_sqlite(
conn: &Connection,
line: &JournalLine,
) -> Result<(), StoreError> {
let p = &line.payload;
match line.line_type.as_str() {
"store.created" => {}
@@ -3795,6 +3774,47 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
Ok(())
}
/// Attachments in the projection, optionally only one entry's, by id.
pub(crate) fn query_attachments(
conn: &Connection,
entry_id: Option<EntryId>,
) -> Result<Vec<Attachment>, StoreError> {
let mut sql = String::from(
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
FROM attachments a \
JOIN blobs b ON b.sha256 = a.blob_sha",
);
if entry_id.is_some() {
sql.push_str(" WHERE a.entry_id = ?");
}
sql.push_str(" ORDER BY a.id");
let mut stmt = conn.prepare(&sql)?;
let rows: Vec<Attachment> = match entry_id {
Some(eid) => stmt
.query_map(params![eid as i64], row_to_attachment)?
.collect::<Result<_, _>>()?,
None => stmt
.query_map([], row_to_attachment)?
.collect::<Result<_, _>>()?,
};
Ok(rows)
}
/// An attachment's bytes, verified against its SHA-256.
pub(crate) fn read_attachment_bytes(
conn: &Connection,
blobs: &BlobStore,
attachment_id: u64,
) -> Result<Vec<u8>, StoreError> {
let sha: String = conn.query_row(
"SELECT blob_sha FROM attachments WHERE id = ?",
params![attachment_id as i64],
|r| r.get(0),
)?;
blobs.get(&sha)
}
fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
Ok(Attachment {
id: r.get::<_, i64>(0)? as u64,
+145
View File
@@ -0,0 +1,145 @@
//! Snapshot: a read-only view that follows a live writer.
use chrono::NaiveDate;
use ledger_core::reports::snapshot;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
use rust_decimal::Decimal;
use std::io::Write;
use std::path::PathBuf;
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
let e = store.create_entry(d, "x").unwrap();
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
store.post_entry(e).unwrap();
e
}
#[test]
fn a_snapshot_follows_the_writer_and_never_writes() {
let dir = test_dir("follow");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(&mut store, 1, cash, equity, 1000);
store
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
.unwrap();
let listing = |dir: &std::path::Path| {
let mut v: Vec<_> = walk(dir);
v.sort();
v
};
let before = listing(&dir);
let mut snap = Snapshot::load(&dir).unwrap();
assert_eq!(listing(&dir), before, "loading writes nothing");
assert_eq!(snap.journal_seq(), store.journal_seq());
assert_eq!(
snapshot(snap.ledger()).unwrap(),
snapshot(store.ledger()).unwrap()
);
let atts = snap.attachments(Some(e)).unwrap();
assert_eq!(atts.len(), 1);
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
// The writer keeps going; refresh picks up exactly the new lines.
assert_eq!(snap.refresh().unwrap(), 0);
let seq = store.journal_seq();
post(&mut store, 2, cash, equity, 250);
let added = snap.refresh().unwrap();
assert_eq!(added as u64, store.journal_seq() - seq);
assert_eq!(
snapshot(snap.ledger()).unwrap(),
snapshot(store.ledger()).unwrap()
);
}
#[test]
fn a_half_written_line_waits_for_the_next_refresh() {
let dir = test_dir("torn");
let mut store = Store::create(&dir, Currency::USD).unwrap();
store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
drop(store);
let mut snap = Snapshot::load(&dir).unwrap();
let seq = snap.journal_seq();
// Simulate the writer mid-append: half a line, no newline yet.
let journal = dir.join(JOURNAL_FILE);
let full = std::fs::read(&journal).unwrap();
let mut store = Store::open(&dir).unwrap();
store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
drop(store);
let grown = std::fs::read(&journal).unwrap();
let new_line = &grown[full.len()..];
std::fs::write(&journal, &full).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&journal)
.unwrap();
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
f.flush().unwrap();
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
f.flush().unwrap();
assert_eq!(snap.refresh().unwrap(), 1);
assert_eq!(snap.journal_seq(), seq + 1);
assert!(snap.ledger().chart().find_by_code("3000").is_some());
}
#[test]
fn a_rewritten_journal_reloads() {
let dir = test_dir("rewrite");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
let journal = dir.join(JOURNAL_FILE);
let early = std::fs::read(&journal).unwrap();
post(&mut store, 1, cash, equity, 10);
drop(store);
let mut snap = Snapshot::load(&dir).unwrap();
let later = snap.journal_seq();
// Restored from an earlier backup: shorter file.
std::fs::write(&journal, &early).unwrap();
snap.refresh().unwrap();
assert!(snap.journal_seq() < later);
assert!(snap.ledger().posted_entries().next().is_none());
}
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap() {
let e = e.unwrap();
let m = e.metadata().unwrap();
if m.is_dir() {
out.extend(walk(&e.path()));
} else {
out.push((e.path().display().to_string(), m.len()));
}
}
out
}