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

+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
}