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

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