Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s

A project is a class with facts (description, status, kind, party,
dates, links, notes) in one project.set event behind the format
feature projects. Four tools (books.add_project, update_project,
projects, project); documents and decisions link to projects; B-37:
on-spec costs are expensed as incurred, repayment is revenue, and the
unrecovered cost is never below zero (Kani). Viewer: a Projects list
and a project page with its figures, by month and by account. The
sidebar measures its footer instead of reserving a fixed 172 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 03:50:52 -06:00
1 parent 0495726001
commit 321b94562a
71 files changed
+7446 -217

No files matched your search

+67 -6
View File
@@ -22,6 +22,7 @@ pub struct PageState {
pub accounts: pages::accounts::State,
pub assets: pages::assets::State,
pub parties: pages::parties::State,
pub projects: pages::projects::State,
pub documents: pages::documents::State,
pub journal: pages::journal::State,
}
@@ -412,6 +413,7 @@ impl App {
"accounts" => Route::Accounts,
"assets" => Route::Assets,
"parties" => Route::Parties,
"projects" => Route::Projects,
"documents" => Route::Documents,
"company" => Route::Company,
"journal" => Route::Journal,
@@ -494,6 +496,12 @@ impl App {
}
Route::Party(party_id)
}
T::Project { class_id } => {
if b.ledger.get_class(class_id).is_none() {
return;
}
Route::Project(class_id)
}
T::Reconciliation { id } => {
// Its own page: statement, balance, the lines it cleared.
if b.reconciliation(id).is_none() {
@@ -601,8 +609,16 @@ impl App {
let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
// Short windows: the links scroll above the status box
// instead of the box drawing over them.
let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
// instead of the box drawing over them. The box's height
// is the one it was drawn at last frame.
let footer_id = egui::Id::new("sidebar_footer_h");
let footer_h = ui
.ctx()
.data(|d| d.get_temp::<f32>(footer_id))
.unwrap_or(SIDEBAR_FOOTER);
// At least one link always shows; below that the footer
// gives way (footer_layout).
let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(32.0);
egui::ScrollArea::vertical()
.id_salt("sidebar_links")
.max_height(links_h)
@@ -613,6 +629,10 @@ impl App {
vec2(ui.available_width(), 32.0),
Sense::click(),
);
// Named for assistive tech (and the tests).
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title())
});
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.surface);
@@ -668,7 +688,7 @@ impl App {
}
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let footer = footer_layout(ui, footer_h, Align::Min, |ui| {
self.scheme_menu(ui, false);
if let Some(b) = &books {
ui.add_space(2.0);
@@ -685,6 +705,7 @@ impl App {
);
}
});
remember_footer(ui, footer_id, footer.response.rect.height(), footer_h);
});
}
@@ -722,7 +743,12 @@ impl App {
ui.add_space(18.0);
let current = self.nav.current.section();
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
let footer_id = egui::Id::new("rail_footer_h");
let footer_h = ui
.ctx()
.data(|d| d.get_temp::<f32>(footer_id))
.unwrap_or(RAIL_FOOTER);
let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(36.0);
egui::ScrollArea::vertical()
.id_salt("rail_links")
.max_height(links_h)
@@ -733,6 +759,9 @@ impl App {
vec2(ui.available_width(), 36.0),
Sense::click(),
);
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title())
});
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.surface);
@@ -768,7 +797,7 @@ impl App {
}
}
});
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
let footer = footer_layout(ui, footer_h, Align::Center, |ui| {
self.scheme_menu(ui, true);
if let Some(b) = &books {
self.menu.ui(ui, b, true);
@@ -814,6 +843,7 @@ impl App {
resp.on_hover_text(tip);
}
});
remember_footer(ui, footer_id, footer.response.rect.height(), footer_h);
});
}
@@ -948,10 +978,41 @@ impl App {
/// Width of the icon rail.
const RAIL_W: f32 = 60.0;
/// Height kept clear under the sidebar's links for the status box and the
/// theme toggle; the rail's equivalent.
/// theme toggle before the footer is first measured; the rail's
/// equivalent. After the first frame the footer's drawn height is used
/// ([`remember_footer`]).
const SIDEBAR_FOOTER: f32 = 172.0;
const RAIL_FOOTER: f32 = 84.0;
/// Space kept between the last link and the footer.
const FOOTER_GAP: f32 = 8.0;
/// Keep the footer's drawn height for the next frame's links, and draw
/// again at once when it changed (the first frame, a check appearing).
/// The sidebar's footer, bottom up: on the panel's floor when it fits
/// under the links, else right under them, running off the bottom of the
/// window, so it gives way and never draws over a link.
fn footer_layout<R>(
ui: &mut Ui,
footer_h: f32,
align: Align,
add: impl FnOnce(&mut Ui) -> R,
) -> egui::InnerResponse<R> {
if ui.available_height() >= footer_h {
ui.with_layout(Layout::bottom_up(align), add)
} else {
let w = ui.available_width();
ui.allocate_ui_with_layout(vec2(w, footer_h), Layout::bottom_up(align), add)
}
}
fn remember_footer(ui: &Ui, id: egui::Id, drawn: f32, used: f32) {
if (drawn - used).abs() > 0.5 {
ui.ctx().data_mut(|d| d.insert_temp(id, drawn));
ui.ctx().request_repaint();
}
}
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
let p = pal(ui.ctx());
Frame::new()
+15
View File
@@ -1056,6 +1056,14 @@ impl Books {
.unwrap_or_else(|| format!("party {id}"))
}
/// A project's name ("project 3" for none such).
pub fn project_label(&self, id: ledger_core::ClassId) -> String {
self.ledger
.get_class(id)
.map(|c| c.name.clone())
.unwrap_or_else(|| format!("project {id}"))
}
/// Whether a party files a 1099: it has a 1099 role (contractor,
/// landlord), or its vendor record has been decided for one (from any
/// date: its W-9 matters for those payments).
@@ -1352,6 +1360,12 @@ impl Books {
Subject::Account(a) => self.account_label(a),
Subject::Asset(a) => self.asset_label(a),
Subject::Party(p) => self.party_label(p),
Subject::Project(c) => format!(
"Project {}",
u32::try_from(c)
.ok()
.map_or_else(|| c.to_string(), |c| self.project_label(c))
),
Subject::Invoice(i) => format!("Invoice {i}"),
Subject::Bill(i) => format!("Bill {i}"),
Subject::Period(i) => l
@@ -1390,6 +1404,7 @@ impl Books {
Subject::Account(a) => Route::Account(a),
Subject::Asset(a) => Route::Asset(a),
Subject::Party(p) => Route::Party(p),
Subject::Project(c) => Route::Project(u32::try_from(c).ok()?),
Subject::Invoice(i) => Route::Entry(l.get_invoice(i)?.entry_id),
Subject::Bill(i) => Route::Entry(l.get_bill(i)?.entry_id),
Subject::PayrollRun(i) => Route::Entry(l.get_payroll_run(i)?.entry_id),
+147 -9
View File
@@ -236,6 +236,10 @@ fn tooltip(
pub struct BarGroup {
pub label: String,
pub values: Vec<Decimal>,
/// The group's year, when the chart spans more than one: the first
/// label drawn, and each drawn in a new year, gains it ("Jan 26"),
/// whichever labels a crowded chart leaves out.
pub year: Option<i32>,
}
/// Grouped bars (e.g. income and spending per month), with a legend.
@@ -245,6 +249,22 @@ pub fn bar_chart(
groups: &[BarGroup],
series: &[(&str, Color32)],
height: f32,
) -> Option<usize> {
bars(ui, groups, series, height, true)
}
/// [`bar_chart`] that opens nothing: hovering shows each group's figures,
/// and the pointer stays an arrow, since no page shows those figures.
pub fn bar_chart_view(ui: &mut Ui, groups: &[BarGroup], series: &[(&str, Color32)], height: f32) {
bars(ui, groups, series, height, false);
}
fn bars(
ui: &mut Ui,
groups: &[BarGroup],
series: &[(&str, Color32)],
height: f32,
clickable: bool,
) -> Option<usize> {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
@@ -259,7 +279,12 @@ pub fn bar_chart(
ui.add_space(8.0);
}
});
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
let sense = if clickable {
Sense::click()
} else {
Sense::hover()
};
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), sense);
let painter = ui.painter_at(rect);
if groups.is_empty() {
return None;
@@ -293,7 +318,12 @@ pub fn bar_chart(
let bw = ((gw * 0.7) / n).min(28.0);
let mut clicked = None;
let hover = resp.hover_pos();
let mut drawn_year: Option<i32> = None;
for (gi, g) in groups.iter().enumerate() {
let full = match g.year {
Some(y) => format!("{} {:02}", g.label, y.rem_euclid(100)),
None => g.label.clone(),
};
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));
@@ -325,10 +355,12 @@ pub fn bar_chart(
// the latest so it's always labeled.
let every = (34.0 / gw).ceil().max(1.0) as usize;
if (groups.len() - 1 - gi).is_multiple_of(every) {
let new_year = g.year.is_some() && g.year != drawn_year;
drawn_year = g.year.or(drawn_year);
painter.text(
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
Align2::CENTER_TOP,
&g.label,
if new_year { &full } else { &g.label },
theme::regular(11.0),
p.faint,
);
@@ -342,7 +374,7 @@ pub fn bar_chart(
tooltip(
&painter,
pos2(gx + gw / 2.0, plot.top()),
&g.label,
&full,
&lines.join("\n"),
p.surface,
p.border,
@@ -350,10 +382,12 @@ pub fn bar_chart(
p.muted,
rect,
);
if resp.clicked() {
clicked = Some(gi);
if clickable {
if resp.clicked() {
clicked = Some(gi);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
}
clicked
@@ -362,6 +396,17 @@ pub fn bar_chart(
/// 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> {
hbars_signed(ui, rows, color, color)
}
/// [`hbars`] with a row below zero (a loss) drawn in `negative`, its
/// amount too, so the longest bar can't read as the best.
pub fn hbars_signed(
ui: &mut Ui,
rows: &[(String, Decimal)],
color: Color32,
negative: Color32,
) -> Option<usize> {
let p = pal(ui.ctx());
let max = rows
.iter()
@@ -371,13 +416,21 @@ pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<
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());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
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 below = *v < Decimal::ZERO;
let fill = if below { negative } else { color };
let amount_color = if below && negative != color {
negative
} else {
p.text
};
let amount = fmt::dollars(*v);
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), amount_color);
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(
@@ -392,14 +445,14 @@ pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<
pos2(bar_left, rect.center().y - 6.0),
vec2(w.max(2.0), 12.0),
);
painter.rect_filled(bar, CornerRadius::same(3), color);
painter.rect_filled(bar, CornerRadius::same(3), fill);
painter.galley(
pos2(
rect.right() - ag.size().x - 6.0,
rect.center().y - ag.size().y / 2.0,
),
ag,
p.text,
amount_color,
);
if resp.clicked() {
clicked = Some(i);
@@ -491,4 +544,89 @@ mod tests {
);
}
}
/// Months from November 2024 through October 2026, as the project and
/// party charts give them.
fn two_years() -> Vec<BarGroup> {
(0..24)
.map(|i| {
let m = d(2024, 11, 1) + chrono::Months::new(i);
BarGroup {
label: m.format("%b").to_string(),
values: vec![Decimal::new(i64::from(i) * 100, 2)],
year: Some(chrono::Datelike::year(&m)),
}
})
.collect()
}
/// A crowded chart over two years drops every other label, January's
/// too, yet the first label drawn and the first drawn in each new year
/// carry the year, so no month name stands for two months unread.
#[test]
fn a_crowded_chart_keeps_its_years() {
for width in [380.0, 800.0] {
let groups = two_years();
let mut h = Harness::builder()
.with_size(egui::vec2(width, 300.0))
.build_ui(move |ui| {
bar_chart_view(ui, &groups, &[("Net", Color32::GRAY)], 200.0);
});
crate::theme::install_fonts(&h.ctx);
crate::theme::apply(&h.ctx, false);
h.run();
let mut labels: Vec<(String, f32)> = painted(h.output())
.into_iter()
.filter(|(t, _, _)| {
t.len() <= 6 && t.chars().next().is_some_and(char::is_alphabetic)
})
.filter(|(t, _, _)| t != "Net")
.map(|(t, p, _)| (t, p.x))
.collect();
labels.sort_by(|a, b| a.1.total_cmp(&b.1));
let names: Vec<&str> = labels.iter().map(|l| l.0.as_str()).collect();
assert!(names.len() < 24, "{width}: crowded, so thinned: {names:?}");
let tagged = |n: &&str| n.ends_with(char::is_numeric);
assert!(tagged(&names[0]), "{width}: {names:?}");
for year in [" 25", " 26"] {
assert_eq!(
names.iter().filter(|n| n.ends_with(year)).count(),
1,
"{width}: {names:?}"
);
}
// 2026's tag sits on the first label drawn in 2026.
let at = names.iter().position(|n| n.ends_with(" 26")).unwrap();
assert!(
["Jan", "Feb", "Mar"].contains(&&names[at][..3]) && !names[at - 1].ends_with(" 26"),
"{width}: {names:?}"
);
}
}
/// A chart with nothing to open takes no click: hovering shows its
/// figures, and the pointer stays an arrow.
#[test]
fn a_view_only_chart_shows_figures_and_takes_no_click() {
let groups = two_years();
let mut h = Harness::builder()
.with_size(egui::vec2(800.0, 300.0))
.build_ui(move |ui| {
bar_chart_view(ui, &groups, &[("Net", Color32::GRAY)], 200.0);
});
crate::theme::install_fonts(&h.ctx);
crate::theme::apply(&h.ctx, false);
h.run();
h.hover_at(egui::pos2(780.0, 120.0));
h.run();
let texts = painted(h.output());
assert!(
texts.iter().any(|(t, _, _)| t == "Oct 26"),
"the tooltip names its month and year"
);
assert_ne!(
h.output().platform_output.cursor_icon,
egui::CursorIcon::PointingHand
);
}
}
+67 -3
View File
@@ -242,8 +242,25 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
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")?;
// The projects (classes with their facts, rules.md B-37).
let client_class = s.add_project(
"Client work",
&ledger_core::ProjectFacts {
description: Some("Design work for the studio's clients".into()),
kind: Some(ledger_core::ProjectKind::Client),
..Default::default()
},
)?;
let template_class = s.add_project(
"Templates",
&ledger_core::ProjectFacts {
description: Some("Notion and Figma templates sold on Gumroad".into()),
kind: Some(ledger_core::ProjectKind::Internal),
started_on: Some(start),
links: vec!["https://juniper.example/templates".into()],
..Default::default()
},
)?;
// Each vendor's 1099 decision, as the owner made it when they were
// first paid; the card is a payment method, so what was charged to it
@@ -282,6 +299,21 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
.iter()
.map(|n| s.add_customer(n))
.collect::<Result<_, _>>()?;
// Work on spec for a customer, hoping they adopt it: its costs are
// expenses, and nothing is receivable (rules.md B-37 item 4).
let tidepool_started = start + Months::new(13);
let tidepool = s.add_project(
"Tidepool app",
&ledger_core::ProjectFacts {
description: Some(
"An events app built for Tidepool Records ahead of a contract".into(),
),
kind: Some(ledger_core::ProjectKind::OnSpec),
party: s.ledger().party_of_customer(customers[3]),
started_on: Some(tidepool_started),
..Default::default()
},
)?;
// The party directory: who they are, beyond the vendor and customer
// records (roles, W-9 facts with only the TIN's last four, sites).
@@ -827,7 +859,12 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
),
)),
Some(riley),
Some(client_class),
// The second milestone builds the Tidepool app.
Some(if month_index == 13 {
tidepool
} else {
client_class
}),
)?;
}
}
@@ -962,6 +999,33 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
)?;
s.link_attachment(contract_doc, Subject::Party(sam_party))?;
s.link_attachment(contract_doc, Subject::Company)?;
// The Tidepool app's pitch, linked to it, and the decision to build it
// on spec.
let (pitch, _, _) = s.file_document(
"other",
"Tidepool app pitch.pdf",
"application/pdf",
&receipt_pdf(
"Tidepool app pitch",
&[
"Juniper Studio LLC for Tidepool Records".into(),
"An events app: shows, tickets, a mailing list".into(),
format!("Sent {}", tidepool_started.format("%B %-d, %Y")),
],
),
Some("The pitch"),
)?;
s.link_attachment(pitch, Subject::Project(u64::from(tidepool)))?;
s.record_decision(&ledger_core::conventions::NewDecision {
subject: "The Tidepool app".into(),
decision_md: "Build the Tidepool app on spec; bill only if they adopt it.".into(),
decided_by: vec!["Alex Rivera".into()],
decided_on: tidepool_started,
evidence_attachment: Some(pitch),
supersedes: None,
citation: None,
projects: vec![tidepool],
})?;
// Statements and reconciliations: savings reconciled through last
// month; checking through the month before (its first reconciliation
+11 -1
View File
@@ -70,6 +70,10 @@ pub enum Route {
Parties,
/// One party.
Party(PartyId),
/// The projects (classes with their facts).
Projects,
/// One project.
Project(ledger_core::ClassId),
Documents,
Document(u64),
Company,
@@ -89,6 +93,7 @@ impl Route {
Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts,
Route::Assets | Route::Asset(_) => Section::Assets,
Route::Parties | Route::Party(_) => Section::Parties,
Route::Projects | Route::Project(_) => Section::Projects,
Route::Documents | Route::Document(_) => Section::Documents,
Route::Company => Section::Company,
Route::Journal | Route::JournalEvent(_) => Section::Journal,
@@ -106,13 +111,14 @@ pub enum Section {
Accounts,
Assets,
Parties,
Projects,
Documents,
Company,
Journal,
}
impl Section {
pub const ALL: [Section; 11] = [
pub const ALL: [Section; 12] = [
Section::Overview,
Section::Cash,
Section::Profit,
@@ -121,6 +127,7 @@ impl Section {
Section::Accounts,
Section::Assets,
Section::Parties,
Section::Projects,
Section::Documents,
Section::Company,
Section::Journal,
@@ -136,6 +143,7 @@ impl Section {
Section::Accounts => "Accounts",
Section::Assets => "Assets",
Section::Parties => "Parties",
Section::Projects => "Projects",
Section::Documents => "Documents",
Section::Company => "Company",
Section::Journal => "Journal",
@@ -153,6 +161,7 @@ impl Section {
Section::Accounts => i::TREE_STRUCTURE,
Section::Assets => i::PACKAGE,
Section::Parties => i::ADDRESS_BOOK,
Section::Projects => i::BRIEFCASE,
Section::Documents => i::FILES,
Section::Company => i::BUILDINGS,
Section::Journal => i::SCROLL,
@@ -169,6 +178,7 @@ impl Section {
Section::Accounts => Route::Accounts,
Section::Assets => Route::Assets,
Section::Parties => Route::Parties,
Section::Projects => Route::Projects,
Section::Documents => Route::Documents,
Section::Company => Route::Company,
Section::Journal => Route::Journal,
+10
View File
@@ -385,6 +385,16 @@ pub(crate) fn row(
Sense::hover()
},
);
if click {
// Named by its main cell, for assistive tech (and the tests).
let name = cells
.iter()
.find(|c| c.1 == 0.0)
.or(cells.first())
.map(|c| c.0.clone())
.unwrap_or_default();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
}
if click && resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+1
View File
@@ -135,6 +135,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.map(|(s, _, i, o)| BarGroup {
label: s.format("%b").to_string(),
values: vec![*i, *o],
year: None,
})
.collect();
charts::bar_chart(
+27 -2
View File
@@ -50,7 +50,7 @@ fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
/// How the books are kept, as the owners decided: conventions, decisions,
/// and what is open (each only when there is something to show).
fn kept_cards(ui: &mut Ui, b: &Books) {
fn kept_cards(ui: &mut Ui, b: &Books, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let kept = b.ledger.conventions();
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
@@ -95,6 +95,27 @@ fn kept_cards(ui: &mut Ui, b: &Books) {
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
&d.new.decision_md,
);
if !d.new.projects.is_empty() {
ui.horizontal_wrapped(|ui| {
for &c in &d.new.projects {
if widgets::link(
ui,
RichText::new(format!(
"{} {}",
icon::BRIEFCASE,
b.project_label(c)
))
.font(theme::regular(12.0))
.color(p.accent),
)
.on_hover_text("Open this project")
.clicked()
{
*go = Some(Route::Project(c));
}
}
});
}
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
ui.add_space(6.0);
}
@@ -132,6 +153,7 @@ fn kept_cards(ui: &mut Ui, b: &Books) {
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let mut kept_go: Option<Route> = None;
page(ui, "company", |ui| {
let c = &b.settings.company;
widgets::page_header(
@@ -192,7 +214,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
);
},
);
kept_cards(ui, b);
kept_cards(ui, b, &mut kept_go);
}
_ => {
widgets::titled_card(
@@ -332,6 +354,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
recurring(ui, b);
parties(ui, b, shell);
});
if let Some(r) = kept_go {
shell.go(r);
}
}
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
+1
View File
@@ -371,6 +371,7 @@ pub fn subject_icon(s: Subject) -> &'static str {
Subject::Account(_) => icon::TREE_STRUCTURE,
Subject::Asset(_) => icon::PACKAGE,
Subject::Party(_) => icon::ADDRESS_BOOK,
Subject::Project(_) => icon::BRIEFCASE,
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
Subject::Period(_) => icon::CALENDAR_BLANK,
Subject::PayrollRun(_) => icon::USERS,
+10 -4
View File
@@ -239,13 +239,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
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(
if let Some((cid, c)) = line
.class_id
.and_then(|c| class_names.get(&c).map(|n| (c, n)))
&& widgets::link(
ui,
RichText::new(format!("{} {c}", icon::TAG))
.font(theme::regular(11.5))
.color(p.muted),
);
)
.on_hover_text("Open this project")
.clicked()
{
shell.go(Route::Project(cid));
}
if let (Some(cur), Some(fa)) =
(&line.foreign_currency, line.foreign_amount)
+22 -3
View File
@@ -479,7 +479,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
.map_or(Route::Company, Route::Party),
),
),
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
"class.added" => ev(
icon::BRIEFCASE,
format!("Added project {}", s(p, "name")),
u32::try_from(id).ok().map(Route::Project),
),
"project.set" => {
let status = ledger_core::ProjectStatus::parse(s(p, "status"))
.map_or("", |x| x.words())
.to_lowercase();
let kind = match p.get("kind").and_then(|v| v.as_str()) {
Some(k) => ledger_core::ProjectKind::parse(k)
.map_or(k.to_string(), |x| x.words().to_lowercase()),
None => "kind not recorded".into(),
};
ev(
icon::BRIEFCASE,
format!("Project {}: {status}, {kind}", s(p, "name")),
u32::try_from(id).ok().map(Route::Project),
)
}
"party.added" => {
let roles: Vec<String> = p
.get("roles")
@@ -1114,8 +1133,8 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
.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());
.map(|c| format!("project {}", c.name))
.unwrap_or_else(|| "no project".into());
Event {
icon: icon::TAG,
text: format!(
+3
View File
@@ -11,6 +11,7 @@ pub mod journal;
pub mod overview;
pub mod parties;
pub mod profit;
pub mod projects;
pub mod reconciliation;
pub mod reports;
pub mod transactions;
@@ -61,6 +62,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
Route::Parties => parties::show_list(ui, b, shell),
Route::Party(id) => parties::show_party(ui, b, *id, shell),
Route::Projects => projects::show_list(ui, b, shell),
Route::Project(id) => projects::show_project(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),
+1
View File
@@ -397,6 +397,7 @@ fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
year: None,
})
.collect();
let clicked = charts::bar_chart(
+69 -31
View File
@@ -322,7 +322,7 @@ fn paid_cell(paid: Decimal, p: &theme::Palette) -> (String, egui::Color32) {
}
/// A filter chip.
fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
pub(crate) fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
let p = pal(ui.ctx());
ui.add(
egui::Button::new(
@@ -642,7 +642,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
);
});
ui.add_space(4.0);
widgets::copyable(
widgets::copyable_truncated(
ui,
RichText::new(party.label()).font(theme::bold(26.0)),
party.label(),
@@ -654,7 +654,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
.font(theme::regular(12.5))
.color(p.muted),
);
widgets::copyable(ui, RichText::new(&party.name), &party.name);
widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name);
});
}
ui.add_space(6.0);
@@ -841,6 +841,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
}
});
let projects = b.ledger.projects_of_party(id);
if !projects.is_empty() {
projects_card(ui, b, &projects, &mut go);
ui.add_space(14.0);
}
entries_card(ui, b, &info, &mut go);
ui.add_space(14.0);
if !info.invoices.is_empty() || !info.bills.is_empty() {
@@ -879,26 +884,9 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
for &i in &info.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));
if widgets::link_truncated(
ui,
RichText::new(&ev.text).font(theme::regular(12.5)),
)
.clicked()
{
go = Some(Route::JournalEvent(line.seq));
}
ui.label(
RichText::new(format!(
"event {} · {}",
line.seq,
fmt::timestamp(&line.ts)
))
.font(theme::regular(11.0))
.color(p.faint),
);
});
if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() {
go = Some(Route::JournalEvent(line.seq));
}
}
},
);
@@ -919,14 +907,14 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
facts(ui, |ui, k| {
let text = |ui: &mut Ui, v: &Option<String>| match v {
Some(t) => {
widgets::copyable(ui, RichText::new(t), t);
widgets::copyable_truncated(ui, RichText::new(t), t);
}
None => {
ui.label(RichText::new("not recorded").color(p.faint));
}
};
k(ui, "Name");
widgets::copyable(ui, RichText::new(&party.name), &party.name);
widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name);
ui.end_row();
k(ui, "Display name");
text(ui, &party.display_name);
@@ -1223,6 +1211,59 @@ fn records_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu
);
}
/// The projects for or with the party (rules.md B-37), by name: status
/// and net to date; a row opens its project.
fn projects_card(ui: &mut Ui, b: &Books, ids: &[ledger_core::ClassId], go: &mut Option<Route>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
"Projects",
|ui| {
widgets::pill(ui, &ids.len().to_string(), p.muted, p.hover);
},
|ui| {
let amt = widgets::AMOUNT_MIN + 24.0;
let w = ui.available_width().max(100.0 + amt + 180.0);
widgets::min_width(ui, "party_projects", w, |ui| {
for &id in ids {
let name = b.project_label(id);
let f = b.ledger.project_facts(id).unwrap_or_default();
let net = ledger_core::project_to_date(&b.ledger, id, b.today)
.ok()
.map(|t| t.net());
let (net_t, net_c) = match net {
Some(n) if n.is_zero() => ("—".to_string(), p.faint),
Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative),
Some(n) => (fmt::accounting(n), p.text),
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
};
let sub = format!("{} · net to date", super::projects::kind_words(f.kind));
let resp = row(
ui,
w,
&[
(name, 0.0, false, p.text),
(
f.status.words().to_string(),
100.0,
false,
super::projects::status_colors(f.status, &p).0,
),
(net_t, amt, true, net_c),
],
Some((sub.as_str(), p.faint)),
false,
true,
);
if resp.on_hover_text("Open this project").clicked() {
*go = Some(Route::Project(id));
}
}
});
},
);
}
/// Every entry tagged with the party, newest first.
fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>) {
let p = pal(ui.ctx());
@@ -1608,15 +1649,12 @@ fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate)
let groups: Vec<crate::charts::BarGroup> = shown
.iter()
.map(|m| crate::charts::BarGroup {
label: if multi_year && m.month.month() == 1 {
m.month.format("%b %y").to_string()
} else {
m.month.format("%b").to_string()
},
label: m.month.format("%b").to_string(),
values: vec![m.paid, m.received],
year: multi_year.then(|| m.month.year()),
})
.collect();
crate::charts::bar_chart(
crate::charts::bar_chart_view(
ui,
&groups,
&[("Paid out", p.series[2]), ("Received in", p.series[1])],
+35 -16
View File
@@ -18,10 +18,15 @@ 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> {
pub(crate) fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
part.checked_div(whole)?
.checked_mul(Decimal::from(100))
.map(|x| x.round_dp(1))
.map(|x| {
// One place always, so 100% prints as "100.0", as 64.0 does.
let mut x = x.round_dp(1);
x.rescale(1);
x
})
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
@@ -74,6 +79,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
year: None,
})
.collect();
if let Some(i) = charts::bar_chart(
@@ -160,10 +166,14 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
);
ui.add_space(16.0);
spending_changes(ui, b, shell);
let mut go = None;
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => by_customer(ui, b),
_ => by_class(ui, b),
_ => by_class(ui, b, &mut go),
});
if let Some(r) = go {
shell.go(r);
}
});
}
@@ -359,46 +369,52 @@ fn rows_total(by: &BTreeMap<String, Decimal>) -> Option<Decimal> {
.try_fold(Decimal::ZERO, |t, v| t.checked_add(*v))
}
fn by_class(ui: &mut Ui, b: &Books) {
/// Each project's net income this year, a bar each; a bar opens its
/// project.
fn by_class(ui: &mut Ui, b: &Books, go: &mut Option<Route>) {
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",
"Net income by project, 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.",
icon::BRIEFCASE,
"No projects",
"Tag lines with a project to see profit by each.",
);
return;
}
let mut rows: Vec<(String, Decimal)> = classes
let mut rows: Vec<(ledger_core::ClassId, 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))
.then(|| (c.id, c.name.clone(), net))
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
rows.sort_by_key(|r| std::cmp::Reverse(r.2));
if rows.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classified income or spending this year",
icon::BRIEFCASE,
"No project income or spending this year",
"",
);
return;
}
charts::hbars(ui, &rows, p.series[0]);
let ids: Vec<ledger_core::ClassId> = rows.iter().map(|r| r.0).collect();
let bars: Vec<(String, Decimal)> = rows.into_iter().map(|r| (r.1, r.2)).collect();
if let Some(i) = charts::hbars_signed(ui, &bars, p.series[0], p.negative) {
*go = Some(Route::Project(ids[i]));
}
ui.label(
RichText::new("Only lines tagged with a class count here.")
RichText::new("Only lines tagged with a project count here.")
.font(theme::regular(12.0))
.color(p.faint),
);
@@ -418,6 +434,9 @@ mod tests {
assert_eq!(pct(cents(2_500), cents(20_000)), Some(Decimal::new(125, 1)));
assert_eq!(pct(cents(1), cents(3)), Some(Decimal::new(333, 1)));
assert_eq!(pct(cents(1), Decimal::ZERO), None);
// One place whatever the value.
assert_eq!(pct(cents(500), cents(500)).unwrap().to_string(), "100.0");
assert_eq!(pct(cents(-100), cents(200)).unwrap().to_string(), "-50.0");
assert_eq!(pct(Decimal::MAX, Decimal::new(1, 2)), None);
}
@@ -440,6 +459,6 @@ mod tests {
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Profit, |_| {});
find(&texts, "Invoiced by customer, this year");
find(&texts, "Acme is 100% of invoiced income.");
find(&texts, "Acme is 100.0% of invoiced income.");
}
}
+915
View File
@@ -0,0 +1,915 @@
//! Projects: every class, with its facts (what it is, where it stands, who
//! it is for), and one project completely: what it earned and cost, by
//! month and by account, its entries, documents, decisions and history
//! (rules.md B-37). Every figure is ledger-core's (`project_figures`,
//! `project_to_date`, `project_entries`), the same the server's
//! `books.projects` and `books.project` return; this module only arranges
//! them, and never writes.
use super::assets::{facts, row};
use super::page;
use super::parties::chip;
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::{Datelike, NaiveDate};
use egui::{RichText, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
use rust_decimal::Decimal;
/// The list's filter, kept across navigation.
#[derive(Default)]
pub struct State {
/// Only projects with this status (None: every one).
pub status: Option<ProjectStatus>,
}
/// One project on the list: its id, name, facts and net to date.
pub struct Listed {
pub id: ClassId,
pub name: String,
pub facts: ProjectFacts,
pub to_date: Option<ledger_core::ProjectToDate>,
}
/// Every project (every class), by name ignoring capitals, with its
/// figures to date through today.
pub fn listed(b: &Books) -> Vec<Listed> {
let mut out: Vec<Listed> = b
.ledger
.list_classes()
.into_iter()
.map(|c| Listed {
id: c.id,
name: c.name.clone(),
facts: b.ledger.project_facts(c.id).unwrap_or_default(),
to_date: ledger_core::project_to_date(&b.ledger, c.id, b.today).ok(),
})
.collect();
out.sort_by_key(|a| (a.name.to_lowercase(), a.id));
out
}
/// A status's colors: (text, fill).
pub(crate) fn status_colors(
s: ProjectStatus,
p: &theme::Palette,
) -> (egui::Color32, egui::Color32) {
match s {
ProjectStatus::Active => (p.positive, p.positive_soft),
ProjectStatus::Paused => (p.warning, p.warning_soft),
ProjectStatus::Finished => (p.muted, p.hover),
ProjectStatus::Abandoned => (p.faint, p.hover),
}
}
/// A kind in words, or that it isn't recorded.
pub fn kind_words(k: Option<ProjectKind>) -> &'static str {
k.map_or("Kind not recorded", ProjectKind::words)
}
/// The list's sentence: how many in each status, and the net across them.
fn summary(rows: &[Listed]) -> String {
if rows.is_empty() {
return "No projects yet.".into();
}
let parts: Vec<String> = ProjectStatus::ALL
.into_iter()
.filter_map(|s| {
let n = rows.iter().filter(|r| r.facts.status == s).count();
(n > 0).then(|| format!("{n} {}", s.key()))
})
.collect();
let net: Option<Decimal> = rows
.iter()
.map(|r| r.to_date.as_ref().map(|t| t.net()))
.try_fold(Decimal::ZERO, |t, n| t.checked_add(n?));
let counts = if parts.len() == 1 && rows.len() > 1 {
format!(
"{}, all {}",
fmt::count(rows.len(), "project", "projects"),
rows[0].facts.status.key()
)
} else if rows.len() == 1 {
format!("1 project, {}", rows[0].facts.status.key())
} else {
format!(
"{}: {}",
fmt::count(rows.len(), "project", "projects"),
parts.join(", ")
)
};
format!(
"{counts}. Net to date across them: {}.",
fmt::dollars_or(net)
)
}
/// The projects.
pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "projects", |ui| {
let all = listed(b);
widgets::page_header(ui, "Projects", &summary(&all));
if all.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::BRIEFCASE,
"No projects yet",
"The agent adds them (books.add_project). Tag lines with a project to see what it earns and costs.",
)
});
return;
}
let st = &mut shell.state.projects;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
if chip(ui, &format!("All {}", all.len()), st.status.is_none()) {
st.status = None;
}
for s in ProjectStatus::ALL {
let n = all.iter().filter(|r| r.facts.status == s).count();
if n == 0 {
continue;
}
let on = st.status == Some(s);
if chip(ui, &format!("{} {n}", s.words()), on) {
st.status = if on { None } else { Some(s) };
}
}
});
ui.add_space(12.0);
let rows: Vec<&Listed> = all
.iter()
.filter(|r| st.status.is_none_or(|s| r.facts.status == s))
.collect();
let mut go = None;
widgets::titled_card(
ui,
"Projects",
|ui| {
widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover);
},
|ui| {
let amt = widgets::AMOUNT_MIN + 24.0;
let avail = ui.available_width();
let status_w = 100.0;
let kind_w = if avail >= 760.0 { 140.0 } else { 0.0 };
let party_w = if avail >= 900.0 {
(avail * 0.18).clamp(140.0, 220.0)
} else {
0.0
};
let w = avail.max(status_w + kind_w + party_w + amt + 180.0);
let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> {
c.into_iter()
.enumerate()
.filter(|(i, _)| (*i != 2 || kind_w > 0.0) && (*i != 3 || party_w > 0.0))
.map(|(_, c)| c)
.collect()
};
widgets::min_width(ui, "project_list", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&cells([
head("Name", 0.0, false),
head("Status", status_w, false),
head("Kind", kind_w, false),
head("Party", party_w, false),
head("Net to date", amt, true),
]),
None,
true,
false,
);
widgets::rule(ui);
for r in &rows {
let (sc, _) = status_colors(r.facts.status, &p);
let party = r
.facts
.party
.map(|x| b.party_label(x))
.unwrap_or_else(|| "—".into());
let (net_t, net_c) = match r.to_date.as_ref().map(|t| t.net()) {
Some(n) if n.is_zero() => ("—".to_string(), p.faint),
Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative),
Some(n) => (fmt::accounting(n), p.text),
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
};
let sub = r
.facts
.description
.clone()
.unwrap_or_else(|| format!("Project {}", r.id));
let sub = sub.lines().next().unwrap_or("").to_string();
let resp = row(
ui,
w,
&cells([
(r.name.clone(), 0.0, false, p.text),
(r.facts.status.words().to_string(), status_w, false, sc),
(kind_words(r.facts.kind).to_string(), kind_w, false, p.muted),
(party, party_w, false, p.muted),
(net_t, amt, true, net_c),
]),
Some((sub.as_str(), p.faint)),
false,
true,
);
let resp = resp.on_hover_text("Open this project");
resp.context_menu(|ui| {
widgets::copy_item(ui, "Copy name", &r.name);
widgets::copy_item(ui, "Copy project id", &r.id.to_string());
});
if resp.clicked() {
go = Some(r.id);
}
}
});
},
);
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(format!(
"Net to date: every posted revenue and expense line tagged to the project through {}, closing entries left out; revenue less expenses.",
fmt::date(b.today)
))
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
if let Some(id) = go {
shell.go(Route::Project(id));
}
});
}
/// The journal events naming a project: its class and project records,
/// links of files to it, and decisions naming it (indexes into
/// [`Books::journal`], oldest first).
pub fn project_events(b: &Books, id: ClassId) -> Vec<usize> {
let id64 = u64::from(id);
let u = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_u64());
b.journal
.iter()
.enumerate()
.filter(|(_, line)| {
let p = &line.payload;
match line.line_type.as_str() {
"class.added" | "project.set" => u(p, "id") == Some(id64),
"attachment.linked" | "attachment.unlinked" => {
p.get("subject")
.and_then(|s| s.get("kind"))
.and_then(|k| k.as_str())
== Some("project")
&& p.get("subject").and_then(|s| u(s, "id")) == Some(id64)
}
"decision.recorded" => p
.get("projects")
.and_then(|v| v.as_array())
.is_some_and(|a| a.iter().any(|x| x.as_u64() == Some(id64))),
_ => false,
}
})
.map(|(i, _)| i)
.collect()
}
/// A number tile: (label, value, how it is computed, the line under it).
type Tile = (
String,
Option<Decimal>,
String,
Option<(String, egui::Color32)>,
);
/// One project, completely.
pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let (Some(class), Some(f)) = (b.ledger.get_class(id), b.ledger.project_facts(id)) else {
page(ui, "project", |ui| {
widgets::empty(
ui,
icon::BRIEFCASE,
"No such project",
"It isn't in these books.",
);
});
return;
};
let mut go: Option<Route> = None;
let today = b.today;
let to_date = ledger_core::project_to_date(&b.ledger, id, today).ok();
let entries = ledger_core::project_entries(&b.ledger, id);
page(ui, "project", |ui| {
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(format!("{} Projects", icon::ARROW_LEFT))
.font(theme::regular(12.5))
.color(p.muted),
)
.clicked()
{
go = Some(Route::Projects);
}
widgets::copyable(
ui,
RichText::new(format!("· Project {id}"))
.font(theme::regular(12.5))
.color(p.muted),
&id.to_string(),
);
});
ui.add_space(4.0);
widgets::copyable_truncated(
ui,
RichText::new(&class.name).font(theme::bold(26.0)),
&class.name,
);
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
let (fg, bg) = status_colors(f.status, &p);
widgets::pill(ui, f.status.words(), fg, bg);
match f.kind {
Some(ProjectKind::OnSpec) => {
widgets::pill(ui, ProjectKind::OnSpec.words(), p.accent, p.accent_soft)
.on_hover_text(
"Work done hoping to be paid: its costs are expenses as they are spent, and a repayment is income only when it arrives.",
);
}
k => {
widgets::pill(ui, kind_words(k), p.muted, p.hover);
}
}
});
if let Some(d) = &f.description {
ui.add_space(6.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(d).font(theme::regular(15.0)).color(p.muted));
});
}
ui.add_space(14.0);
// What it earned and cost, to date.
let span = format!(
"Every posted line tagged to this project through {}, closing entries left out.",
fmt::date(today)
);
let mut tiles: Vec<Tile> = vec![
(
"Income to date".into(),
to_date.as_ref().map(|t| t.revenue),
format!("{span} Revenue lines."),
None,
),
(
"Spending to date".into(),
to_date.as_ref().map(|t| t.expenses),
format!("{span} Expense lines."),
None,
),
(
"Net to date".into(),
to_date.as_ref().map(|t| t.net()),
format!("{span} Income less spending."),
to_date.as_ref().map(|t| {
let n = t.net();
if n < Decimal::ZERO {
("a loss so far".to_string(), p.negative)
} else if n.is_zero() {
("even".to_string(), p.muted)
} else {
("a profit so far".to_string(), p.positive)
}
}),
),
];
if let Some(u) = to_date.as_ref().and_then(|t| t.unrecovered_cost) {
tiles.push((
"Unrecovered cost".into(),
Some(u),
format!(
"{span} Spending less income, never below zero: what it would take to be repaid in full. A hoped-for payment isn't income or a receivable until it arrives (ASC 450-30-25-1), so this is a figure, not something owed to the company."
),
Some(("if repaid: not a receivable".into(), p.muted)),
));
}
if f.kind == Some(ProjectKind::Client)
&& let Some(t) = &to_date
&& !t.revenue.is_zero()
{
tiles.push((
"Margin to date".into(),
None,
format!("{span} Net as a share of income."),
None,
));
}
let margin = to_date
.as_ref()
.and_then(|t| super::profit::pct(t.net(), t.revenue));
let n = tiles.len();
super::grid(ui, n, 170.0, 6, |i, ui| {
let (label, value, how, sub) = &tiles[i];
let text = if label == "Margin to date" {
margin.map_or_else(|| "—".into(), |m| format!("{m}%"))
} else {
fmt::dollars_or(*value)
};
let r = widgets::kpi(
ui,
widgets::Kpi {
label,
value: text,
sub: sub.clone(),
spark: None,
how,
},
);
if let Some(v) = value {
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
}
});
ui.add_space(2.0);
// By month and by account.
let first = entries
.iter()
.filter_map(|(e, _)| b.ledger.get_entry(*e))
.filter(|e| e.status() == ledger_core::EntryStatus::Posted)
.map(|e| e.date())
.min();
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
months_card(ui, b, id, first);
} else {
accounts_card(ui, b, id, &mut go);
}
});
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
if col == 0 {
facts_card(ui, b, id, &class.name, &f, &mut go);
} else {
decisions_card(ui, b, id, &mut go);
}
});
entries_card(ui, b, &entries, &mut go);
ui.add_space(14.0);
let here = [ledger_store::Subject::Project(u64::from(id))];
super::documents::documents_card_with(
ui,
b,
"Documents",
&here,
&here,
"No documents linked to it (its contract, spec or pitch).",
&mut go,
);
ui.add_space(14.0);
let events = project_events(b, id);
widgets::titled_card(
ui,
"History",
|_| {},
|ui| {
if events.is_empty() {
ui.label(RichText::new("No journal events name it.").color(p.muted));
}
for &i in &events {
let line = &b.journal[i];
let ev = super::journal::describe_any(b, line);
if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() {
go = Some(Route::JournalEvent(line.seq));
}
}
},
);
});
if let Some(r) = go {
shell.go(r);
}
}
/// Income, spending and net by month: from the month of its first posted
/// tagged entry through this month, at most the last 24.
fn months_card(ui: &mut Ui, b: &Books, id: ClassId, first: Option<NaiveDate>) {
let p = pal(ui.ctx());
let span = first.map(|f| {
let start = ledger_core::analysis::interval_start(
f.min(b.today),
ledger_core::analysis::Interval::Month,
);
crate::books::Books::last_months((start, b.today), 24)
});
let months = span
.and_then(|(s, e)| ledger_core::project_figures(&b.ledger, id, s, e).ok())
.map(|f| f.months)
.unwrap_or_default();
widgets::titled_card(
ui,
"By month",
|ui| {
if let Some((s, _)) = span {
ui.label(
RichText::new(format!("since {}", fmt::month(s)))
.font(theme::regular(12.0))
.color(p.muted),
);
}
},
|ui| {
if months.is_empty() {
widgets::empty(ui, icon::CHART_BAR, "No tagged lines yet", "");
return;
}
let multi_year =
months.first().map(|m| m.start.year()) != months.last().map(|m| m.start.year());
let groups: Vec<BarGroup> = months
.iter()
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
year: multi_year.then(|| m.start.year()),
})
.collect();
// Hover shows a month's figures; a click opens nothing, since
// the reports are the whole company's, not the project's.
charts::bar_chart_view(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
200.0,
);
},
);
}
/// Its revenue then expense accounts to date, with a total.
fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
let p = pal(ui.ctx());
// The to-date statement alone: project_figures would build every
// month's too, each frame.
let pl = ledger_core::reports::profit_and_loss_for_period(
&b.ledger,
ledger_core::calendar::FIRST_DAY.min(b.today),
b.today,
Some(id),
)
.ok();
widgets::titled_card(
ui,
"By account, to date",
|_| {},
|ui| {
let Some(pl) = pl.filter(|pl| !pl.revenue.is_empty() || !pl.expenses.is_empty()) else {
widgets::empty(ui, icon::TREE_STRUCTURE, "No income or spending tagged", "");
return;
};
let amt = widgets::amount_col_w(ui.available_width(), 1, 160.0, 140.0);
let w = ui.available_width();
let mut line = |ui: &mut Ui, l: &ledger_core::StatementLine, expense: bool| {
let shown = if expense { -l.amount } else { l.amount };
let resp = row(
ui,
w,
&[
(format!("{} {}", l.code, l.name), 0.0, false, p.text),
(
fmt::accounting(shown),
amt,
true,
if shown < Decimal::ZERO {
p.negative
} else {
p.text
},
),
],
None,
false,
true,
);
if resp.on_hover_text("Open this account's register").clicked()
&& let Some(a) = b.ledger.chart().find_by_code(&l.code)
{
*go = Some(Route::Account(a.id));
}
};
for l in &pl.revenue {
line(ui, l, false);
}
for l in &pl.expenses {
line(ui, l, true);
}
widgets::rule(ui);
let net = pl.net_income();
row(
ui,
w,
&[
("Net".to_string(), 0.0, false, p.text),
(
fmt::accounting(net),
amt,
true,
if net < Decimal::ZERO {
p.negative
} else {
p.text
},
),
],
None,
true,
false,
);
ui.add_space(4.0);
ui.label(
RichText::new(
"Spending shows in parentheses; only revenue and expense lines count.",
)
.font(theme::regular(11.5))
.color(p.faint),
);
},
);
}
/// What it is: kind, status, party, dates, links, ids, description, notes.
fn facts_card(
ui: &mut Ui,
b: &Books,
id: ClassId,
name: &str,
f: &ProjectFacts,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
"Facts",
|_| {},
|ui| {
facts(ui, |ui, k| {
let none = |ui: &mut Ui| {
ui.label(RichText::new("not recorded").color(p.faint));
};
k(ui, "Name");
widgets::copyable_truncated(ui, RichText::new(name), name);
ui.end_row();
k(ui, "Kind");
match f.kind {
Some(kind) => {
ui.label(kind.words());
}
None => none(ui),
}
ui.end_row();
k(ui, "Status");
ui.label(f.status.words());
ui.end_row();
k(ui, "Party");
match f.party {
Some(pid) => {
if widgets::link(ui, RichText::new(b.party_label(pid)).color(p.accent))
.on_hover_text("Open this party")
.clicked()
{
*go = Some(Route::Party(pid));
}
}
None => none(ui),
}
ui.end_row();
k(ui, "Started");
match f.started_on {
Some(d) => {
ui.label(fmt::date(d));
}
None => none(ui),
}
ui.end_row();
if f.ended_on.is_some() || f.status.is_over() {
k(ui, "Ended");
match f.ended_on {
Some(d) => {
ui.label(fmt::date(d));
}
None => none(ui),
}
ui.end_row();
}
k(ui, "Links");
if f.links.is_empty() {
none(ui);
} else {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
for l in &f.links {
widgets::copyable_truncated(ui, RichText::new(l), l);
}
});
}
ui.end_row();
k(ui, "Project id");
widgets::copyable(ui, RichText::new(id.to_string()), &id.to_string());
ui.end_row();
});
if let Some(n) = &f.notes {
ui.add_space(8.0);
ui.label(
RichText::new("Notes")
.font(theme::regular(12.5))
.color(p.muted),
);
ui.add(egui::Label::new(RichText::new(n).font(theme::regular(13.0))).wrap());
}
},
);
}
/// The decisions naming it, newest first; replaced ones muted.
fn decisions_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let decisions: Vec<_> = b
.ledger
.conventions()
.decisions()
.filter(|d| d.new.projects.contains(&id))
.collect();
widgets::titled_card(
ui,
"Decisions",
|ui| {
if !decisions.is_empty() {
widgets::pill(ui, &decisions.len().to_string(), p.muted, p.hover);
}
},
|ui| {
if decisions.is_empty() {
ui.label(RichText::new("No decision names this project.").color(p.muted));
return;
}
for (i, d) in decisions.iter().rev().enumerate() {
if i > 0 {
ui.add_space(4.0);
widgets::rule(ui);
ui.add_space(4.0);
}
let replaced = d.superseded_by.is_some();
let text = if replaced { p.muted } else { p.text };
if widgets::link(
ui,
RichText::new(&d.new.subject)
.font(theme::semibold(13.0))
.color(text),
)
.on_hover_text("Open it in the journal")
.clicked()
{
*go = Some(Route::JournalEvent(d.seq));
}
ui.add(
egui::Label::new(
RichText::new(&d.new.decision_md)
.font(theme::regular(12.5))
.color(if replaced { p.muted } else { p.text }),
)
.wrap(),
);
let mut by = format!(
"{} · {}",
d.new.decided_by.join(" and "),
fmt::date(d.new.decided_on)
);
if let Some(n) = d.superseded_by {
by.push_str(&format!(" · replaced by #{n}"));
}
ui.label(RichText::new(by).font(theme::regular(11.0)).color(p.faint));
}
},
);
}
/// Every entry with a line tagged to it, newest first, with its project
/// amount.
fn entries_card(ui: &mut Ui, b: &Books, entries: &[(u64, Decimal)], go: &mut Option<Route>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
"Entries",
|ui| {
ui.label(
RichText::new(fmt::count(entries.len(), "entry", "entries"))
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if entries.is_empty() {
ui.label(
RichText::new("No line in the books is tagged with it yet.").color(p.muted),
);
return;
}
let amt = widgets::AMOUNT_MIN + 24.0;
let w = ui.available_width().max(104.0 + 64.0 + 150.0 + amt + 200.0);
widgets::min_width(ui, "project_entries", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Date", 104.0, false),
head("Entry", 64.0, false),
head("Description", 0.0, false),
head("Kind", 150.0, false),
head("Project amount", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for (eid, amount) in entries {
let Some(e) = b.entry(*eid) else {
continue;
};
let draft = e.status != ledger_core::EntryStatus::Posted;
let text = if draft { p.muted } else { p.text };
let kind = if draft {
format!("{} · draft", e.origin)
} else {
e.origin.to_string()
};
let mut sub: Vec<String> = Vec::new();
if e.amount != Some(amount.abs()) {
sub.push(format!("of {}", fmt::dollars_or(e.amount)));
}
if !e.receipts.is_empty() {
sub.push(fmt::count(e.receipts.len(), "receipt", "receipts"));
}
let accounts: Vec<String> =
e.accounts.iter().map(|a| b.account_name(*a)).collect();
sub.push(accounts.join(" → "));
let sub = sub.join(" · ");
let resp = row(
ui,
w,
&[
(fmt::date(e.date), 104.0, false, text),
(format!("#{}", e.id), 64.0, false, p.muted),
(e.description.clone(), 0.0, false, text),
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
(
fmt::accounting(*amount),
amt,
true,
if *amount < Decimal::ZERO {
p.negative
} else {
text
},
),
],
Some((sub.as_str(), p.faint)),
false,
true,
);
resp.context_menu(|ui| {
widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id));
widgets::copy_item(ui, "Copy project amount", &amount.to_string());
});
if resp.on_hover_text("Open this entry").clicked() {
*go = Some(Route::Entry(e.id));
}
}
});
ui.add_space(4.0);
ui.label(
RichText::new(
"Project amount: the entry's tagged income less its tagged spending; a tagged balance-sheet line counts nothing.",
)
.font(theme::regular(11.5))
.color(p.faint),
);
},
);
}
+21
View File
@@ -244,6 +244,10 @@ pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
/// "reconciliation reconciled", its account's code and name, and its
/// statement date (as shown and as YYYY-MM-DD); it opens
/// Route::Reconciliation(its id), voided ones too, saying so.
/// INVARIANTS (projects):
/// 9. A project is listed when the query fuzzily matches its name and
/// description; a query found whole in its name (ignoring case) adds
/// 500. It opens Route::Project(its id).
/// ```
pub fn search(b: &Books, query: &str) -> Vec<Item> {
let q = query.trim();
@@ -381,6 +385,22 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
});
}
}
for c in b.ledger.list_classes() {
let f = b.ledger.project_facts(c.id).unwrap_or_default();
let desc = f.description.as_deref().unwrap_or("");
let hay = format!("{} {desc}", c.name);
if let Some(score) = fuzzy(&hay, q) {
let whole = c.name.to_lowercase().contains(&q.to_lowercase());
let kind = f.kind.map_or("Kind not recorded", |k| k.words());
out.push(Item {
icon: icon::BRIEFCASE,
title: c.name.clone(),
detail: format!("{kind} · {}", f.status.words()),
route: Route::Project(c.id),
score: score + if whole { 500 } else { 0 },
});
}
}
for st in &b.statements {
let file = b
.attachment(st.attachment_id)
@@ -514,6 +534,7 @@ pub fn describe(b: &Books, r: &Route) -> String {
.unwrap_or_default(),
Route::Asset(id) => b.asset_label(*id),
Route::Party(id) => b.party_label(*id),
Route::Project(id) => b.project_label(*id),
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),
Route::JournalEvent(seq) => format!("Event {seq}"),
_ => String::new(),
+1
View File
@@ -990,6 +990,7 @@ mod painted {
Route::Reports(ReportKind::BalanceSheet),
Route::Accounts,
Route::Parties,
Route::Projects,
Route::Journal,
];
// A page a thread: each checks every scheme.
+52 -1
View File
@@ -73,6 +73,37 @@ pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response {
.response
}
/// One journal event on a History card: its icon, its text as a link
/// (cut with an ellipsis), then its number and time, which keep their
/// room, so a long name in the text never widens the card. Returns the
/// link's response.
pub fn history_row(ui: &mut Ui, icon: &str, text: &str, seq: u64, ts: &str) -> Response {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(icon).color(p.muted));
let meta = format!("event {seq} · {}", crate::fmt::timestamp(ts));
let meta_w = ui
.painter()
.layout_no_wrap(meta.clone(), theme::regular(11.0), p.faint)
.size()
.x;
let w = (ui.available_width() - meta_w - ui.spacing().item_spacing.x).max(40.0);
let link = ui
.scope(|ui| {
ui.set_max_width(w);
link_truncated(ui, RichText::new(text).font(theme::regular(12.5)))
})
.inner;
ui.label(
RichText::new(meta)
.font(theme::regular(11.0))
.color(p.faint),
);
link
})
.inner
}
/// Text that looks like plain text until you hover it, then like a link.
pub fn link(ui: &mut Ui, text: RichText) -> Response {
link_label(ui, egui::Label::new(text))
@@ -276,11 +307,31 @@ pub fn recently_copied(ui: &Ui, id: egui::Id) -> bool {
/// A value that copies itself when clicked: a pointer and a copy icon on
/// hover, a check for a moment after.
pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response {
copyable_label(ui, text, value, false)
}
/// [`copyable`] on one line, cut with an ellipsis where the space ends
/// (the copy icon's slot kept), the full text on hover: for text of any
/// length with no spaces to wrap at, a long name or a web address.
pub fn copyable_truncated(ui: &mut Ui, text: RichText, value: &str) -> Response {
copyable_label(ui, text, value, true)
}
fn copyable_label(ui: &mut Ui, text: RichText, value: &str, truncate: bool) -> Response {
let id = ui.id().with(("copy", value));
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let r = ui.add(egui::Label::new(text).sense(Sense::click()));
let r = if truncate {
let w = (ui.available_width() - 18.0).max(24.0);
ui.scope(|ui| {
ui.set_max_width(w);
ui.add(egui::Label::new(text).truncate().sense(Sense::click()))
})
.inner
} else {
ui.add(egui::Label::new(text).sense(Sense::click()))
};
let recent = recently_copied(ui, id);
let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
if r.hovered() || recent {
+6 -1
View File
@@ -49,6 +49,7 @@ fn crumbs_link_every_level_but_the_last() {
let entry = b.entries.first().unwrap().id;
let mut routes = vec![
Route::Party(party),
Route::Project(b.ledger.list_classes()[0].id),
Route::Reconciliation(rec.id),
Route::Entry(entry),
Route::Reports(ReportKind::ProfitAndLoss),
@@ -112,7 +113,11 @@ fn clicking_a_crumb_goes_there() {
h.get_all_by_label(&account).next().unwrap().click();
h.run_steps(3);
assert_eq!(h.state().route(), &Route::Account(rec.account_id));
h.get_by_label("Accounts").click();
// The crumb, not the sidebar's link of the same name (left of it).
h.get_all_by_label("Accounts")
.max_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
.unwrap()
.click();
h.run_steps(3);
assert_eq!(h.state().route(), &Route::Accounts);
}
+120
View File
@@ -13,6 +13,7 @@
use chrono::{Datelike, NaiveDate};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::parties::{EntryFlow, entry_flow, money_accounts};
use ledger_core::{AccountType, Currency, Money, PartyShare, Role, Share, Side};
use ledger_gui::books::{Books, PartyPeriod, share_of_what_for};
@@ -276,3 +277,122 @@ fn the_page_state_a_test_sets_is_the_apps() {
PartyPeriod::Year(2025)
);
}
/// The sidebar link named `name` (the leftmost node of that name).
fn sidebar_link(h: &Harness<'_, App>, name: &str) -> egui::Rect {
h.get_all_by_role_and_label(egui::accesskit::Role::Button, name)
.map(|n| n.rect())
.min_by(|a, b| a.left().total_cmp(&b.left()))
.unwrap_or_else(|| panic!("no {name} link"))
}
/// A short window (owner, 2026-10-21: the links went under the status
/// box): scrolled to its end, the last link stops above the box.
#[test]
fn sidebar_links_never_draw_under_the_status_box() {
let f = fixture("sidebar-short");
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 500.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
h.run_steps(4);
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
.unwrap()
.scroll_to_me();
h.run_steps(4);
let journal = sidebar_link(&h, "Journal");
let status = h.get_by_label_contains("files verified").rect();
assert!(
journal.bottom() <= status.top(),
"Journal ends at {} under the status box at {}",
journal.bottom(),
status.top()
);
}
/// The rail's equivalent: its last link stops above its footer.
#[test]
fn rail_links_never_draw_under_its_footer() {
let f = fixture("rail-short");
let mut h = Harness::builder()
.with_size(egui::vec2(900.0, 420.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
h.run_steps(4);
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
.unwrap()
.scroll_to_me();
h.run_steps(4);
let journal = sidebar_link(&h, "Journal");
assert!(journal.right() <= 60.0, "the rail is drawn: {journal:?}");
let footer = h
.ctx
.data(|d| d.get_temp::<f32>(egui::Id::new("rail_footer_h")))
.expect("the rail's footer was measured");
// The rail's frame keeps 16 points under its footer.
let footer_top = 420.0 - 16.0 - footer;
assert!(
journal.bottom() <= footer_top,
"Journal ends at {} under the footer at {footer_top}",
journal.bottom()
);
}
/// A window too short for the links and the status box together keeps
/// one link whole above the box (the box gives way, off the bottom),
/// sidebar and rail alike.
#[test]
fn a_very_short_window_keeps_one_link() {
for (w, h_px, tag) in [(1440.0, 220.0, "sidebar-tiny"), (900.0, 200.0, "rail-tiny")] {
let f = fixture(tag);
let mut h = Harness::builder()
.with_size(egui::vec2(w, h_px))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
h.run_steps(4);
let first = sidebar_link(&h, "Overview");
assert!(
first.height() >= 31.0 && first.bottom() <= h_px,
"{tag}: Overview at {first:?}"
);
if let Some(status) = h.query_by_label_contains("files verified") {
assert!(
first.bottom() <= status.rect().top(),
"{tag}: Overview ends at {} under the status box at {}",
first.bottom(),
status.rect().top()
);
}
let key = if w < 1000.0 {
"rail_footer_h"
} else {
"sidebar_footer_h"
};
let footer = h
.ctx
.data(|d| d.get_temp::<f32>(egui::Id::new(key)))
.expect("the footer was measured");
assert!(footer > 0.0, "{tag}");
}
}
/// At the usual height every section's link shows and nothing scrolls.
#[test]
fn a_normal_window_shows_every_link_unscrolled() {
let f = fixture("sidebar-normal");
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 920.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
h.run_steps(4);
let first = sidebar_link(&h, "Overview");
for s in ledger_gui::nav::Section::ALL {
sidebar_link(&h, s.title());
}
let status = h.get_by_label_contains("files verified").rect();
assert!(sidebar_link(&h, "Journal").bottom() <= status.top());
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
.unwrap()
.scroll_to_me();
h.run_steps(4);
assert_eq!(sidebar_link(&h, "Overview"), first, "the links scrolled");
}
+2
View File
@@ -116,6 +116,7 @@ fn the_company_page_shows_how_the_books_are_kept() {
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![],
})
.unwrap();
s.record_decision(&NewDecision {
@@ -126,6 +127,7 @@ fn the_company_page_shows_how_the_books_are_kept() {
evidence_attachment: None,
supersedes: Some(first),
citation: None,
projects: vec![],
})
.unwrap();
s.raise_open_item(&item("Mail the IRS letter", Some(d(2026, 10, 16))))
+96
View File
@@ -198,11 +198,13 @@ fn routes(b: &Books) -> Vec<Route> {
Route::Accounts,
Route::Assets,
Route::Parties,
Route::Projects,
Route::Documents,
Route::Company,
Route::Journal,
];
out.extend(ReportKind::ALL.map(Route::Reports));
out.extend(b.ledger.list_classes().iter().map(|c| Route::Project(c.id)));
out.extend((1..=b.ledger.next_entry_id()).map(Route::Entry));
out.extend(b.ledger.chart().all().map(|a| Route::Account(a.id)));
out.extend(b.ledger.registered_assets().map(|a| Route::Asset(a.id)));
@@ -535,3 +537,97 @@ fn an_older_books_dirs_config_json_sets_its_receipt_rule() {
.unwrap();
assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
}
/// rules.md B-37: the classes an earlier version wrote read as projects
/// with the default facts (active, kind not recorded), listed by name,
/// their figures ledger-core's.
#[test]
fn old_books_classes_read_as_projects() {
let mut seen = 0;
// Copies of their own: the shards above copy the same fixtures under
// the fixtures' names, side by side.
let root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books");
let mut fixtures: Vec<PathBuf> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.is_dir())
.collect();
fixtures.sort();
let copies = fixtures.iter().map(|f| {
let name = f.file_name().unwrap().to_string_lossy().to_string();
let t = temp(&format!("projects-{name}"));
copy_books(f, &t.0.join("books-data"));
write_settings(&t.0);
(name, t)
});
for (name, dir) in copies {
let b = data::load_once(&ledger_gui::Source {
data_dir: dir.0.join("books-data"),
today: Some(d(2026, 10, 12)),
})
.unwrap();
let listed = ledger_gui::pages::projects::listed(&b);
assert_eq!(listed.len(), b.ledger.list_classes().len(), "{name}");
for p in &listed {
seen += 1;
assert_eq!(p.facts, ledger_core::ProjectFacts::default(), "{name}");
assert_eq!(
p.to_date,
ledger_core::project_to_date(&b.ledger, p.id, b.today).ok(),
"{name}"
);
}
let names: Vec<String> = listed.iter().map(|p| p.name.to_lowercase()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "{name}: alphabetical");
}
// No fixture holds a class: books with the lines an earlier version
// wrote for one (class.added, a line tagged with it), and nothing more.
let t = temp("old-class");
let data = t.0.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
let rev = store
.add_account(
"4000",
"Sales",
ledger_core::AccountType::Revenue,
None,
false,
)
.unwrap();
let class = store.add_class("Harbor app").unwrap();
let e = store.create_entry(d(2026, 3, 1), "sale").unwrap();
let usd =
|c| ledger_core::Money::new(rust_decimal::Decimal::new(c, 2), ledger_core::Currency::USD);
store
.add_line(e, cash, ledger_core::Side::Debit, usd(5000))
.unwrap();
store
.add_line(e, rev, ledger_core::Side::Credit, usd(5000))
.unwrap();
store.set_line_class(e, 1, Some(class)).unwrap();
store.post_entry(e).unwrap();
let journal = std::fs::read_to_string(data.join(ledger_store::JOURNAL_FILE)).unwrap();
assert!(!journal.contains("project.set"), "nothing new was written");
drop(store);
write_settings(&t.0);
let b = data::load_once(&ledger_gui::Source {
data_dir: data,
today: Some(d(2026, 10, 12)),
})
.unwrap();
let listed = ledger_gui::pages::projects::listed(&b);
assert_eq!(listed.len(), 1);
seen += 1;
assert_eq!(listed[0].name, "Harbor app");
assert_eq!(listed[0].facts, ledger_core::ProjectFacts::default());
let t = listed[0].to_date.as_ref().unwrap();
assert_eq!(t.revenue, rust_decimal::Decimal::new(5000, 2));
assert_eq!(t.unrecovered_cost, None);
assert!(seen > 0);
}
+590
View File
@@ -0,0 +1,590 @@
//! Projects in the viewer (rules.md B-37): the list and a project's page
//! show ledger-core's figures; rows, links and bars open what they name;
//! the journal, books.show and the party page know projects.
use chrono::NaiveDate;
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::conventions::NewDecision;
use ledger_core::{AccountType, ClassId, Currency, Money, ProjectFacts, ProjectKind, Side};
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
use ledger_gui::pages::journal::describe;
use ledger_gui::pages::projects::listed;
use ledger_gui::{App, Source, data};
use ledger_store::ops::ViewerTarget;
use ledger_store::{Store, Subject};
use rust_decimal::Decimal;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn today() -> NaiveDate {
d(2026, 10, 15)
}
/// Books with three projects: "Zeta site" (client work for Riley Chen:
/// $5,000.00 earned, $1,800.00 of contractors), "alpha app" (on spec:
/// $300.00 of costs, and test devices bought for it, an asset), and
/// "Beta" (a bare class); rent is no project's. A pitch is linked to
/// alpha, and a decision names it.
struct Fixture {
_dir: TempDir,
books: Books,
zeta: ClassId,
alpha: ClassId,
beta: ClassId,
riley: u64,
alpha_cost: u64,
pitch: u64,
}
fn fixture(tag: &str) -> Fixture {
let dir = TempDir(
std::env::temp_dir().join(format!("ledger-gui-projects-{tag}-{}", std::process::id())),
);
let _ = std::fs::remove_dir_all(&dir.0);
let data_dir = dir.0.join("books-data");
let mut s = Store::create(&data_dir, Currency::USD).unwrap();
let mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap();
let cash = add("1000", "Cash", AccountType::Asset);
let devices = add("1200", "Equipment", AccountType::Asset);
let revenue = add("4000", "Design Services", AccountType::Revenue);
let contractors = add("6000", "Contractors", AccountType::Expense);
let rent = add("6200", "Rent", AccountType::Expense);
let riley = s.add_party("Riley Chen", &[]).unwrap();
let zeta = s
.add_project(
"Zeta site",
&ProjectFacts {
kind: Some(ProjectKind::Client),
party: Some(riley),
description: Some("A site for Riley".into()),
..Default::default()
},
)
.unwrap();
let alpha = s
.add_project(
"alpha app",
&ProjectFacts {
kind: Some(ProjectKind::OnSpec),
links: vec!["https://alpha.example".into()],
..Default::default()
},
)
.unwrap();
let beta = s.add_class("Beta").unwrap();
let post = |s: &mut Store, date, what: &str, dr, cr, c, tag: Option<(ClassId, bool)>| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
if let Some((class, debit)) = tag {
s.set_line_class(e, if debit { 0 } else { 1 }, Some(class))
.unwrap();
}
s.post_entry(e).unwrap();
e
};
post(
&mut s,
d(2026, 3, 2),
"Zeta milestone",
cash,
revenue,
500_000,
Some((zeta, false)),
);
post(
&mut s,
d(2026, 3, 9),
"Zeta contractor",
contractors,
cash,
180_000,
Some((zeta, true)),
);
let alpha_cost = post(
&mut s,
d(2026, 4, 1),
"Alpha prototype",
contractors,
cash,
30_000,
Some((alpha, true)),
);
post(
&mut s,
d(2026, 4, 2),
"Alpha test devices",
devices,
cash,
90_000,
Some((alpha, true)),
);
post(&mut s, d(2026, 4, 30), "Rent", rent, cash, 150_000, None);
let (pitch, _, _) = s
.file_document(
"other",
"alpha pitch.pdf",
"application/pdf",
b"%PDF-1.4 pitch",
None,
)
.unwrap();
s.link_attachment(pitch, Subject::Project(u64::from(alpha)))
.unwrap();
s.record_decision(&NewDecision {
subject: "Alpha on spec".into(),
decision_md: "Build it on spec.".into(),
decided_by: vec!["Tobin Harlow".into()],
decided_on: d(2026, 4, 1),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![alpha],
})
.unwrap();
drop(s);
let books = load(&dir);
Fixture {
_dir: dir,
books,
zeta,
alpha,
beta,
riley,
alpha_cost,
pitch,
}
}
fn load(dir: &TempDir) -> Books {
data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(today()),
})
.unwrap()
}
fn harness(f: &Fixture, route: Route) -> Harness<'static, App> {
let books = load(&f._dir);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 2000.0))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
h.run_steps(3);
h
}
#[test]
fn projects_page_lists_every_project_alphabetically() {
let f = fixture("list");
let rows = listed(&f.books);
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["alpha app", "Beta", "Zeta site"]);
assert_eq!(rows[1].facts, ProjectFacts::default());
assert_eq!(Section::Projects.home(), Route::Projects);
assert_eq!(Route::Project(f.beta).section(), Section::Projects);
let h = harness(&f, Route::Projects);
h.get_by_label("3 projects, all active. Net to date across them: $2,900.00.");
for n in names {
h.get_by_role_and_label(Role::Button, n);
}
assert_eq!(rows[1].facts.kind, None, "Beta: kind not recorded");
}
#[test]
fn project_figures_are_ledger_cores() {
let f = fixture("figures");
let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap();
assert_eq!((t.revenue, t.expenses), (cents(500_000), cents(180_000)));
let row = listed(&f.books)
.into_iter()
.find(|r| r.id == f.zeta)
.unwrap();
assert_eq!(row.to_date, Some(t.clone()));
let h = harness(&f, Route::Project(f.zeta));
h.get_by_label("$5,000.00");
h.get_by_label("$1,800.00");
h.get_by_label("$3,200.00");
// By account to date: the same statement lines.
let fig = ledger_core::project_figures(
&f.books.ledger,
f.zeta,
ledger_core::calendar::FIRST_DAY,
today(),
)
.unwrap();
for l in fig.period.revenue.iter().chain(&fig.period.expenses) {
h.get_by_role_and_label(Role::Button, &format!("{} {}", l.code, l.name));
}
h.get_by_label("Margin to date");
h.get_by_label("64.0%");
}
#[test]
fn unrecovered_cost_shows_only_on_spec() {
let f = fixture("unrecovered");
let h = harness(&f, Route::Project(f.alpha));
h.get_by_label("Unrecovered cost");
h.get_by_label("if repaid: not a receivable");
// The devices (an asset) count nothing: spending and the unrecovered
// cost are both the prototype's $300.00.
assert_eq!(h.get_all_by_label("$300.00").count(), 2);
let h = harness(&f, Route::Project(f.zeta));
assert!(h.query_by_label("Unrecovered cost").is_none());
}
#[test]
fn a_project_row_opens_the_project() {
let f = fixture("row");
let mut h = harness(&f, Route::Projects);
h.get_by_role_and_label(Role::Button, "alpha app").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
}
#[test]
fn an_entry_row_opens_the_entry() {
let f = fixture("entry-row");
let mut h = harness(&f, Route::Project(f.alpha));
h.get_by_role_and_label(Role::Button, "Alpha prototype")
.click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Entry(f.alpha_cost));
}
#[test]
fn a_document_opens_the_document() {
let f = fixture("doc");
let mut h = harness(&f, Route::Project(f.alpha));
h.get_by_label("alpha pitch.pdf").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Document(f.pitch));
}
#[test]
fn the_party_link_opens_the_party() {
let f = fixture("party-link");
let mut h = harness(&f, Route::Project(f.zeta));
h.get_by_label("Riley Chen").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Party(f.riley));
}
#[test]
fn profit_by_project_bar_opens_the_project() {
let f = fixture("profit");
let mut h = harness(&f, Route::Profit);
h.get_by_label("Net income by project, this year");
h.get_by_role_and_label(Role::Button, "Zeta site").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.zeta));
}
#[test]
fn party_page_lists_its_projects() {
let f = fixture("party");
assert_eq!(f.books.ledger.projects_of_party(f.riley), vec![f.zeta]);
let mut h = harness(&f, Route::Party(f.riley));
h.get_by_role_and_label(Role::Button, "Zeta site").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.zeta));
}
#[test]
fn entry_line_project_opens_the_project() {
let f = fixture("entry-line");
let mut h = harness(&f, Route::Entry(f.alpha_cost));
h.get_by_label(&format!("{} alpha app", egui_phosphor::regular::TAG))
.click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
}
#[test]
fn journal_describes_project_set() {
let f = fixture("journal");
let b = &f.books;
let texts: Vec<(String, Option<Route>)> = b
.journal
.iter()
.filter_map(|l| describe(b, l).map(|e| (e.text, e.route)))
.collect();
let has = |t: &str, r: Route| texts.iter().any(|(x, y)| x == t && *y == Some(r.clone()));
assert!(
has("Added project Zeta site", Route::Project(f.zeta)),
"{texts:?}"
);
assert!(
has(
"Project Zeta site: active, client work",
Route::Project(f.zeta)
),
"{texts:?}"
);
assert!(
has(
"Project alpha app: active, on spec",
Route::Project(f.alpha)
),
"{texts:?}"
);
assert!(
has("Added project Beta", Route::Project(f.beta)),
"{texts:?}"
);
assert!(
texts
.iter()
.any(|(t, _)| t == "Linked alpha pitch.pdf to Project alpha app"),
"{texts:?}"
);
}
#[test]
fn show_project_target_opens_its_page() {
let f = fixture("show");
let mut h = harness(&f, Route::Overview);
h.state_mut()
.show(ViewerTarget::Project { class_id: f.alpha });
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
h.state_mut().show(ViewerTarget::Project { class_id: 999 });
h.run_steps(1);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
h.state_mut().show(ViewerTarget::Page {
page: "projects".into(),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Projects);
// Every project's page, and one that doesn't exist, render.
for c in [f.zeta, f.alpha, f.beta, 999] {
h.state_mut().goto(Route::Project(c));
h.run_steps(2);
}
}
/// A project named `name` with `link`, a $100.00 sale and a decision, in
/// books of its own; a harness `w` by `h` on its page.
fn edge(tag: &str, name: &str, link: &str, w: f32, h: f32) -> (TempDir, Harness<'static, App>) {
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-edge-{tag}-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&dir.0);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let revenue = s
.add_account("4000", "Design Services", AccountType::Revenue, None, false)
.unwrap();
let id = s
.add_project(
name,
&ProjectFacts {
kind: Some(ProjectKind::Client),
links: vec![link.to_string()],
..Default::default()
},
)
.unwrap();
let e = s.create_entry(d(2026, 3, 2), "Sale").unwrap();
s.add_line(e, cash, Side::Debit, usd(10_000)).unwrap();
s.add_line(e, revenue, Side::Credit, usd(10_000)).unwrap();
s.set_line_class(e, 1, Some(id)).unwrap();
s.post_entry(e).unwrap();
s.record_decision(&NewDecision {
subject: "Take the job".into(),
decision_md: "Do it.".into(),
decided_by: vec!["Tobin Harlow".into()],
decided_on: d(2026, 3, 1),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![id],
})
.unwrap();
drop(s);
let books = load(&dir);
let mut harness = Harness::builder()
.with_size(egui::vec2(w, h))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Project(id)));
harness.run_steps(4);
// For eyes: SHOT_DIR keeps a picture of each edge page.
if let Ok(out) = std::env::var("SHOT_DIR") {
let _ = std::fs::create_dir_all(&out);
if let Ok(img) = harness.render() {
img.save(std::path::Path::new(&out).join(format!("{tag}.png")))
.unwrap();
}
}
(dir, harness)
}
/// A 200-character link with no space to wrap at stays inside the Facts
/// card: left of the Decisions card beside it, and inside the window.
#[test]
fn a_long_link_stays_in_its_card() {
let link = format!("https://git.example/{}", "a".repeat(180));
for (w, tag) in [(1440.0, "link-1440"), (900.0, "link-900")] {
let (_dir, h) = edge(tag, "Harbor", &link, w, 2600.0);
let shown = h.get_by_label_contains("https://git.example/").rect();
let decisions = h.get_by_label("Decisions").rect();
assert!(
shown.right() <= w,
"{w}: the link runs to {}",
shown.right()
);
if decisions.left() > shown.left() {
assert!(
shown.right() <= decisions.left(),
"{w}: the link runs to {}, under Decisions at {}",
shown.right(),
decisions.left()
);
}
let net = h.get_by_label("Net to date").rect();
assert!(net.right() <= w, "{w}: Net to date at {net:?}");
}
}
/// A 200-character name, with spaces or without, keeps the page inside
/// the window: the title and every tile show.
#[test]
fn a_long_name_keeps_the_page_in_the_window() {
for (name, tag) in [
("Harbor ".repeat(28).trim().to_string(), "name-spaces"),
("H".repeat(200), "name-nospace"),
] {
let (_dir, h) = edge(tag, &name, "https://harbor.example", 1440.0, 2000.0);
for tile in ["Income to date", "Net to date", "Margin to date"] {
let r = h.get_by_label(tile).rect();
assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}");
}
let decisions = h.get_by_label("Decisions").rect();
assert!(
decisions.right() <= 1440.0,
"{tag}: Decisions at {decisions:?}"
);
let named: Vec<_> = h
.query_all_by_label_contains("HHHHHHHHHH")
.chain(h.query_all_by_label_contains("Harbor Harbor"))
.collect();
assert!(!named.is_empty(), "{tag}: the name shows");
for title in named {
assert!(
title.rect().right() <= 1440.0,
"{tag}: {title:?} at {:?}",
title.rect()
);
}
}
}
/// The by-month bars are the project's own: a click opens no report (the
/// reports are the whole company's), and the margin keeps one place.
#[test]
fn a_month_bar_opens_nothing_and_margin_keeps_one_place() {
let (_dir, mut h) = edge("month", "Harbor", "https://harbor.example", 1440.0, 2000.0);
let route = h.state_mut().route().clone();
// All income, no spending: 100.0%, as 64.0% elsewhere.
h.get_by_label("100.0%");
let title = h.get_by_label("By month").rect();
let at = egui::pos2(title.left() + 200.0, title.bottom() + 120.0);
h.hover_at(at);
h.run_steps(2);
for pressed in [true, false] {
h.event(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(1);
}
h.run_steps(2);
assert_eq!(h.state_mut().route(), &route);
}
/// An entry with no receipt says nothing about receipts.
#[test]
fn an_entry_without_receipts_says_nothing_of_them() {
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let f = fixture("no-receipts");
let h = harness(&f, Route::Project(f.zeta));
let mut texts = Vec::new();
for c in &h.output().shapes {
walk(&c.shape, &mut texts);
}
let subs: Vec<&String> = texts.iter().filter(|t| t.contains("Contractors")).collect();
assert!(
!subs.is_empty(),
"the entries' sub-lines are painted: {texts:?}"
);
assert!(
!texts.iter().any(|t| t.contains("0 receipts")),
"no \"0 receipts\": {subs:?}"
);
}
/// The party page has the same long-name edge: a 200-character name keeps
/// to its Facts card and the window.
#[test]
fn a_long_party_name_keeps_to_its_card() {
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-party-long-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&dir.0);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let name = "P".repeat(200);
let party = s.add_party(&name, &[]).unwrap();
drop(s);
let books = load(&dir);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Party(party)));
h.run_steps(4);
let linked = h.get_by_label("Linked records").rect();
let named: Vec<_> = h.query_all_by_label_contains("PPPPPPPPPP").collect();
assert!(!named.is_empty());
for n in named {
let r = n.rect();
assert!(r.right() <= 1440.0, "{n:?} at {r:?}");
// The Facts card's Name row, beside Linked records.
let key = h.get_by_label("Name").rect();
if (r.center().y - key.center().y).abs() < 12.0 && r.left() < linked.left() {
assert!(
r.right() <= linked.left(),
"{n:?} at {r:?} under Linked records"
);
}
}
}
+7
View File
@@ -136,6 +136,9 @@ fn the_viewer_never_writes_into_the_books() {
.map(|a| a.id)
.unwrap();
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
// Every project's page too (visiting_project_pages_changes_nothing).
let projects: Vec<u32> = books.ledger.list_classes().iter().map(|c| c.id).collect();
assert!(!projects.is_empty(), "the demo has projects");
drop(books);
// Follow live for a moment.
@@ -178,12 +181,16 @@ fn the_viewer_never_writes_into_the_books() {
Route::Asset(asset),
Route::Parties,
Route::Party(party),
Route::Projects,
Route::Document(doc),
Route::JournalEvent(event),
Route::Documents,
Route::Company,
Route::Journal,
];
let routes = routes
.into_iter()
.chain(projects.iter().map(|&c| Route::Project(c)));
for route in routes {
let books = data::load_once(&source).unwrap();
let mut harness = Harness::builder()
+19
View File
@@ -89,6 +89,22 @@ fn screenshots() {
.rev()
.find(|a| loaded.links_of(a.id).len() > 1)
.map_or(last_doc, |a| a.id);
// A project by name (SHOT_PROJECT: an id): the on-spec one, and the
// client work, else the first.
let project_named = |name: &str| {
loaded
.ledger
.list_classes()
.into_iter()
.find(|c| c.name == name)
.or_else(|| loaded.ledger.list_classes().into_iter().next())
.map_or(1, |c| c.id)
};
let project = std::env::var("SHOT_PROJECT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or_else(|| project_named("Tidepool app"));
let project_client = project_named("Client work");
drop(loaded);
let routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview),
@@ -122,6 +138,9 @@ fn screenshots() {
("asset", Route::Asset(first_asset)),
("parties", Route::Parties),
("party", Route::Party(party)),
("projects", Route::Projects),
("project", Route::Project(project)),
("project-client", Route::Project(project_client)),
("document", Route::Document(last_doc)),
("document-linked", Route::Document(linked_doc)),
("reconciliation", Route::Reconciliation(reconciliation)),