mod by_year;
mod changes;
mod follow;
pub(super) mod plans;
#[cfg(test)]
mod tests;
mod views;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use bevy_app::{App, Startup, Update};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::prelude::{
Commands, Entity, IntoScheduleConfigs, Query, Res, ResMut, Resource, With,
};
use bevy_ecs::system::SystemParam;
use plurimus::core::UiWidget;
use plurimus::widgets::WidgetSystems;
use retiretui_engine::params::TaxTables;
use retiretui_engine::plan::{Dollars, Plan};
use retiretui_engine::project::Projection;
use crate::commands::compare::Metric;
use crate::commands::table::basis_amount;
use super::chart::{Mark, Series, SeriesChart};
use super::command::Outcome;
use super::hints::Hints;
use super::journal;
use super::layout::{self, Body};
use super::nav::{self, Page};
use super::present::{compact_money, signed_money};
use super::session::{self, Session, Shown};
use super::success::{self, Successes};
use super::theme::{Repainted, Theme};
use super::tools::{self, HelpLine};
use super::watch;
use follow::ComparedDoc;
pub(crate) use follow::open_highlighted;
pub(crate) use views::{Charted, cycle_view, next_metric, previous_metric};
pub fn plugin(app: &mut App) {
app.init_resource::<Compared>();
app.init_resource::<Charted>();
app.add_systems(Startup, spawn_compare.after(layout::spawn_frame));
app.add_systems(
Update,
follow::follow_disk
.after(watch::poll_watch)
.run_if(watch::on_beat),
);
app.add_systems(
Update,
(
(
views::refresh_views,
session::track_cursor::<by_year::ByYearTable>,
)
.chain(),
show_help,
(plans::refresh_plans, changes::refresh_changes)
.chain()
.after(success::work_through)
.before(Repainted)
.before(WidgetSystems::Layout),
),
);
}
const METRIC_HINTS: Hints = Hints(&[("←→", "metric")]);
const HELP: &str = "Compare this plan with others in its folder: c adds one.";
const UNREACHED: &str = "-";
#[derive(Resource, Default)]
pub struct Compared {
docs: Vec<ComparedDoc>,
baseline: Option<PathBuf>,
is_difference: bool,
}
impl Compared {
#[cfg(test)]
pub fn has(&self, path: &Path) -> bool {
self.paths().any(|held| held == path)
}
pub fn paths(&self) -> impl Iterator<Item = &Path> {
self.docs.iter().map(|doc| doc.path.as_path())
}
pub(crate) fn plans(&self) -> impl Iterator<Item = &Plan> {
self.docs.iter().map(|doc| &doc.projected.plan)
}
pub fn toggle(&mut self, path: PathBuf, tables: &TaxTables) {
if let Some(at) = self.docs.iter().position(|doc| doc.path == path) {
self.remove(at);
return;
}
let name = session::file_name(&path).into_owned();
match self.take_in(path, tables) {
Ok(()) => journal::say(format!("compared {name}")),
Err(reason) => journal::warn(format!("{name} not compared: {reason}")),
}
}
fn remove(&mut self, at: usize) {
let doc = self.docs.remove(at);
if self.baseline.as_ref() == Some(&doc.path) {
self.baseline = None;
}
journal::say(format!(
"{} no longer compared",
session::file_name(&doc.path)
));
}
pub fn take_in(&mut self, path: PathBuf, tables: &TaxTables) -> Result<(), String> {
let doc = ComparedDoc::read(path, tables)?;
match self.docs.iter().position(|held| held.path == doc.path) {
Some(at) => self.docs[at] = doc,
None => self.docs.push(doc),
}
Ok(())
}
fn baseline_at(&self) -> usize {
let path = self.baseline.as_ref();
let at = path.and_then(|path| self.docs.iter().position(|doc| &doc.path == path));
at.map_or(0, |at| at + 1)
}
}
fn spawn_compare(bodies: Query<Entity, With<Body>>, mut commands: Commands) {
let Ok(body) = bodies.single() else {
return;
};
let view = nav::spawn_surface(&mut commands, body, Some(Page::Compare));
commands.entity(view).insert(METRIC_HINTS);
plans::spawn(&mut commands, view);
views::spawn(&mut commands, view);
tools::spawn_help(&mut commands, view, Page::Compare);
}
#[derive(SystemParam)]
pub(crate) struct Plans<'w> {
shown: Shown<'w>,
session: Res<'w, Session>,
compared: Res<'w, Compared>,
charted: Res<'w, Charted>,
successes: Res<'w, Successes>,
theme: Res<'w, Theme>,
}
impl Plans<'_> {
fn is_plan_changed(&self) -> bool {
self.shown.projected.is_changed()
|| self.session.is_changed()
|| self.compared.is_changed()
|| self.theme.is_changed()
}
fn is_changed(&self) -> bool {
self.is_plan_changed() || self.shown.is_changed() || self.charted.is_changed()
}
fn year(&self) -> i16 {
let spans = self
.each()
.map(|(_, projection)| session::span(&projection.years));
let shows = spans.fold((i16::MAX, i16::MIN), |(first, last), (from, to)| {
(first.min(from), last.max(to))
});
self.shown.year_among(shows)
}
fn each(&self) -> impl Iterator<Item = (Cow<'_, str>, &Projection)> {
let document = (self.session.file_name(), &self.shown.projected.projection);
let compared = self.compared.docs.iter();
std::iter::once(document)
.chain(compared.map(|doc| (session::file_name(&doc.path), &doc.projected.projection)))
}
fn each_plan(&self) -> impl Iterator<Item = &Plan> {
let document = std::iter::once(&self.shown.projected.plan);
document.chain(self.compared.plans())
}
fn against(&self) -> Option<(usize, Cow<'_, str>, &Projection)> {
if !self.compared.is_difference {
return None;
}
let at = self.compared.baseline_at();
let (name, projection) = self.each().nth(at)?;
Some((at, name, projection))
}
fn amounts(&self, metric: Metric) -> Vec<BTreeMap<i16, Dollars>> {
let nominal = self.shown.basis.nominal;
let own: Vec<BTreeMap<i16, Dollars>> = (self.each())
.map(|(_, projection)| {
let rows = projection.years.iter();
rows.map(|row| {
let amount = basis_amount(metric.value(row), row.deflator, nominal);
(row.year, amount)
})
.collect()
})
.collect();
let Some((at, ..)) = self.against() else {
return own;
};
let base = &own[at];
let less_base = |amounts: &BTreeMap<i16, Dollars>| {
let each = amounts.iter();
each.filter_map(|(year, amount)| Some((*year, amount - base.get(year)?)))
.collect()
};
(own.iter().enumerate())
.map(|(place, amounts)| {
if place == at {
amounts.clone()
} else {
less_base(amounts)
}
})
.collect()
}
fn chart(&self, metric: Metric) -> SeriesChart {
let baseline = self.against().map(|(at, ..)| at);
let series = (self.each().zip(self.amounts(metric)).enumerate())
.map(|(place, ((label, _), amounts))| {
let is_baseline = baseline == Some(place);
let points = (amounts.into_iter())
.map(|(year, amount)| {
let drawn = if is_baseline { 0.0 } else { amount as f64 };
(f64::from(year), drawn)
})
.collect();
let color = self.theme.series(place);
Series {
label: label.into_owned(),
color,
points,
}
})
.collect();
let mut chart = SeriesChart::of(series, self.theme.dimmed());
chart.is_legend_hidden = true;
chart.marks = vec![Mark::cursor(self.year(), &self.theme)];
chart
}
fn figures_in(&self, amounts: &[BTreeMap<i16, Dollars>], year: i16) -> Vec<String> {
let baseline = self.against().map(|(at, ..)| at);
(amounts.iter().enumerate())
.map(|(place, amounts)| {
let amount = amounts.get(&year).copied();
let is_difference = baseline.is_some_and(|at| at != place);
let figure = if is_difference {
amount.map(signed_money)
} else {
amount.map(compact_money)
};
figure.unwrap_or_else(|| UNREACHED.to_owned())
})
.collect()
}
}
fn show_help(theme: Res<Theme>, mut lines: Query<(&mut UiWidget, &HelpLine)>) {
if theme.is_changed() {
tools::show_help(&mut lines, Page::Compare, HELP, &theme);
}
}
pub(crate) fn remove_highlighted(
cursor: plans::Cursor,
session: Res<Session>,
mut compared: ResMut<Compared>,
) -> Outcome {
match cursor.place().checked_sub(1) {
Some(at) if at < compared.docs.len() => {
compared.remove(at);
Outcome::Done
}
_ => Outcome::Refused(format!(
"{} is the document, not a compared plan",
session.file_name()
)),
}
}
pub(crate) fn choose_baseline(cursor: plans::Cursor, mut compared: ResMut<Compared>) -> Outcome {
let at = cursor.place().checked_sub(1);
let doc = at.and_then(|at| compared.docs.get(at));
compared.baseline = doc.map(|doc| doc.path.clone());
Outcome::Done
}
pub(crate) fn toggle_difference(mut compared: ResMut<Compared>) -> Outcome {
compared.is_difference = !compared.is_difference;
Outcome::Done
}