Projects: the snapshot of a project's figures
check / check (push) Canceled after 0s

The project page opens on a snapshot: revenue, direct costs, the
contribution before overhead and its margin of revenue (a segment
margin: revenue less every cost traced to the project, fixed and
variable; B-37 item 7), in the black or red, and for on-spec work the
cost unrecovered if repaid. To date or this fiscal year. By month,
revenue and costs with the net to date in its own strip; costs by
category; the list colors each net by its sign. books.project carries
the same figures, and a parity test runs the viewer and the MCP on the
same books to the cent. Tests honor TMPDIR.

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 06:54:26 -06:00
1 parent d24653558c
commit 3eda95eb3c
23 files changed
+2231 -246

No files matched your search

+114 -18
View File
@@ -250,19 +250,45 @@ pub fn bar_chart(
series: &[(&str, Color32)],
height: f32,
) -> Option<usize> {
bars(ui, groups, series, height, true)
bars(ui, groups, series, None, 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);
bars(ui, groups, series, None, height, false);
}
/// A line under a [`bar_chart_view`]'s bars: one value per group, marked
/// at each group's center (e.g. a running net toward break-even), in a
/// strip of its own with its own scale and a $0 rule, so a running total
/// many times one group's figures doesn't flatten the bars.
pub struct Overlay<'a> {
pub name: &'a str,
pub color: Color32,
pub values: &'a [Decimal],
}
/// [`bar_chart_view`] with an [`Overlay`] line in a strip under the bars
/// ([`LINE_STRIP`] taller); its value joins each group's hover figures.
pub fn bar_chart_view_with_line(
ui: &mut Ui,
groups: &[BarGroup],
series: &[(&str, Color32)],
line: &Overlay<'_>,
height: f32,
) {
bars(ui, groups, series, Some(line), height, false);
}
/// How much taller an [`Overlay`] makes a bar chart: its strip.
pub const LINE_STRIP: f32 = 84.0;
fn bars(
ui: &mut Ui,
groups: &[BarGroup],
series: &[(&str, Color32)],
line: Option<&Overlay<'_>>,
height: f32,
clickable: bool,
) -> Option<usize> {
@@ -278,17 +304,32 @@ fn bars(
);
ui.add_space(8.0);
}
if let Some(l) = line {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 10.0), Sense::hover());
ui.painter().line_segment(
[r.left_center(), r.right_center()],
Stroke::new(2.0, l.color),
);
ui.painter().circle_filled(r.center(), 3.0, l.color);
ui.label(
egui::RichText::new(l.name)
.font(theme::regular(12.0))
.color(p.muted),
);
}
});
let sense = if clickable {
Sense::click()
} else {
Sense::hover()
};
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), sense);
let strip_h = if line.is_some() { LINE_STRIP } else { 0.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height + strip_h), sense);
let painter = ui.painter_at(rect);
if groups.is_empty() {
return None;
}
// The bars' scale is the bars' own; the line has its strip's.
let all: Vec<f64> = groups
.iter()
.flat_map(|g| g.values.iter().map(|v| f(*v)))
@@ -297,7 +338,17 @@ fn bars(
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 plot = Rect::from_min_max(
rect.min + vec2(52.0, 6.0),
pos2(rect.max.x - 4.0, rect.min.y + height - 22.0),
);
let strip = line.map(|_| {
Rect::from_min_max(
pos2(plot.left(), plot.bottom() + 24.0),
pos2(plot.right(), plot.bottom() + strip_h - 4.0),
)
});
let bottom = strip.map_or(plot.bottom(), |s| s.bottom());
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
for t in &ts {
let yy = y(*t);
@@ -319,13 +370,57 @@ fn bars(
let mut clicked = None;
let hover = resp.hover_pos();
let mut drawn_year: Option<i32> = None;
let mut tip: Option<(egui::Pos2, String, String)> = None;
// The line goes over the hover band and under the tooltip, on its
// own scale with room above and below (its markers never sit on the
// strip's edge), and a dashed $0: break-even.
let mut overlay = Vec::new();
if let (Some(l), Some(s)) = (line, strip) {
let vals: Vec<f64> = l.values.iter().take(groups.len()).map(|v| f(*v)).collect();
let lo = vals.iter().cloned().fold(0.0f64, f64::min);
let hi = vals.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0);
let pad = (hi - lo) * 0.12;
let (lo, hi) = (lo - pad, hi + pad);
let sy = |v: f64| s.bottom() - ((v - lo) / (hi - lo)) as f32 * s.height();
let zero = sy(0.0);
overlay.extend(egui::Shape::dashed_line(
&[pos2(s.left(), zero), pos2(s.right(), zero)],
Stroke::new(1.0, p.faint),
4.0,
3.0,
));
let label = |v: f64, at: f32| {
let g = painter.layout_no_wrap(money_label(v), theme::regular(11.0), p.faint);
let r = Align2::RIGHT_CENTER.anchor_size(pos2(s.left() - 8.0, at), g.size());
egui::Shape::galley(r.min, g, p.faint)
};
overlay.push(label(0.0, zero));
let (vmin, vmax) = (
vals.iter().cloned().fold(0.0f64, f64::min),
vals.iter().cloned().fold(0.0f64, f64::max),
);
for v in [vmax, vmin] {
if v != 0.0 && (sy(v) - zero).abs() >= 12.0 {
overlay.push(label(v, sy(v)));
}
}
let pts: Vec<egui::Pos2> = vals
.iter()
.enumerate()
.map(|(gi, v)| pos2(plot.left() + gw * (gi as f32 + 0.5), sy(*v)))
.collect();
overlay.push(egui::Shape::line(pts.clone(), Stroke::new(2.0, l.color)));
for pt in pts {
overlay.push(egui::Shape::circle_filled(pt, 3.0, l.color));
}
}
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 grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, bottom));
let hovered = hover.is_some_and(|h| grect.contains(h));
if hovered {
painter.rect_filled(grect, CornerRadius::same(6), p.hover);
@@ -358,7 +453,7 @@ fn bars(
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),
pos2(gx + gw / 2.0, bottom + 6.0),
Align2::CENTER_TOP,
if new_year { &full } else { &g.label },
theme::regular(11.0),
@@ -366,22 +461,17 @@ fn bars(
);
}
if hovered {
let lines: Vec<String> = series
let mut 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()),
&full,
&lines.join("\n"),
p.surface,
p.border,
p.text,
p.muted,
rect,
);
if let Some(l) = line
&& let Some(v) = l.values.get(gi)
{
lines.push(format!("{}: {}", l.name, fmt::dollars(*v)));
}
tip = Some((pos2(gx + gw / 2.0, plot.top()), full, lines.join("\n")));
if clickable {
if resp.clicked() {
clicked = Some(gi);
@@ -390,6 +480,12 @@ fn bars(
}
}
}
painter.extend(overlay);
if let Some((at, title, body)) = tip {
tooltip(
&painter, at, &title, &body, p.surface, p.border, p.text, p.muted, rect,
);
}
clicked
}
+7 -1
View File
@@ -246,7 +246,13 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
let client_class = s.add_project(
"Client work",
&ledger_core::ProjectFacts {
description: Some("Design work for the studio's clients".into()),
// Its invoices can't be tagged to it: an invoice posts one
// untagged revenue line (Ledger::create_invoice takes no
// class), so only the contractors it pays show here, said so.
description: Some(
"Design work for the studio's clients. Only its contractors are tagged to it: an invoice can't carry a project yet, so what the clients pay shows on the profit and loss, not here."
.into(),
),
kind: Some(ledger_core::ProjectKind::Client),
..Default::default()
},
+518 -149
View File
@@ -1,7 +1,9 @@
//! 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`,
//! it is for), and one project completely: its snapshot (revenue, direct
//! costs, contribution before overhead and its margin, to date or this
//! fiscal year, rules.md B-37 item 7), revenue and costs by month with the
//! running net, costs by category 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.
@@ -27,6 +29,9 @@ use rust_decimal::Decimal;
pub struct State {
/// Only projects with this status (None: every one).
pub status: Option<ProjectStatus>,
/// A project's snapshot shows this fiscal year to date (B-30's
/// omitted dates), not to date.
pub fiscal: bool,
}
/// One project on the list: its id, name, facts and net to date.
@@ -160,17 +165,24 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
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 };
// The cue repeats what the net's color says: it goes
// first as the list narrows, then the kind.
let kind_w = if avail >= 640.0 { 140.0 } else { 0.0 };
let cue_w = if avail >= 760.0 { 120.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<_> {
let w = avail.max(status_w + kind_w + party_w + cue_w + amt + 180.0);
let cells = |c: [(String, f32, bool, egui::Color32); 6]| -> Vec<_> {
c.into_iter()
.enumerate()
.filter(|(i, _)| (*i != 2 || kind_w > 0.0) && (*i != 3 || party_w > 0.0))
.filter(|(i, _)| {
(*i != 2 || kind_w > 0.0)
&& (*i != 3 || party_w > 0.0)
&& (*i != 4 || cue_w > 0.0)
})
.map(|(_, c)| c)
.collect()
};
@@ -184,6 +196,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
head("Status", status_w, false),
head("Kind", kind_w, false),
head("Party", party_w, false),
head("", cue_w, true),
head("Net to date", amt, true),
]),
None,
@@ -198,12 +211,29 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.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),
let snap = r.to_date.as_ref().map(Snap::to_date);
let (net_t, net_c) = match &snap {
Some(s) if s.standing == Standing::Nothing => {
("—".to_string(), p.faint)
}
Some(s) => (fmt::accounting(s.contribution), s.standing.colors(&p).0),
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
};
// A small cue beside the net: where it stands.
let (cue_t, cue_c) = match snap.as_ref().map(|s| s.standing) {
Some(st @ (Standing::Black | Standing::Red | Standing::Even)) => {
let mark = match st {
Standing::Black => icon::TREND_UP,
Standing::Red => icon::TREND_DOWN,
_ => icon::EQUALS,
};
(
format!("{mark} {}", st.words().to_lowercase()),
st.colors(&p).0,
)
}
_ => (String::new(), p.faint),
};
let sub = r
.facts
.description
@@ -218,6 +248,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
(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),
(cue_t, cue_w, true, cue_c),
(net_t, amt, true, net_c),
]),
Some((sub.as_str(), p.faint)),
@@ -240,7 +271,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
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.",
"Net to date: every posted revenue and expense line tagged to the project through {}, closing entries left out; revenue less its direct costs, before overhead (untagged company costs aren't counted).",
fmt::date(b.today)
))
.font(theme::regular(11.5))
@@ -285,14 +316,248 @@ pub fn project_events(b: &Books, id: ClassId) -> Vec<usize> {
.collect()
}
/// A number tile: (label, value, how it is computed, the line under it).
type Tile = (
String,
Option<Decimal>,
/// A snapshot tile: (label, value, the line under it, how it is
/// computed, the amount its menu copies).
type Tile<'a> = (
&'a str,
String,
Option<(String, egui::Color32)>,
String,
Option<Decimal>,
);
/// Where a project stands on its contribution before overhead.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Standing {
/// Contribution above zero.
Black,
/// Contribution below zero.
Red,
/// Lines tagged, netting to zero (a cost and its reversal too).
Even,
/// No revenue or expense line tagged (in the span shown).
Nothing,
}
impl Standing {
/// From a contribution, and whether any posted revenue or expense
/// line is tagged at all (counted lines, not accounts left with a
/// balance: a reversed cost is tagged and breaks even).
pub fn of(contribution: Decimal, any: bool) -> Self {
if contribution > Decimal::ZERO {
Standing::Black
} else if contribution < Decimal::ZERO {
Standing::Red
} else if any {
Standing::Even
} else {
Standing::Nothing
}
}
/// Its words on a pill.
pub fn words(self) -> &'static str {
match self {
Standing::Black => "In the black",
Standing::Red => "In the red",
Standing::Even => "Break-even",
Standing::Nothing => "Nothing tagged yet",
}
}
/// Its colors: (text, fill), the palette's positive and negative.
pub fn colors(self, p: &theme::Palette) -> (egui::Color32, egui::Color32) {
match self {
Standing::Black => (p.positive, p.positive_soft),
Standing::Red => (p.negative, p.negative_soft),
Standing::Even | Standing::Nothing => (p.muted, p.hover),
}
}
}
/// What a snapshot shows, all ledger-core's: to date
/// (`project_to_date`) or for a period (`project_figures`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Snap {
/// Tagged revenue.
pub revenue: Decimal,
/// Tagged expenses: its direct costs.
pub expenses: Decimal,
/// Revenue less direct costs: its contribution before overhead.
pub contribution: Decimal,
/// Contribution as a percent of revenue (none without revenue).
pub margin_pct: Option<Decimal>,
/// Where it stands.
pub standing: Standing,
}
impl Snap {
/// To date.
pub fn to_date(t: &ledger_core::ProjectToDate) -> Self {
let any = t.any_tagged();
Snap {
revenue: t.revenue,
expenses: t.expenses,
contribution: t.net(),
margin_pct: t.margin_pct(),
standing: Standing::of(t.net(), any),
}
}
/// For the figures' period.
pub fn period(f: &ledger_core::ProjectFigures) -> Self {
let pl = &f.period;
let any = f.period_lines > 0;
Snap {
revenue: pl.total_revenue,
expenses: pl.total_expenses,
contribution: pl.net_income(),
margin_pct: f.period_margin_pct(),
standing: Standing::of(pl.net_income(), any),
}
}
}
/// The note on contribution: what it counts and what it leaves out.
pub const CONTRIBUTION_NOTE: &str = "Revenue less this project's direct costs: every expense line tagged to it, fixed and variable alike (what management accounting calls a segment margin, not a variable-cost contribution margin). Company overhead that isn't tagged to a project (rent, software, insurance, the owner's time) isn't counted, so a project in the black can still leave the company in the red. A management-accounting view (rules.md B-37 item 7), not a GAAP figure.";
/// The snapshot: a header with where it stands to date and the to date /
/// fiscal year switch, then its tiles (`shown`: to date, or for `year`,
/// the fiscal year to date, when `fiscal`). Returns the switch's new
/// setting (true: this fiscal year) when clicked.
fn snapshot(
ui: &mut Ui,
b: &Books,
kind: Option<ProjectKind>,
to_date: Option<&ledger_core::ProjectToDate>,
year: Option<(NaiveDate, NaiveDate)>,
shown: Option<Snap>,
fiscal: bool,
) -> Option<bool> {
let p = pal(ui.ctx());
let mut switch = None;
let year = year.filter(|_| fiscal);
let span = match year {
Some((s, e)) => format!(
"Every posted line tagged to this project from {} through {} (the fiscal year to date), closing entries left out.",
fmt::date(s),
fmt::date(e)
),
None => format!(
"Every posted line tagged to this project through {}, closing entries left out.",
fmt::date(b.today)
),
};
let when = match year {
Some((s, _)) => format!("since {}", fmt::date(s)),
None => format!("through {}", fmt::date(b.today)),
};
ui.horizontal(|ui| {
ui.label(RichText::new("Snapshot").font(theme::semibold(14.0)));
// Where it stands is always to date, whichever view: the list's
// cue too, and the page's to-date cards beside it agree.
if let Some(t) = to_date.map(Snap::to_date) {
let (fg, bg) = t.standing.colors(&p);
widgets::pill(ui, t.standing.words(), fg, bg).on_hover_text(format!(
"Contribution before overhead, to date (through {}): {}.",
fmt::date(b.today),
fmt::dollars(t.contribution)
));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
if chip(ui, "This fiscal year", fiscal) && !fiscal {
switch = Some(true);
}
if chip(ui, "To date", !fiscal) && fiscal {
switch = Some(false);
}
});
});
ui.add_space(8.0);
let money = |v: Option<Decimal>| fmt::dollars_or(v);
let contribution_sub = shown.as_ref().map(|s| {
let (fg, _) = s.standing.colors(&p);
let text = match s.standing {
Standing::Black | Standing::Red | Standing::Even => "before overhead",
Standing::Nothing if fiscal => "nothing this fiscal year",
Standing::Nothing => "nothing tagged yet",
};
(
text.to_string(),
if s.standing == Standing::Nothing {
p.muted
} else {
fg
},
)
});
let mut tiles: Vec<Tile<'_>> = vec![
(
"Revenue",
money(shown.as_ref().map(|s| s.revenue)),
Some((when.clone(), p.muted)),
format!("{span} Revenue lines."),
shown.as_ref().map(|s| s.revenue),
),
(
"Direct costs",
money(shown.as_ref().map(|s| s.expenses)),
Some((when.clone(), p.muted)),
format!("{span} Expense lines: the costs tagged to it."),
shown.as_ref().map(|s| s.expenses),
),
(
"Contribution",
money(shown.as_ref().map(|s| s.contribution)),
contribution_sub,
format!("{span} {CONTRIBUTION_NOTE}"),
shown.as_ref().map(|s| s.contribution),
),
];
if let Some(m) = shown.as_ref().and_then(|s| s.margin_pct) {
tiles.push((
"Contribution margin",
format!("{m}%"),
Some(("of revenue".to_string(), p.muted)),
format!("{span} Contribution as a share of revenue, to one place. Shown only when there is revenue."),
None,
));
}
if kind == Some(ProjectKind::OnSpec)
&& let Some(u) = to_date.and_then(|t| t.unrecovered_cost)
{
tiles.push((
"Unrecovered cost",
fmt::dollars(u),
Some(("if repaid: not a receivable".into(), p.muted)),
format!(
"Every posted line tagged to this project through {}, whichever view: direct costs less revenue to date, 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.",
fmt::date(b.today)
),
Some(u),
));
}
let n = tiles.len();
super::grid(ui, n, 170.0, 6, |i, ui| {
let (label, value, sub, how, copy) = &tiles[i];
let r = widgets::kpi(
ui,
widgets::Kpi {
label,
value: value.clone(),
sub: sub.clone(),
spark: None,
how,
},
);
if let Some(v) = copy {
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
}
});
switch
}
/// One project, completely.
pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
@@ -308,9 +573,10 @@ pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>)
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);
let fiscal = shell.state.projects.fiscal;
let figs = figures(ui.ctx(), b, id, f.started_on, fiscal);
let to_date = figs.to_date.as_ref();
let entries = &figs.entries;
page(ui, "project", |ui| {
ui.horizontal(|ui| {
if widgets::link(
@@ -363,110 +629,37 @@ pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>)
}
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)),
));
// The snapshot: what it earned and cost, to date or this fiscal
// year, before overhead.
let shown = if fiscal {
figs.year.as_ref().map(Snap::period)
} else {
to_date.map(Snap::to_date)
};
let year = figs.year.as_ref().map(|y| (y.period.start, y.period.end));
if let Some(fy) = snapshot(ui, b, f.kind, to_date, year, shown, fiscal) {
shell.state.projects.fiscal = fy;
}
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| {
// By month and by category.
super::columns(ui, 860.0, &[1.3, 1.0], |col, ui| {
if col == 0 {
months_card(ui, b, id, first);
months_card(ui, &figs);
} else {
accounts_card(ui, b, id, &mut go);
costs_card(ui, b, to_date, &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 {
accounts_card(ui, b, to_date, &mut go);
ui.add_space(16.0);
decisions_card(ui, b, id, &mut go);
}
});
entries_card(ui, b, &entries, &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(
@@ -503,21 +696,108 @@ pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>)
}
}
/// 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>) {
/// The most months the by-month chart shows: the latest.
pub const CHART_MONTHS: usize = 24;
/// The months the by-month chart spans: from the month the project
/// started, or of its first posted tagged entry if earlier, through
/// today, the last [`CHART_MONTHS`] at most. None with neither date.
pub fn chart_span(
today: NaiveDate,
started_on: Option<NaiveDate>,
first_entry: Option<NaiveDate>,
) -> Option<(NaiveDate, NaiveDate)> {
let from = match (started_on, first_entry) {
(Some(s), Some(e)) => s.min(e),
(s, e) => s.or(e)?,
};
let start = ledger_core::analysis::interval_start(
from.min(today),
ledger_core::analysis::Interval::Month,
);
Some(Books::last_months((start, today), CHART_MONTHS as u32))
}
/// A project page's figures, all ledger-core's, worked out once per load
/// of the books (and the fiscal year's once it is asked for), not every
/// frame: a hovered chart repaints continuously.
#[derive(Clone)]
pub struct PageFigures {
/// Through today (`project_to_date`).
pub to_date: Option<ledger_core::ProjectToDate>,
/// Every entry tagged to it (`project_entries`).
pub entries: Vec<(u64, Decimal)>,
/// The by-month chart's span ([`chart_span`]) and figures, when
/// anything tagged is posted.
pub months: Option<((NaiveDate, NaiveDate), ledger_core::ProjectFigures)>,
/// The fiscal year to date (B-30's omitted dates, as `books.project`),
/// only when the snapshot shows it.
pub year: Option<ledger_core::ProjectFigures>,
}
/// [`PageFigures`] for project `id`, kept in egui's memory against the
/// load of the books they came from (a reload, a new day or another
/// project works them out again).
pub fn figures(
ctx: &egui::Context,
b: &Books,
id: ClassId,
started_on: Option<NaiveDate>,
fiscal: bool,
) -> std::sync::Arc<PageFigures> {
type Stamp = (chrono::DateTime<chrono::Utc>, String, NaiveDate, bool);
let key = egui::Id::new(("project_figures", id));
let stamp: Stamp = (b.loaded_at, b.head.clone(), b.today, fiscal);
if let Some((was, figs)) = ctx.data(|d| d.get_temp::<(Stamp, std::sync::Arc<PageFigures>)>(key))
&& was == stamp
{
return figs;
}
let figs = std::sync::Arc::new(page_figures(b, id, started_on, fiscal));
ctx.data_mut(|d| d.insert_temp(key, (stamp, figs.clone())));
figs
}
/// [`PageFigures`], worked out.
pub fn page_figures(
b: &Books,
id: ClassId,
started_on: Option<NaiveDate>,
fiscal: bool,
) -> PageFigures {
let to_date = ledger_core::project_to_date(&b.ledger, id, b.today).ok();
let entries = ledger_core::project_entries(&b.ledger, id);
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();
// Nothing posted yet: no bars to draw, however long ago it started.
let months = chart_span(b.today, started_on, first)
.filter(|_| first.is_some())
.and_then(|(s, e)| {
let fig = ledger_core::project_figures(&b.ledger, id, s, e).ok()?;
Some(((s, e), fig))
});
let year = fiscal
.then(|| b.periods().statement(None, None).ok())
.flatten()
.and_then(|(s, e)| ledger_core::project_figures(&b.ledger, id, s, e).ok());
PageFigures {
to_date,
entries,
months,
year,
}
}
/// Revenue and costs by month, with the net to date at each month's end:
/// the trend toward or away from break-even.
fn months_card(ui: &mut Ui, figs: &PageFigures) {
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();
let span = figs.months.as_ref().map(|(span, _)| *span);
let fig = figs.months.as_ref().map(|(_, fig)| fig);
widgets::titled_card(
ui,
"By month",
@@ -531,55 +811,143 @@ fn months_card(ui: &mut Ui, b: &Books, id: ClassId, first: Option<NaiveDate>) {
}
},
|ui| {
if months.is_empty() {
let Some(fig) = fig.filter(|f| !f.months.is_empty()) else {
widgets::empty(ui, icon::CHART_BAR, "No tagged lines yet", "");
return;
}
};
let months = &fig.months;
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()],
values: vec![m.revenue, m.expenses],
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(
charts::bar_chart_view_with_line(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
&[("Revenue", p.series[1]), ("Direct costs", p.series[2])],
&charts::Overlay {
name: "Net to date",
color: p.series[0],
values: &fig.months_net_to_date,
},
200.0,
);
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(
"Net to date: every tagged line through each month's end, before overhead; above $0 it is in the black.",
)
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
},
);
}
/// Its direct costs by expense account to date, largest first; a click
/// opens the account's register.
fn costs_card(
ui: &mut Ui,
b: &Books,
to_date: Option<&ledger_core::ProjectToDate>,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
let mut lines: Vec<&ledger_core::StatementLine> = to_date
.map(|t| t.expenses_by_account.iter().collect())
.unwrap_or_default();
// Largest first, then by code: the bars rank.
lines.sort_by(|x, y| y.amount.cmp(&x.amount).then_with(|| x.code.cmp(&y.code)));
widgets::titled_card(
ui,
"Costs by category, to date",
|ui| {
if let Some(t) = to_date.filter(|_| !lines.is_empty()) {
ui.label(
RichText::new(fmt::dollars(t.expenses))
.font(theme::regular(12.0))
.color(p.muted),
);
}
},
|ui| {
if lines.is_empty() {
// Costs tagged and cancelled out (a reversal, or repaid
// exactly) leave no account, but they were tagged.
if to_date.is_some_and(|t| t.expenses_lines > 0) {
widgets::empty(
ui,
icon::CHART_BAR_HORIZONTAL,
"Tagged costs net to zero",
"Every cost tagged to it was reversed or repaid in full.",
);
} else {
widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No costs tagged", "");
}
return;
}
let rows: Vec<(String, Decimal)> = lines
.iter()
.map(|l| (format!("{} {}", l.code, l.name), l.amount))
.collect();
if let Some(i) = charts::hbars_signed(ui, &rows, p.series[2], p.positive)
&& let Some(a) = b.ledger.chart().find_by_code(&lines[i].code)
{
*go = Some(Route::Account(a.id));
}
if lines.iter().any(|l| l.amount < Decimal::ZERO) {
ui.add_space(4.0);
ui.add(
egui::Label::new(
RichText::new(
"Below zero: an account credited more than it cost (a reimbursement).",
)
.font(theme::regular(11.5))
.color(p.faint),
)
.wrap(),
);
}
},
);
}
/// Its revenue then expense accounts to date, with a total.
fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
fn accounts_card(
ui: &mut Ui,
b: &Books,
to_date: Option<&ledger_core::ProjectToDate>,
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", "");
let Some(t) = to_date
.filter(|t| !t.revenue_by_account.is_empty() || !t.expenses_by_account.is_empty())
else {
if to_date.is_some_and(|t| t.any_tagged()) {
widgets::empty(
ui,
icon::TREE_STRUCTURE,
"Tagged lines net to zero",
"Each account's tagged lines cancel out (a reversal, or a cost repaid in full): a contribution of 0.00.",
);
} 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);
@@ -612,19 +980,19 @@ fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
*go = Some(Route::Account(a.id));
}
};
for l in &pl.revenue {
for l in &t.revenue_by_account {
line(ui, l, false);
}
for l in &pl.expenses {
for l in &t.expenses_by_account {
line(ui, l, true);
}
widgets::rule(ui);
let net = pl.net_income();
let net = t.net();
row(
ui,
w,
&[
("Net".to_string(), 0.0, false, p.text),
("Contribution".to_string(), 0.0, false, p.text),
(
fmt::accounting(net),
amt,
@@ -639,7 +1007,8 @@ fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
None,
true,
false,
);
)
.on_hover_text(CONTRIBUTION_NOTE);
ui.add_space(4.0);
ui.label(
RichText::new(