From 88a1495fbfb3308d02304743f0246b9061f90700 Mon Sep 17 00:00:00 2001 From: Ryan MacArthur Date: Tue, 29 Sep 2026 17:02:57 -0300 Subject: [PATCH] fix: anchor budget weeks to Monday like the rest of the budget calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly budget layout counted ISO weeks while every other part of the budget system uses Monday-anchored week numbering (week 1 starts on the Monday on or before January 1). The two calendars diverge whenever Jan 1 falls on a Friday, Saturday, or Sunday — first real break in 2027, where ISO reports 52 weeks and Dec 31 lands in week 53, so the final week of the year got no weekly budget and per-month amounts split by the wrong week count. Align the cache with models::budget_week_* and add tests for E:/dev/beira/sessions/dev/TwoCents/2021/2027 (both start on a Friday): 53 weeks, Dec 31 in week 53, December spans five weeks. Also ignore Beira session artifacts (session.json, metrics.jsonl). --- .gitignore | 4 ++ CHANGELOG.md | 3 ++ src/main.rs | 132 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 896beb2..ff7fa35 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ yarn-error.log* .env.* .vscode/ .idea/ + +# Beira session artifacts (live transcript + usage metrics) +session.json +metrics.jsonl diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd9fd3..f13d328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to TwoCents are documented here. ## [Unreleased] +### Fixed +- Budget week layout now uses the same Monday-anchored calendar as the rest of the budget system. It previously counted ISO weeks, which diverge from the app's week numbering whenever January 1 falls on a Friday, Saturday, or Sunday (first affected year: 2027), leaving the final week of the year without a weekly budget and splitting per-week amounts by the wrong month's week count. + ## [v0.1.3] - 2026-09-28 ### Added diff --git a/src/main.rs b/src/main.rs index 232bf0b..b30c1d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,14 +37,18 @@ use crate::models::*; use crate::ui::theme; use crate::ui::widgets::*; -/// Cached week counts for the current budget year +/// Cached week layout for the budget year. Week numbers follow the same +/// Monday-anchored calendar as `models::budget_week_*`: week 1 starts on +/// the Monday on or before January 1. A week belongs to the month of its +/// last in-year day, so the per-month and per-quarter week counts always +/// sum to `total_weeks`. #[derive(Clone)] struct WeekCountCache { year: i32, weeks_in_month: [i32; 13], // index 1-12 weeks_in_quarter: [i32; 5], // index 1-4 total_weeks: i32, - week_to_month: [i32; 54], // week number → month (1-12) + week_to_month: [i32; 54], // week number -> month (1-12) } impl WeekCountCache { @@ -53,32 +57,15 @@ impl WeekCountCache { let mut weeks_in_quarter = [0i32; 5]; let mut week_to_month = [0i32; 54]; - for month in 1..=12 { - let first_day = chrono::NaiveDate::from_ymd_opt(year, month as u32, 1).unwrap(); - let last_day = if month == 12 { - chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() - chrono::Duration::days(1) - } else { - chrono::NaiveDate::from_ymd_opt(year, (month + 1) as u32, 1).unwrap() - - chrono::Duration::days(1) - }; - - let mut weeks = std::collections::HashSet::new(); - let mut current = first_day; - while current <= last_day { - let week_num = current.iso_week().week() as i32; - weeks.insert(week_num); - week_to_month[week_num as usize] = month as i32; - current += chrono::Duration::days(1); - } - - weeks_in_month[month as usize] = weeks.len() as i32; - let quarter = (month - 1) / 3 + 1; - weeks_in_quarter[quarter as usize] += weeks.len() as i32; + let total_weeks = crate::models::budget_week_count(year); + for week in 1..=total_weeks { + let (_, week_end) = crate::models::budget_week_range(year, week); + let month = week_end.month() as i32; + week_to_month[week as usize] = month; + weeks_in_month[month as usize] += 1; + weeks_in_quarter[((month - 1) / 3 + 1) as usize] += 1; } - let dec31 = chrono::NaiveDate::from_ymd_opt(year, 12, 31).unwrap(); - let total_weeks = dec31.iso_week().week() as i32; - WeekCountCache { year, weeks_in_month, @@ -1923,4 +1910,97 @@ mod budget_tests { assert_eq!(legacy_cap(2026), 30_000); assert_eq!(legacy_cap(2008), 0); } + + fn week_cache(year: i32) -> WeekCountCache { + WeekCountCache::build(year) + } + + #[test] + fn week_cache_agrees_with_budget_week_calendar() { + // The cache feeds the weekly snapshots and the propagation math, so it + // must use the same Monday-anchored week numbering as models.rs — + // never ISO week numbers, which diverge whenever Jan 1 is Fri/Sat/Sun. + for year in 2024..=2035 { + let cache = week_cache(year); + assert_eq!( + cache.total_weeks, + budget_week_count(year), + "total_weeks disagrees with budget_week_count for {year}" + ); + + let mut month_sum = 0; + for month in 1..=12 { + month_sum += cache.weeks_in_month[month as usize]; + } + let mut quarter_sum = 0; + for q in 1..=4 { + quarter_sum += cache.weeks_in_quarter[q as usize]; + } + assert_eq!( + month_sum, cache.total_weeks, + "month weeks != total ({year})" + ); + assert_eq!( + quarter_sum, cache.total_weeks, + "quarter weeks != total ({year})" + ); + + let mut previous_month = 0; + for week in 1..=cache.total_weeks { + let month = cache.week_to_month[week as usize]; + assert!( + (1..=12).contains(&month), + "week {week} of {year} mapped to month {month}" + ); + assert!( + month >= previous_month, + "week_to_month went backwards at week {week} of {year}" + ); + previous_month = month; + + // Each numbered week must start on a Monday and its month must + // be the month of its last in-year day. + let start = budget_week_start(year, week); + assert_eq!( + start.weekday().num_days_from_monday(), + 0, + "week {week} of {year} does not start on a Monday" + ); + let end = budget_period_date_range(BudgetGranularity::Weekly, year, week).1; + assert_eq!( + month, + end.month() as i32, + "week {week} of {year} assigned to the wrong month" + ); + } + } + } + + #[test] + fn week_cache_covers_iso_divergent_years() { + // Jan 1 falls on a Friday in 2021 and 2027, so the Monday-anchored + // calendar has 53 weeks while ISO year-week numbering reports 52. + // The old ISO-based cache dropped the final week (which holds Dec 31) + // and shifted every week-to-month mapping by one. + for year in [2021, 2027] { + let dec31 = chrono::NaiveDate::from_ymd_opt(year, 12, 31).unwrap(); + assert_eq!(dec31.weekday().num_days_from_monday(), 4, "not a Friday"); + + let cache = week_cache(year); + assert_eq!(cache.total_weeks, 53, "{year} should have 53 weeks"); + assert_eq!( + cache.week_to_month[53], 12, + "the last week of {year} must belong to December" + ); + assert_eq!( + budget_period_for_date(BudgetGranularity::Weekly, year, dec31), + Some(53), + "Dec 31 of {year} must land in week 53" + ); + assert_eq!( + cache.weeks_in_month[12], 5, + "December {year} spans five Monday-anchored weeks" + ); + } + } }