Fill handle: Fill Days, Fill Weekdays, Fill Months and Fill Years for a date

When a fill starts from a date, the right-drag fill menu and the Auto
Fill Options button now also offer Fill Days, Fill Weekdays, Fill Months
and Fill Years. edit.autoFill takes them as the modes days, weekdays,
months and years.

A lone date steps by one unit. Two or more dates step by the difference
between the first two in that unit, or by one unit when both fall in
the same one. Fill Weekdays skips Saturday and Sunday, and a start on a
weekend moves to the next weekday first. Fill Months and Fill Years keep
the day of the month, clamped to the month's last day. A source that is
not all dates fills as a plain drag does.

Fills by months or years now keep the time of day of a date with a
time. A plain drag of dates a month apart and Series by Month or Year
used to drop it.
This commit is contained in:
dotCooCoo
2026-10-24 06:56:26 -07:00
parent 3099df934e
commit 00edfd0690
8 changed files with 309 additions and 14 deletions
+5 -1
View File
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoFill",
[],
None,
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
has_doc,
auto_fill
),
@@ -1462,6 +1462,10 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
"formats" => crate::fill::FillMode::Formats,
"values" => crate::fill::FillMode::ValuesOnly,
"extend" => crate::fill::FillMode::Extend,
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
_ => crate::fill::FillMode::Series,
};
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
+145 -6
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
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};
use gridcraft_model::{Cell, Formula, Workbook};
use gridcraft_numfmt::{FormatKind, NumberFormat};
use crate::Result;
@@ -23,15 +23,43 @@ pub enum FillMode {
ValuesOnly,
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
Extend,
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
/// A lone date steps by one unit; two or more step by the difference between the first two,
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
/// extends like a plain drag.
Dates(DateUnit),
}
/// The step of a [`FillMode::Dates`] fill.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateUnit {
Day,
/// Monday to Friday: Saturday and Sunday are skipped.
Weekday,
Month,
Year,
}
impl FillMode {
/// The modes that continue the source's series rather than copy it.
fn extends_series(self) -> bool {
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
}
}
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
/// and `h:mm` don't).
fn is_date_format(fmt: &str) -> bool {
NumberFormat::parse(fmt).kind() == FormatKind::Date
}
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
/// months or years: a number (not a formula) shown in a date format.
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
}
/// Built-in custom lists.
fn lists() -> Vec<Vec<String>> {
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
@@ -102,7 +130,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
};
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
let dates = match mode {
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
_ => None,
};
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
match (mode, detected) {
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
(_, p) => p,
@@ -130,7 +163,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
writes.push((*t, Some(existing)));
continue;
}
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
if let Some(f) = &cell.formula {
if let Some(e) = f.expr() {
@@ -204,9 +237,63 @@ enum Pattern {
months: i64,
sys: gridcraft_core::DateSystem,
},
/// Dates stepping by weekdays, skipping Saturday and Sunday.
Weekdays {
last: f64,
first: f64,
step: i64,
sys: gridcraft_core::DateSystem,
},
}
impl Pattern {
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
if !is_date_format(fmt) {
return None;
}
let mut nums = Vec::with_capacity(cells.len());
for (_, cell) in cells {
let cell = cell.as_ref()?;
if cell.formula.is_some() || !cell.value.is_number() {
return None;
}
nums.push(cell.value.as_f64()?);
}
let first = *nums.first()?;
let last = *nums.last()?;
let second = nums.get(1).copied();
// The difference between the first two dates in the unit, and one unit when they fall in
// the same one.
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
Some(match unit {
DateUnit::Day => {
let days = second.map_or(1.0, |b| b.floor() - first.floor());
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
}
DateUnit::Weekday => {
let step = match second {
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
None => 1,
};
Pattern::Weekdays { last, first, step, sys }
}
DateUnit::Month | DateUnit::Year => {
let months = match second {
Some(b) => {
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
let years = b.year as i64 - a.year as i64;
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
}
None if unit == DateUnit::Year => 12,
None => 1,
};
Pattern::Months { last, first, months, sys }
}
})
}
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
@@ -343,20 +430,72 @@ impl Pattern {
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
Some(Value::number(s))
}
Pattern::Weekdays { last, first, step, sys } => {
let base = if forward { *last } else { *first };
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
}
}
}
}
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
}
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
if n == 0 {
return Some(serial);
}
let dir = n.signum() as f64;
let mut d = serial;
let next_weekday = |d: &mut f64| -> Option<()> {
loop {
*d += dir;
if is_weekday(sys, *d)? {
return Some(());
}
}
};
next_weekday(&mut d)?;
// From a weekday, every seven days hold five weekdays.
let rest = n.unsigned_abs() - 1;
d += dir * (rest / 5 * 7) as f64;
for _ in 0..rest % 5 {
next_weekday(&mut d)?;
}
datetime_from_serial(sys, d)?;
Some(d)
}
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
/// counting whole days; `None` outside the supported date range.
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
let days = (hi - lo) as i64;
let mut count = days / 7 * 5;
let mut d = lo + (days / 7 * 7) as f64;
for _ in 0..days % 7 {
d += 1.0;
if is_weekday(sys, d)? {
count += 1;
}
}
Some(count * sign)
}
/// `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.
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
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))
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
}
fn round15(x: f64) -> f64 {
+80
View File
@@ -941,6 +941,86 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
}
#[test]
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
let mut s = s();
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
// Friday 1/30/2026 is serial 46052.
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
// From Saturday 1/31, the first weekday is Monday 2/2.
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
// A number that isn't a date fills as a plain drag does: a lone number copies.
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
assert_eq!(v(&s, "F2"), Value::Number(7.0));
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
// a month apart, and the Series dialog by months.
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
}
#[test]
fn only_a_number_in_a_date_format_offers_the_date_fills() {
let mut s = s();
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
assert!(!dates(&s, "A4"), "a formula is not offered");
assert!(!dates(&s, "A5"), "a time is not a date");
assert!(!dates(&s, "A6"), "an empty cell is not a date");
}
#[test]
fn insert_delete_cells_adjust() {
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();