use retiretui_engine::plan::{Allocation, IncomeKind, Plan, TreatmentClass};
use retiretui_engine::project::{Timeline, YearRow};
use retiretui_engine::tax::{MEDICARE_AGE, rmd_start_age};
use super::rows::Entry;
use crate::commands::table::basis_amount;
use crate::commands::tui::nav::Page;
use crate::commands::tui::present::{
account_name, compact_money, event_name, income_name, mix, residence,
};
use crate::commands::tui::session::{Projected, span};
pub(super) fn entries(projected: &Projected, nominal: bool) -> Vec<Entry> {
let plan = &projected.plan;
let years = &projected.projection.years;
let timeline = Timeline::new(plan);
let mut found: Vec<Entry> = [
events(plan, &timeline),
claims(plan, &timeline, years, nominal),
incomes_starting(plan, &timeline),
medicare(plan),
rmds(plan),
contributions_ending(plan, &timeline),
moves(plan, &timeline),
glide_steps(plan, &timeline),
]
.into_iter()
.flatten()
.collect();
let (first, last) = span(years);
found.retain(|entry| {
entry
.year
.is_some_and(|year| (first..=last).contains(&year))
});
found.sort_by_key(|entry| entry.year);
found
}
fn events(plan: &Plan, timeline: &Timeline) -> Vec<Entry> {
let each = plan.events.iter().enumerate();
each.filter_map(|(at, event)| {
let year = (*timeline.events.get(&event.id)?)?;
let name = event_name(plan, &event.id).to_owned();
Some(Entry::dated(year, name, (Page::Events, Some(at))))
})
.collect()
}
fn claims(plan: &Plan, timeline: &Timeline, years: &[YearRow], nominal: bool) -> Vec<Entry> {
let each = plan.income.iter().enumerate();
each.filter(|(_, income)| income.kind == IncomeKind::SocialSecurity)
.filter_map(|(at, income)| {
let year = timeline.income.get(&income.id)?.first()?;
let full = years.iter().find(|row| row.year == year + 1);
let paid = full.and_then(|row| {
let amount = *row.income.get(&income.id)?;
Some(basis_amount(amount, row.deflator, nominal))
});
let mut text = format!("Social Security · {}", plan.person_name(&income.owner));
if let Some(paid) = paid {
text = format!("{text}, {} a year", compact_money(paid));
}
Some(Entry::dated(year, text, (Page::Income, Some(at))))
})
.collect()
}
fn incomes_starting(plan: &Plan, timeline: &Timeline) -> Vec<Entry> {
let each = plan.income.iter().enumerate();
each.filter(|(_, income)| {
!matches!(income.kind, IncomeKind::Salary | IncomeKind::SocialSecurity)
})
.filter_map(|(at, income)| {
let window = timeline.income.get(&income.id)?;
let name = income_name(plan, &income.id);
let (year, text) = match (window.start, window.on) {
(Some(year), _) => (year, format!("{name} starts")),
(None, Some(year)) => (year, format!("{name} arrives")),
(None, None) => return None,
};
Some(Entry::dated(year, text, (Page::Income, Some(at))))
})
.collect()
}
fn medicare(plan: &Plan) -> Vec<Entry> {
let each = plan.household.people.iter().enumerate();
each.map(|(at, person)| {
let year = person.birth.year() + i16::from(MEDICARE_AGE);
let text = format!("Medicare · {}", person.display_name());
Entry::dated(year, text, (Page::People, Some(at)))
})
.collect()
}
fn rmds(plan: &Plan) -> Vec<Entry> {
let each = plan.household.people.iter().enumerate();
each.filter(|(_, person)| {
(plan.accounts.iter()).any(|account| {
account.owner == person.id && account.treatment() == TreatmentClass::Deferred
})
})
.map(|(at, person)| {
let birth = person.birth.year();
let year = birth + i16::from(rmd_start_age(birth));
let text = format!("RMDs start · {}", person.display_name());
Entry::dated(year, text, (Page::People, Some(at)))
})
.collect()
}
fn contributions_ending(plan: &Plan, timeline: &Timeline) -> Vec<Entry> {
let each = plan.contributions.iter().enumerate();
each.filter_map(|(at, contribution)| {
let year = timeline.contributions.get(&contribution.id)?.end?;
let name = contribution.name.as_deref().unwrap_or(&contribution.id);
let text = format!("{name} ends");
Some(Entry::dated(year, text, (Page::Contributions, Some(at))))
})
.collect()
}
fn moves(plan: &Plan, timeline: &Timeline) -> Vec<Entry> {
let each = plan.residency.iter().zip(&timeline.residency).enumerate();
each.filter_map(|(at, (residency, year))| {
let text = format!("moves to {}", residence(residency));
Some(Entry::dated((*year)?, text, (Page::Residency, Some(at))))
})
.collect()
}
fn glide_steps(plan: &Plan, timeline: &Timeline) -> Vec<Entry> {
let mut found = Vec::new();
for (at, account) in plan.accounts.iter().enumerate() {
let Some(years) = timeline.glide_steps.get(&account.id) else {
continue;
};
let Some(Allocation::GlidePath(steps)) = &account.allocation else {
continue;
};
let name = account_name(plan, &account.id);
for (step, year) in steps.iter().zip(years).skip(1) {
let Some(year) = *year else {
continue;
};
let text = format!(
"{name} glide step: {}",
mix(step.stocks, step.bonds, step.cash)
);
found.push(Entry::dated(year, text, (Page::Accounts, Some(at))));
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::tui::support::{projected_from, test_projected};
const MILESTONES_PLAN: &str = r#"
schema = 1
[plan]
name = "milestones"
start_year = 2026
horizon_age = 90
inflation = 0.025
[household]
filing = "single"
[[household.people]]
id = "me"
name = "Sam"
birth = 1970-06-15
[[residency]]
country = "us"
state = "or"
[[residency]]
country = "us"
state = "wa"
from = { event = "retire" }
[[events]]
id = "retire"
name = "Sam retires"
trigger = { age = 62, owner = "me" }
[[accounts]]
id = "cash"
kind = "cash"
owner = "me"
balance = 10000
[[accounts]]
id = "k"
name = "401(k)"
kind = "401k"
owner = "me"
balance = 500000
allocation = [
{ from = { date = 2026-01-01 }, stocks = 0.9, bonds = 0.1 },
{ from = { age = 60, owner = "me" }, stocks = 0.6, bonds = 0.4 },
]
[[income]]
id = "salary"
kind = "salary"
owner = "me"
amount = 100000
end = { event = "retire", offset = -1 }
[[income]]
id = "pension"
name = "Pension"
kind = "pension"
owner = "me"
amount = 20000
start = { event = "retire" }
[[income]]
id = "ss"
kind = "social-security"
owner = "me"
start = { age = 67, owner = "me" }
[[contributions]]
id = "deferral"
name = "Deferral"
to = "k"
amount = 20000
end = { event = "retire" }
[[expenses]]
id = "living"
amount = 60000
"#;
fn lines(projected: &Projected, nominal: bool) -> Vec<String> {
let entries = entries(projected, nominal);
entries.iter().map(Entry::line).collect()
}
fn first_full_year(projected: &Projected, nominal: bool) -> String {
let row = (projected.projection.years.iter())
.find(|row| row.year == 2038)
.unwrap();
let paid = basis_amount(row.income["ss"], row.deflator, nominal);
format!("2037 Social Security · Sam, {} a year", compact_money(paid))
}
#[test]
fn every_kind_of_milestone_lands_in_its_year_in_order() {
let projected = projected_from(MILESTONES_PLAN);
assert_eq!(projected.plan.validate(), []);
let expected = [
"2030 401(k) glide step: 60/40".to_owned(),
"2032 Sam retires".to_owned(),
"2032 Pension starts".to_owned(),
"2032 Deferral ends".to_owned(),
"2032 moves to Washington".to_owned(),
"2035 Medicare · Sam".to_owned(),
first_full_year(&projected, false),
"2045 RMDs start · Sam".to_owned(),
];
assert_eq!(lines(&projected, false), expected);
let nominal = lines(&projected, true);
assert_eq!(nominal[6], first_full_year(&projected, true));
assert_ne!(nominal[6], expected[6], "the benefit follows the basis");
}
#[test]
fn each_milestone_opens_the_item_it_comes_from() {
let projected = projected_from(MILESTONES_PLAN);
let targets: Vec<_> = (entries(&projected, false).iter())
.map(|entry| entry.opens.unwrap())
.collect();
let expected = [
(Page::Accounts, Some(1)),
(Page::Events, Some(0)),
(Page::Income, Some(1)),
(Page::Contributions, Some(0)),
(Page::Residency, Some(1)),
(Page::People, Some(0)),
(Page::Income, Some(2)),
(Page::People, Some(0)),
];
assert_eq!(targets, expected);
}
#[test]
fn a_year_past_the_projection_is_left_out() {
let lines = lines(&test_projected(), false);
assert_eq!(lines, ["2045 Medicare · me"], "RMDs would start in 2055");
}
}