From ded67f51d261695e0f9d30f561dc3df3a978f8a5 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:28:07 +0200 Subject: [PATCH] fix(fill): clamp month and year date series to the month's last day Fill Series by month used the previous cell's day as an overflowing DATE() day, so Jan 31 + 1 month became Mar 3 and February was skipped. The same overflow affected year steps from Feb 29 and the autofill month pattern. Each item is now computed from the start date plus n months and the day is clamped to the target month's length (Jan 31, Feb 28, Mar 31, Apr 30), so the clamp does not compound. Leap years are handled. Fixes #132 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/fill.rs | 22 ++++++++++++----- crates/engine/src/tests.rs | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/fill.rs b/crates/engine/src/fill.rs index cf9e08f..20d9959 100644 --- a/crates/engine/src/fill.rs +++ b/crates/engine/src/fill.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, serial_from_ymd}; +use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd}; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula}; @@ -258,15 +258,25 @@ impl Pattern { } Pattern::Months { last, first, months } => { let base = if forward { *last } else { *first }; - let d = datetime_from_serial(gridcraft_core::DateSystem::D1900, base)?; - let m = d.month as i64 + months * k * sign; - let s = serial_from_ymd(gridcraft_core::DateSystem::D1900, d.year as i64, m, d.day as i64)?; + let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?; Some(Value::number(s)) } } } } +/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the +/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date, +/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. +fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option { + let d = datetime_from_serial(sys, serial)?; + let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?; + let year = total.div_euclid(12); + let month = total.rem_euclid(12) + 1; + let last = days_in_month(i32::try_from(year).ok()?, month as u32); + serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)) +} + fn round15(x: f64) -> f64 { if x == 0.0 || !x.is_finite() { return x; @@ -310,8 +320,8 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s "date" => { let d = datetime_from_serial(sys, v); match (unit, d) { - ("month", Some(d)) => serial_from_ymd(sys, d.year as i64, d.month as i64 + step as i64, d.day as i64).unwrap_or(v), - ("year", Some(d)) => serial_from_ymd(sys, d.year as i64 + step as i64, d.month as i64, d.day as i64).unwrap_or(v), + ("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), + ("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v), ("weekday", Some(_)) => { let mut n = v; let mut left = step.abs() as i64; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..a283a13 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -190,3 +190,53 @@ fn ink_strokes_and_ink_to_shape() { assert_eq!(s.execute("draw.inkToShape", json!({})).unwrap()["kind"], "Line"); assert!(s.execute("draw.stroke", json!({"points": [[1.0, 1.0]]})).is_err()); } + +fn ymd(y: i64, m: i64, d: i64) -> Value { + Value::Number(gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, y, m, d).unwrap()) +} + +fn series_date(seed: &str, range: &str, unit: &str) -> Session { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": seed})).unwrap(); + s.execute("edit.fillSeries", json!({"range": range, "direction": "columns", "type": "date", "dateUnit": unit})).unwrap(); + s +} + +#[test] +fn fill_series_month_clamps_to_month_end() { + let s = series_date("2026-01-31", "A1:A4", "month"); + assert_eq!(v(&s, "A1"), ymd(2026, 1, 31)); + assert_eq!(v(&s, "A2"), ymd(2026, 2, 28)); + assert_eq!(v(&s, "A3"), ymd(2026, 3, 31)); + assert_eq!(v(&s, "A4"), ymd(2026, 4, 30)); + // Mid-month seeds are untouched. + let s = series_date("2026-01-15", "A1:A4", "month"); + assert_eq!(v(&s, "A4"), ymd(2026, 4, 15)); +} + +#[test] +fn fill_series_month_leap_year() { + let s = series_date("2028-01-31", "A1:A3", "month"); + assert_eq!(v(&s, "A2"), ymd(2028, 2, 29)); + assert_eq!(v(&s, "A3"), ymd(2028, 3, 31)); + let s = series_date("1996-01-31", "A1:A3", "month"); + assert_eq!(v(&s, "A2"), ymd(1996, 2, 29)); +} + +#[test] +fn fill_series_year_clamps_leap_day() { + let s = series_date("2028-02-29", "A1:A5", "year"); + assert_eq!(v(&s, "A2"), ymd(2029, 2, 28)); + assert_eq!(v(&s, "A5"), ymd(2032, 2, 29)); +} + +#[test] +fn autofill_month_pattern_clamps_to_month_end() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["2026-01-31"], ["2026-03-31"]]})).unwrap(); + s.execute("edit.autoFill", json!({"source": "A1:A2", "target": "A1:A6"})).unwrap(); + assert_eq!(v(&s, "A3"), ymd(2026, 5, 31)); + assert_eq!(v(&s, "A4"), ymd(2026, 7, 31)); + assert_eq!(v(&s, "A5"), ymd(2026, 9, 30)); + assert_eq!(v(&s, "A6"), ymd(2026, 11, 30)); +}