use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::category::names_any;
use super::{Category, Family};
use crate::plan::{EstimateSettings, Expense, Lean, Plan};
const BUCKET_WORDS: [&str; 3] = ["misc", "other", "general"];
pub(super) const NEW_BUCKET_ID: &str = "other";
pub(super) const NEW_BUCKET_NAME: &str = "Other";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Target {
Expense(usize),
New(Category),
NewBucket,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum Why {
Assigned,
Named,
Family,
Bucket,
Large,
Always,
}
pub(super) struct Placer<'a> {
settings: &'a EstimateSettings,
named: BTreeMap<Category, usize>,
kin: BTreeMap<Family, usize>,
assigned: BTreeMap<Category, usize>,
bucket: Target,
spending: f64,
}
static UNSTATED: EstimateSettings = EstimateSettings {
lean: Lean::Large,
large_share: None,
bucket: None,
assign: Vec::new(),
};
impl<'a> Placer<'a> {
pub(super) fn new(plan: &'a Plan, spending: f64) -> Self {
let settings = plan.estimate.as_ref().unwrap_or(&UNSTATED);
let mut named = BTreeMap::new();
let mut kin = BTreeMap::new();
for (at, expense) in plan.expenses.iter().enumerate() {
for category in Category::ALL.into_iter().filter(|c| c.matches(expense)) {
named.entry(category).or_insert(at);
if let Some(family) = category.family() {
kin.entry(family).or_insert(at);
}
}
}
let assigned = settings
.assign
.iter()
.filter_map(|it| Some((it.category, position(plan, |e| e.id == it.expense)?)))
.collect();
Self {
settings,
named,
kin,
assigned,
bucket: bucket_position(plan).map_or(Target::NewBucket, Target::Expense),
spending,
}
}
pub(super) const fn bucket(&self) -> Target {
self.bucket
}
pub(super) fn place(&self, category: Category, spent: f64) -> (Target, Why) {
if let Some(&at) = self.assigned.get(&category) {
return (Target::Expense(at), Why::Assigned);
}
if let Some(&at) = self.named.get(&category) {
return (Target::Expense(at), Why::Named);
}
let is_large = spent >= self.settings.large_share() * self.spending;
match self.settings.lean {
Lean::New => return (Target::New(category), Why::Always),
Lean::Large if is_large => return (Target::New(category), Why::Large),
Lean::Large | Lean::Existing => {}
}
let kin = category.family().and_then(|family| self.kin.get(&family));
kin.map_or((self.bucket, Why::Bucket), |&at| {
(Target::Expense(at), Why::Family)
})
}
}
pub(super) fn bucket_position(plan: &Plan) -> Option<usize> {
let named = plan.estimate.as_ref().and_then(|it| it.bucket.as_deref());
named
.and_then(|id| position(plan, |e| e.id == id))
.or_else(|| position(plan, |e| names_any(e, &BUCKET_WORDS)))
}
fn position(plan: &Plan, is: impl Fn(&Expense) -> bool) -> Option<usize> {
plan.expenses.iter().position(is)
}