From ed593042f0ccdc1aeef98fa6ef3bad5c6e7aebff Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 28 Jul 2025 23:56:12 -0700 Subject: [PATCH 01/17] merge networkdays, networkdays.intl #33 --- .../src/expressions/parser/static_analysis.rs | 35 ++ base/src/functions/date_and_time.rs | 279 ++++++++++++++ base/src/functions/mod.rs | 12 +- base/src/test/mod.rs | 1 + base/src/test/test_networkdays.rs | 347 ++++++++++++++++++ docs/src/functions/date-and-time.md | 4 +- .../date_and_time/networkdays.intl.md | 72 +++- .../functions/date_and_time/networkdays.md | 50 ++- 8 files changed, 789 insertions(+), 11 deletions(-) create mode 100644 base/src/test/test_networkdays.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f19436..bbd011fe 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -575,6 +575,37 @@ fn args_signature_xnpv(arg_count: usize) -> Vec { } } +// NETWORKDAYS(start_date, end_date, [holidays]) +// Parameters: start_date (scalar), end_date (scalar), holidays (optional vector) +fn args_signature_networkdays(arg_count: usize) -> Vec { + if arg_count == 2 { + vec![Signature::Scalar, Signature::Scalar] + } else if arg_count == 3 { + vec![Signature::Scalar, Signature::Scalar, Signature::Vector] + } else { + vec![Signature::Error; arg_count] + } +} + +// NETWORKDAYS.INTL(start_date, end_date, [weekend], [holidays]) +// Parameters: start_date (scalar), end_date (scalar), weekend (optional scalar), holidays (optional vector) +fn args_signature_networkdays_intl(arg_count: usize) -> Vec { + if arg_count == 2 { + vec![Signature::Scalar, Signature::Scalar] + } else if arg_count == 3 { + vec![Signature::Scalar, Signature::Scalar, Signature::Scalar] + } else if arg_count == 4 { + vec![ + Signature::Scalar, + Signature::Scalar, + Signature::Scalar, + Signature::Vector, + ] + } else { + vec![Signature::Error; arg_count] + } +} + // FIXME: This is terrible duplications of efforts. We use the signature in at least three different places: // 1. When computing the function // 2. Checking the arguments to see if we need to insert the implicit intersection operator @@ -785,6 +816,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Unicode => args_signature_scalars(arg_count, 1, 0), Function::Geomean => vec![Signature::Vector; arg_count], + Function::Networkdays => args_signature_networkdays(arg_count), + Function::NetworkdaysIntl => args_signature_networkdays_intl(arg_count), } } @@ -990,5 +1023,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Eomonth => scalar_arguments(args), Function::Formulatext => not_implemented(args), Function::Geomean => not_implemented(args), + Function::Networkdays => not_implemented(args), + Function::NetworkdaysIntl => not_implemented(args), } } diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 8134b216..b7d25ad4 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -247,6 +247,285 @@ impl Model { CalcResult::Number(serial_number as f64) } + fn get_array_of_dates( + &mut self, + arg: &Node, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let mut values = Vec::new(); + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(v) => { + let date_serial = v.floor() as i64; + if from_excel_date(date_serial).is_err() { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }); + } + values.push(date_serial); + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + )); + } + for row in left.row..=right.row { + for column in left.column..=right.column { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(v) => { + let date_serial = v.floor() as i64; + if from_excel_date(date_serial).is_err() { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }); + } + values.push(date_serial); + } + CalcResult::EmptyCell => { + // Empty cells are ignored in holiday lists + } + e @ CalcResult::Error { .. } => return Err(e), + _ => { + // Non-numeric values in holiday lists should cause VALUE error + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + } + } + CalcResult::String(_) => { + // String holidays should cause VALUE error + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + e @ CalcResult::Error { .. } => return Err(e), + _ => { + // Other non-numeric types should cause VALUE error + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + Ok(values) + } + + pub(crate) fn fn_networkdays(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + let end_serial = match self.get_number(&args[1], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + let mut holidays: std::collections::HashSet = std::collections::HashSet::new(); + if args.len() == 3 { + let values = match self.get_array_of_dates(&args[2], cell) { + Ok(v) => v, + Err(e) => return e, + }; + for v in values { + holidays.insert(v); + } + } + + let (from, to, sign) = if start_serial <= end_serial { + (start_serial, end_serial, 1.0) + } else { + (end_serial, start_serial, -1.0) + }; + let mut count = 0i64; + for serial in from..=to { + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + let weekday = date.weekday().number_from_monday(); + let is_weekend = matches!(weekday, 6 | 7); + if !is_weekend && !holidays.contains(&serial) { + count += 1; + } + } + CalcResult::Number(count as f64 * sign) + } + + fn parse_weekend_pattern( + &mut self, + node: Option<&Node>, + cell: CellReferenceIndex, + ) -> Result<[bool; 7], CalcResult> { + // Default: Saturday-Sunday weekend (pattern 1) + let mut weekend = [false, false, false, false, false, true, true]; + if node.is_none() { + return Ok(weekend); + } + let node_ref = match node { + Some(n) => n, + None => return Ok(weekend), + }; + + match self.evaluate_node_in_context(node_ref, cell) { + CalcResult::Number(n) => { + let code = n.trunc() as i32; + if (n - n.trunc()).abs() > f64::EPSILON { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid weekend".to_string(), + )); + } + weekend = match code { + 1 | 0 => [false, false, false, false, false, true, true], // Saturday-Sunday + 2 => [true, false, false, false, false, false, true], // Sunday-Monday + 3 => [true, true, false, false, false, false, false], // Monday-Tuesday + 4 => [false, true, true, false, false, false, false], // Tuesday-Wednesday + 5 => [false, false, true, true, false, false, false], // Wednesday-Thursday + 6 => [false, false, false, true, true, false, false], // Thursday-Friday + 7 => [false, false, false, false, true, true, false], // Friday-Saturday + 11 => [false, false, false, false, false, false, true], // Sunday only + 12 => [true, false, false, false, false, false, false], // Monday only + 13 => [false, true, false, false, false, false, false], // Tuesday only + 14 => [false, false, true, false, false, false, false], // Wednesday only + 15 => [false, false, false, true, false, false, false], // Thursday only + 16 => [false, false, false, false, true, false, false], // Friday only + 17 => [false, false, false, false, false, true, false], // Saturday only + _ => { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid weekend".to_string(), + )) + } + }; + Ok(weekend) + } + CalcResult::String(s) => { + if s.len() != 7 { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )); + } + if !s.chars().all(|c| c == '0' || c == '1') { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )); + } + weekend = [false; 7]; + for (i, ch) in s.chars().enumerate() { + weekend[i] = ch == '1'; + } + Ok(weekend) + } + CalcResult::Boolean(_) => Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )), + e @ CalcResult::Error { .. } => Err(e), + CalcResult::Range { .. } => Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid weekend".to_string(), + }), + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(weekend), + CalcResult::Array(_) => Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid weekend".to_string(), + }), + } + } + + pub(crate) fn fn_networkdays_intl( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if !(2..=4).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + let end_serial = match self.get_number(&args[1], cell) { + Ok(n) => n.floor() as i64, + Err(e) => return e, + }; + + let weekend_pattern = match self.parse_weekend_pattern(args.get(2), cell) { + Ok(p) => p, + Err(e) => return e, + }; + + let mut holidays: std::collections::HashSet = std::collections::HashSet::new(); + if args.len() == 4 { + let values = match self.get_array_of_dates(&args[3], cell) { + Ok(v) => v, + Err(e) => return e, + }; + for v in values { + holidays.insert(v); + } + } + + let (from, to, sign) = if start_serial <= end_serial { + (start_serial, end_serial, 1.0) + } else { + (end_serial, start_serial, -1.0) + }; + let mut count = 0i64; + for serial in from..=to { + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + let weekday = date.weekday().number_from_monday() as usize - 1; + if !weekend_pattern[weekday] && !holidays.contains(&serial) { + count += 1; + } + } + CalcResult::Number(count as f64 * sign) + } + pub(crate) fn fn_today(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); if args_count != 0 { diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72d..f37ac107 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -155,6 +155,8 @@ pub enum Function { Now, Today, Year, + Networkdays, + NetworkdaysIntl, // Financial Cumipmt, @@ -253,7 +255,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -363,6 +365,8 @@ impl Function { Function::Eomonth, Function::Date, Function::Edate, + Function::Networkdays, + Function::NetworkdaysIntl, Function::Today, Function::Now, Function::Pmt, @@ -632,6 +636,8 @@ impl Function { "MONTH" => Some(Function::Month), "DATE" => Some(Function::Date), "EDATE" => Some(Function::Edate), + "NETWORKDAYS" => Some(Function::Networkdays), + "NETWORKDAYS.INTL" => Some(Function::NetworkdaysIntl), "TODAY" => Some(Function::Today), "NOW" => Some(Function::Now), // Financial @@ -842,6 +848,8 @@ impl fmt::Display for Function { Function::Eomonth => write!(f, "EOMONTH"), Function::Date => write!(f, "DATE"), Function::Edate => write!(f, "EDATE"), + Function::Networkdays => write!(f, "NETWORKDAYS"), + Function::NetworkdaysIntl => write!(f, "NETWORKDAYS.INTL"), Function::Today => write!(f, "TODAY"), Function::Now => write!(f, "NOW"), Function::Pmt => write!(f, "PMT"), @@ -1083,6 +1091,8 @@ impl Model { Function::Month => self.fn_month(args, cell), Function::Date => self.fn_date(args, cell), Function::Edate => self.fn_edate(args, cell), + Function::Networkdays => self.fn_networkdays(args, cell), + Function::NetworkdaysIntl => self.fn_networkdays_intl(args, cell), Function::Today => self.fn_today(args, cell), Function::Now => self.fn_now(args, cell), // Financial diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d..b1c2abba 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -64,6 +64,7 @@ mod test_issue_155; mod test_ln; mod test_log; mod test_log10; +mod test_networkdays; mod test_percentage; mod test_set_functions_error_handling; mod test_today; diff --git a/base/src/test/test_networkdays.rs b/base/src/test/test_networkdays.rs new file mode 100644 index 00000000..8e672d0c --- /dev/null +++ b/base/src/test/test_networkdays.rs @@ -0,0 +1,347 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +// Test data: Jan 1-10, 2023 week +const JAN_1_2023: i32 = 44927; // Sunday +const JAN_2_2023: i32 = 44928; // Monday +const JAN_6_2023: i32 = 44932; // Friday +const JAN_9_2023: i32 = 44935; // Monday +const JAN_10_2023: i32 = 44936; // Tuesday + +#[test] +fn networkdays_calculates_weekdays_excluding_weekends() { + let mut model = new_empty_model(); + + model._set("A1", &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Should count 7 weekdays in 10-day span" + ); +} + +#[test] +fn networkdays_handles_reverse_date_order() { + let mut model = new_empty_model(); + + model._set("A1", &format!("=NETWORKDAYS({JAN_10_2023},{JAN_1_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "-7", + "Reversed dates should return negative count" + ); +} + +#[test] +fn networkdays_excludes_holidays_from_weekdays() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},{JAN_9_2023})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "6", + "Should exclude Monday holiday from 7 weekdays" + ); +} + +#[test] +fn networkdays_handles_same_start_end_date() { + let mut model = new_empty_model(); + + model._set("A1", &format!("=NETWORKDAYS({JAN_9_2023},{JAN_9_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "1", + "Same weekday date should count as 1 workday" + ); +} + +#[test] +fn networkdays_accepts_holiday_ranges() { + let mut model = new_empty_model(); + + model._set("B1", &JAN_2_2023.to_string()); + model._set("B2", &JAN_6_2023.to_string()); + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1:B2)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "5", + "Should exclude 2 holidays from 7 weekdays" + ); +} + +#[test] +fn networkdays_intl_uses_standard_weekend_by_default() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Default should be Saturday-Sunday weekend" + ); +} + +#[test] +fn networkdays_intl_supports_numeric_weekend_patterns() { + let mut model = new_empty_model(); + + // Pattern 2 = Sunday-Monday weekend + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},2)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "6", + "Sunday-Monday weekend should give 6 workdays" + ); +} + +#[test] +fn networkdays_intl_supports_single_day_weekends() { + let mut model = new_empty_model(); + + // Pattern 11 = Sunday only weekend + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},11)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "8", + "Sunday-only weekend should give 8 workdays" + ); +} + +#[test] +fn networkdays_intl_supports_string_weekend_patterns() { + let mut model = new_empty_model(); + + // "0000110" = Friday-Saturday weekend + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000110\")"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "8", + "Friday-Saturday weekend should give 8 workdays" + ); +} + +#[test] +fn networkdays_intl_no_weekends_counts_all_days() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000000\")"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "10", + "No weekends should count all 10 days" + ); +} + +#[test] +fn networkdays_intl_combines_custom_weekends_with_holidays() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS.INTL({JAN_1_2023},{JAN_10_2023},\"0000110\",{JAN_9_2023})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Should exclude both weekend and holiday" + ); +} + +#[test] +fn networkdays_validates_argument_count() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS()"); + model._set("A2", "=NETWORKDAYS(1,2,3,4)"); + model._set("A3", "=NETWORKDAYS.INTL()"); + model._set("A4", "=NETWORKDAYS.INTL(1,2,3,4,5)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#ERROR!"); + assert_eq!(model._get_text("A2"), "#ERROR!"); + assert_eq!(model._get_text("A3"), "#ERROR!"); + assert_eq!(model._get_text("A4"), "#ERROR!"); +} + +#[test] +fn networkdays_rejects_invalid_dates() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS(-1,100)"); + model._set("A2", "=NETWORKDAYS(1,3000000)"); + model._set("A3", "=NETWORKDAYS(\"text\",100)"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#NUM!"); + assert_eq!(model._get_text("A3"), "#VALUE!"); +} + +#[test] +fn networkdays_intl_rejects_invalid_weekend_patterns() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS.INTL(1,10,99)"); + model._set("A2", "=NETWORKDAYS.INTL(1,10,\"111110\")"); + model._set("A3", "=NETWORKDAYS.INTL(1,10,\"11111000\")"); + model._set("A4", "=NETWORKDAYS.INTL(1,10,\"1111102\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), "#NUM!"); + assert_eq!(model._get_text("A2"), "#VALUE!"); + assert_eq!(model._get_text("A3"), "#VALUE!"); + assert_eq!(model._get_text("A4"), "#VALUE!"); +} + +#[test] +fn networkdays_rejects_invalid_holidays() { + let mut model = new_empty_model(); + + model._set("B1", "invalid"); + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1)"), + ); + model._set( + "A2", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},-1)"), + ); + + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "#VALUE!", + "Should reject non-numeric holidays" + ); + assert_eq!( + model._get_text("A2"), + "#NUM!", + "Should reject out-of-range holidays" + ); +} + +#[test] +fn networkdays_handles_weekend_only_periods() { + let mut model = new_empty_model(); + + let saturday = JAN_1_2023 - 1; + model._set("A1", &format!("=NETWORKDAYS({saturday},{JAN_1_2023})")); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "0", + "Weekend-only period should count 0 workdays" + ); +} + +#[test] +fn networkdays_ignores_holidays_outside_date_range() { + let mut model = new_empty_model(); + + let future_holiday = JAN_10_2023 + 100; + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},{future_holiday})"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Out-of-range holidays should be ignored" + ); +} + +#[test] +fn networkdays_handles_empty_holiday_ranges() { + let mut model = new_empty_model(); + + model._set( + "A1", + &format!("=NETWORKDAYS({JAN_1_2023},{JAN_10_2023},B1:B3)"), + ); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "7", + "Empty holiday range should be treated as no holidays" + ); +} + +#[test] +fn networkdays_handles_minimum_valid_dates() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS(1,7)"); + model.evaluate(); + + assert_eq!( + model._get_text("A1"), + "5", + "Should handle earliest Excel dates correctly" + ); +} + +#[test] +fn networkdays_handles_large_date_ranges_efficiently() { + let mut model = new_empty_model(); + + model._set("A1", "=NETWORKDAYS(1,365)"); + model.evaluate(); + + assert!( + !model._get_text("A1").starts_with('#'), + "Large ranges should not error" + ); +} diff --git a/docs/src/functions/date-and-time.md b/docs/src/functions/date-and-time.md index 2a479420..9f242d77 100644 --- a/docs/src/functions/date-and-time.md +++ b/docs/src/functions/date-and-time.md @@ -23,8 +23,8 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | ISOWEEKNUM | | – | | MINUTE | | – | | MONTH | | [MONTH](date_and_time/month) | -| NETWORKDAYS | | – | -| NETWORKDAYS.INTL | | – | +| NETWORKDAYS | | [NETWORKDAYS](date_and_time/networkdays) | +| NETWORKDAYS.INTL | | [NETWORKDAYS.INTL](date_and_time/networkdays.intl) | | NOW | | – | | SECOND | | – | | TIME | | – | diff --git a/docs/src/functions/date_and_time/networkdays.intl.md b/docs/src/functions/date_and_time/networkdays.intl.md index d3e3937b..893a3e67 100644 --- a/docs/src/functions/date_and_time/networkdays.intl.md +++ b/docs/src/functions/date_and_time/networkdays.intl.md @@ -4,9 +4,73 @@ outline: deep lang: en-US --- -# NETWORKDAYS.INTL +# NETWORKDAYS.INTL function ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +**Note:** This draft page is under construction 🚧 +::: + +## Overview +NETWORKDAYS.INTL is a function of the Date and Time category that calculates the number of working days between two dates, with customizable weekend definitions and optionally specified holidays. + +## Usage + +### Syntax +**NETWORKDAYS.INTL(start_date, end_date, [weekend], [holidays]) => workdays** + +### Argument descriptions +* *start_date* ([number](/features/value-types#numbers), required). The start date expressed as a [serial number](/features/serial-numbers.md). +* *end_date* ([number](/features/value-types#numbers), required). The end date expressed as a [serial number](/features/serial-numbers.md). +* *weekend* ([number](/features/value-types#numbers) or [string](/features/value-types#strings), optional). Defines which days are considered weekends. Default is 1 (Saturday-Sunday). +* *holidays* ([array](/features/value-types#arrays) or [range](/features/ranges), optional). A list of dates to exclude from the calculation, expressed as serial numbers. + +### Weekend parameter options +The _weekend_ parameter can be specified in two ways: + +**Numeric codes:** +- 1 (default): Saturday and Sunday +- 2: Sunday and Monday +- 3: Monday and Tuesday +- 4: Tuesday and Wednesday +- 5: Wednesday and Thursday +- 6: Thursday and Friday +- 7: Friday and Saturday +- 11: Sunday only +- 12: Monday only +- 13: Tuesday only +- 14: Wednesday only +- 15: Thursday only +- 16: Friday only +- 17: Saturday only + +**String pattern:** A 7-character string of "0" and "1" where "1" indicates a weekend day. The string represents Monday through Sunday. For example, "0000011" means Saturday and Sunday are weekends. + +### Additional guidance +- If the supplied _start_date_ and _end_date_ arguments have fractional parts, NETWORKDAYS.INTL uses their [floor values](https://en.wikipedia.org/wiki/Floor_and_ceiling_functions). +- If _start_date_ is later than _end_date_, the function returns a negative number. +- Empty cells in the _holidays_ array are ignored. +- The calculation includes both the start and end dates if they are workdays. + +### Returned value +NETWORKDAYS.INTL returns a [number](/features/value-types#numbers) representing the count of working days between the two dates. + +### Error conditions +* In common with many other IronCalc functions, NETWORKDAYS.INTL propagates errors that are found in its arguments. +* If fewer than 2 or more than 4 arguments are supplied, then NETWORKDAYS.INTL returns the [`#ERROR!`](/features/error-types.md#error) error. +* If the *start_date* or *end_date* arguments are not (or cannot be converted to) [numbers](/features/value-types#numbers), then NETWORKDAYS.INTL returns the [`#VALUE!`](/features/error-types.md#value) error. +* If the *start_date* or *end_date* values are outside the valid date range, then NETWORKDAYS.INTL returns the [`#NUM!`](/features/error-types.md#num) error. +* If the *weekend* parameter is an invalid numeric code or an improperly formatted string, then NETWORKDAYS.INTL returns the [`#NUM!`](/features/error-types.md#num) or [`#VALUE!`](/features/error-types.md#value) error. +* If the *holidays* array contains non-numeric values, then NETWORKDAYS.INTL returns the [`#VALUE!`](/features/error-types.md#value) error. + + + +## Details +IronCalc utilizes Rust's [chrono](https://docs.rs/chrono/latest/chrono/) crate to implement the NETWORKDAYS.INTL function. This function provides more flexibility than NETWORKDAYS by allowing custom weekend definitions. + +## Examples +[See some examples in IronCalc](https://app.ironcalc.com/?example=networkdays-intl). + +## Links +* See also IronCalc's [NETWORKDAYS](/functions/date_and_time/networkdays.md) function for the basic version with fixed weekends. +* Visit Microsoft Excel's [NETWORKDAYS.INTL function](https://support.microsoft.com/en-us/office/networkdays-intl-function-a9b26239-4f20-46a1-9ab8-4e925bfd5e28) page. +* Both [Google Sheets](https://support.google.com/docs/answer/3093019) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/NETWORKDAYS.INTL) provide versions of the NETWORKDAYS.INTL function. \ No newline at end of file diff --git a/docs/src/functions/date_and_time/networkdays.md b/docs/src/functions/date_and_time/networkdays.md index 9fd886f1..8a2c7602 100644 --- a/docs/src/functions/date_and_time/networkdays.md +++ b/docs/src/functions/date_and_time/networkdays.md @@ -4,9 +4,51 @@ outline: deep lang: en-US --- -# NETWORKDAYS +# NETWORKDAYS function ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) -::: \ No newline at end of file +**Note:** This draft page is under construction 🚧 +::: + +## Overview +NETWORKDAYS is a function of the Date and Time category that calculates the number of working days between two dates, excluding weekends (Saturday and Sunday by default) and optionally specified holidays. + +## Usage + +### Syntax +**NETWORKDAYS(start_date, end_date, [holidays]) => workdays** + +### Argument descriptions +* *start_date* ([number](/features/value-types#numbers), required). The start date expressed as a [serial number](/features/serial-numbers.md). +* *end_date* ([number](/features/value-types#numbers), required). The end date expressed as a [serial number](/features/serial-numbers.md). +* *holidays* ([array](/features/value-types#arrays) or [range](/features/ranges), optional). A list of dates to exclude from the calculation, expressed as serial numbers. + +### Additional guidance +- If the supplied _start_date_ and _end_date_ arguments have fractional parts, NETWORKDAYS uses their [floor values](https://en.wikipedia.org/wiki/Floor_and_ceiling_functions). +- If _start_date_ is later than _end_date_, the function returns a negative number. +- Weekend days are Saturday and Sunday by default. Use [NETWORKDAYS.INTL](networkdays.intl) for custom weekend definitions. +- Empty cells in the _holidays_ array are ignored. +- The calculation includes both the start and end dates if they are workdays. + +### Returned value +NETWORKDAYS returns a [number](/features/value-types#numbers) representing the count of working days between the two dates. + +### Error conditions +* In common with many other IronCalc functions, NETWORKDAYS propagates errors that are found in its arguments. +* If fewer than 2 or more than 3 arguments are supplied, then NETWORKDAYS returns the [`#ERROR!`](/features/error-types.md#error) error. +* If the *start_date* or *end_date* arguments are not (or cannot be converted to) [numbers](/features/value-types#numbers), then NETWORKDAYS returns the [`#VALUE!`](/features/error-types.md#value) error. +* If the *start_date* or *end_date* values are outside the valid date range, then NETWORKDAYS returns the [`#NUM!`](/features/error-types.md#num) error. +* If the *holidays* array contains non-numeric values, then NETWORKDAYS returns the [`#VALUE!`](/features/error-types.md#value) error. + + + +## Details +IronCalc utilizes Rust's [chrono](https://docs.rs/chrono/latest/chrono/) crate to implement the NETWORKDAYS function. The function treats Saturday and Sunday as weekend days. + +## Examples +[See some examples in IronCalc](https://app.ironcalc.com/?example=networkdays). + +## Links +* See also IronCalc's [NETWORKDAYS.INTL](/functions/date_and_time/networkdays.intl.md) function for custom weekend definitions. +* Visit Microsoft Excel's [NETWORKDAYS function](https://support.microsoft.com/en-us/office/networkdays-function-48e717bf-a7a3-495f-969e-5005e3eb18e7) page. +* Both [Google Sheets](https://support.google.com/docs/answer/3093018) and [LibreOffice Calc](https://wiki.documentfoundation.org/Documentation/Calc_Functions/NETWORKDAYS) provide versions of the NETWORKDAYS function. \ No newline at end of file From 4060729a821f4122d5f49ee7e37e0f3def2f7f85 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 28 Jul 2025 23:59:15 -0700 Subject: [PATCH 02/17] merge time, timevalue, hour, minute, second #35 --- .../src/expressions/parser/static_analysis.rs | 10 + base/src/functions/date_and_time.rs | 265 +++++++++ base/src/functions/mod.rs | 27 +- base/src/test/mod.rs | 1 + base/src/test/test_fn_time.rs | 520 ++++++++++++++++++ 5 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 base/src/test/test_fn_time.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index bbd011fe..b10b8b24 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -725,6 +725,11 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 2, 0), Function::Eomonth => args_signature_scalars(arg_count, 2, 0), Function::Month => args_signature_scalars(arg_count, 1, 0), + Function::Time => args_signature_scalars(arg_count, 3, 0), + Function::Timevalue => args_signature_scalars(arg_count, 1, 0), + Function::Hour => args_signature_scalars(arg_count, 1, 0), + Function::Minute => args_signature_scalars(arg_count, 1, 0), + Function::Second => args_signature_scalars(arg_count, 1, 0), Function::Now => args_signature_no_args(arg_count), Function::Today => args_signature_no_args(arg_count), Function::Year => args_signature_scalars(arg_count, 1, 0), @@ -932,6 +937,11 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Day => not_implemented(args), Function::Edate => not_implemented(args), Function::Month => not_implemented(args), + Function::Time => not_implemented(args), + Function::Timevalue => not_implemented(args), + Function::Hour => not_implemented(args), + Function::Minute => not_implemented(args), + Function::Second => not_implemented(args), Function::Now => not_implemented(args), Function::Today => not_implemented(args), Function::Year => not_implemented(args), diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index b7d25ad4..994710e7 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -1,6 +1,8 @@ use chrono::DateTime; use chrono::Datelike; use chrono::Months; +use chrono::NaiveDateTime; +use chrono::NaiveTime; use chrono::Timelike; use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; @@ -14,6 +16,163 @@ use crate::{ expressions::token::Error, formatter::dates::from_excel_date, model::Model, }; +fn parse_time_string(text: &str) -> Option { + let text = text.trim(); + + // First, try custom parsing for edge cases like "24:00:00", "23:60:00", "23:59:60" + // that need normalization to match Excel behavior + if let Some(time_fraction) = parse_time_with_normalization(text) { + return Some(time_fraction); + } + + // First, try manual parsing for simple "N PM" / "N AM" format + if let Some((hour_str, is_pm)) = parse_simple_am_pm(text) { + if let Ok(hour) = hour_str.parse::() { + if (1..=12).contains(&hour) { + let hour_24 = if is_pm { + if hour == 12 { + 12 + } else { + hour + 12 + } + } else if hour == 12 { + 0 + } else { + hour + }; + let time = NaiveTime::from_hms_opt(hour_24, 0, 0)?; + return Some(time.num_seconds_from_midnight() as f64 / 86_400.0); + } + } + } + + // Standard patterns + let patterns_time = ["%H:%M:%S", "%H:%M", "%I:%M %p", "%I %p", "%I:%M:%S %p"]; + for p in patterns_time { + if let Ok(t) = NaiveTime::parse_from_str(text, p) { + return Some(t.num_seconds_from_midnight() as f64 / 86_400.0); + } + } + + let patterns_dt = [ + // ISO formats + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M", + // Excel-style date formats with AM/PM + "%d-%b-%Y %I:%M:%S %p", // "22-Aug-2011 6:35:00 AM" + "%d-%b-%Y %I:%M %p", // "22-Aug-2011 6:35 AM" + "%d-%b-%Y %H:%M:%S", // "22-Aug-2011 06:35:00" + "%d-%b-%Y %H:%M", // "22-Aug-2011 06:35" + // US date formats with AM/PM + "%m/%d/%Y %I:%M:%S %p", // "8/22/2011 6:35:00 AM" + "%m/%d/%Y %I:%M %p", // "8/22/2011 6:35 AM" + "%m/%d/%Y %H:%M:%S", // "8/22/2011 06:35:00" + "%m/%d/%Y %H:%M", // "8/22/2011 06:35" + // European date formats with AM/PM + "%d/%m/%Y %I:%M:%S %p", // "22/8/2011 6:35:00 AM" + "%d/%m/%Y %I:%M %p", // "22/8/2011 6:35 AM" + "%d/%m/%Y %H:%M:%S", // "22/8/2011 06:35:00" + "%d/%m/%Y %H:%M", // "22/8/2011 06:35" + ]; + for p in patterns_dt { + if let Ok(dt) = NaiveDateTime::parse_from_str(text, p) { + return Some(dt.time().num_seconds_from_midnight() as f64 / 86_400.0); + } + } + if let Ok(dt) = DateTime::parse_from_rfc3339(text) { + return Some(dt.time().num_seconds_from_midnight() as f64 / 86_400.0); + } + None +} + +// Custom parser that handles time normalization like Excel does +fn parse_time_with_normalization(text: &str) -> Option { + // Try to parse H:M:S format with potential overflow values + let parts: Vec<&str> = text.split(':').collect(); + + if parts.len() == 3 { + // H:M:S format + if let (Ok(h), Ok(m), Ok(s)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + // Only normalize specific edge cases that Excel handles + // Don't normalize arbitrary large values like 25:00:00 + if should_normalize_time_components(h, m, s) { + return Some(normalize_time_components(h, m, s)); + } + } + } else if parts.len() == 2 { + // H:M format (assume seconds = 0) + if let (Ok(h), Ok(m)) = (parts[0].parse::(), parts[1].parse::()) { + // Only normalize specific edge cases + if should_normalize_time_components(h, m, 0) { + return Some(normalize_time_components(h, m, 0)); + } + } + } + + None +} + +// Normalize time components with overflow handling (like Excel) +fn normalize_time_components(hour: i32, minute: i32, second: i32) -> f64 { + // Convert everything to total seconds + let mut total_seconds = hour * 3600 + minute * 60 + second; + + // Handle negative values by wrapping around + if total_seconds < 0 { + total_seconds = total_seconds.rem_euclid(86400); + } + + // Normalize to within a day (0-86399 seconds) + total_seconds %= 86400; + + // Convert to fraction of a day + total_seconds as f64 / 86400.0 +} + +// Check if time components should be normalized (only specific Excel edge cases) +fn should_normalize_time_components(hour: i32, minute: i32, second: i32) -> bool { + // Only normalize these specific cases that Excel handles: + // 1. Hour 24 with valid minutes/seconds + // 2. Hour 23 with minute 60 (becomes 24:00) + // 3. Any time with second 60 that normalizes to exactly 24:00 + + if hour == 24 && (0..=59).contains(&minute) && (0..=59).contains(&second) { + return true; // 24:MM:SS -> normalize to next day + } + + if hour == 23 && minute == 60 && (0..=59).contains(&second) { + return true; // 23:60:SS -> normalize to 24:00:SS + } + + if (0..=23).contains(&hour) && (0..=59).contains(&minute) && second == 60 { + // Check if this normalizes to exactly 24:00:00 + let total_seconds = hour * 3600 + minute * 60 + second; + return total_seconds == 86400; // Exactly 24:00:00 + } + + false +} + +// Helper function to parse simple "N PM" / "N AM" formats +fn parse_simple_am_pm(text: &str) -> Option<(&str, bool)> { + if let Some(hour_part) = text.strip_suffix(" PM") { + if hour_part.chars().all(|c| c.is_ascii_digit()) { + return Some((hour_part, true)); + } + } else if let Some(hour_part) = text.strip_suffix(" AM") { + if hour_part.chars().all(|c| c.is_ascii_digit()) { + return Some((hour_part, false)); + } + } + None +} + impl Model { pub(crate) fn fn_day(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); @@ -586,4 +745,110 @@ impl Model { CalcResult::Number(days_from_1900 as f64 + days.fract()) } + + pub(crate) fn fn_time(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + let hour = match self.get_number(&args[0], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let minute = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let second = match self.get_number(&args[2], cell) { + Ok(f) => f, + Err(e) => return e, + }; + if hour < 0.0 || minute < 0.0 || second < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let total_seconds = hour.floor() * 3600.0 + minute.floor() * 60.0 + second.floor(); + let day_seconds = 24.0 * 3600.0; + let secs = total_seconds.rem_euclid(day_seconds); + CalcResult::Number(secs / day_seconds) + } + + pub(crate) fn fn_timevalue(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let text = match self.get_string(&args[0], cell) { + Ok(s) => s, + Err(e) => return e, + }; + match parse_time_string(&text) { + Some(value) => CalcResult::Number(value), + None => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid time".to_string(), + }, + } + } + + pub(crate) fn fn_hour(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let hours = (value.rem_euclid(1.0) * 24.0).floor(); + CalcResult::Number(hours) + } + + pub(crate) fn fn_minute(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let total_seconds = (value.rem_euclid(1.0) * 86400.0).floor(); + let minutes = ((total_seconds / 60.0) as i64 % 60) as f64; + CalcResult::Number(minutes) + } + + pub(crate) fn fn_second(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + let total_seconds = (value.rem_euclid(1.0) * 86400.0).floor(); + let seconds = (total_seconds as i64 % 60) as f64; + CalcResult::Number(seconds) + } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index f37ac107..a35d0110 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -152,6 +152,11 @@ pub enum Function { Edate, Eomonth, Month, + Time, + Timevalue, + Hour, + Minute, + Second, Now, Today, Year, @@ -255,7 +260,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -367,6 +372,11 @@ impl Function { Function::Edate, Function::Networkdays, Function::NetworkdaysIntl, + Function::Time, + Function::Timevalue, + Function::Hour, + Function::Minute, + Function::Second, Function::Today, Function::Now, Function::Pmt, @@ -638,6 +648,11 @@ impl Function { "EDATE" => Some(Function::Edate), "NETWORKDAYS" => Some(Function::Networkdays), "NETWORKDAYS.INTL" => Some(Function::NetworkdaysIntl), + "TIME" => Some(Function::Time), + "TIMEVALUE" => Some(Function::Timevalue), + "HOUR" => Some(Function::Hour), + "MINUTE" => Some(Function::Minute), + "SECOND" => Some(Function::Second), "TODAY" => Some(Function::Today), "NOW" => Some(Function::Now), // Financial @@ -850,6 +865,11 @@ impl fmt::Display for Function { Function::Edate => write!(f, "EDATE"), Function::Networkdays => write!(f, "NETWORKDAYS"), Function::NetworkdaysIntl => write!(f, "NETWORKDAYS.INTL"), + Function::Time => write!(f, "TIME"), + Function::Timevalue => write!(f, "TIMEVALUE"), + Function::Hour => write!(f, "HOUR"), + Function::Minute => write!(f, "MINUTE"), + Function::Second => write!(f, "SECOND"), Function::Today => write!(f, "TODAY"), Function::Now => write!(f, "NOW"), Function::Pmt => write!(f, "PMT"), @@ -1093,6 +1113,11 @@ impl Model { Function::Edate => self.fn_edate(args, cell), Function::Networkdays => self.fn_networkdays(args, cell), Function::NetworkdaysIntl => self.fn_networkdays_intl(args, cell), + Function::Time => self.fn_time(args, cell), + Function::Timevalue => self.fn_timevalue(args, cell), + Function::Hour => self.fn_hour(args, cell), + Function::Minute => self.fn_minute(args, cell), + Function::Second => self.fn_second(args, cell), Function::Today => self.fn_today(args, cell), Function::Now => self.fn_now(args, cell), // Financial diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index b1c2abba..26e15a13 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -27,6 +27,7 @@ mod test_fn_sum; mod test_fn_sumifs; mod test_fn_textbefore; mod test_fn_textjoin; +mod test_fn_time; mod test_fn_unicode; mod test_frozen_rows_columns; mod test_general; diff --git a/base/src/test/test_fn_time.rs b/base/src/test/test_fn_time.rs new file mode 100644 index 00000000..862aa0a1 --- /dev/null +++ b/base/src/test/test_fn_time.rs @@ -0,0 +1,520 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +// Helper constants for common time values with detailed documentation +const MIDNIGHT: &str = "0"; // 00:00:00 = 0/24 = 0 +const NOON: &str = "0.5"; // 12:00:00 = 12/24 = 0.5 +const TIME_14_30: &str = "0.604166667"; // 14:30:00 = 14.5/24 ≈ 0.604166667 +const TIME_14_30_45: &str = "0.6046875"; // 14:30:45 = 14.5125/24 = 0.6046875 +const TIME_14_30_59: &str = "0.604849537"; // 14:30:59 (from floored fractional inputs) +const TIME_23_59_59: &str = "0.999988426"; // 23:59:59 = 23.99972.../24 ≈ 0.999988426 + +// Excel documentation test values with explanations +const TIME_2_24_AM: &str = "0.1"; // 2:24 AM = 2.4/24 = 0.1 +const TIME_2_PM: &str = "0.583333333"; // 2:00 PM = 14/24 ≈ 0.583333333 +const TIME_6_45_PM: &str = "0.78125"; // 6:45 PM = 18.75/24 = 0.78125 +const TIME_6_35_AM: &str = "0.274305556"; // 6:35 AM = 6.583333.../24 ≈ 0.274305556 +const TIME_2_30_AM: &str = "0.104166667"; // 2:30 AM = 2.5/24 ≈ 0.104166667 +const TIME_1_AM: &str = "0.041666667"; // 1:00 AM = 1/24 ≈ 0.041666667 +const TIME_9_PM: &str = "0.875"; // 9:00 PM = 21/24 = 0.875 +const TIME_2_AM: &str = "0.083333333"; // 2:00 AM = 2/24 ≈ 0.083333333 + // Additional helper: 1-second past midnight (00:00:01) +const TIME_00_00_01: &str = "0.000011574"; // 1 second = 1/86400 ≈ 0.000011574 + +/// Helper function to set up and evaluate a model with time expressions +fn test_time_expressions(expressions: &[(&str, &str)]) -> crate::model::Model { + let mut model = new_empty_model(); + for (cell, formula) in expressions { + model._set(cell, formula); + } + model.evaluate(); + model +} + +/// Helper function to test component extraction for a given time value +/// Returns (hour, minute, second) as strings +fn test_component_extraction(time_value: &str) -> (String, String, String) { + let model = test_time_expressions(&[ + ("A1", &format!("=HOUR({time_value})")), + ("B1", &format!("=MINUTE({time_value})")), + ("C1", &format!("=SECOND({time_value})")), + ]); + ( + model._get_text("A1").to_string(), + model._get_text("B1").to_string(), + model._get_text("C1").to_string(), + ) +} + +#[test] +fn test_excel_timevalue_compatibility() { + // Test cases based on Excel's official documentation and examples + let model = test_time_expressions(&[ + // Excel documentation examples + ("A1", "=TIMEVALUE(\"2:24 AM\")"), // Should be 0.1 + ("A2", "=TIMEVALUE(\"2 PM\")"), // Should be 0.583333... (14/24) + ("A3", "=TIMEVALUE(\"6:45 PM\")"), // Should be 0.78125 (18.75/24) + ("A4", "=TIMEVALUE(\"18:45\")"), // Same as above, 24-hour format + // Date-time format (date should be ignored) + ("B1", "=TIMEVALUE(\"22-Aug-2011 6:35 AM\")"), // Should be ~0.2743 + ("B2", "=TIMEVALUE(\"2023-01-01 14:30:00\")"), // Should be 0.604166667 + // Edge cases that Excel should support + ("C1", "=TIMEVALUE(\"12:00 AM\")"), // Midnight: 0 + ("C2", "=TIMEVALUE(\"12:00 PM\")"), // Noon: 0.5 + ("C3", "=TIMEVALUE(\"11:59:59 PM\")"), // Almost midnight: 0.999988426 + // Single digit variations + ("D1", "=TIMEVALUE(\"1 AM\")"), // 1:00 AM + ("D2", "=TIMEVALUE(\"9 PM\")"), // 9:00 PM + ("D3", "=TIMEVALUE(\"12 AM\")"), // Midnight + ("D4", "=TIMEVALUE(\"12 PM\")"), // Noon + ]); + + // Excel documentation examples - verify exact values + assert_eq!(model._get_text("A1"), *TIME_2_24_AM); // 2:24 AM + assert_eq!(model._get_text("A2"), *TIME_2_PM); // 2 PM = 14:00 + assert_eq!(model._get_text("A3"), *TIME_6_45_PM); // 6:45 PM = 18:45 + assert_eq!(model._get_text("A4"), *TIME_6_45_PM); // 18:45 (24-hour) + + // Date-time formats (date ignored, extract time only) + assert_eq!(model._get_text("B1"), *TIME_6_35_AM); // 6:35 AM ≈ 0.2743 + assert_eq!(model._get_text("B2"), *TIME_14_30); // 14:30:00 + + // Edge cases + assert_eq!(model._get_text("C1"), *MIDNIGHT); // 12:00 AM = 00:00 + assert_eq!(model._get_text("C2"), *NOON); // 12:00 PM = 12:00 + assert_eq!(model._get_text("C3"), *TIME_23_59_59); // 11:59:59 PM + + // Single digit hours + assert_eq!(model._get_text("D1"), *TIME_1_AM); // 1:00 AM + assert_eq!(model._get_text("D2"), *TIME_9_PM); // 9:00 PM = 21:00 + assert_eq!(model._get_text("D3"), *MIDNIGHT); // 12 AM = 00:00 + assert_eq!(model._get_text("D4"), *NOON); // 12 PM = 12:00 +} + +#[test] +fn test_time_function_basic_cases() { + let model = test_time_expressions(&[ + ("A1", "=TIME(0,0,0)"), // Midnight + ("A2", "=TIME(12,0,0)"), // Noon + ("A3", "=TIME(14,30,0)"), // 2:30 PM + ("A4", "=TIME(23,59,59)"), // Max time + ]); + + assert_eq!(model._get_text("A1"), *MIDNIGHT); + assert_eq!(model._get_text("A2"), *NOON); + assert_eq!(model._get_text("A3"), *TIME_14_30); + assert_eq!(model._get_text("A4"), *TIME_23_59_59); +} + +#[test] +fn test_time_function_normalization() { + let model = test_time_expressions(&[ + ("A1", "=TIME(25,0,0)"), // Hours > 24 wrap around + ("A2", "=TIME(48,0,0)"), // 48 hours = 0 (2 full days) + ("A3", "=TIME(0,90,0)"), // 90 minutes = 1.5 hours + ("A4", "=TIME(0,0,90)"), // 90 seconds = 1.5 minutes + ("A5", "=TIME(14.9,30.9,59.9)"), // Fractional inputs floored to 14:30:59 + ]); + + assert_eq!(model._get_text("A1"), *TIME_1_AM); // 1:00:00 + assert_eq!(model._get_text("A2"), *MIDNIGHT); // 0:00:00 + assert_eq!(model._get_text("A3"), *"0.0625"); // 1:30:00 + assert_eq!(model._get_text("A4"), *"0.001041667"); // 0:01:30 + assert_eq!(model._get_text("A5"), *TIME_14_30_59); // 14:30:59 (floored) +} + +#[test] +fn test_time_function_precision_edge_cases() { + let model = test_time_expressions(&[ + // High precision fractional seconds + ("A1", "=TIME(14,30,45.999)"), // Fractional seconds should be floored + ("A2", "=SECOND(TIME(14,30,45.999))"), // Should extract 45, not 46 + // Very large normalization values + ("B1", "=TIME(999,999,999)"), // Extreme normalization test + ("B2", "=HOUR(999.5)"), // Multiple days, extract hour from fractional part + ("B3", "=MINUTE(999.75)"), // Multiple days, extract minute + // Boundary conditions at rollover points + ("C1", "=TIME(24,60,60)"), // Should normalize to next day (00:01:00) + ("C2", "=HOUR(0.999999999)"), // Almost 24 hours should be 23 + ("C3", "=MINUTE(0.999999999)"), // Almost 24 hours, extract minutes + ("C4", "=SECOND(0.999999999)"), // Almost 24 hours, extract seconds + // Precision at boundaries + ("D1", "=TIME(23,59,59.999)"), // Very close to midnight + ("D2", "=TIME(0,0,0.001)"), // Just after midnight + ]); + + // Fractional seconds are floored + assert_eq!(model._get_text("A2"), *"45"); // 45.999 floored to 45 + + // Multiple days should work with rem_euclid + assert_eq!(model._get_text("B2"), *"12"); // 999.5 days, hour = 12 (noon) + + // Boundary normalization + assert_eq!(model._get_text("C1"), *"0.042361111"); // 24:60:60 = 01:01:00 (normalized) + assert_eq!(model._get_text("C2"), *"23"); // Almost 24 hours = 23:xx:xx + + // High precision should be handled correctly + let result_d1 = model._get_text("D1").parse::().unwrap(); + assert!(result_d1 < 1.0 && result_d1 > 0.999); // Very close to but less than 1.0 +} + +#[test] +fn test_time_function_errors() { + let model = test_time_expressions(&[ + ("A1", "=TIME()"), // Wrong arg count + ("A2", "=TIME(12)"), // Wrong arg count + ("A3", "=TIME(12,30,0,0)"), // Wrong arg count + ("B1", "=TIME(-1,0,0)"), // Negative hour + ("B2", "=TIME(0,-1,0)"), // Negative minute + ("B3", "=TIME(0,0,-1)"), // Negative second + ]); + + // Wrong argument count + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + + // Negative values should return #NUM! error + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); +} + +#[test] +fn test_timevalue_function_formats() { + let model = test_time_expressions(&[ + // Basic formats + ("A1", "=TIMEVALUE(\"14:30\")"), + ("A2", "=TIMEVALUE(\"14:30:45\")"), + ("A3", "=TIMEVALUE(\"00:00:00\")"), + // AM/PM formats + ("B1", "=TIMEVALUE(\"2:30 PM\")"), + ("B2", "=TIMEVALUE(\"2:30 AM\")"), + ("B3", "=TIMEVALUE(\"12:00 PM\")"), // Noon + ("B4", "=TIMEVALUE(\"12:00 AM\")"), // Midnight + // Single hour with AM/PM (now supported!) + ("B5", "=TIMEVALUE(\"2 PM\")"), + ("B6", "=TIMEVALUE(\"2 AM\")"), + // Date-time formats (extract time only) + ("C1", "=TIMEVALUE(\"2023-01-01 14:30:00\")"), + ("C2", "=TIMEVALUE(\"2023-01-01T14:30:00\")"), + // Whitespace handling + ("D1", "=TIMEVALUE(\" 14:30 \")"), + ]); + + // Basic formats + assert_eq!(model._get_text("A1"), *TIME_14_30); + assert_eq!(model._get_text("A2"), *TIME_14_30_45); + assert_eq!(model._get_text("A3"), *MIDNIGHT); + + // AM/PM formats + assert_eq!(model._get_text("B1"), *TIME_14_30); // 2:30 PM = 14:30 + assert_eq!(model._get_text("B2"), *TIME_2_30_AM); // 2:30 AM + assert_eq!(model._get_text("B3"), *NOON); // 12:00 PM = noon + assert_eq!(model._get_text("B4"), *MIDNIGHT); // 12:00 AM = midnight + + // Single hour AM/PM formats (now supported!) + assert_eq!(model._get_text("B5"), *TIME_2_PM); // 2 PM = 14:00 + assert_eq!(model._get_text("B6"), *TIME_2_AM); // 2 AM = 02:00 + + // Date-time formats + assert_eq!(model._get_text("C1"), *TIME_14_30); + assert_eq!(model._get_text("C2"), *TIME_14_30); + + // Whitespace + assert_eq!(model._get_text("D1"), *TIME_14_30); +} + +#[test] +fn test_timevalue_function_errors() { + let model = test_time_expressions(&[ + ("A1", "=TIMEVALUE()"), // Wrong arg count + ("A2", "=TIMEVALUE(\"14:30\", \"x\")"), // Wrong arg count + ("B1", "=TIMEVALUE(\"invalid\")"), // Invalid format + ("B2", "=TIMEVALUE(\"25:00\")"), // Invalid hour + ("B3", "=TIMEVALUE(\"14:70\")"), // Invalid minute + ("B4", "=TIMEVALUE(\"\")"), // Empty string + ("B5", "=TIMEVALUE(\"2PM\")"), // Missing space (still unsupported) + ]); + + // Wrong argument count should return #ERROR! + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + + // Invalid formats should return #VALUE! + assert_eq!(model._get_text("B1"), *"#VALUE!"); + assert_eq!(model._get_text("B2"), *"#VALUE!"); + assert_eq!(model._get_text("B3"), *"#VALUE!"); + assert_eq!(model._get_text("B4"), *"#VALUE!"); + assert_eq!(model._get_text("B5"), *"#VALUE!"); // "2PM" no space - not supported +} + +#[test] +fn test_time_component_extraction_comprehensive() { + // Test component extraction using helper function for consistency + + // Test basic time values + let test_cases = [ + (MIDNIGHT, ("0", "0", "0")), // 00:00:00 + (NOON, ("12", "0", "0")), // 12:00:00 + (TIME_14_30, ("14", "30", "0")), // 14:30:00 + (TIME_23_59_59, ("23", "59", "59")), // 23:59:59 + ]; + + for (time_value, expected) in test_cases { + let (hour, minute, second) = test_component_extraction(time_value); + assert_eq!(hour, expected.0, "Hour mismatch for {time_value}"); + assert_eq!(minute, expected.1, "Minute mismatch for {time_value}"); + assert_eq!(second, expected.2, "Second mismatch for {time_value}"); + } + + // Test multiple days (extract from fractional part) + let (hour, minute, second) = test_component_extraction("1.5"); // Day 2, 12:00 + assert_eq!( + (hour, minute, second), + ("12".to_string(), "0".to_string(), "0".to_string()) + ); + + let (hour, minute, second) = test_component_extraction("100.604166667"); // Day 101, 14:30 + assert_eq!( + (hour, minute, second), + ("14".to_string(), "30".to_string(), "0".to_string()) + ); + + // Test precision at boundaries + let (hour, _, _) = test_component_extraction("0.041666666"); // Just under 1:00 AM + assert_eq!(hour, "0"); + + let (hour, _, _) = test_component_extraction("0.041666667"); // Exactly 1:00 AM + assert_eq!(hour, "1"); + + let (hour, _, _) = test_component_extraction("0.041666668"); // Just over 1:00 AM + assert_eq!(hour, "1"); + + // Test very large day values + let (hour, minute, second) = test_component_extraction("1000000.25"); // Million days + 6 hours + assert_eq!( + (hour, minute, second), + ("6".to_string(), "0".to_string(), "0".to_string()) + ); +} + +#[test] +fn test_time_component_function_errors() { + let model = test_time_expressions(&[ + // Wrong argument counts + ("A1", "=HOUR()"), // No arguments + ("A2", "=MINUTE()"), // No arguments + ("A3", "=SECOND()"), // No arguments + ("A4", "=HOUR(1, 2)"), // Too many arguments + ("A5", "=MINUTE(1, 2)"), // Too many arguments + ("A6", "=SECOND(1, 2)"), // Too many arguments + // Negative values should return #NUM! + ("B1", "=HOUR(-0.5)"), // Negative value + ("B2", "=MINUTE(-1)"), // Negative value + ("B3", "=SECOND(-1)"), // Negative value + ("B4", "=HOUR(-0.000001)"), // Slightly negative + ("B5", "=MINUTE(-0.000001)"), // Slightly negative + ("B6", "=SECOND(-0.000001)"), // Slightly negative + ]); + + // Wrong argument count should return #ERROR! + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); + assert_eq!(model._get_text("A3"), *"#ERROR!"); + assert_eq!(model._get_text("A4"), *"#ERROR!"); + assert_eq!(model._get_text("A5"), *"#ERROR!"); + assert_eq!(model._get_text("A6"), *"#ERROR!"); + + // Negative values should return #NUM! + assert_eq!(model._get_text("B1"), *"#NUM!"); + assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("B3"), *"#NUM!"); + assert_eq!(model._get_text("B4"), *"#NUM!"); + assert_eq!(model._get_text("B5"), *"#NUM!"); + assert_eq!(model._get_text("B6"), *"#NUM!"); +} + +#[test] +fn test_time_functions_integration() { + // Test how TIME, TIMEVALUE and component extraction functions work together + let model = test_time_expressions(&[ + // Create times with both functions + ("A1", "=TIME(14,30,45)"), + ("A2", "=TIMEVALUE(\"14:30:45\")"), + // Extract components from TIME function results + ("B1", "=HOUR(A1)"), + ("B2", "=MINUTE(A1)"), + ("B3", "=SECOND(A1)"), + // Extract components from TIMEVALUE function results + ("C1", "=HOUR(A2)"), + ("C2", "=MINUTE(A2)"), + ("C3", "=SECOND(A2)"), + // Test additional TIME variations + ("D1", "=TIME(14,0,0)"), // 14:00:00 + ("E1", "=HOUR(D1)"), // Extract hour from 14:00:00 + ("E2", "=MINUTE(D1)"), // Extract minute from 14:00:00 + ("E3", "=SECOND(D1)"), // Extract second from 14:00:00 + ]); + + // TIME and TIMEVALUE should produce equivalent results + assert_eq!(model._get_text("A1"), model._get_text("A2")); + + // Extracting components should work consistently + assert_eq!(model._get_text("B1"), *"14"); + assert_eq!(model._get_text("B2"), *"30"); + assert_eq!(model._get_text("B3"), *"45"); + assert_eq!(model._get_text("C1"), *"14"); + assert_eq!(model._get_text("C2"), *"30"); + assert_eq!(model._get_text("C3"), *"45"); + + // Components from TIME(14,0,0) + assert_eq!(model._get_text("E1"), *"14"); + assert_eq!(model._get_text("E2"), *"0"); + assert_eq!(model._get_text("E3"), *"0"); +} + +#[test] +fn test_time_function_extreme_values() { + // Test missing edge cases: very large fractional inputs + let model = test_time_expressions(&[ + // Extremely large fractional values to TIME function + ("A1", "=TIME(999999.9, 999999.9, 999999.9)"), // Very large fractional inputs + ("A2", "=TIME(1e6, 1e6, 1e6)"), // Scientific notation inputs + ("A3", "=TIME(0.000001, 0.000001, 0.000001)"), // Very small fractional inputs + // Large day values for component extraction (stress test) + ("B1", "=HOUR(999999.999)"), // Almost a million days + ("B2", "=MINUTE(999999.999)"), + ("B3", "=SECOND(999999.999)"), + // Edge case: exactly 1.0 (should be midnight of next day) + ("C1", "=HOUR(1.0)"), + ("C2", "=MINUTE(1.0)"), + ("C3", "=SECOND(1.0)"), + // Very high precision values + ("D1", "=HOUR(0.999999999999)"), // Almost exactly 24:00:00 + ("D2", "=MINUTE(0.999999999999)"), + ("D3", "=SECOND(0.999999999999)"), + ]); + + // Large fractional inputs should be floored and normalized + let result_a1 = model._get_text("A1").parse::().unwrap(); + assert!( + (0.0..1.0).contains(&result_a1), + "Result should be valid time fraction" + ); + + // Component extraction should work with very large values + let hour_b1 = model._get_text("B1").parse::().unwrap(); + assert!((0..=23).contains(&hour_b1), "Hour should be 0-23"); + + // Exactly 1.0 should be midnight (start of next day) + assert_eq!(model._get_text("C1"), *"0"); + assert_eq!(model._get_text("C2"), *"0"); + assert_eq!(model._get_text("C3"), *"0"); + + // Very high precision should still extract valid components + let hour_d1 = model._get_text("D1").parse::().unwrap(); + assert!((0..=23).contains(&hour_d1), "Hour should be 0-23"); +} + +#[test] +fn test_timevalue_malformed_but_parseable() { + // Test missing edge case: malformed but potentially parseable strings + let model = test_time_expressions(&[ + // Test various malformed but potentially parseable time strings + ("A1", "=TIMEVALUE(\"14:30:00.123\")"), // Milliseconds (might be truncated) + ("A2", "=TIMEVALUE(\"14:30:00.999\")"), // High precision milliseconds + ("A3", "=TIMEVALUE(\"02:30:00\")"), // Leading zero hours + ("A4", "=TIMEVALUE(\"2:05:00\")"), // Single digit hour, zero-padded minute + // Boundary cases for AM/PM parsing + ("B1", "=TIMEVALUE(\"11:59:59 PM\")"), // Just before midnight + ("B2", "=TIMEVALUE(\"12:00:01 AM\")"), // Just after midnight + ("B3", "=TIMEVALUE(\"12:00:01 PM\")"), // Just after noon + ("B4", "=TIMEVALUE(\"11:59:59 AM\")"), // Just before noon + // Test various date-time combinations + ("C1", "=TIMEVALUE(\"2023-12-31T23:59:59\")"), // ISO format at year end + ("C2", "=TIMEVALUE(\"2023-01-01 00:00:01\")"), // New year, just after midnight + // Test potential edge cases that might still be parseable + ("D1", "=TIMEVALUE(\"24:00:00\")"), // Should error (invalid hour) + ("D2", "=TIMEVALUE(\"23:60:00\")"), // Should error (invalid minute) + ("D3", "=TIMEVALUE(\"23:59:60\")"), // Should error (invalid second) + ]); + + // Milliseconds are not supported, should return a #VALUE! error like Excel + assert_eq!(model._get_text("A1"), *"#VALUE!"); + assert_eq!(model._get_text("A2"), *"#VALUE!"); + + // Leading zeros should work fine + assert_eq!(model._get_text("A3"), *TIME_2_30_AM); // 02:30:00 should parse as 2:30:00 + + // AM/PM boundary cases should work + let result_b1 = model._get_text("B1").parse::().unwrap(); + assert!( + result_b1 > 0.99 && result_b1 < 1.0, + "11:59:59 PM should be very close to 1.0" + ); + + let result_b2 = model._get_text("B2").parse::().unwrap(); + assert!( + result_b2 > 0.0 && result_b2 < 0.01, + "12:00:01 AM should be very close to 0.0" + ); + + // ISO 8601 format with "T" separator should be parsed correctly + assert_eq!(model._get_text("C1"), *TIME_23_59_59); // 23:59:59 → almost midnight + assert_eq!(model._get_text("C2"), *TIME_00_00_01); // 00:00:01 → one second past midnight + + // Time parser normalizes edge cases to midnight (Excel compatibility) + assert_eq!(model._get_text("D1"), *"0"); // 24:00:00 = midnight of next day + assert_eq!(model._get_text("D2"), *"0"); // 23:60:00 normalizes to 24:00:00 = midnight + assert_eq!(model._get_text("D3"), *"0"); // 23:59:60 normalizes to 24:00:00 = midnight +} + +#[test] +fn test_performance_stress_with_extreme_values() { + // Test performance/stress cases with extreme values + let model = test_time_expressions(&[ + // Very large numbers that should still work + ("A1", "=TIME(2147483647, 0, 0)"), // Max i32 hours + ("A2", "=TIME(0, 2147483647, 0)"), // Max i32 minutes + ("A3", "=TIME(0, 0, 2147483647)"), // Max i32 seconds + // Component extraction with extreme day values + ("B1", "=HOUR(1e15)"), // Very large day number + ("B2", "=MINUTE(1e15)"), + ("B3", "=SECOND(1e15)"), + // Edge of floating point precision + ("C1", "=HOUR(1.7976931348623157e+308)"), // Near max f64 + ("C2", "=HOUR(2.2250738585072014e-308)"), // Near min positive f64 + // Multiple TIME function calls with large values + ("D1", "=TIME(1000000, 1000000, 1000000)"), // Large normalized values + ("D2", "=HOUR(D1)"), // Extract from large TIME result + ("D3", "=MINUTE(D1)"), + ("D4", "=SECOND(D1)"), + ]); + + // All results should be valid (not errors) even with extreme inputs + for cell in ["A1", "A2", "A3", "B1", "B2", "B3", "D1", "D2", "D3", "D4"] { + let result = model._get_text(cell); + assert!( + result != *"#ERROR!" && result != *"#NUM!" && result != *"#VALUE!", + "Cell {cell} should not error with extreme values: {result}", + ); + } + + // Results should be mathematically valid + let hour_b1 = model._get_text("B1").parse::().unwrap(); + let minute_b2 = model._get_text("B2").parse::().unwrap(); + let second_b3 = model._get_text("B3").parse::().unwrap(); + + assert!((0..=23).contains(&hour_b1)); + assert!((0..=59).contains(&minute_b2)); + assert!((0..=59).contains(&second_b3)); + + // TIME function results should be valid time fractions + let time_d1 = model._get_text("D1").parse::().unwrap(); + assert!( + (0.0..1.0).contains(&time_d1), + "TIME result should be valid fraction" + ); +} From 45509791127f2fd2092d7997299fb4c63cf8a041 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 00:08:14 -0700 Subject: [PATCH 03/17] merge datedif, datevalue #36 --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/date_and_time.rs | 356 ++++++++++++++++++ base/src/functions/mod.rs | 12 +- base/src/test/test_fn_datevalue_datedif.rs | 182 +++++++++ docs/src/functions/date-and-time.md | 4 +- docs/src/functions/date_and_time/datedif.md | 3 +- docs/src/functions/date_and_time/datevalue.md | 3 +- 7 files changed, 557 insertions(+), 7 deletions(-) create mode 100644 base/src/test/test_fn_datevalue_datedif.rs diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index b10b8b24..ce9193ec 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -721,6 +721,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec vec![Signature::Vector; arg_count], Function::Minifs => vec![Signature::Vector; arg_count], Function::Date => args_signature_scalars(arg_count, 3, 0), + Function::Datedif => args_signature_scalars(arg_count, 3, 0), + Function::Datevalue => args_signature_scalars(arg_count, 1, 0), Function::Day => args_signature_scalars(arg_count, 1, 0), Function::Edate => args_signature_scalars(arg_count, 2, 0), Function::Eomonth => args_signature_scalars(arg_count, 2, 0), @@ -934,6 +936,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Maxifs => not_implemented(args), Function::Minifs => not_implemented(args), Function::Date => not_implemented(args), + Function::Datedif => not_implemented(args), + Function::Datevalue => not_implemented(args), Function::Day => not_implemented(args), Function::Edate => not_implemented(args), Function::Month => not_implemented(args), diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 994710e7..cfcbf278 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -173,6 +173,186 @@ fn parse_simple_am_pm(text: &str) -> Option<(&str, bool)> { None } +fn parse_day_simple(day_str: &str) -> Result { + let bytes_len = day_str.len(); + if bytes_len == 0 || bytes_len > 2 { + return Err("Not a valid day".to_string()); + } + match day_str.parse::() { + Ok(y) => Ok(y), + Err(_) => Err("Not a valid day".to_string()), + } +} + +fn parse_month_simple(month_str: &str) -> Result { + let bytes_len = month_str.len(); + if bytes_len == 0 { + return Err("Not a valid month".to_string()); + } + if bytes_len <= 2 { + // Numeric month representation. Ensure it is within the valid range 1-12. + return match month_str.parse::() { + Ok(m) if (1..=12).contains(&m) => Ok(m), + _ => Err("Not a valid month".to_string()), + }; + } + + // Textual month representations. + // Use standard 3-letter abbreviations (e.g. "Sep") but also accept the legacy "Sept". + let month_names_short = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let month_names_long = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + if let Some(m) = month_names_short + .iter() + .position(|&r| r.eq_ignore_ascii_case(month_str)) + { + return Ok(m as u32 + 1); + } + // Special-case the non-standard abbreviation "Sept" so older inputs still work. + if month_str.eq_ignore_ascii_case("Sept") { + return Ok(9); + } + + if let Some(m) = month_names_long + .iter() + .position(|&r| r.eq_ignore_ascii_case(month_str)) + { + return Ok(m as u32 + 1); + } + Err("Not a valid month".to_string()) +} + +fn parse_year_simple(year_str: &str) -> Result { + let bytes_len = year_str.len(); + if bytes_len != 2 && bytes_len != 4 { + return Err("Not a valid year".to_string()); + } + let y = year_str + .parse::() + .map_err(|_| "Not a valid year".to_string())?; + if y < 30 && bytes_len == 2 { + Ok(2000 + y) + } else if y < 100 && bytes_len == 2 { + Ok(1900 + y) + } else { + Ok(y) + } +} + +fn parse_datevalue_text(value: &str) -> Result { + let separator = if value.contains('/') { + '/' + } else if value.contains('-') { + '-' + } else { + return Err("Not a valid date".to_string()); + }; + + let mut parts: Vec<&str> = value.split(separator).map(|s| s.trim()).collect(); + if parts.len() != 3 { + return Err("Not a valid date".to_string()); + } + + // Identify the year: prefer the one that is four-digit numeric, otherwise assume the third part. + let mut year_idx: usize = 2; + for (idx, p) in parts.iter().enumerate() { + if p.len() == 4 && p.chars().all(char::is_numeric) { + year_idx = idx; + break; + } + } + + let year_str = parts[year_idx]; + // Remove the year from the remaining vector to process day / month. + parts.remove(year_idx); + let part1 = parts[0]; + let part2 = parts[1]; + + // Helper closures + let is_numeric = |s: &str| s.chars().all(char::is_numeric); + + // Determine month and day. + let (month_str, day_str) = if !is_numeric(part1) { + // textual month in first + (part1, part2) + } else if !is_numeric(part2) { + // textual month in second + (part2, part1) + } else { + // Both numeric – apply disambiguation rules + let v1: u32 = part1.parse().unwrap_or(0); + let v2: u32 = part2.parse().unwrap_or(0); + match (v1 > 12, v2 > 12) { + (true, false) => (part2, part1), // first cannot be month + (false, true) => (part1, part2), // second cannot be month + _ => (part1, part2), // ambiguous -> assume MM/DD + } + }; + + let day = parse_day_simple(day_str)?; + let month = parse_month_simple(month_str)?; + let year = parse_year_simple(year_str)?; + + match date_to_serial_number(day, month, year) { + Ok(n) => Ok(n), + Err(_) => Err("Not a valid date".to_string()), + } +} + +impl Model { + fn get_date_serial( + &mut self, + node: &Node, + cell: CellReferenceIndex, + ) -> Result { + let result = self.evaluate_node_in_context(node, cell); + match result { + CalcResult::Number(f) => Ok(f.floor() as i64), + CalcResult::String(s) => match parse_datevalue_text(&s) { + Ok(n) => Ok(n as i64), + Err(_) => Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid date".to_string(), + }), + }, + CalcResult::Boolean(b) => { + if b { + Ok(1) + } else { + Ok(0) + } + } + error @ CalcResult::Error { .. } => Err(error), + CalcResult::Range { .. } => Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }), + CalcResult::EmptyCell | CalcResult::EmptyArg => Ok(0), + CalcResult::Array(_) => Err(CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }), + } + } +} + impl Model { pub(crate) fn fn_day(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); @@ -851,4 +1031,180 @@ impl Model { let seconds = (total_seconds as i64 % 60) as f64; CalcResult::Number(seconds) } + + pub(crate) fn fn_datevalue(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::String(s) => match parse_datevalue_text(&s) { + Ok(n) => CalcResult::Number(n as f64), + Err(_) => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid date".to_string(), + }, + }, + CalcResult::Number(f) => CalcResult::Number(f.floor()), + CalcResult::Boolean(b) => { + if b { + CalcResult::Number(1.0) + } else { + CalcResult::Number(0.0) + } + } + err @ CalcResult::Error { .. } => err, + CalcResult::Range { .. } | CalcResult::Array(_) => CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + }, + CalcResult::EmptyCell | CalcResult::EmptyArg => CalcResult::Number(0.0), + } + } + + pub(crate) fn fn_datedif(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let start_serial = match self.get_date_serial(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + let end_serial = match self.get_date_serial(&args[1], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if end_serial < start_serial { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Start date greater than end date".to_string(), + }; + } + let start = match from_excel_date(start_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + let end = match from_excel_date(end_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + } + } + }; + + let unit = match self.get_string(&args[2], cell) { + Ok(s) => s.to_ascii_uppercase(), + Err(e) => return e, + }; + + let result = match unit.as_str() { + "Y" => { + let mut years = end.year() - start.year(); + if (end.month(), end.day()) < (start.month(), start.day()) { + years -= 1; + } + years as f64 + } + "M" => { + let mut months = + (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32); + if end.day() < start.day() { + months -= 1; + } + months as f64 + } + "D" => (end_serial - start_serial) as f64, + "YM" => { + let mut months = + (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32); + if end.day() < start.day() { + months -= 1; + } + (months % 12).abs() as f64 + } + "YD" => { + // Helper to create a date or early-return with #NUM! if impossible + let make_date = |y: i32, m: u32, d: u32| -> Result { + match chrono::NaiveDate::from_ymd_opt(y, m, d) { + Some(dt) => Ok(dt), + None => Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid date".to_string(), + }), + } + }; + + // Compute the last valid day of a given month/year. + let make_last_day_of_month = + |y: i32, m: u32| -> Result { + let (next_y, next_m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + let first_next = make_date(next_y, next_m, 1)?; + let last_day = first_next - chrono::Duration::days(1); + make_date(y, m, last_day.day()) + }; + + // Attempt to build the anniversary date in the end year. + let mut start_adj = + match chrono::NaiveDate::from_ymd_opt(end.year(), start.month(), start.day()) { + Some(d) => d, + None => match make_last_day_of_month(end.year(), start.month()) { + Ok(d) => d, + Err(e) => return e, + }, + }; + + // If the adjusted date is after the end date, shift one year back. + if start_adj > end { + let shift_year = end.year() - 1; + start_adj = match chrono::NaiveDate::from_ymd_opt( + shift_year, + start.month(), + start.day(), + ) { + Some(d) => d, + None => match make_last_day_of_month(shift_year, start.month()) { + Ok(d) => d, + Err(e) => return e, + }, + }; + } + + (end - start_adj).num_days() as f64 + } + "MD" => { + let mut months = + (end.year() - start.year()) * 12 + (end.month() as i32 - start.month() as i32); + if end.day() < start.day() { + months -= 1; + } + let start_shifted = if months >= 0 { + start + Months::new(months as u32) + } else { + start - Months::new((-months) as u32) + }; + (end - start_shifted).num_days() as f64 + } + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid unit".to_string(), + }; + } + }; + CalcResult::Number(result) + } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index a35d0110..a1b9b98b 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -148,6 +148,8 @@ pub enum Function { // Date and time Date, + Datedif, + Datevalue, Day, Edate, Eomonth, @@ -260,7 +262,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -369,6 +371,8 @@ impl Function { Function::Month, Function::Eomonth, Function::Date, + Function::Datedif, + Function::Datevalue, Function::Edate, Function::Networkdays, Function::NetworkdaysIntl, @@ -645,6 +649,8 @@ impl Function { "EOMONTH" => Some(Function::Eomonth), "MONTH" => Some(Function::Month), "DATE" => Some(Function::Date), + "DATEDIF" => Some(Function::Datedif), + "DATEVALUE" => Some(Function::Datevalue), "EDATE" => Some(Function::Edate), "NETWORKDAYS" => Some(Function::Networkdays), "NETWORKDAYS.INTL" => Some(Function::NetworkdaysIntl), @@ -862,6 +868,8 @@ impl fmt::Display for Function { Function::Month => write!(f, "MONTH"), Function::Eomonth => write!(f, "EOMONTH"), Function::Date => write!(f, "DATE"), + Function::Datedif => write!(f, "DATEDIF"), + Function::Datevalue => write!(f, "DATEVALUE"), Function::Edate => write!(f, "EDATE"), Function::Networkdays => write!(f, "NETWORKDAYS"), Function::NetworkdaysIntl => write!(f, "NETWORKDAYS.INTL"), @@ -1110,6 +1118,8 @@ impl Model { Function::Eomonth => self.fn_eomonth(args, cell), Function::Month => self.fn_month(args, cell), Function::Date => self.fn_date(args, cell), + Function::Datedif => self.fn_datedif(args, cell), + Function::Datevalue => self.fn_datevalue(args, cell), Function::Edate => self.fn_edate(args, cell), Function::Networkdays => self.fn_networkdays(args, cell), Function::NetworkdaysIntl => self.fn_networkdays_intl(args, cell), diff --git a/base/src/test/test_fn_datevalue_datedif.rs b/base/src/test/test_fn_datevalue_datedif.rs new file mode 100644 index 00000000..8ec4fa24 --- /dev/null +++ b/base/src/test/test_fn_datevalue_datedif.rs @@ -0,0 +1,182 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::types::Cell; + +// Helper to evaluate a formula and return the formatted text +fn eval_formula(formula: &str) -> String { + let mut model = new_empty_model(); + model._set("A1", formula); + model.evaluate(); + model._get_text("A1") +} + +// Helper that evaluates a formula and returns the raw value of A1 as a Result +fn eval_formula_raw_number(formula: &str) -> Result { + let mut model = new_empty_model(); + model._set("A1", formula); + model.evaluate(); + match model._get_cell("A1") { + Cell::NumberCell { v, .. } => Ok(*v), + Cell::BooleanCell { v, .. } => Ok(if *v { 1.0 } else { 0.0 }), + Cell::ErrorCell { ei, .. } => Err(format!("{}", ei)), + _ => Err(model._get_text("A1")), + } +} + +#[test] +fn test_datevalue_basic_numeric() { + // DATEVALUE should return the serial number representing the date, **not** a formatted date + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2/1/2023\")").unwrap(), + 44958.0 + ); +} + +#[test] +fn test_datevalue_mmdd_with_leading_zero() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"02/01/2023\")").unwrap(), + 44958.0 + ); // 1-Feb-2023 +} + +#[test] +fn test_datevalue_iso() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2023-01-02\")").unwrap(), + 44928.0 + ); +} + +#[test] +fn test_datevalue_month_name() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2-Jan-23\")").unwrap(), + 44928.0 + ); +} + +#[test] +fn test_datevalue_ambiguous_ddmm() { + // 01/02/2023 interpreted as MM/DD -> 2-Jan-2023 + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"01/02/2023\")").unwrap(), + 44929.0 + ); +} + +#[test] +fn test_datevalue_ddmm_unambiguous() { + // 15/01/2023 should be 15-Jan-2023 since 15 cannot be month + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"15/01/2023\")").unwrap(), + 44941.0 + ); +} + +#[test] +fn test_datevalue_leap_day() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"29/02/2020\")").unwrap(), + 43890.0 + ); +} + +#[test] +fn test_datevalue_year_first_text_month() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"2023/Jan/15\")").unwrap(), + 44941.0 + ); +} + +#[test] +fn test_datevalue_mmdd_with_day_gt_12() { + assert_eq!( + eval_formula_raw_number("=DATEVALUE(\"6/15/2021\")").unwrap(), + 44373.0 + ); +} + +#[test] +fn test_datevalue_error_conditions() { + let cases = [ + "=DATEVALUE(\"31/04/2023\")", // invalid day (Apr has 30 days) + "=DATEVALUE(\"13/13/2023\")", // invalid month + "=DATEVALUE(\"not a date\")", // non-date text + ]; + for formula in cases { + let result = eval_formula(formula); + assert_eq!(result, *"#VALUE!", "Expected #VALUE! for {}", formula); + } +} + +// Helper to set and evaluate a single DATEDIF call +fn eval_datedif(unit: &str) -> String { + let mut model = new_empty_model(); + let formula = format!("=DATEDIF(\"2020-01-01\", \"2021-06-15\", \"{}\")", unit); + model._set("A1", &formula); + model.evaluate(); + model._get_text("A1") +} + +#[test] +fn test_datedif_y() { + assert_eq!(eval_datedif("Y"), *"1"); +} + +#[test] +fn test_datedif_m() { + assert_eq!(eval_datedif("M"), *"17"); +} + +#[test] +fn test_datedif_d() { + assert_eq!(eval_datedif("D"), *"531"); +} + +#[test] +fn test_datedif_ym() { + assert_eq!(eval_datedif("YM"), *"5"); +} + +#[test] +fn test_datedif_yd() { + assert_eq!(eval_datedif("YD"), *"165"); +} + +#[test] +fn test_datedif_md() { + assert_eq!(eval_datedif("MD"), *"14"); +} + +#[test] +fn test_datedif_edge_and_error_cases() { + let mut model = new_empty_model(); + // Leap-year spanning + model._set("A1", "=DATEDIF(\"28/2/2020\", \"1/3/2020\", \"D\")"); + // End date before start date => #NUM! + model._set("A2", "=DATEDIF(\"1/2/2021\", \"1/1/2021\", \"D\")"); + // Invalid unit => #VALUE! + model._set("A3", "=DATEDIF(\"1/1/2020\", \"1/1/2021\", \"Z\")"); + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"2"); + assert_eq!(model._get_text("A2"), *"#NUM!"); + assert_eq!(model._get_text("A3"), *"#VALUE!"); +} + +#[test] +fn test_datedif_mixed_case_unit() { + assert_eq!(eval_datedif("yD"), *"165"); // mixed-case should work +} + +#[test] +fn test_datedif_error_propagation() { + // Invalid date in arguments should propagate #VALUE! + let mut model = new_empty_model(); + model._set("A1", "=DATEDIF(\"bad\", \"bad\", \"Y\")"); + model.evaluate(); + assert_eq!(model._get_text("A1"), *"#VALUE!"); +} diff --git a/docs/src/functions/date-and-time.md b/docs/src/functions/date-and-time.md index 9f242d77..1c71f24a 100644 --- a/docs/src/functions/date-and-time.md +++ b/docs/src/functions/date-and-time.md @@ -12,8 +12,8 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | Function | Status | Documentation | | ---------------- | ---------------------------------------------- | ------------- | | DATE | | – | -| DATEDIF | | – | -| DATEVALUE | | – | +| DATEDIF | | [DATEDIF](date_and_time/datedif) | +| DATEVALUE | | [DATEVALUE](date_and_time/datevalue) | | DAY | | [DAY](date_and_time/day) | | DAYS | | – | | DAYS360 | | – | diff --git a/docs/src/functions/date_and_time/datedif.md b/docs/src/functions/date_and_time/datedif.md index 1cb23a38..6b19c6e9 100644 --- a/docs/src/functions/date_and_time/datedif.md +++ b/docs/src/functions/date_and_time/datedif.md @@ -7,6 +7,5 @@ lang: en-US # DATEDIF ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/datevalue.md b/docs/src/functions/date_and_time/datevalue.md index 5f211e8c..da62d378 100644 --- a/docs/src/functions/date_and_time/datevalue.md +++ b/docs/src/functions/date_and_time/datevalue.md @@ -7,6 +7,5 @@ lang: en-US # DATEVALUE ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From efc9594c4a18880f6056cf0dbb88c1cd6ebc3234 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 00:17:28 -0700 Subject: [PATCH 04/17] merge days, days360, weekday, weeknum, workday, workday.intl, yearfrac, isoweeknum #41 --- .../src/expressions/parser/static_analysis.rs | 16 + base/src/functions/date_and_time.rs | 596 +++++++++++++++++- base/src/functions/mod.rs | 42 +- base/src/test/test_date_and_time.rs | 384 +++++++++++ docs/src/functions/date-and-time.md | 16 +- docs/src/functions/date_and_time/days.md | 3 +- docs/src/functions/date_and_time/days360.md | 3 +- .../src/functions/date_and_time/isoweeknum.md | 3 +- docs/src/functions/date_and_time/weekday.md | 3 +- docs/src/functions/date_and_time/weeknum.md | 3 +- .../functions/date_and_time/workday.intl.md | 3 +- docs/src/functions/date_and_time/workday.md | 3 +- docs/src/functions/date_and_time/yearfrac.md | 3 +- 13 files changed, 1049 insertions(+), 29 deletions(-) diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index ce9193ec..c08e9b0e 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -735,6 +735,14 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_no_args(arg_count), Function::Today => args_signature_no_args(arg_count), Function::Year => args_signature_scalars(arg_count, 1, 0), + Function::Days => args_signature_scalars(arg_count, 2, 0), + Function::Days360 => args_signature_scalars(arg_count, 2, 1), + Function::Weekday => args_signature_scalars(arg_count, 1, 1), + Function::Weeknum => args_signature_scalars(arg_count, 1, 1), + Function::Workday => args_signature_scalars(arg_count, 2, 1), + Function::WorkdayIntl => args_signature_scalars(arg_count, 2, 2), + Function::Yearfrac => args_signature_scalars(arg_count, 2, 1), + Function::Isoweeknum => args_signature_scalars(arg_count, 1, 0), Function::Cumipmt => args_signature_scalars(arg_count, 6, 0), Function::Cumprinc => args_signature_scalars(arg_count, 6, 0), Function::Db => args_signature_scalars(arg_count, 4, 1), @@ -949,6 +957,14 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Now => not_implemented(args), Function::Today => not_implemented(args), Function::Year => not_implemented(args), + Function::Days => not_implemented(args), + Function::Days360 => not_implemented(args), + Function::Weekday => not_implemented(args), + Function::Weeknum => not_implemented(args), + Function::Workday => not_implemented(args), + Function::WorkdayIntl => not_implemented(args), + Function::Yearfrac => not_implemented(args), + Function::Isoweeknum => not_implemented(args), Function::Cumipmt => not_implemented(args), Function::Cumprinc => not_implemented(args), Function::Db => not_implemented(args), diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index cfcbf278..57d8d62e 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -12,8 +12,12 @@ use crate::formatter::dates::date_to_serial_number; use crate::formatter::dates::permissive_date_to_serial_number; use crate::model::get_milliseconds_since_epoch; use crate::{ - calc_result::CalcResult, constants::EXCEL_DATE_BASE, expressions::parser::Node, - expressions::token::Error, formatter::dates::from_excel_date, model::Model, + calc_result::CalcResult, + constants::EXCEL_DATE_BASE, + expressions::parser::{ArrayNode, Node}, + expressions::token::Error, + formatter::dates::from_excel_date, + model::Model, }; fn parse_time_string(text: &str) -> Option { @@ -351,9 +355,7 @@ impl Model { }), } } -} -impl Model { pub(crate) fn fn_day(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); if args_count != 1 { @@ -1207,4 +1209,590 @@ impl Model { }; CalcResult::Number(result) } + + pub(crate) fn fn_days(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 2 { + return CalcResult::new_args_number_error(cell); + } + let end_serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let start_serial = match self.get_number(&args[1], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + if from_excel_date(end_serial).is_err() || from_excel_date(start_serial).is_err() { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + CalcResult::Number((end_serial - start_serial) as f64) + } + + pub(crate) fn fn_days360(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let end_serial = match self.get_number(&args[1], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let method = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f != 0.0, + Err(s) => return s, + } + } else { + false + }; + let start_date = match from_excel_date(start_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + let end_date = match from_excel_date(end_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + + fn last_day_feb(year: i32) -> u32 { + if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { + 29 + } else { + 28 + } + } + let mut sd_day = start_date.day(); + let sd_month = start_date.month(); + let sd_year = start_date.year(); + let mut ed_day = end_date.day(); + let ed_month = end_date.month(); + let ed_year = end_date.year(); + + if method { + if sd_day == 31 { + sd_day = 30; + } + if ed_day == 31 { + ed_day = 30; + } + } else { + if (sd_month == 2 && sd_day == last_day_feb(sd_year)) || sd_day == 31 { + sd_day = 30; + } + if ed_month == 2 && ed_day == last_day_feb(ed_year) && sd_day == 30 { + ed_day = 30; + } + if ed_day == 31 && sd_day >= 30 { + ed_day = 30; + } + } + + let result = (ed_year - sd_year) * 360 + + (ed_month as i32 - sd_month as i32) * 30 + + (ed_day as i32 - sd_day as i32); + CalcResult::Number(result as f64) + } + + pub(crate) fn fn_weekday(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(1..=2).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + let return_type = if args.len() == 2 { + match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + } + } else { + 1 + }; + let weekday = date.weekday(); + let num = match return_type { + 1 => weekday.num_days_from_sunday() + 1, + 2 => weekday.number_from_monday(), + 3 => (weekday.number_from_monday() - 1) % 7, // 0-based Monday start + 11..=17 => { + let start = (return_type - 11) as u32; // 0 for Monday + ((weekday.number_from_monday() + 7 - start) % 7) + 1 + } + 0 => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid return_type".to_string()) + } + _ => return CalcResult::new_error(Error::NUM, cell, "Invalid return_type".to_string()), + } as u32; + CalcResult::Number(num as f64) + } + + pub(crate) fn fn_weeknum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(1..=2).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + let return_type = if args.len() == 2 { + match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + } + } else { + 1 + }; + if return_type == 21 { + let w = date.iso_week().week(); + return CalcResult::Number(w as f64); + } + let start_offset = match return_type { + 1 => chrono::Weekday::Sun, + 2 | 11 => chrono::Weekday::Mon, + 12 => chrono::Weekday::Tue, + 13 => chrono::Weekday::Wed, + 14 => chrono::Weekday::Thu, + 15 => chrono::Weekday::Fri, + 16 => chrono::Weekday::Sat, + 17 => chrono::Weekday::Sun, + x if x <= 0 || x == 3 => { + return CalcResult::new_error(Error::VALUE, cell, "Invalid return_type".to_string()) + } + _ => return CalcResult::new_error(Error::NUM, cell, "Invalid return_type".to_string()), + }; + let mut first = match chrono::NaiveDate::from_ymd_opt(date.year(), 1, 1) { + Some(d) => d, + None => { + return CalcResult::new_error( + Error::NUM, + cell, + "Out of range parameters for date".to_string(), + ); + } + }; + while first.weekday() != start_offset { + first -= chrono::Duration::days(1); + } + let week = ((date - first).num_days() / 7 + 1) as i64; + CalcResult::Number(week as f64) + } + + pub(crate) fn fn_isoweeknum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let date = match from_excel_date(serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + CalcResult::Number(date.iso_week().week() as f64) + } + + fn is_weekend(day: chrono::Weekday, weekend_mask: &[bool; 7]) -> bool { + match day { + chrono::Weekday::Mon => weekend_mask[0], + chrono::Weekday::Tue => weekend_mask[1], + chrono::Weekday::Wed => weekend_mask[2], + chrono::Weekday::Thu => weekend_mask[3], + chrono::Weekday::Fri => weekend_mask[4], + chrono::Weekday::Sat => weekend_mask[5], + chrono::Weekday::Sun => weekend_mask[6], + } + } + + pub(crate) fn fn_workday(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let mut date = match from_excel_date(start_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + let mut days = match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + }; + let weekend = [false, false, false, false, false, true, true]; + let holiday_set = match self.get_holiday_set(args.get(2), cell) { + Ok(h) => h, + Err(e) => return e, + }; + while days != 0 { + if days > 0 { + date += chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend) && !holiday_set.contains(&date) { + days -= 1; + } + } else { + date -= chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend) && !holiday_set.contains(&date) { + days += 1; + } + } + } + let serial = date.num_days_from_ce() - EXCEL_DATE_BASE; + CalcResult::Number(serial as f64) + } + + fn get_holiday_set( + &mut self, + arg_option: Option<&Node>, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let mut holiday_set = std::collections::HashSet::new(); + + if let Some(arg) = arg_option { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => { + let serial = value.floor() as i64; + match from_excel_date(serial) { + Ok(date) => { + holiday_set.insert(date); + } + Err(_) => { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + CalcResult::Range { left, right } => { + let sheet = left.sheet; + for row in left.row..=right.row { + for column in left.column..=right.column { + let cell_ref = CellReferenceIndex { sheet, row, column }; + match self.evaluate_cell(cell_ref) { + CalcResult::Number(value) => { + let serial = value.floor() as i64; + match from_excel_date(serial) { + Ok(date) => { + holiday_set.insert(date); + } + Err(_) => { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + CalcResult::EmptyCell => { + // Ignore empty cells + } + CalcResult::Error { .. } => { + // Propagate errors + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Error in holiday date".to_string(), + }); + } + _ => { + // Ignore non-numeric values + } + } + } + } + } + CalcResult::Array(array) => { + for row in array { + for value in row { + match value { + ArrayNode::Number(num) => { + let serial = num.floor() as i64; + match from_excel_date(serial) { + Ok(date) => { + holiday_set.insert(date); + } + Err(_) => { + return Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid holiday date".to_string(), + }); + } + } + } + ArrayNode::Error(error) => { + return Err(CalcResult::Error { + error, + origin: cell, + message: "Error in holiday array".to_string(), + }); + } + _ => { + // Ignore non-numeric values + } + } + } + } + } + error @ CalcResult::Error { .. } => return Err(error), + _ => { + // Ignore other types + } + } + } + + Ok(holiday_set) + } + + fn weekend_from_arg( + &mut self, + arg: Option<&Node>, + cell: CellReferenceIndex, + ) -> Result<[bool; 7], CalcResult> { + if let Some(node) = arg { + match self.evaluate_node_in_context(node, cell) { + CalcResult::Number(n) => { + let code = n as i32; + let mask = match code { + 1 => [false, false, false, false, false, true, true], + 2 => [true, false, false, false, false, true, false], + 3 => [true, true, false, false, false, false, false], + 4 => [false, true, true, false, false, false, false], + 5 => [false, false, true, true, false, false, false], + 6 => [false, false, false, true, true, false, false], + 7 => [false, false, false, false, true, true, false], + 11 => [false, false, false, false, false, false, true], + 12 => [true, false, false, false, false, false, false], + 13 => [false, true, false, false, false, false, false], + 14 => [false, false, true, false, false, false, false], + 15 => [false, false, false, true, false, false, false], + 16 => [false, false, false, false, true, false, false], + 17 => [false, false, false, false, false, true, false], + _ => { + return Err(CalcResult::new_error( + Error::NUM, + cell, + "Invalid weekend".to_string(), + )) + } + }; + Ok(mask) + } + CalcResult::String(s) => { + let bytes = s.as_bytes(); + if bytes.len() == 7 && bytes.iter().all(|c| *c == b'0' || *c == b'1') { + let mut mask = [false; 7]; + for (i, b) in bytes.iter().enumerate() { + mask[i] = *b == b'1'; + } + Ok(mask) + } else { + Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )) + } + } + e @ CalcResult::Error { .. } => Err(e), + _ => Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid weekend".to_string(), + )), + } + } else { + Ok([false, false, false, false, false, true, true]) + } + } + + pub(crate) fn fn_workday_intl( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + ) -> CalcResult { + if !(2..=4).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let mut date = match from_excel_date(start_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }; + } + }; + let mut days = match self.get_number(&args[1], cell) { + Ok(f) => f as i32, + Err(s) => return s, + }; + let weekend_mask = match self.weekend_from_arg(args.get(2), cell) { + Ok(m) => m, + Err(e) => return e, + }; + let holiday_set = match self.get_holiday_set(args.get(3), cell) { + Ok(h) => h, + Err(e) => return e, + }; + while days != 0 { + if days > 0 { + date += chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend_mask) && !holiday_set.contains(&date) + { + days -= 1; + } + } else { + date -= chrono::Duration::days(1); + if !Self::is_weekend(date.weekday(), &weekend_mask) && !holiday_set.contains(&date) + { + days += 1; + } + } + } + let serial = date.num_days_from_ce() - EXCEL_DATE_BASE; + CalcResult::Number(serial as f64) + } + + pub(crate) fn fn_yearfrac(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if !(2..=3).contains(&args.len()) { + return CalcResult::new_args_number_error(cell); + } + let start_serial = match self.get_number(&args[0], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let end_serial = match self.get_number(&args[1], cell) { + Ok(c) => c.floor() as i64, + Err(s) => return s, + }; + let basis = if args.len() == 3 { + match self.get_number(&args[2], cell) { + Ok(f) => f as i32, + Err(s) => return s, + } + } else { + 0 + }; + let start_date = match from_excel_date(start_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::new_error( + Error::NUM, + cell, + "Out of range parameters for date".to_string(), + ) + } + }; + let end_date = match from_excel_date(end_serial) { + Ok(d) => d, + Err(_) => { + return CalcResult::new_error( + Error::NUM, + cell, + "Out of range parameters for date".to_string(), + ) + } + }; + let days = (end_date - start_date).num_days() as f64; + let result = match basis { + 0 => { + let d360 = self.fn_days360(args, cell); + if let CalcResult::Number(n) = d360 { + n / 360.0 + } else { + return d360; + } + } + 1 => { + let year_days = if start_date.year() == end_date.year() { + if (start_date.year() % 4 == 0 && start_date.year() % 100 != 0) + || start_date.year() % 400 == 0 + { + 366.0 + } else { + 365.0 + } + } else { + 365.0 + }; + days / year_days + } + 2 => days / 360.0, + 3 => days / 365.0, + 4 => { + let d360 = self.fn_days360( + &[args[0].clone(), args[1].clone(), Node::NumberKind(1.0)], + cell, + ); + if let CalcResult::Number(n) = d360 { + n / 360.0 + } else { + return d360; + } + } + _ => return CalcResult::new_error(Error::NUM, cell, "Invalid basis".to_string()), + }; + CalcResult::Number(result) + } } diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index a1b9b98b..0e227bf1 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -164,6 +164,14 @@ pub enum Function { Year, Networkdays, NetworkdaysIntl, + Days, + Days360, + Weekday, + Weeknum, + Workday, + WorkdayIntl, + Yearfrac, + Isoweeknum, // Financial Cumipmt, @@ -262,7 +270,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -383,6 +391,14 @@ impl Function { Function::Second, Function::Today, Function::Now, + Function::Days, + Function::Days360, + Function::Weekday, + Function::Weeknum, + Function::Workday, + Function::WorkdayIntl, + Function::Yearfrac, + Function::Isoweeknum, Function::Pmt, Function::Pv, Function::Rate, @@ -661,6 +677,14 @@ impl Function { "SECOND" => Some(Function::Second), "TODAY" => Some(Function::Today), "NOW" => Some(Function::Now), + "DAYS" => Some(Function::Days), + "DAYS360" => Some(Function::Days360), + "WEEKDAY" => Some(Function::Weekday), + "WEEKNUM" => Some(Function::Weeknum), + "WORKDAY" => Some(Function::Workday), + "WORKDAY.INTL" => Some(Function::WorkdayIntl), + "YEARFRAC" => Some(Function::Yearfrac), + "ISOWEEKNUM" => Some(Function::Isoweeknum), // Financial "PMT" => Some(Function::Pmt), "PV" => Some(Function::Pv), @@ -880,6 +904,14 @@ impl fmt::Display for Function { Function::Second => write!(f, "SECOND"), Function::Today => write!(f, "TODAY"), Function::Now => write!(f, "NOW"), + Function::Days => write!(f, "DAYS"), + Function::Days360 => write!(f, "DAYS360"), + Function::Weekday => write!(f, "WEEKDAY"), + Function::Weeknum => write!(f, "WEEKNUM"), + Function::Workday => write!(f, "WORKDAY"), + Function::WorkdayIntl => write!(f, "WORKDAY.INTL"), + Function::Yearfrac => write!(f, "YEARFRAC"), + Function::Isoweeknum => write!(f, "ISOWEEKNUM"), Function::Pmt => write!(f, "PMT"), Function::Pv => write!(f, "PV"), Function::Rate => write!(f, "RATE"), @@ -1130,6 +1162,14 @@ impl Model { Function::Second => self.fn_second(args, cell), Function::Today => self.fn_today(args, cell), Function::Now => self.fn_now(args, cell), + Function::Days => self.fn_days(args, cell), + Function::Days360 => self.fn_days360(args, cell), + Function::Weekday => self.fn_weekday(args, cell), + Function::Weeknum => self.fn_weeknum(args, cell), + Function::Workday => self.fn_workday(args, cell), + Function::WorkdayIntl => self.fn_workday_intl(args, cell), + Function::Yearfrac => self.fn_yearfrac(args, cell), + Function::Isoweeknum => self.fn_isoweeknum(args, cell), // Financial Function::Pmt => self.fn_pmt(args, cell), Function::Pv => self.fn_pv(args, cell), diff --git a/base/src/test/test_date_and_time.rs b/base/src/test/test_date_and_time.rs index 7216700a..5924c57f 100644 --- a/base/src/test/test_date_and_time.rs +++ b/base/src/test/test_date_and_time.rs @@ -6,6 +6,11 @@ /// We can also enter examples that illustrate/document a part of the function use crate::{cell::CellValue, test::util::new_empty_model}; +// Excel uses a serial date system where Jan 1, 1900 = 1 (though it treats 1900 as a leap year) +// Most test dates are documented inline, but we define boundary values here: +const EXCEL_MAX_DATE: f64 = 2958465.0; // Dec 31, 9999 - used in boundary tests +const EXCEL_INVALID_DATE: f64 = 2958466.0; // One day past max - used in error tests + #[test] fn test_fn_date_arguments() { let mut model = new_empty_model(); @@ -216,3 +221,382 @@ fn test_date_early_dates() { Ok(CellValue::Number(61.0)) ); } +#[test] +fn test_days_function() { + let mut model = new_empty_model(); + + // Basic functionality + model._set("A1", "=DAYS(44570,44561)"); + model._set("A2", "=DAYS(44561,44570)"); // Reversed order + model._set("A3", "=DAYS(44561,44561)"); + + // Edge cases + model._set("A4", "=DAYS(1,2)"); // Early dates + model._set( + "A5", + &format!("=DAYS({},{})", EXCEL_MAX_DATE, EXCEL_MAX_DATE - 1.0), + ); // Near max date + + // Error cases - wrong argument count + model._set("A6", "=DAYS()"); + model._set("A7", "=DAYS(44561)"); + model._set("A8", "=DAYS(44561,44570,1)"); + + // Error cases - invalid dates + model._set("A9", "=DAYS(-1,44561)"); + model._set("A10", &format!("=DAYS(44561,{EXCEL_INVALID_DATE})")); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"9"); + assert_eq!(model._get_text("A2"), *"-9"); + assert_eq!(model._get_text("A3"), *"0"); + assert_eq!(model._get_text("A4"), *"-1"); // DAYS(1,2) = 1-2 = -1 + assert_eq!(model._get_text("A5"), *"1"); + assert_eq!(model._get_text("A6"), *"#ERROR!"); + assert_eq!(model._get_text("A7"), *"#ERROR!"); + assert_eq!(model._get_text("A8"), *"#ERROR!"); + assert_eq!(model._get_text("A9"), *"#NUM!"); + assert_eq!(model._get_text("A10"), *"#NUM!"); +} + +#[test] +fn test_days360_function() { + let mut model = new_empty_model(); + + // Basic functionality with different basis values + model._set("A1", "=DAYS360(44196,44560)"); // Default basis (US 30/360) + model._set("A2", "=DAYS360(44196,44560,FALSE)"); // US 30/360 explicitly + model._set("A3", "=DAYS360(44196,44560,TRUE)"); // European 30/360 + + // Same date + model._set("A4", "=DAYS360(44561,44561)"); + model._set("A5", "=DAYS360(44561,44561,TRUE)"); + + // Reverse order (negative result) + model._set("A6", "=DAYS360(44560,44196)"); + model._set("A7", "=DAYS360(44560,44196,TRUE)"); + + // Edge cases + model._set("A8", "=DAYS360(1,2)"); + model._set("A9", "=DAYS360(1,2,FALSE)"); + + // Error cases - wrong argument count + model._set("A10", "=DAYS360()"); + model._set("A11", "=DAYS360(44561)"); + model._set("A12", "=DAYS360(44561,44570,TRUE,1)"); + + // Error cases - invalid dates + model._set("A13", "=DAYS360(-1,44561)"); + model._set("A14", &format!("=DAYS360(44561,{EXCEL_INVALID_DATE})")); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"360"); + assert_eq!(model._get_text("A2"), *"360"); + assert_eq!(model._get_text("A3"), *"360"); + assert_eq!(model._get_text("A4"), *"0"); + assert_eq!(model._get_text("A5"), *"0"); + assert_eq!(model._get_text("A6"), *"-360"); + assert_eq!(model._get_text("A7"), *"-360"); + assert_eq!(model._get_text("A8"), *"1"); + assert_eq!(model._get_text("A9"), *"1"); + assert_eq!(model._get_text("A10"), *"#ERROR!"); + assert_eq!(model._get_text("A11"), *"#ERROR!"); + assert_eq!(model._get_text("A12"), *"#ERROR!"); + assert_eq!(model._get_text("A13"), *"#NUM!"); + assert_eq!(model._get_text("A14"), *"#NUM!"); +} + +#[test] +fn test_weekday_function() { + let mut model = new_empty_model(); + + // Test return_type parameter variations with one known date (Friday 44561) + model._set("A1", "=WEEKDAY(44561)"); // Default: Sun=1, Fri=6 + model._set("A2", "=WEEKDAY(44561,2)"); // Mon=1, Fri=5 + model._set("A3", "=WEEKDAY(44561,3)"); // Mon=0, Fri=4 + + // Test boundary days (Sun/Mon) to verify return_type logic + model._set("A4", "=WEEKDAY(44556,1)"); // Sunday: should be 1 + model._set("A5", "=WEEKDAY(44556,2)"); // Sunday: should be 7 + model._set("A6", "=WEEKDAY(44557,2)"); // Monday: should be 1 + + // Error cases + model._set("A7", "=WEEKDAY()"); // Wrong arg count + model._set("A8", "=WEEKDAY(44561,0)"); // Invalid return_type + model._set("A9", "=WEEKDAY(-1)"); // Invalid date + + model.evaluate(); + + // Core functionality + assert_eq!(model._get_text("A1"), *"6"); // Friday default + assert_eq!(model._get_text("A2"), *"5"); // Friday Mon=1 + assert_eq!(model._get_text("A3"), *"4"); // Friday Mon=0 + + // Boundary verification + assert_eq!(model._get_text("A4"), *"1"); // Sunday Sun=1 + assert_eq!(model._get_text("A5"), *"7"); // Sunday Mon=1 + assert_eq!(model._get_text("A6"), *"1"); // Monday Mon=1 + + // Error cases + assert_eq!(model._get_text("A7"), *"#ERROR!"); + assert_eq!(model._get_text("A8"), *"#VALUE!"); + assert_eq!(model._get_text("A9"), *"#NUM!"); +} + +#[test] +fn test_weeknum_function() { + let mut model = new_empty_model(); + + // Test different return_type values (1=week starts Sunday, 2=week starts Monday) + model._set("A1", "=WEEKNUM(44561)"); // Default return_type=1 + model._set("A2", "=WEEKNUM(44561,1)"); // Sunday start + model._set("A3", "=WEEKNUM(44561,2)"); // Monday start + + // Test year boundaries + model._set("A4", "=WEEKNUM(43831,1)"); // Jan 1, 2020 (Wednesday) + model._set("A5", "=WEEKNUM(43831,2)"); // Jan 1, 2020 (Wednesday) + model._set("A6", "=WEEKNUM(44196,1)"); // Dec 31, 2020 (Thursday) + model._set("A7", "=WEEKNUM(44196,2)"); // Dec 31, 2020 (Thursday) + + // Test first and last weeks of year + model._set("A8", "=WEEKNUM(44197,1)"); // Jan 1, 2021 (Friday) + model._set("A9", "=WEEKNUM(44197,2)"); // Jan 1, 2021 (Friday) + model._set("A10", "=WEEKNUM(44561,1)"); // Dec 31, 2021 (Friday) + model._set("A11", "=WEEKNUM(44561,2)"); // Dec 31, 2021 (Friday) + + // Error cases - wrong argument count + model._set("A12", "=WEEKNUM()"); + model._set("A13", "=WEEKNUM(44561,1,1)"); + + // Error cases - invalid return_type + model._set("A14", "=WEEKNUM(44561,0)"); + model._set("A15", "=WEEKNUM(44561,3)"); + model._set("A16", "=WEEKNUM(44561,-1)"); + + // Error cases - invalid dates + model._set("A17", "=WEEKNUM(-1)"); + model._set("A18", &format!("=WEEKNUM({EXCEL_INVALID_DATE})")); + + model.evaluate(); + + // Basic functionality + assert_eq!(model._get_text("A1"), *"53"); // Week 53 + assert_eq!(model._get_text("A2"), *"53"); // Week 53 (Sunday start) + assert_eq!(model._get_text("A3"), *"53"); // Week 53 (Monday start) + + // Year boundary tests + assert_eq!(model._get_text("A4"), *"1"); // Jan 1, 2020 (Sunday start) + assert_eq!(model._get_text("A5"), *"1"); // Jan 1, 2020 (Monday start) + assert_eq!(model._get_text("A6"), *"53"); // Dec 31, 2020 (Sunday start) + assert_eq!(model._get_text("A7"), *"53"); // Dec 31, 2020 (Monday start) + + // 2021 tests + assert_eq!(model._get_text("A8"), *"1"); // Jan 1, 2021 (Sunday start) + assert_eq!(model._get_text("A9"), *"1"); // Jan 1, 2021 (Monday start) + assert_eq!(model._get_text("A10"), *"53"); // Dec 31, 2021 (Sunday start) + assert_eq!(model._get_text("A11"), *"53"); // Dec 31, 2021 (Monday start) + + // Error cases + assert_eq!(model._get_text("A12"), *"#ERROR!"); + assert_eq!(model._get_text("A13"), *"#ERROR!"); + assert_eq!(model._get_text("A14"), *"#VALUE!"); + assert_eq!(model._get_text("A15"), *"#VALUE!"); + assert_eq!(model._get_text("A16"), *"#VALUE!"); + assert_eq!(model._get_text("A17"), *"#NUM!"); + assert_eq!(model._get_text("A18"), *"#NUM!"); +} + +#[test] +fn test_workday_function() { + let mut model = new_empty_model(); + + // Basic functionality + model._set("A1", "=WORKDAY(44560,1)"); + model._set("A2", "=WORKDAY(44561,-1)"); + model._set("A3", "=WORKDAY(44561,0)"); + model._set("A4", "=WORKDAY(44560,5)"); + + // Test with holidays + model._set("B1", "44561"); + model._set("A5", "=WORKDAY(44560,1,B1)"); // Should skip the holiday + model._set("B2", "44562"); + model._set("B3", "44563"); + model._set("A6", "=WORKDAY(44560,3,B1:B3)"); // Multiple holidays + + // Test starting on weekend + model._set("A7", "=WORKDAY(44562,1)"); // Saturday start + model._set("A8", "=WORKDAY(44563,1)"); // Sunday start + + // Test negative workdays + model._set("A9", "=WORKDAY(44565,-3)"); // Go backwards 3 days + model._set("A10", "=WORKDAY(44565,-5,B1:B3)"); // Backwards with holidays + + // Edge cases + model._set("A11", "=WORKDAY(1,1)"); // Early date + model._set("A12", "=WORKDAY(100000,10)"); // Large numbers + + // Error cases - wrong argument count + model._set("A13", "=WORKDAY()"); + model._set("A14", "=WORKDAY(44560)"); + model._set("A15", "=WORKDAY(44560,1,B1,B2)"); + + // Error cases - invalid dates + model._set("A16", "=WORKDAY(-1,1)"); + model._set("A17", &format!("=WORKDAY({EXCEL_INVALID_DATE},1)")); + + // Error cases - invalid holiday dates + model._set("B4", "-1"); + model._set("A18", "=WORKDAY(44560,1,B4)"); + + model.evaluate(); + + // Basic functionality + assert_eq!(model._get_text("A1"), *"44561"); // 1 day forward + assert_eq!(model._get_text("A2"), *"44560"); // 1 day backward + assert_eq!(model._get_text("A3"), *"44561"); // 0 days + assert_eq!(model._get_text("A4"), *"44567"); // 5 days forward + + // With holidays + assert_eq!(model._get_text("A5"), *"44564"); // Skip holiday, go to Monday + assert_eq!(model._get_text("A6"), *"44566"); // Skip multiple holidays + + // Weekend starts + assert_eq!(model._get_text("A7"), *"44564"); // From Saturday + assert_eq!(model._get_text("A8"), *"44564"); // From Sunday + + // Negative workdays + assert_eq!(model._get_text("A9"), *"44560"); // 3 days back + assert_eq!(model._get_text("A10"), *"44557"); // 5 days back with holidays + + // Edge cases + assert_eq!(model._get_text("A11"), *"2"); // Early date + assert_eq!(model._get_text("A12"), *"100014"); // Large numbers + + // Error cases + assert_eq!(model._get_text("A13"), *"#ERROR!"); + assert_eq!(model._get_text("A14"), *"#ERROR!"); + assert_eq!(model._get_text("A15"), *"#ERROR!"); + assert_eq!(model._get_text("A16"), *"#NUM!"); + assert_eq!(model._get_text("A17"), *"#NUM!"); + assert_eq!(model._get_text("A18"), *"#NUM!"); // Invalid holiday +} + +#[test] +fn test_workday_intl_function() { + let mut model = new_empty_model(); + + // Test key weekend mask types + model._set("A1", "=WORKDAY.INTL(44560,1,1)"); // Numeric: standard (Sat-Sun) + model._set("A2", "=WORKDAY.INTL(44560,1,2)"); // Numeric: Sun-Mon + model._set("A3", "=WORKDAY.INTL(44560,1,\"0000001\")"); // String: Sunday only + model._set("A4", "=WORKDAY.INTL(44560,1,\"1100000\")"); // String: Mon-Tue + + // Test with holidays + model._set("B1", "44561"); + model._set("A5", "=WORKDAY.INTL(44560,2,1,B1)"); // Standard + holiday + model._set("A6", "=WORKDAY.INTL(44560,2,7,B1)"); // Fri-Sat + holiday + + // Basic edge cases + model._set("A7", "=WORKDAY.INTL(44561,0,1)"); // Zero days + model._set("A8", "=WORKDAY.INTL(44565,-1,1)"); // Negative days + + // Error cases + model._set("A9", "=WORKDAY.INTL()"); // Wrong arg count + model._set("A10", "=WORKDAY.INTL(44560,1,0)"); // Invalid weekend mask + model._set("A11", "=WORKDAY.INTL(44560,1,\"123\")"); // Invalid string mask + model._set("A12", "=WORKDAY.INTL(-1,1,1)"); // Invalid date + + model.evaluate(); + + // Weekend mask functionality + assert_eq!(model._get_text("A1"), *"44561"); // Standard weekend + assert_eq!(model._get_text("A2"), *"44561"); // Sun-Mon weekend + assert_eq!(model._get_text("A3"), *"44561"); // Sunday only + assert_eq!(model._get_text("A4"), *"44561"); // Mon-Tue weekend + + // With holidays + assert_eq!(model._get_text("A5"), *"44565"); // Skip holiday + standard weekend + assert_eq!(model._get_text("A6"), *"44564"); // Skip holiday + Fri-Sat weekend + + // Edge cases + assert_eq!(model._get_text("A7"), *"44561"); // Zero days + assert_eq!(model._get_text("A8"), *"44564"); // Negative days + + // Error cases + assert_eq!(model._get_text("A9"), *"#ERROR!"); + assert_eq!(model._get_text("A10"), *"#NUM!"); + assert_eq!(model._get_text("A11"), *"#VALUE!"); + assert_eq!(model._get_text("A12"), *"#NUM!"); +} + +#[test] +fn test_yearfrac_function() { + let mut model = new_empty_model(); + + // Test key basis values (not exhaustive - just verify parameter works) + model._set("A1", "=YEARFRAC(44561,44926)"); // Default (30/360) + model._set("A2", "=YEARFRAC(44561,44926,1)"); // Actual/actual + model._set("A3", "=YEARFRAC(44561,44926,4)"); // European 30/360 + + // Edge cases + model._set("A4", "=YEARFRAC(44561,44561,1)"); // Same date = 0 + model._set("A5", "=YEARFRAC(44926,44561,1)"); // Reverse = negative + model._set("A6", "=YEARFRAC(44197,44562,1)"); // Exact year (2021) + + // Error cases + model._set("A7", "=YEARFRAC()"); // Wrong arg count + model._set("A8", "=YEARFRAC(44561,44926,5)"); // Invalid basis + model._set("A9", "=YEARFRAC(-1,44926,1)"); // Invalid date + + model.evaluate(); + + // Basic functionality (approximate values expected) + assert_eq!(model._get_text("A1"), *"1"); // About 1 year + assert_eq!(model._get_text("A2"), *"1"); // About 1 year + assert_eq!(model._get_text("A3"), *"1"); // About 1 year + + // Edge cases + assert_eq!(model._get_text("A4"), *"0"); // Same date + assert_eq!(model._get_text("A5"), *"-1"); // Negative + assert_eq!(model._get_text("A6"), *"1"); // Exact year + + // Error cases + assert_eq!(model._get_text("A7"), *"#ERROR!"); + assert_eq!(model._get_text("A8"), *"#NUM!"); // Invalid basis should return #NUM! + assert_eq!(model._get_text("A9"), *"#NUM!"); +} + +#[test] +fn test_isoweeknum_function() { + let mut model = new_empty_model(); + + // Basic functionality + model._set("A1", "=ISOWEEKNUM(44563)"); // Mid-week date + model._set("A2", "=ISOWEEKNUM(44561)"); // Year-end date + + // Key ISO week boundaries (just critical cases) + model._set("A3", "=ISOWEEKNUM(44197)"); // Jan 1, 2021 (Fri) -> Week 53 of 2020 + model._set("A4", "=ISOWEEKNUM(44200)"); // Jan 4, 2021 (Mon) -> Week 1 of 2021 + model._set("A5", "=ISOWEEKNUM(44564)"); // Jan 3, 2022 (Mon) -> Week 1 of 2022 + + // Error cases + model._set("A6", "=ISOWEEKNUM()"); // Wrong arg count + model._set("A7", "=ISOWEEKNUM(-1)"); // Invalid date + + model.evaluate(); + + // Basic functionality + assert_eq!(model._get_text("A1"), *"52"); + assert_eq!(model._get_text("A2"), *"52"); + + // ISO week boundaries + assert_eq!(model._get_text("A3"), *"53"); // Week 53 of previous year + assert_eq!(model._get_text("A4"), *"1"); // Week 1 of current year + assert_eq!(model._get_text("A5"), *"1"); // Week 1 of next year + + // Error cases + assert_eq!(model._get_text("A6"), *"#ERROR!"); + assert_eq!(model._get_text("A7"), *"#NUM!"); +} diff --git a/docs/src/functions/date-and-time.md b/docs/src/functions/date-and-time.md index 1c71f24a..b53ffd1a 100644 --- a/docs/src/functions/date-and-time.md +++ b/docs/src/functions/date-and-time.md @@ -15,12 +15,12 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | DATEDIF | | [DATEDIF](date_and_time/datedif) | | DATEVALUE | | [DATEVALUE](date_and_time/datevalue) | | DAY | | [DAY](date_and_time/day) | -| DAYS | | – | -| DAYS360 | | – | +| DAYS | | – | +| DAYS360 | | – | | EDATE | | – | | EOMONTH | | – | | HOUR | | – | -| ISOWEEKNUM | | – | +| ISOWEEKNUM | | – | | MINUTE | | – | | MONTH | | [MONTH](date_and_time/month) | | NETWORKDAYS | | [NETWORKDAYS](date_and_time/networkdays) | @@ -30,9 +30,9 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | TIME | | – | | TIMEVALUE | | – | | TODAY | | – | -| WEEKDAY | | – | -| WEEKNUM | | – | -| WORKDAY | | – | -| WORKDAY.INTL | | – | +| WEEKDAY | | – | +| WEEKNUM | | – | +| WORKDAY | | – | +| WORKDAY.INTL | | – | | YEAR | | [YEAR](date_and_time/year) | -| YEARFRAC | | – | +| YEARFRAC | | – | diff --git a/docs/src/functions/date_and_time/days.md b/docs/src/functions/date_and_time/days.md index af83a735..8f8cd9a7 100644 --- a/docs/src/functions/date_and_time/days.md +++ b/docs/src/functions/date_and_time/days.md @@ -7,6 +7,5 @@ lang: en-US # DAYS ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/days360.md b/docs/src/functions/date_and_time/days360.md index 6cee3efd..f7b516e4 100644 --- a/docs/src/functions/date_and_time/days360.md +++ b/docs/src/functions/date_and_time/days360.md @@ -7,6 +7,5 @@ lang: en-US # DAYS360 ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/isoweeknum.md b/docs/src/functions/date_and_time/isoweeknum.md index 1473cc0c..19091e5d 100644 --- a/docs/src/functions/date_and_time/isoweeknum.md +++ b/docs/src/functions/date_and_time/isoweeknum.md @@ -7,6 +7,5 @@ lang: en-US # ISOWEEKNUM ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/weekday.md b/docs/src/functions/date_and_time/weekday.md index 63c28e59..e843f1c6 100644 --- a/docs/src/functions/date_and_time/weekday.md +++ b/docs/src/functions/date_and_time/weekday.md @@ -7,6 +7,5 @@ lang: en-US # WEEKDAY ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/weeknum.md b/docs/src/functions/date_and_time/weeknum.md index 7706d731..942da396 100644 --- a/docs/src/functions/date_and_time/weeknum.md +++ b/docs/src/functions/date_and_time/weeknum.md @@ -7,6 +7,5 @@ lang: en-US # WEEKNUM ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/workday.intl.md b/docs/src/functions/date_and_time/workday.intl.md index b0aa1c2b..b6694d9d 100644 --- a/docs/src/functions/date_and_time/workday.intl.md +++ b/docs/src/functions/date_and_time/workday.intl.md @@ -7,6 +7,5 @@ lang: en-US # WORKDAY.INTL ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/workday.md b/docs/src/functions/date_and_time/workday.md index 6c7cd1ed..cbe3acc0 100644 --- a/docs/src/functions/date_and_time/workday.md +++ b/docs/src/functions/date_and_time/workday.md @@ -7,6 +7,5 @@ lang: en-US # WORKDAY ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/yearfrac.md b/docs/src/functions/date_and_time/yearfrac.md index 60521c20..e8bebfe1 100644 --- a/docs/src/functions/date_and_time/yearfrac.md +++ b/docs/src/functions/date_and_time/yearfrac.md @@ -7,6 +7,5 @@ lang: en-US # YEARFRAC ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file From 7c57accb404380ed45cec4773faf1fe32b2eb7c9 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 01:07:31 -0700 Subject: [PATCH 05/17] from excel helper --- base/src/functions/date_and_time.rs | 245 ++++++++-------------------- 1 file changed, 72 insertions(+), 173 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 57d8d62e..d99d00da 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -365,15 +365,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial_number) { - Ok(date) => date, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + let date = match self.from_excel_date(serial_number, cell) { + Ok(d) => d, + Err(e) => return e, }; let day = date.day() as f64; CalcResult::Number(day) @@ -388,15 +382,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial_number) { - Ok(date) => date, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + let date = match self.from_excel_date(serial_number, cell) { + Ok(d) => d, + Err(e) => return e, }; let month = date.month() as f64; CalcResult::Number(month) @@ -421,15 +409,9 @@ impl Model { } Err(s) => return s, }; - let date = match from_excel_date(serial_number) { - Ok(date) => date, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + let date = match self.from_excel_date(serial_number, cell) { + Ok(d) => d, + Err(e) => return e, }; if serial_number > MAXIMUM_DATE_SERIAL_NUMBER as i64 { return CalcResult::Error { @@ -526,15 +508,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial_number) { - Ok(date) => date, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + let date = match self.from_excel_date(serial_number, cell) { + Ok(d) => d, + Err(e) => return e, }; let year = date.year() as f64; CalcResult::Number(year) @@ -550,17 +526,10 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial_number) { - Ok(date) => date, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + let date = match self.from_excel_date(serial_number, cell) { + Ok(d) => d, + Err(e) => return e, }; - let months = match self.get_number(&args[1], cell) { Ok(c) => { let t = c.trunc(); @@ -597,13 +566,10 @@ impl Model { match self.evaluate_node_in_context(arg, cell) { CalcResult::Number(v) => { let date_serial = v.floor() as i64; - if from_excel_date(date_serial).is_err() { - return Err(CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }); - } + match self.from_excel_date(date_serial, cell) { + Ok(d) => d, + Err(e) => return Err(e), + }; values.push(date_serial); } CalcResult::Range { left, right } => { @@ -623,13 +589,10 @@ impl Model { }) { CalcResult::Number(v) => { let date_serial = v.floor() as i64; - if from_excel_date(date_serial).is_err() { - return Err(CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }); - } + match self.from_excel_date(date_serial, cell) { + Ok(d) => d, + Err(e) => return Err(e), + }; values.push(date_serial); } CalcResult::EmptyCell => { @@ -699,15 +662,9 @@ impl Model { }; let mut count = 0i64; for serial in from..=to { - let date = match from_excel_date(serial) { + let date = match self.from_excel_date(serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + Err(e) => return e, }; let weekday = date.weekday().number_from_monday(); let is_weekend = matches!(weekday, 6 | 7); @@ -718,6 +675,21 @@ impl Model { CalcResult::Number(count as f64 * sign) } + fn from_excel_date( + &self, + serial: i64, + cell: CellReferenceIndex, + ) -> Result { + match from_excel_date(serial) { + Ok(date) => Ok(date), + Err(_) => Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Out of range parameters for date".to_string(), + }), + } + } + fn parse_weekend_pattern( &mut self, node: Option<&Node>, @@ -849,15 +821,9 @@ impl Model { }; let mut count = 0i64; for serial in from..=to { - let date = match from_excel_date(serial) { + let date = match self.from_excel_date(serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + Err(e) => return e, }; let weekday = date.weekday().number_from_monday() as usize - 1; if !weekend_pattern[weekday] && !holidays.contains(&serial) { @@ -1085,27 +1051,14 @@ impl Model { message: "Start date greater than end date".to_string(), }; } - let start = match from_excel_date(start_serial) { + let start = match self.from_excel_date(start_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + Err(e) => return e, }; - let end = match from_excel_date(end_serial) { + let end = match self.from_excel_date(end_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - } - } + Err(e) => return e, }; - let unit = match self.get_string(&args[2], cell) { Ok(s) => s.to_ascii_uppercase(), Err(e) => return e, @@ -1222,13 +1175,14 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - if from_excel_date(end_serial).is_err() || from_excel_date(start_serial).is_err() { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + match self.from_excel_date(start_serial, cell) { + Ok(d) => d, + Err(e) => return e, + }; + match self.from_excel_date(end_serial, cell) { + Ok(d) => d, + Err(e) => return e, + }; CalcResult::Number((end_serial - start_serial) as f64) } @@ -1252,27 +1206,14 @@ impl Model { } else { false }; - let start_date = match from_excel_date(start_serial) { + let start_date = match self.from_excel_date(start_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; - let end_date = match from_excel_date(end_serial) { + let end_date = match self.from_excel_date(end_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; - fn last_day_feb(year: i32) -> u32 { if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 29 @@ -1320,15 +1261,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial) { + let date = match self.from_excel_date(serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; let return_type = if args.len() == 2 { match self.get_number(&args[1], cell) { @@ -1363,15 +1298,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial) { + let date = match self.from_excel_date(serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; let return_type = if args.len() == 2 { match self.get_number(&args[1], cell) { @@ -1424,15 +1353,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match from_excel_date(serial) { + let date = match self.from_excel_date(serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; CalcResult::Number(date.iso_week().week() as f64) } @@ -1457,15 +1380,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let mut date = match from_excel_date(start_serial) { + let mut date = match self.from_excel_date(start_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; let mut days = match self.get_number(&args[1], cell) { Ok(f) => f as i32, @@ -1673,15 +1590,9 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let mut date = match from_excel_date(start_serial) { + let mut date = match self.from_excel_date(start_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } + Err(e) => return e, }; let mut days = match self.get_number(&args[1], cell) { Ok(f) => f as i32, @@ -1734,25 +1645,13 @@ impl Model { } else { 0 }; - let start_date = match from_excel_date(start_serial) { + let start_date = match self.from_excel_date(start_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::new_error( - Error::NUM, - cell, - "Out of range parameters for date".to_string(), - ) - } + Err(e) => return e, }; - let end_date = match from_excel_date(end_serial) { + let end_date = match self.from_excel_date(end_serial, cell) { Ok(d) => d, - Err(_) => { - return CalcResult::new_error( - Error::NUM, - cell, - "Out of range parameters for date".to_string(), - ) - } + Err(e) => return e, }; let days = (end_date - start_date).num_days() as f64; let result = match basis { From 470b8f2bc89ffd338f3cd900456b5b46c5ebb0b9 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 01:39:16 -0700 Subject: [PATCH 06/17] fix build --- base/src/functions/date_and_time.rs | 59 ++++++++++++++--------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index d99d00da..04d8e3ae 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -365,7 +365,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial_number, cell) { + let date = match self.excel_date(serial_number, cell) { Ok(d) => d, Err(e) => return e, }; @@ -382,7 +382,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial_number, cell) { + let date = match self.excel_date(serial_number, cell) { Ok(d) => d, Err(e) => return e, }; @@ -409,7 +409,7 @@ impl Model { } Err(s) => return s, }; - let date = match self.from_excel_date(serial_number, cell) { + let date = match self.excel_date(serial_number, cell) { Ok(d) => d, Err(e) => return e, }; @@ -508,7 +508,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial_number, cell) { + let date = match self.excel_date(serial_number, cell) { Ok(d) => d, Err(e) => return e, }; @@ -526,7 +526,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial_number, cell) { + let date = match self.excel_date(serial_number, cell) { Ok(d) => d, Err(e) => return e, }; @@ -566,10 +566,8 @@ impl Model { match self.evaluate_node_in_context(arg, cell) { CalcResult::Number(v) => { let date_serial = v.floor() as i64; - match self.from_excel_date(date_serial, cell) { - Ok(d) => d, - Err(e) => return Err(e), - }; + // Validate date serial; propagate any error immediately. + self.excel_date(date_serial, cell)?; values.push(date_serial); } CalcResult::Range { left, right } => { @@ -589,10 +587,8 @@ impl Model { }) { CalcResult::Number(v) => { let date_serial = v.floor() as i64; - match self.from_excel_date(date_serial, cell) { - Ok(d) => d, - Err(e) => return Err(e), - }; + // Ensure each date serial is valid; if not, propagate error. + self.excel_date(date_serial, cell)?; values.push(date_serial); } CalcResult::EmptyCell => { @@ -662,7 +658,7 @@ impl Model { }; let mut count = 0i64; for serial in from..=to { - let date = match self.from_excel_date(serial, cell) { + let date = match self.excel_date(serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -675,7 +671,8 @@ impl Model { CalcResult::Number(count as f64 * sign) } - fn from_excel_date( + #[allow(clippy::wrong_self_convention)] + fn excel_date( &self, serial: i64, cell: CellReferenceIndex, @@ -821,7 +818,7 @@ impl Model { }; let mut count = 0i64; for serial in from..=to { - let date = match self.from_excel_date(serial, cell) { + let date = match self.excel_date(serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1051,11 +1048,11 @@ impl Model { message: "Start date greater than end date".to_string(), }; } - let start = match self.from_excel_date(start_serial, cell) { + let start = match self.excel_date(start_serial, cell) { Ok(d) => d, Err(e) => return e, }; - let end = match self.from_excel_date(end_serial, cell) { + let end = match self.excel_date(end_serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1175,11 +1172,11 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - match self.from_excel_date(start_serial, cell) { + match self.excel_date(start_serial, cell) { Ok(d) => d, Err(e) => return e, }; - match self.from_excel_date(end_serial, cell) { + match self.excel_date(end_serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1206,11 +1203,11 @@ impl Model { } else { false }; - let start_date = match self.from_excel_date(start_serial, cell) { + let start_date = match self.excel_date(start_serial, cell) { Ok(d) => d, Err(e) => return e, }; - let end_date = match self.from_excel_date(end_serial, cell) { + let end_date = match self.excel_date(end_serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1261,7 +1258,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial, cell) { + let date = match self.excel_date(serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1286,7 +1283,7 @@ impl Model { return CalcResult::new_error(Error::VALUE, cell, "Invalid return_type".to_string()) } _ => return CalcResult::new_error(Error::NUM, cell, "Invalid return_type".to_string()), - } as u32; + }; CalcResult::Number(num as f64) } @@ -1298,7 +1295,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial, cell) { + let date = match self.excel_date(serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1341,7 +1338,7 @@ impl Model { while first.weekday() != start_offset { first -= chrono::Duration::days(1); } - let week = ((date - first).num_days() / 7 + 1) as i64; + let week = (date - first).num_days() / 7 + 1; CalcResult::Number(week as f64) } @@ -1353,7 +1350,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let date = match self.from_excel_date(serial, cell) { + let date = match self.excel_date(serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1380,7 +1377,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let mut date = match self.from_excel_date(start_serial, cell) { + let mut date = match self.excel_date(start_serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1590,7 +1587,7 @@ impl Model { Ok(c) => c.floor() as i64, Err(s) => return s, }; - let mut date = match self.from_excel_date(start_serial, cell) { + let mut date = match self.excel_date(start_serial, cell) { Ok(d) => d, Err(e) => return e, }; @@ -1645,11 +1642,11 @@ impl Model { } else { 0 }; - let start_date = match self.from_excel_date(start_serial, cell) { + let start_date = match self.excel_date(start_serial, cell) { Ok(d) => d, Err(e) => return e, }; - let end_date = match self.from_excel_date(end_serial, cell) { + let end_date = match self.excel_date(end_serial, cell) { Ok(d) => d, Err(e) => return e, }; From ecb905fcfbc987cabd061f98a3cef99cafae2d57 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 01:45:46 -0700 Subject: [PATCH 07/17] date time macros --- base/src/functions/date_and_time.rs | 180 +++++++++++----------------- 1 file changed, 71 insertions(+), 109 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 04d8e3ae..470731ef 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -5,6 +5,57 @@ use chrono::NaiveDateTime; use chrono::NaiveTime; use chrono::Timelike; +// --------------------------------------------------------------------------- +// Helper macros to eliminate boilerplate in date/time component extraction +// functions (DAY, MONTH, YEAR, HOUR, MINUTE, SECOND). +// --------------------------------------------------------------------------- + +// Generate DAY / MONTH / YEAR helpers – simply convert the serial number to a +// NaiveDate and return the requested component as a number. +macro_rules! date_part_fn { + ($name:ident, $method:ident) => { + pub(crate) fn $name(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let serial_number = match self.get_number(&args[0], cell) { + Ok(num) => num.floor() as i64, + Err(e) => return e, + }; + let date = match self.excel_date(serial_number, cell) { + Ok(d) => d, + Err(e) => return e, + }; + CalcResult::Number(date.$method() as f64) + } + }; +} + +// Generate HOUR / MINUTE / SECOND helpers – extract the desired component from +// a day-fraction value. We pass an extraction closure so each helper can keep +// its own formula while sharing the surrounding boilerplate. +macro_rules! time_part_fn { + ($name:ident, $extract:expr) => { + pub(crate) fn $name(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 1 { + return CalcResult::new_args_number_error(cell); + } + let value = match self.get_number(&args[0], cell) { + Ok(v) => v, + Err(e) => return e, + }; + if value < 0.0 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid time".to_string(), + }; + } + CalcResult::Number(($extract)(value)) + } + }; +} + use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; use crate::constants::MINIMUM_DATE_SERIAL_NUMBER; use crate::expressions::types::CellReferenceIndex; @@ -356,39 +407,12 @@ impl Model { } } - pub(crate) fn fn_day(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let args_count = args.len(); - if args_count != 1 { - return CalcResult::new_args_number_error(cell); - } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, - }; - let date = match self.excel_date(serial_number, cell) { - Ok(d) => d, - Err(e) => return e, - }; - let day = date.day() as f64; - CalcResult::Number(day) - } - - pub(crate) fn fn_month(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let args_count = args.len(); - if args_count != 1 { - return CalcResult::new_args_number_error(cell); - } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, - }; - let date = match self.excel_date(serial_number, cell) { - Ok(d) => d, - Err(e) => return e, - }; - let month = date.month() as f64; - CalcResult::Number(month) - } + // ----------------------------------------------------------------------- + // Auto-generated DATE part helpers (DAY / MONTH / YEAR) + // ----------------------------------------------------------------------- + date_part_fn!(fn_day, day); + date_part_fn!(fn_month, month); + date_part_fn!(fn_year, year); pub(crate) fn fn_eomonth(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); @@ -499,23 +523,6 @@ impl Model { } } - pub(crate) fn fn_year(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let args_count = args.len(); - if args_count != 1 { - return CalcResult::new_args_number_error(cell); - } - let serial_number = match self.get_number(&args[0], cell) { - Ok(c) => c.floor() as i64, - Err(s) => return s, - }; - let date = match self.excel_date(serial_number, cell) { - Ok(d) => d, - Err(e) => return e, - }; - let year = date.year() as f64; - CalcResult::Number(year) - } - // date, months pub(crate) fn fn_edate(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { let args_count = args.len(); @@ -891,6 +898,10 @@ impl Model { CalcResult::Number(days_from_1900 as f64 + days.fract()) } + // ----------------------------------------------------------------------- + // Auto-generated TIME part helpers (HOUR / MINUTE / SECOND) + // ----------------------------------------------------------------------- + pub(crate) fn fn_time(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 3 { return CalcResult::new_args_number_error(cell); @@ -920,6 +931,16 @@ impl Model { CalcResult::Number(secs / day_seconds) } + time_part_fn!(fn_hour, |v: f64| (v.rem_euclid(1.0) * 24.0).floor()); + time_part_fn!(fn_minute, |v: f64| { + let total_seconds = (v.rem_euclid(1.0) * 86400.0).floor(); + ((total_seconds / 60.0) as i64 % 60) as f64 + }); + time_part_fn!(fn_second, |v: f64| { + let total_seconds = (v.rem_euclid(1.0) * 86400.0).floor(); + (total_seconds as i64 % 60) as f64 + }); + pub(crate) fn fn_timevalue(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 1 { return CalcResult::new_args_number_error(cell); @@ -938,65 +959,6 @@ impl Model { } } - pub(crate) fn fn_hour(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 1 { - return CalcResult::new_args_number_error(cell); - } - let value = match self.get_number(&args[0], cell) { - Ok(v) => v, - Err(e) => return e, - }; - if value < 0.0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Invalid time".to_string(), - }; - } - let hours = (value.rem_euclid(1.0) * 24.0).floor(); - CalcResult::Number(hours) - } - - pub(crate) fn fn_minute(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 1 { - return CalcResult::new_args_number_error(cell); - } - let value = match self.get_number(&args[0], cell) { - Ok(v) => v, - Err(e) => return e, - }; - if value < 0.0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Invalid time".to_string(), - }; - } - let total_seconds = (value.rem_euclid(1.0) * 86400.0).floor(); - let minutes = ((total_seconds / 60.0) as i64 % 60) as f64; - CalcResult::Number(minutes) - } - - pub(crate) fn fn_second(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - if args.len() != 1 { - return CalcResult::new_args_number_error(cell); - } - let value = match self.get_number(&args[0], cell) { - Ok(v) => v, - Err(e) => return e, - }; - if value < 0.0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Invalid time".to_string(), - }; - } - let total_seconds = (value.rem_euclid(1.0) * 86400.0).floor(); - let seconds = (total_seconds as i64 % 60) as f64; - CalcResult::Number(seconds) - } - pub(crate) fn fn_datevalue(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 1 { return CalcResult::new_args_number_error(cell); From 0b7201d64b7aa098e375b8bc520aea67e8d9b6a6 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 02:31:03 -0700 Subject: [PATCH 08/17] de-dupe weekend --- base/src/functions/date_and_time.rs | 96 +++++------------------------ 1 file changed, 17 insertions(+), 79 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 470731ef..0afe9106 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -694,7 +694,7 @@ impl Model { } } - fn parse_weekend_pattern( + fn weekend_mask( &mut self, node: Option<&Node>, cell: CellReferenceIndex, @@ -720,20 +720,20 @@ impl Model { )); } weekend = match code { - 1 | 0 => [false, false, false, false, false, true, true], // Saturday-Sunday - 2 => [true, false, false, false, false, false, true], // Sunday-Monday - 3 => [true, true, false, false, false, false, false], // Monday-Tuesday - 4 => [false, true, true, false, false, false, false], // Tuesday-Wednesday - 5 => [false, false, true, true, false, false, false], // Wednesday-Thursday - 6 => [false, false, false, true, true, false, false], // Thursday-Friday - 7 => [false, false, false, false, true, true, false], // Friday-Saturday - 11 => [false, false, false, false, false, false, true], // Sunday only - 12 => [true, false, false, false, false, false, false], // Monday only - 13 => [false, true, false, false, false, false, false], // Tuesday only - 14 => [false, false, true, false, false, false, false], // Wednesday only - 15 => [false, false, false, true, false, false, false], // Thursday only - 16 => [false, false, false, false, true, false, false], // Friday only - 17 => [false, false, false, false, false, true, false], // Saturday only + 1 => [false, false, false, false, false, true, true], // Saturday-Sunday + 2 => [true, false, false, false, false, false, true], // Sunday-Monday + 3 => [true, true, false, false, false, false, false], // Monday-Tuesday + 4 => [false, true, true, false, false, false, false], // Tuesday-Wednesday + 5 => [false, false, true, true, false, false, false], // Wednesday-Thursday + 6 => [false, false, false, true, true, false, false], // Thursday-Friday + 7 => [false, false, false, false, true, true, false], // Friday-Saturday + 11 => [false, false, false, false, false, false, true], // Sunday only + 12 => [true, false, false, false, false, false, false], // Monday only + 13 => [false, true, false, false, false, false, false], // Tuesday only + 14 => [false, false, true, false, false, false, false], // Wednesday only + 15 => [false, false, false, true, false, false, false], // Thursday only + 16 => [false, false, false, false, true, false, false], // Friday only + 17 => [false, false, false, false, false, true, false], // Saturday only _ => { return Err(CalcResult::new_error( Error::NUM, @@ -802,7 +802,7 @@ impl Model { Err(e) => return e, }; - let weekend_pattern = match self.parse_weekend_pattern(args.get(2), cell) { + let weekend_pattern = match self.weekend_mask(args.get(2), cell) { Ok(p) => p, Err(e) => return e, }; @@ -1475,68 +1475,6 @@ impl Model { Ok(holiday_set) } - fn weekend_from_arg( - &mut self, - arg: Option<&Node>, - cell: CellReferenceIndex, - ) -> Result<[bool; 7], CalcResult> { - if let Some(node) = arg { - match self.evaluate_node_in_context(node, cell) { - CalcResult::Number(n) => { - let code = n as i32; - let mask = match code { - 1 => [false, false, false, false, false, true, true], - 2 => [true, false, false, false, false, true, false], - 3 => [true, true, false, false, false, false, false], - 4 => [false, true, true, false, false, false, false], - 5 => [false, false, true, true, false, false, false], - 6 => [false, false, false, true, true, false, false], - 7 => [false, false, false, false, true, true, false], - 11 => [false, false, false, false, false, false, true], - 12 => [true, false, false, false, false, false, false], - 13 => [false, true, false, false, false, false, false], - 14 => [false, false, true, false, false, false, false], - 15 => [false, false, false, true, false, false, false], - 16 => [false, false, false, false, true, false, false], - 17 => [false, false, false, false, false, true, false], - _ => { - return Err(CalcResult::new_error( - Error::NUM, - cell, - "Invalid weekend".to_string(), - )) - } - }; - Ok(mask) - } - CalcResult::String(s) => { - let bytes = s.as_bytes(); - if bytes.len() == 7 && bytes.iter().all(|c| *c == b'0' || *c == b'1') { - let mut mask = [false; 7]; - for (i, b) in bytes.iter().enumerate() { - mask[i] = *b == b'1'; - } - Ok(mask) - } else { - Err(CalcResult::new_error( - Error::VALUE, - cell, - "Invalid weekend".to_string(), - )) - } - } - e @ CalcResult::Error { .. } => Err(e), - _ => Err(CalcResult::new_error( - Error::VALUE, - cell, - "Invalid weekend".to_string(), - )), - } - } else { - Ok([false, false, false, false, false, true, true]) - } - } - pub(crate) fn fn_workday_intl( &mut self, args: &[Node], @@ -1557,7 +1495,7 @@ impl Model { Ok(f) => f as i32, Err(s) => return s, }; - let weekend_mask = match self.weekend_from_arg(args.get(2), cell) { + let weekend_mask = match self.weekend_mask(args.get(2), cell) { Ok(m) => m, Err(e) => return e, }; From 6deee1720965f8f206aae7ad485ee483b42545b0 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 02:49:22 -0700 Subject: [PATCH 09/17] serial helper --- base/src/functions/date_and_time.rs | 198 +++++++++++----------------- 1 file changed, 75 insertions(+), 123 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 0afe9106..52a04abe 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -564,18 +564,23 @@ impl Model { CalcResult::Number(serial_number as f64) } - fn get_array_of_dates( + /// Walk a scalar / range / array node and invoke the provided closure with every + /// numeric date serial that is encountered. + fn collect_serial_numbers( &mut self, - arg: &Node, + node: &Node, cell: CellReferenceIndex, - ) -> Result, CalcResult> { - let mut values = Vec::new(); - match self.evaluate_node_in_context(arg, cell) { + mut handle: F, + ) -> Result<(), CalcResult> + where + F: FnMut(i64) -> Result<(), CalcResult>, + { + match self.evaluate_node_in_context(node, cell) { CalcResult::Number(v) => { - let date_serial = v.floor() as i64; - // Validate date serial; propagate any error immediately. - self.excel_date(date_serial, cell)?; - values.push(date_serial); + let serial = v.floor() as i64; + // Validate serial is in bounds + self.excel_date(serial, cell)?; + handle(serial)?; } CalcResult::Range { left, right } => { if left.sheet != right.sheet { @@ -593,45 +598,74 @@ impl Model { column, }) { CalcResult::Number(v) => { - let date_serial = v.floor() as i64; - // Ensure each date serial is valid; if not, propagate error. - self.excel_date(date_serial, cell)?; - values.push(date_serial); + let serial = v.floor() as i64; + self.excel_date(serial, cell)?; + handle(serial)?; } CalcResult::EmptyCell => { - // Empty cells are ignored in holiday lists + // ignore empty cells } e @ CalcResult::Error { .. } => return Err(e), _ => { - // Non-numeric values in holiday lists should cause VALUE error + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid holiday date".to_string(), + )) + } + } + } + } + } + CalcResult::Array(array) => { + for row in array { + for value in row { + match value { + ArrayNode::Number(num) => { + let serial = num.floor() as i64; + self.excel_date(serial, cell)?; + handle(serial)?; + } + ArrayNode::Error(error) => { return Err(CalcResult::Error { - error: Error::VALUE, + error, origin: cell, - message: "Invalid holiday date".to_string(), + message: "Error in array".to_string(), }); } + _ => { + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid holiday date".to_string(), + )) + } } } } } - CalcResult::String(_) => { - // String holidays should cause VALUE error - return Err(CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Invalid holiday date".to_string(), - }); - } e @ CalcResult::Error { .. } => return Err(e), _ => { - // Other non-numeric types should cause VALUE error - return Err(CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Invalid holiday date".to_string(), - }); + return Err(CalcResult::new_error( + Error::VALUE, + cell, + "Invalid holiday date".to_string(), + )) } } + Ok(()) + } + + fn get_array_of_dates( + &mut self, + arg: &Node, + cell: CellReferenceIndex, + ) -> Result, CalcResult> { + let mut values = Vec::new(); + self.collect_serial_numbers(arg, cell, |serial| { + values.push(serial); + Ok(()) + })?; Ok(values) } @@ -1377,99 +1411,17 @@ impl Model { let mut holiday_set = std::collections::HashSet::new(); if let Some(arg) = arg_option { - match self.evaluate_node_in_context(arg, cell) { - CalcResult::Number(value) => { - let serial = value.floor() as i64; - match from_excel_date(serial) { - Ok(date) => { - holiday_set.insert(date); - } - Err(_) => { - return Err(CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Invalid holiday date".to_string(), - }); - } - } - } - CalcResult::Range { left, right } => { - let sheet = left.sheet; - for row in left.row..=right.row { - for column in left.column..=right.column { - let cell_ref = CellReferenceIndex { sheet, row, column }; - match self.evaluate_cell(cell_ref) { - CalcResult::Number(value) => { - let serial = value.floor() as i64; - match from_excel_date(serial) { - Ok(date) => { - holiday_set.insert(date); - } - Err(_) => { - return Err(CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Invalid holiday date".to_string(), - }); - } - } - } - CalcResult::EmptyCell => { - // Ignore empty cells - } - CalcResult::Error { .. } => { - // Propagate errors - return Err(CalcResult::Error { - error: Error::VALUE, - origin: cell, - message: "Error in holiday date".to_string(), - }); - } - _ => { - // Ignore non-numeric values - } - } - } - } - } - CalcResult::Array(array) => { - for row in array { - for value in row { - match value { - ArrayNode::Number(num) => { - let serial = num.floor() as i64; - match from_excel_date(serial) { - Ok(date) => { - holiday_set.insert(date); - } - Err(_) => { - return Err(CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Invalid holiday date".to_string(), - }); - } - } - } - ArrayNode::Error(error) => { - return Err(CalcResult::Error { - error, - origin: cell, - message: "Error in holiday array".to_string(), - }); - } - _ => { - // Ignore non-numeric values - } - } - } - } - } - error @ CalcResult::Error { .. } => return Err(error), - _ => { - // Ignore other types + self.collect_serial_numbers(arg, cell, |serial| match from_excel_date(serial) { + Ok(date) => { + holiday_set.insert(date); + Ok(()) } - } + Err(_) => Err(CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "Invalid holiday date".to_string(), + }), + })?; } Ok(holiday_set) From 209537172fbdbe52f9607525b614e7e01c9d7967 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 02:52:33 -0700 Subject: [PATCH 10/17] de-dupe now today --- base/src/functions/date_and_time.rs | 81 ++++++++++++----------------- 1 file changed, 34 insertions(+), 47 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 52a04abe..5b0f72b5 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -669,6 +669,18 @@ impl Model { Ok(values) } + // Returns the current date/time as an Excel serial number in the model's configured timezone. + // Used by TODAY() and NOW(). + fn current_excel_serial(&self) -> Option { + let seconds = get_milliseconds_since_epoch() / 1000; + DateTime::from_timestamp(seconds, 0).map(|dt| { + let local_time = dt.with_timezone(&self.tz); + let days_from_1900 = local_time.num_days_from_ce() - EXCEL_DATE_BASE; + let fraction = (local_time.num_seconds_from_midnight() as f64) / (60.0 * 60.0 * 24.0); + days_from_1900 as f64 + fraction + }) + } + pub(crate) fn fn_networkdays(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if !(2..=3).contains(&args.len()) { return CalcResult::new_args_number_error(cell); @@ -872,70 +884,41 @@ impl Model { } pub(crate) fn fn_today(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let args_count = args.len(); - if args_count != 0 { + if !args.is_empty() { return CalcResult::Error { error: Error::ERROR, origin: cell, message: "Wrong number of arguments".to_string(), }; } - // milliseconds since January 1, 1970 00:00:00 UTC. - let milliseconds = get_milliseconds_since_epoch(); - let seconds = milliseconds / 1000; - let local_time = match DateTime::from_timestamp(seconds, 0) { - Some(dt) => dt.with_timezone(&self.tz), - None => { - return CalcResult::Error { - error: Error::ERROR, - origin: cell, - message: "Invalid date".to_string(), - } - } - }; - // 693_594 is computed as: - // NaiveDate::from_ymd(1900, 1, 1).num_days_from_ce() - 2 - // The 2 days offset is because of Excel 1900 bug - let days_from_1900 = local_time.num_days_from_ce() - 693_594; - - CalcResult::Number(days_from_1900 as f64) + match self.current_excel_serial() { + Some(serial) => CalcResult::Number(serial.floor()), + None => CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Invalid date".to_string(), + }, + } } pub(crate) fn fn_now(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { - let args_count = args.len(); - if args_count != 0 { + if !args.is_empty() { return CalcResult::Error { error: Error::ERROR, origin: cell, message: "Wrong number of arguments".to_string(), }; } - // milliseconds since January 1, 1970 00:00:00 UTC. - let milliseconds = get_milliseconds_since_epoch(); - let seconds = milliseconds / 1000; - let local_time = match DateTime::from_timestamp(seconds, 0) { - Some(dt) => dt.with_timezone(&self.tz), - None => { - return CalcResult::Error { - error: Error::ERROR, - origin: cell, - message: "Invalid date".to_string(), - } - } - }; - // 693_594 is computed as: - // NaiveDate::from_ymd(1900, 1, 1).num_days_from_ce() - 2 - // The 2 days offset is because of Excel 1900 bug - let days_from_1900 = local_time.num_days_from_ce() - 693_594; - let days = (local_time.num_seconds_from_midnight() as f64) / (60.0 * 60.0 * 24.0); - - CalcResult::Number(days_from_1900 as f64 + days.fract()) + match self.current_excel_serial() { + Some(serial) => CalcResult::Number(serial), + None => CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Invalid date".to_string(), + }, + } } - // ----------------------------------------------------------------------- - // Auto-generated TIME part helpers (HOUR / MINUTE / SECOND) - // ----------------------------------------------------------------------- - pub(crate) fn fn_time(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 3 { return CalcResult::new_args_number_error(cell); @@ -965,6 +948,10 @@ impl Model { CalcResult::Number(secs / day_seconds) } + // ----------------------------------------------------------------------- + // Auto-generated TIME part helpers (HOUR / MINUTE / SECOND) + // ----------------------------------------------------------------------- + time_part_fn!(fn_hour, |v: f64| (v.rem_euclid(1.0) * 24.0).floor()); time_part_fn!(fn_minute, |v: f64| { let total_seconds = (v.rem_euclid(1.0) * 86400.0).floor(); From 11d1f045ac67e46127fbfd0e3227362c2fdf9b77 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 02:59:25 -0700 Subject: [PATCH 11/17] weekend pattern enum --- base/src/functions/date_and_time.rs | 81 +++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 5b0f72b5..6cfa0e21 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -71,6 +71,68 @@ use crate::{ model::Model, }; +#[derive(Debug, Clone, Copy)] +enum WeekendPattern { + SatSun, + SunMon, + MonTue, + TueWed, + WedThu, + ThuFri, + FriSat, + SunOnly, + MonOnly, + TueOnly, + WedOnly, + ThuOnly, + FriOnly, + SatOnly, +} + +impl std::convert::TryFrom for WeekendPattern { + type Error = (); + fn try_from(code: i32) -> Result { + Ok(match code { + 1 => Self::SatSun, + 2 => Self::SunMon, + 3 => Self::MonTue, + 4 => Self::TueWed, + 5 => Self::WedThu, + 6 => Self::ThuFri, + 7 => Self::FriSat, + 11 => Self::SunOnly, + 12 => Self::MonOnly, + 13 => Self::TueOnly, + 14 => Self::WedOnly, + 15 => Self::ThuOnly, + 16 => Self::FriOnly, + 17 => Self::SatOnly, + _ => return Err(()), + }) + } +} + +impl WeekendPattern { + fn to_mask(self) -> [bool; 7] { + match self { + Self::SatSun => [false, false, false, false, false, true, true], + Self::SunMon => [true, false, false, false, false, false, true], + Self::MonTue => [true, true, false, false, false, false, false], + Self::TueWed => [false, true, true, false, false, false, false], + Self::WedThu => [false, false, true, true, false, false, false], + Self::ThuFri => [false, false, false, true, true, false, false], + Self::FriSat => [false, false, false, false, true, true, false], + Self::SunOnly => [false, false, false, false, false, false, true], + Self::MonOnly => [true, false, false, false, false, false, false], + Self::TueOnly => [false, true, false, false, false, false, false], + Self::WedOnly => [false, false, true, false, false, false, false], + Self::ThuOnly => [false, false, false, true, false, false, false], + Self::FriOnly => [false, false, false, false, true, false, false], + Self::SatOnly => [false, false, false, false, false, true, false], + } + } +} + fn parse_time_string(text: &str) -> Option { let text = text.trim(); @@ -765,22 +827,9 @@ impl Model { "Invalid weekend".to_string(), )); } - weekend = match code { - 1 => [false, false, false, false, false, true, true], // Saturday-Sunday - 2 => [true, false, false, false, false, false, true], // Sunday-Monday - 3 => [true, true, false, false, false, false, false], // Monday-Tuesday - 4 => [false, true, true, false, false, false, false], // Tuesday-Wednesday - 5 => [false, false, true, true, false, false, false], // Wednesday-Thursday - 6 => [false, false, false, true, true, false, false], // Thursday-Friday - 7 => [false, false, false, false, true, true, false], // Friday-Saturday - 11 => [false, false, false, false, false, false, true], // Sunday only - 12 => [true, false, false, false, false, false, false], // Monday only - 13 => [false, true, false, false, false, false, false], // Tuesday only - 14 => [false, false, true, false, false, false, false], // Wednesday only - 15 => [false, false, false, true, false, false, false], // Thursday only - 16 => [false, false, false, false, true, false, false], // Friday only - 17 => [false, false, false, false, false, true, false], // Saturday only - _ => { + weekend = match WeekendPattern::try_from(code) { + Ok(pattern) => pattern.to_mask(), + Err(_) => { return Err(CalcResult::new_error( Error::NUM, cell, From 109f5770f4f16a824a0465184d0207cdd3896d03 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 03:00:12 -0700 Subject: [PATCH 12/17] remove unused clippy wrong self --- base/src/functions/date_and_time.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 6cfa0e21..88aecdfe 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -786,7 +786,6 @@ impl Model { CalcResult::Number(count as f64 * sign) } - #[allow(clippy::wrong_self_convention)] fn excel_date( &self, serial: i64, From 6bd924c003927cb451f0acf997c8a17d03f4d386 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 03:00:25 -0700 Subject: [PATCH 13/17] fix docs --- docs/src/functions/date-and-time.md | 10 +++++----- docs/src/functions/date_and_time/hour.md | 4 ++-- docs/src/functions/date_and_time/minute.md | 4 ++-- docs/src/functions/date_and_time/second.md | 4 ++-- docs/src/functions/date_and_time/time.md | 4 ++-- docs/src/functions/date_and_time/timevalue.md | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/src/functions/date-and-time.md b/docs/src/functions/date-and-time.md index b53ffd1a..f331eb5f 100644 --- a/docs/src/functions/date-and-time.md +++ b/docs/src/functions/date-and-time.md @@ -19,16 +19,16 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | DAYS360 | | – | | EDATE | | – | | EOMONTH | | – | -| HOUR | | – | +| HOUR | | [HOUR](date_and_time/hour) | | ISOWEEKNUM | | – | -| MINUTE | | – | +| MINUTE | | [MINUTE](date_and_time/minute) | | MONTH | | [MONTH](date_and_time/month) | | NETWORKDAYS | | [NETWORKDAYS](date_and_time/networkdays) | | NETWORKDAYS.INTL | | [NETWORKDAYS.INTL](date_and_time/networkdays.intl) | | NOW | | – | -| SECOND | | – | -| TIME | | – | -| TIMEVALUE | | – | +| SECOND | | [SECOND](date_and_time/second) | +| TIME | | [TIME](date_and_time/time) | +| TIMEVALUE | | [TIMEVALUE](date_and_time/timevalue) | | TODAY | | – | | WEEKDAY | | – | | WEEKNUM | | – | diff --git a/docs/src/functions/date_and_time/hour.md b/docs/src/functions/date_and_time/hour.md index e7e03cb4..77d5bd2f 100644 --- a/docs/src/functions/date_and_time/hour.md +++ b/docs/src/functions/date_and_time/hour.md @@ -7,6 +7,6 @@ lang: en-US # HOUR ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +**Note:** This draft page is under construction 🚧 +The HOUR function is implemented and available in IronCalc. ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/minute.md b/docs/src/functions/date_and_time/minute.md index 91994f17..e026a20d 100644 --- a/docs/src/functions/date_and_time/minute.md +++ b/docs/src/functions/date_and_time/minute.md @@ -7,6 +7,6 @@ lang: en-US # MINUTE ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +**Note:** This draft page is under construction 🚧 +The MINUTE function is implemented and available in IronCalc. ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/second.md b/docs/src/functions/date_and_time/second.md index 3cf53386..afd899ac 100644 --- a/docs/src/functions/date_and_time/second.md +++ b/docs/src/functions/date_and_time/second.md @@ -7,6 +7,6 @@ lang: en-US # SECOND ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +**Note:** This draft page is under construction 🚧 +The SECOND function is implemented and available in IronCalc. ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/time.md b/docs/src/functions/date_and_time/time.md index 5d0fb2d9..1762ff07 100644 --- a/docs/src/functions/date_and_time/time.md +++ b/docs/src/functions/date_and_time/time.md @@ -7,6 +7,6 @@ lang: en-US # TIME ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +**Note:** This draft page is under construction 🚧 +The TIME function is implemented and available in IronCalc. ::: \ No newline at end of file diff --git a/docs/src/functions/date_and_time/timevalue.md b/docs/src/functions/date_and_time/timevalue.md index 283e4943..d408a2b8 100644 --- a/docs/src/functions/date_and_time/timevalue.md +++ b/docs/src/functions/date_and_time/timevalue.md @@ -7,6 +7,6 @@ lang: en-US # TIMEVALUE ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +**Note:** This draft page is under construction 🚧 +The TIMEVALUE function is implemented and available in IronCalc. ::: \ No newline at end of file From e84114c5329024510b784d4f851195747641bc0b Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 12:02:28 -0700 Subject: [PATCH 14/17] add test coverage --- base/src/functions/date_and_time.rs | 5 +- base/src/test/mod.rs | 5 ++ base/src/test/test_datedif_leap_month_end.rs | 33 +++++++++++ base/src/test/test_days360_month_end.rs | 43 ++++++++++++++ base/src/test/test_weekday_return_types.rs | 26 +++++++++ base/src/test/test_weeknum_return_types.rs | 31 ++++++++++ base/src/test/test_yearfrac_basis.rs | 60 ++++++++++++++++++++ 7 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 base/src/test/test_datedif_leap_month_end.rs create mode 100644 base/src/test/test_days360_month_end.rs create mode 100644 base/src/test/test_weekday_return_types.rs create mode 100644 base/src/test/test_weeknum_return_types.rs create mode 100644 base/src/test/test_yearfrac_basis.rs diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 88aecdfe..7eb5bcbc 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -1307,8 +1307,9 @@ impl Model { 2 => weekday.number_from_monday(), 3 => (weekday.number_from_monday() - 1) % 7, // 0-based Monday start 11..=17 => { - let start = (return_type - 11) as u32; // 0 for Monday - ((weekday.number_from_monday() + 7 - start) % 7) + 1 + let start = (return_type - 11) as u32; // 0 = Monday, 6 = Sunday + let zero_based = weekday.number_from_monday() - 1; // 0..6, Monday = 0 + ((zero_based + 7 - start) % 7) + 1 } 0 => { return CalcResult::new_error(Error::VALUE, cell, "Invalid return_type".to_string()) diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index 26e15a13..3a843028 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -7,6 +7,8 @@ mod test_column_width; mod test_criteria; mod test_currency; mod test_date_and_time; +mod test_datedif_leap_month_end; +mod test_days360_month_end; mod test_error_propagation; mod test_fn_average; mod test_fn_averageifs; @@ -44,8 +46,11 @@ mod test_sheets; mod test_styles; mod test_trigonometric; mod test_true_false; +mod test_weekday_return_types; +mod test_weeknum_return_types; mod test_workbook; mod test_worksheet; +mod test_yearfrac_basis; pub(crate) mod util; mod engineering; diff --git a/base/src/test/test_datedif_leap_month_end.rs b/base/src/test/test_datedif_leap_month_end.rs new file mode 100644 index 00000000..f30a46a4 --- /dev/null +++ b/base/src/test/test_datedif_leap_month_end.rs @@ -0,0 +1,33 @@ +use crate::test::util::new_empty_model; + +#[test] +fn test_datedif_yd_leap_year_edge_cases() { + let mut model = new_empty_model(); + + // 29 Feb 2020 → 28 Feb 2021 (should be 0 days) + model._set("A1", "=DATEDIF(\"29/2/2020\", \"28/2/2021\", \"YD\")"); + + // 29 Feb 2020 → 1 Mar 2021 (should be 1 day) + model._set("A2", "=DATEDIF(\"29/2/2020\", \"2021-03-01\", \"YD\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"0"); + assert_eq!(model._get_text("A2"), *"1"); +} + +#[test] +fn test_datedif_md_month_end_edge_cases() { + let mut model = new_empty_model(); + + // 31 Jan 2021 → 28 Feb 2021 (non-leap) => 28 + model._set("B1", "=DATEDIF(\"31/1/2021\", \"28/2/2021\", \"MD\")"); + + // 31 Jan 2020 → 29 Feb 2020 (leap) => 29 + model._set("B2", "=DATEDIF(\"31/1/2020\", \"29/2/2020\", \"MD\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"28"); + assert_eq!(model._get_text("B2"), *"29"); +} diff --git a/base/src/test/test_days360_month_end.rs b/base/src/test/test_days360_month_end.rs new file mode 100644 index 00000000..6c5cfdef --- /dev/null +++ b/base/src/test/test_days360_month_end.rs @@ -0,0 +1,43 @@ +use crate::test::util::new_empty_model; + +#[test] +fn test_days360_month_end_us() { + let mut model = new_empty_model(); + + // 31 Jan 2021 -> 28 Feb 2021 (non-leap) + model._set("A1", "=DAYS360(DATE(2021,1,31),DATE(2021,2,28))"); + + // 31 Jan 2020 -> 28 Feb 2020 (leap year – not last day of Feb) + model._set("A2", "=DAYS360(DATE(2020,1,31),DATE(2020,2,28))"); + + // 28 Feb 2020 -> 31 Mar 2020 (leap year span crossing month ends) + model._set("A3", "=DAYS360(DATE(2020,2,28),DATE(2020,3,31))"); + + // 30 Apr 2021 -> 31 May 2021 (end-of-month adjustment rule) + model._set("A4", "=DAYS360(DATE(2021,4,30),DATE(2021,5,31))"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"30"); + assert_eq!(model._get_text("A2"), *"28"); + assert_eq!(model._get_text("A3"), *"33"); + assert_eq!(model._get_text("A4"), *"30"); +} + +#[test] +fn test_days360_month_end_european() { + let mut model = new_empty_model(); + + // European basis = TRUE (or 1) + model._set("B1", "=DAYS360(DATE(2021,1,31),DATE(2021,2,28),TRUE)"); + + model._set("B2", "=DAYS360(DATE(2020,1,31),DATE(2020,2,29),TRUE)"); + + model._set("B3", "=DAYS360(DATE(2021,8,31),DATE(2021,9,30),TRUE)"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"28"); + assert_eq!(model._get_text("B2"), *"29"); + assert_eq!(model._get_text("B3"), *"30"); +} diff --git a/base/src/test/test_weekday_return_types.rs b/base/src/test/test_weekday_return_types.rs new file mode 100644 index 00000000..cb849a96 --- /dev/null +++ b/base/src/test/test_weekday_return_types.rs @@ -0,0 +1,26 @@ +use crate::test::util::new_empty_model; + +#[test] +fn test_weekday_return_types_11_to_17() { + let mut model = new_empty_model(); + + // Test date: 44561 corresponds to a Friday (2021-12-31). We verify the + // numeric result for each custom week start defined by return_type 11-17. + model._set("A1", "=WEEKDAY(44561,11)"); // Monday start + model._set("A2", "=WEEKDAY(44561,12)"); // Tuesday start + model._set("A3", "=WEEKDAY(44561,13)"); // Wednesday start + model._set("A4", "=WEEKDAY(44561,14)"); // Thursday start + model._set("A5", "=WEEKDAY(44561,15)"); // Friday start + model._set("A6", "=WEEKDAY(44561,16)"); // Saturday start + model._set("A7", "=WEEKDAY(44561,17)"); // Sunday start + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"5"); // Mon=1 .. Sun=7 ⇒ Fri=5 + assert_eq!(model._get_text("A2"), *"4"); // Tue start ⇒ Fri=4 + assert_eq!(model._get_text("A3"), *"3"); // Wed start ⇒ Fri=3 + assert_eq!(model._get_text("A4"), *"2"); // Thu start ⇒ Fri=2 + assert_eq!(model._get_text("A5"), *"1"); // Fri start ⇒ Fri=1 + assert_eq!(model._get_text("A6"), *"7"); // Sat start ⇒ Fri=7 + assert_eq!(model._get_text("A7"), *"6"); // Sun start ⇒ Fri=6 +} diff --git a/base/src/test/test_weeknum_return_types.rs b/base/src/test/test_weeknum_return_types.rs new file mode 100644 index 00000000..ea70ca09 --- /dev/null +++ b/base/src/test/test_weeknum_return_types.rs @@ -0,0 +1,31 @@ +use crate::test::util::new_empty_model; + +#[test] +fn test_weeknum_return_types_11_to_17_and_21() { + let mut model = new_empty_model(); + + // Date 44561 -> 2021-12-31 (Friday). Previously verified as week 53 (Sunday/Monday start). + // We verify that custom week-start codes 11-17 all map to week 53 and ISO variant (21) maps to 52. + let formulas = [ + ("A1", "=WEEKNUM(44561,11)"), + ("A2", "=WEEKNUM(44561,12)"), + ("A3", "=WEEKNUM(44561,13)"), + ("A4", "=WEEKNUM(44561,14)"), + ("A5", "=WEEKNUM(44561,15)"), + ("A6", "=WEEKNUM(44561,16)"), + ("A7", "=WEEKNUM(44561,17)"), + ("A8", "=WEEKNUM(44561,21)"), // ISO week numbering + ]; + for (cell, formula) in formulas { + model._set(cell, formula); + } + + model.evaluate(); + + // All 11-17 variations should yield 53 + for cell in ["A1", "A2", "A3", "A4", "A5", "A6", "A7"] { + assert_eq!(model._get_text(cell), *"53", "{} should be 53", cell); + } + // ISO week (return_type 21) + assert_eq!(model._get_text("A8"), *"52"); +} diff --git a/base/src/test/test_yearfrac_basis.rs b/base/src/test/test_yearfrac_basis.rs new file mode 100644 index 00000000..e236976f --- /dev/null +++ b/base/src/test/test_yearfrac_basis.rs @@ -0,0 +1,60 @@ +#![allow(clippy::panic)] +use crate::{cell::CellValue, test::util::new_empty_model}; + +#[test] +fn test_yearfrac_basis_2_actual_360() { + let mut model = new_empty_model(); + + // Non-leap span of exactly 360 days should result in 1.0 + model._set("A1", "=YEARFRAC(44561,44921,2)"); + + // Leap-year span of 366 days: Jan 1 2020 → Jan 1 2021 + model._set("A2", "=YEARFRAC(43831,44197,2)"); + + // Reverse order should yield negative value + model._set("A3", "=YEARFRAC(44921,44561,2)"); + + model.evaluate(); + + // 360/360 + assert_eq!(model._get_text("A1"), *"1"); + + // 366/360 ≈ 1.0166666667 (tolerance 1e-10) + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!A2") { + assert!((v - 1.016_666_666_7).abs() < 1e-10); + } else { + panic!("Expected numeric value in A2"); + } + + // Negative symmetric of A1 + assert_eq!(model._get_text("A3"), *"-1"); +} + +#[test] +fn test_yearfrac_basis_3_actual_365() { + let mut model = new_empty_model(); + + // Non-leap span of exactly 365 days should result in 1.0 + model._set("B1", "=YEARFRAC(44561,44926,3)"); + + // Leap-year span of 366 days + model._set("B2", "=YEARFRAC(43831,44197,3)"); + + // Same date should be 0 + model._set("B3", "=YEARFRAC(44561,44561,3)"); + + model.evaluate(); + + // 365/365 + assert_eq!(model._get_text("B1"), *"1"); + + // 366/365 ≈ 1.002739726 (tolerance 1e-10) + if let Ok(CellValue::Number(v)) = model.get_cell_value_by_ref("Sheet1!B2") { + assert!((v - 1.002_739_726).abs() < 1e-10); + } else { + panic!("Expected numeric value in B2"); + } + + // Same date + assert_eq!(model._get_text("B3"), *"0"); +} From 11ca992cfb687f2b1055aa4a4489fcdfccaa4b63 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 12:59:41 -0700 Subject: [PATCH 15/17] fix build --- base/src/test/test_weeknum_return_types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/test/test_weeknum_return_types.rs b/base/src/test/test_weeknum_return_types.rs index ea70ca09..be89d61d 100644 --- a/base/src/test/test_weeknum_return_types.rs +++ b/base/src/test/test_weeknum_return_types.rs @@ -24,7 +24,7 @@ fn test_weeknum_return_types_11_to_17_and_21() { // All 11-17 variations should yield 53 for cell in ["A1", "A2", "A3", "A4", "A5", "A6", "A7"] { - assert_eq!(model._get_text(cell), *"53", "{} should be 53", cell); + assert_eq!(model._get_text(cell), *"53", "{cell} should be 53"); } // ISO week (return_type 21) assert_eq!(model._get_text("A8"), *"52"); From ffb0f37a23e633e84fd8be9998d30980680bbb67 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Tue, 29 Jul 2025 13:00:14 -0700 Subject: [PATCH 16/17] fix cursor comment --- base/src/expressions/parser/static_analysis.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index c08e9b0e..3abade6f 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -341,7 +341,8 @@ fn static_analysis_offset(args: &[Node]) -> StaticResult { } _ => return StaticResult::Unknown, }; - StaticResult::Unknown + // Both height and width are explicitly 1, so OFFSET will return a single cell + StaticResult::Scalar } // fn static_analysis_choose(_args: &[Node]) -> StaticResult { From 22602323edbc5e75ac7045ced01a74eeddeb4f4f Mon Sep 17 00:00:00 2001 From: BrianHung Date: Mon, 29 Sep 2025 02:18:11 -0700 Subject: [PATCH 17/17] PR coments + xlsx date time --- base/src/formatter/dates.rs | 12 ++++--- base/src/functions/date_and_time.rs | 48 ++++++++++++++++++--------- xlsx/tests/calc_tests/DATE_TIME.xlsx | Bin 0 -> 10186 bytes 3 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 xlsx/tests/calc_tests/DATE_TIME.xlsx diff --git a/base/src/formatter/dates.rs b/base/src/formatter/dates.rs index 336ac140..109cd22c 100644 --- a/base/src/formatter/dates.rs +++ b/base/src/formatter/dates.rs @@ -8,6 +8,8 @@ use crate::constants::EXCEL_DATE_BASE; use crate::constants::MAXIMUM_DATE_SERIAL_NUMBER; use crate::constants::MINIMUM_DATE_SERIAL_NUMBER; +pub const DATE_OUT_OF_RANGE_MESSAGE: &str = "Out of range parameters for date"; + #[inline] fn convert_to_serial_number(date: NaiveDate) -> i32 { date.num_days_from_ce() - EXCEL_DATE_BASE @@ -37,7 +39,7 @@ pub fn from_excel_date(days: i64) -> Result { pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result { match NaiveDate::from_ymd_opt(year, month, day) { Some(native_date) => Ok(convert_to_serial_number(native_date)), - None => Err("Out of range parameters for date".to_string()), + None => Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()), } } @@ -55,7 +57,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu return Ok(MINIMUM_DATE_SERIAL_NUMBER); } let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else { - return Err("Out of range parameters for date".to_string()); + return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); }; // One thing to note for example is that even if you started with a year out of range @@ -68,7 +70,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu // As a result, we have to run range checks as we parse the date from the biggest unit to the // smallest unit. if !is_date_within_range(date) { - return Err("Out of range parameters for date".to_string()); + return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); } date = { @@ -80,7 +82,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu date = date + Months::new(abs_month); } if !is_date_within_range(date) { - return Err("Out of range parameters for date".to_string()); + return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); } date }; @@ -94,7 +96,7 @@ pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Resu date = date + Days::new(abs_day); } if !is_date_within_range(date) { - return Err("Out of range parameters for date".to_string()); + return Err(DATE_OUT_OF_RANGE_MESSAGE.to_string()); } date }; diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index 7eb5bcbc..788695af 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -5,6 +5,9 @@ use chrono::NaiveDateTime; use chrono::NaiveTime; use chrono::Timelike; +const SECONDS_PER_DAY: i32 = 86_400; +const SECONDS_PER_DAY_F64: f64 = SECONDS_PER_DAY as f64; + // --------------------------------------------------------------------------- // Helper macros to eliminate boilerplate in date/time component extraction // functions (DAY, MONTH, YEAR, HOUR, MINUTE, SECOND). @@ -61,6 +64,7 @@ use crate::constants::MINIMUM_DATE_SERIAL_NUMBER; use crate::expressions::types::CellReferenceIndex; use crate::formatter::dates::date_to_serial_number; use crate::formatter::dates::permissive_date_to_serial_number; +use crate::formatter::dates::DATE_OUT_OF_RANGE_MESSAGE; use crate::model::get_milliseconds_since_epoch; use crate::{ calc_result::CalcResult, @@ -158,7 +162,7 @@ fn parse_time_string(text: &str) -> Option { hour }; let time = NaiveTime::from_hms_opt(hour_24, 0, 0)?; - return Some(time.num_seconds_from_midnight() as f64 / 86_400.0); + return Some(time.num_seconds_from_midnight() as f64 / SECONDS_PER_DAY_F64); } } } @@ -167,7 +171,7 @@ fn parse_time_string(text: &str) -> Option { let patterns_time = ["%H:%M:%S", "%H:%M", "%I:%M %p", "%I %p", "%I:%M:%S %p"]; for p in patterns_time { if let Ok(t) = NaiveTime::parse_from_str(text, p) { - return Some(t.num_seconds_from_midnight() as f64 / 86_400.0); + return Some(t.num_seconds_from_midnight() as f64 / SECONDS_PER_DAY_F64); } } @@ -195,11 +199,11 @@ fn parse_time_string(text: &str) -> Option { ]; for p in patterns_dt { if let Ok(dt) = NaiveDateTime::parse_from_str(text, p) { - return Some(dt.time().num_seconds_from_midnight() as f64 / 86_400.0); + return Some(dt.time().num_seconds_from_midnight() as f64 / SECONDS_PER_DAY_F64); } } if let Ok(dt) = DateTime::parse_from_rfc3339(text) { - return Some(dt.time().num_seconds_from_midnight() as f64 / 86_400.0); + return Some(dt.time().num_seconds_from_midnight() as f64 / SECONDS_PER_DAY_F64); } None } @@ -242,14 +246,14 @@ fn normalize_time_components(hour: i32, minute: i32, second: i32) -> f64 { // Handle negative values by wrapping around if total_seconds < 0 { - total_seconds = total_seconds.rem_euclid(86400); + total_seconds = total_seconds.rem_euclid(SECONDS_PER_DAY); } // Normalize to within a day (0-86399 seconds) - total_seconds %= 86400; + total_seconds %= SECONDS_PER_DAY; // Convert to fraction of a day - total_seconds as f64 / 86400.0 + total_seconds as f64 / SECONDS_PER_DAY_F64 } // Check if time components should be normalized (only specific Excel edge cases) @@ -270,7 +274,7 @@ fn should_normalize_time_components(hour: i32, minute: i32, second: i32) -> bool if (0..=23).contains(&hour) && (0..=59).contains(&minute) && second == 60 { // Check if this normalizes to exactly 24:00:00 let total_seconds = hour * 3600 + minute * 60 + second; - return total_seconds == 86400; // Exactly 24:00:00 + return total_seconds == SECONDS_PER_DAY; // Exactly 24:00:00 } false @@ -371,15 +375,24 @@ fn parse_year_simple(year_str: &str) -> Result { } fn parse_datevalue_text(value: &str) -> Result { - let separator = if value.contains('/') { + // Trim whitespace and discard any time component (e.g., "2024-02-29 06:00" -> "2024-02-29") + let mut date_str = value.trim(); + if let Some(idx) = date_str.find('T') { + date_str = &date_str[..idx]; + } + if let Some(idx) = date_str.find(' ') { + date_str = &date_str[..idx]; + } + + let separator = if date_str.contains('/') { '/' - } else if value.contains('-') { + } else if date_str.contains('-') { '-' } else { return Err("Not a valid date".to_string()); }; - let mut parts: Vec<&str> = value.split(separator).map(|s| s.trim()).collect(); + let mut parts: Vec<&str> = date_str.split(separator).map(|s| s.trim()).collect(); if parts.len() != 3 { return Err("Not a valid date".to_string()); } @@ -424,6 +437,11 @@ fn parse_datevalue_text(value: &str) -> Result { let month = parse_month_simple(month_str)?; let year = parse_year_simple(year_str)?; + // Excel 1900 leap-year bug: 29-Feb-1900 is treated as serial 60 + if year == 1900 && month == 2 && day == 29 { + return Ok(60); + } + match date_to_serial_number(day, month, year) { Ok(n) => Ok(n), Err(_) => Err("Not a valid date".to_string()), @@ -554,7 +572,7 @@ impl Model { return CalcResult::Error { error: Error::NUM, origin: cell, - message: "Out of range parameters for date".to_string(), + message: DATE_OUT_OF_RANGE_MESSAGE.to_string(), }; } t @@ -1002,11 +1020,11 @@ impl Model { time_part_fn!(fn_hour, |v: f64| (v.rem_euclid(1.0) * 24.0).floor()); time_part_fn!(fn_minute, |v: f64| { - let total_seconds = (v.rem_euclid(1.0) * 86400.0).floor(); + let total_seconds = (v.rem_euclid(1.0) * SECONDS_PER_DAY_F64).floor(); ((total_seconds / 60.0) as i64 % 60) as f64 }); time_part_fn!(fn_second, |v: f64| { - let total_seconds = (v.rem_euclid(1.0) * 86400.0).floor(); + let total_seconds = (v.rem_euclid(1.0) * SECONDS_PER_DAY_F64).floor(); (total_seconds as i64 % 60) as f64 }); @@ -1363,7 +1381,7 @@ impl Model { return CalcResult::new_error( Error::NUM, cell, - "Out of range parameters for date".to_string(), + DATE_OUT_OF_RANGE_MESSAGE.to_string(), ); } }; diff --git a/xlsx/tests/calc_tests/DATE_TIME.xlsx b/xlsx/tests/calc_tests/DATE_TIME.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..96589aa03a3b5f9837cc58a02d4a01e57173301e GIT binary patch literal 10186 zcmeHt^+Q!#^Y@{<1SFL%Iluww4(V=b={hvh-6DtPptO{9hk$@|Nq4t|;GvQJqW3

R97d+4UVecRI`pn*I_Ize$%`6pp1VjP=G5{3-08jx+<;E?+Z~y=<5&(b?K!w+n zaIkkZw|6zt@N_hHF<|wu15;)r!ZT(8;P2=EZ~PZ)pd?XIsRM)?dM>j-b@Y(VUWYOy z#ZC1ES`s4)=q$H#cLv(SvcLcKgNS%RZk7c;bKRCNn*(_aeQLLvEownJ%WMU41!jq> z+`MGp($?^W&;SU%IK=6pTpf~G!r|$pRm*7riKZS<=TVOT3b{EJ5G|@oR#>^x{5vShFi+9U=zL{dntvjgm?qTs8CP z7#Xy&=v1g9Wei0cGyt|LT3ocr&v)$X*-5mlzad)dbO;+&@vc=OD&CNsXM% z!7gm9Kd%2H=YKIH|FZOwctzz75M~%m<~qFhd~z`sS6tp*Os0uS!!JN~0jCy{Lrb>U z%0P~*K^lx8?ceNo-9Nu52-)qWI$Pr`dHsMufTqs9G%Wej=_Se&dS|GVQ^{&4p4;T< zd_bt;%jUPU%#2seUCPl-~BM*Bh^fpkpTTy4pG)Y{EB=iSi&9#z8a3yG?;OL@J#<;8{<<}shXd)JojhnA#k~i8L9ZzamhR)#KS|=k z1}QkbCrBg;0DyC^84oaiGzdfk0@56Vdszv!o3x?zJt3EK*Fa)G2kVzdz^#uG_dw57?3{nR|baaTxC#e;oZpHZY>OA>jj&V-j@@(l=hoD2`Pr{%RK zS#;xE1DPY9mof{jN+RJ711*ng;UsnAzE8i7$2dxpn9vJnd0{jAyc zpv^X;4KC8n{mD7z#mu!OCuZs7NhD6+ z6>W;kip9H8^KR$M3R?q+#v}`qMxJx-W-c@#Q8oozmEu{;yB-8GYZHS|TWIhU=?RdG zBu7GE84b6t-Qn1iAru)lJ&N^WDS_w!;FNoX{1F)ywu7a2ID>%_{q0O#x<2?rIZEKx z&d{gV25gZ)Z`_X(C+(;BN>k1amZhNZUgSv2BBt3oQt+iM^zlvW+be19D+l>vZ*8%{ ztBX(D`NG}rpV?jY_9r?}`j7>A+R=geW#&1Hq@n$EpAqH-GJJEum541Fvx#cYBd2iF zM_#$Pns~U@rj4)*A6Rz-4s6^?!E)~9QV3z5Ic`b19z-oiRAuTV@Emi)58@fz#ntsp zc|YZEGGR0(@zjFd>{y6LI_gMH1_Mh|6CM-vkUej-JO}!MrKdV~l(S{G3)TwQzud(P zVBQ2jYE6%c+>A%%Bcj!5(?}l$(sI>|vQn;G#eugy-pwLJx<#2Tb|a|cNK8+qGk)Xq zC5JF5`Op@(4-0!H3Fcg+JZN|b8V_`(xw3(rzYp&KyH}Kt_bD7%#`Ry_er+XLk~rbp zw;~sDzAuqT96v2Aj$rf{^bZ=2}e z{{P94c$CCF#Cz_DAOHZQ093ephWrsp{>qd8L>0LEl<$7-fA=VfAF$~JVM?Bd-Ug4h zIC1!@ggIG^s=cvG^$T!JW2?1$lYg)TW+a_-P{fiXghys&f_>KjKJj^+ja4ILE zS>D1lSgh%e?QD*)iK25tKF-)M7p2HJLAY9=NMb!@{4)hq}@HWh^22;++hs6`Fp7$RPLW!%mNLtcFkR>|tdkP6Rcy&S=jPRvQ zi!(8~-GcaPp{&mJHjJ2FPnKtTJ`Y#3JKVMPR?lpS{HqNePcbk@=7&A|a8wN|dq`*BVHto5HPoI>tn`qYDuM@%-j8=_u3Itab zT`miBUledBo3W&}fdr4SF)upAcs7tq)bm*wV@%|-lJYh7^Rq-Uv~x)4hF;eZK&;8)1fHCSj!t;Yl2u zu#3_x2M*GSdfU>?G=pJDs|3Or0`h9&$!P<(#k_#D}O7Yrx#ZV+r$SdTY{?E~wSyG7XTziP*JZY#K>1-l~7ylYfv=C-|6o zvOMqEw=|y1=@o)80f_@dc*K3bH_8mE+z|DQ1?;wT9NZddROM_PD1@O}AU7(8poXFU zyFC*lzl@l-yD$)Z@P|SCw2Ba@a?E`&?P^8OOE%y_oWI7v6GLxH(y}Rf?B=bkt+QWP z8Xw_zGqeKi#-VYR+w**T;kiUy^!4h($5y#s-N4}X8iTb-C9LffwsW4;Vpm;JX6Wgp zTwHJ9k+*EXQuLv?`1@(gbcG2~k!px54Yp~m^ZDGN;o7&I9g+7E&lFCvCDo5qN81D@ zi~4J;PGgD(viu%4oo=bLzKVTzrzIlt=rmYPOQ)f~%apLyGdfrEMy!o}+d8{FCIPsr z@@WLS)41|=H5@tq>Y5Cl7J^bE^kQ2*S511RzLT;{(#8a1)IZi!S8>NusktR*5>6xg z@Hu?9#MWEf^~&mfipnzTi_zxcWuxfKPf&Vx`Np~Ga-oKOG`^+J&=grXD4tZ7F&Ai# zWZUxi6Dv>4b;I3RHidYSeJJYI$G%kqi0{!xWw*1(5#3|-H(SPB2P-w3z4*O$)Io5J z@7ppi<1`KS7soSlpC)Jn+f)NWgWGJLV>wRk=j$M~%SsNS`;ptVPJu>QY`5pSUJ5C` z-;3ZCng<%IehSF@M21yt07R;9Gafgsy|TPnZR5D~x+Uy$-smJp{$NgciV3Y{$b5tm zUaY^C9pK5Tg+Mf<^-7$OtBn=!`8#}S>&p% z=>o^QF_{9Hq`=-D9+?z65{|Q@vNN!?k(&UM(p~OV-KwXfO9)b^wT)LZMoK^`Ho;R2 zQenTs;$P@uP_`?DSw3VDLY>dUG{>G?xF!?C}m?JXM-1ES&|zI%`}2v-=3e{ zPTnFiC$h}VhtB+nTK^1wV#@;xz4tG~qyACo`02P@t<3Gr*?wMs`m8fuILc~#m1Po#@XVRMUD^V#mA~Db@wkr$MmYru#VU8f?Os+U&WhDOu^5T! zd<0!3$Y3E>jt$XkDH-BxL~KSY4HFwFp^wOINzO)`5Y`3uVtV7}jkVUR3THc}%Dp4C zNX%rKoQWTqx$sn^>eR(#JZHt-zZ@A(Gf+B6IuVKt2|+Os8S(c!U0Qs?-y2%6K%*B= z_O(@?mFP`oc%yPln!Ny)l(9gO-Ki}gsp*5N-c8*lCt%P5q}GvFQvHytdmq#T75y@n z*Q8nQz?&%grK+W)Vagu6y?3l!44Gm-+G&9Vi|nCcFlI|-Fbv7KRlWXz)a+pJkq_r? zZF^kkbk>{r6Ca{QaP9mcd2=X`Sje?qV9;WO^veM}(r5*Qb+Ric&)KMiZkuG?6u?L+ z8K0Y>Z|M+0Mx|Kvh=v5T4%Tz3ky( zXH;zWnw$dD;8! zcC}o@|3;d}F!8Xw^`_&i|MhY5M27~m!r`v+a_jBkmA3!wkak0m2Q}_Oi-wM z5LtwlxO z+E|C4;dK)jWb<_|B`80_yaZN*4P*UqCA9cD%RYGp>^;{!XL_hYU)X5DBl#H5ICPK1 zuu8}$dV2c7Flipz+7)MJz;x+Iy7QYTZ1kAzy^olJR4TV(A9*21G6B(QjxlG}LXfyx zw?gmL=xMI{>vL`w%rBeOsYCLyZ9LGx&fBTFx0E(7RS-WT;=y{8?8Q2r{i(92G+FAL z`&ss6^*+F%(-bes%3S8JZ6P`hu@2NKmm-_HCu)l{!gDwsui9qDXq|o_Y*lWqW-BK* zEOtIyy+yH2rP9rNPYg|1?0l&=w*ck7z4NuMc3@q8$XSmxXeYMrg>=P;q^Q~<>ZuMs z-G*eE!CHocRJ`b?2`JE-Pial#>H}^ zuGL9APyEPsQ})#yYVA=OXH#PtUs@y-2*}iMLmVjI74_hBWUs#%)!OALn`1{(nfF3k zY7+9$gIz*;K|zoB3P!j3mAfa&U{AJuRFq*)=~<)!1T#$;-x$lh!{19;itn=s+Qp41 zu^XEwjI@j)Wkjsn@x}}twOp|wEhzdfGU?lls^`yq4hxbhzG3$%jqj~p5FK~U&nk3B za}f-Y-SU2#`0@s6kF3KyUhDw%^jqlm18gIU_*9ZR) zolBeRdB?0mCF(GJl>BeyN%{r!3*``UkHs?%L*Rh|^v2Le70O*7yQWy>C_K3a!`6xi zBYhKoy_LL_SoYl%k!s3nGSPkO*{4IX!4FGTLw5}_fd^y~2Y%#%)Wh>t{Sw?7_nnuHzK4@Il*BonEW@c#JkyO8y z`_=z3r`ISY81so(6MNCKQ^tZCvSzQmX{!m_94Qv!vyLrMk`_T&bHSu-O!m_8!VSndA=pJP z;DWQ<`|iGn@z212iC=3yeP0(sx)1(Xe*}IPS5L6H%a3hJuBM&C+#gk;wxe@SHe6&z zSxq?_&D|nd^RbqcPB>e?S73>b{G0Ri1b|rjROwC7{=nk;);B(izLzw-bLw_);+e3^ z;iC$G8hsn|T}IjsJYBudspZ>~a+!oxg3GI8C?1&NqzT(G)uK}7${tS5d40RepHUP0 zoK#d8!j?1^^qhAW;$WjFYpox<;zxjpZ-h(l6b>TlY7IcmZiU&Dt7OwdOGzxxMof%p zBQI{Go+wU{7jUn>At*p&;1`Do8;|O1ik!JZ7&h0?$H%={o(O1d+@TU;(0}4NE-g zoae%X7RiZBjn?o`J$2Bb#5s#|nAYNw4yd5^>E*X2KX%+sUXjWOatx3eTCaH_l#g7c z)##DU3gx?M{x?%FLKuWT92AKH=jUw%d+gFIrC~ciGKjv27J+lz$Iy z4>j`iuA-n&={wOVmEh8}PI^0Dp{<|C!Yi&x&nxiNtp`dT_HkG>gy3W*X~W~$aLNSp zT+QNc(YS{1t1QOgexr#S4Y7?&?yi{VC*j(&+Hj@rQF`=uDlVUZsnwBzw$xwqC5vG@ z4{@t=31^*}_E z9=zoIp!apA-d{fRS>_+(q)Lqn39vy7X%n2FxL?~7)wEx4e7Q6^;ijSpVGZR-A?kzr z9vNSJA6>W-^(elfE|jQ%jZF}7@wCT4%pnQ2P7u`ZJ4F+EJ=rH*E>;>Tma{;+kO?%x z{7K`uk8kNGY&@rrtSNdEHWvmq1%b+vEBzn#1+fM^Nlgu|qkLC$u>dXpt}RT@N09(E za+o+QM1k^`?Jb(dci+bl&#v0O#uU4ZRZ`LUM@Z0#1b5VO;A}L28c#N&)XZ@6UNBB5 zr0@jFv2U+eDGvxQZ9o`<=G6R7SO*LfcqX{kd25u`(K$Tb)EI-Fulr`TzTi}=w+bnF zs-=+?n=5rWS2Kja5ha6cm$6)XWXL#0CEoWw;Gm_vHK}A|lC~vB`+L;e?JinB<>Q=W z1X_Xq8NPL7b5@do4PS8X`V(Fq%@=Yb;{&Oa_;W%6z@l}S%5dktSJFs0> zdZwyB2|op96nSiLuy5rK;VIxbu}u5A#Ypj#>7iSV`KpQ1X9!m%L`6c_?!+pYioYS` z0;Mypt;^)m3g5hT%=B5D?>_QiupLk`A7*FjJAH8SMryd|Aj1N`aZ|h|!x}0H*sOqw)bquXmA$S00xvs{24e_wIQGS0RSyVm!ydSnc2~=bICeUZ z&;n`dtgBcQi&Ro{VdlErevCQgu)tK_$nPuny~k?8;k5)=)c_IJop-qB?s-3EF+D%3 z-4`7{&i*4+ziSjzrdxZc$iKE3#-^vR!|$IwyeAaieG%2n!BoZB!O?}y)WO;O&vyC$ zYNz*JCQ7Z%ejbF`jI%95+$d1>$W;c?iB$LQ{X^mrOlGffM(#-)`Jm2;!R44beFi44 z8O0dbVW7|2;%0WDG>h@}+IuV-m+U>vM}ja;i1%(ORb2HYON!3S z`AxP_wCTZhp3jG-W(w^=$fGKFfVB+{MG zU-s{%Uo>CentIcu6cw-@uRSR*tLrLiDcRlJb(*;@@t2_^Y_!h5bJw5REL?9Ad-^z= zdZ7WdkMfk%Vd4QrpAgfWGFs48uB%ty;J=q|DN+qy!|s1h{IIs$hA-0dSl`vIc->6)l#(9%|=PEDaATd zmTXpysR6uf;N5C{7DZU?b9=jt*S*9GHW78~_clX4WeKQ~06HLe7nYd3&RVru3S-9x z6A~S3Caf~Y6n;rNGQouee+t0s#xbwF7r+24&eyyMn{Ph|C*6sY9cA7g8H-4Ag9o|0 zg*~*t%QayLtLE25gpW$FWmMvzO5J>87YHv4HiM+(2#{uXypnf0>HOX~?oiK)fZ^{< z|9Ggpc0e<)y!LkP$l&Sj)~mC7C-%>b8Bv%+rRH7{&wd)o&r+GGG1yeX%Glcehmq(H z%cHl0aFJJ+ck@6CP*Si5g7-Rt7lPVIN-?@8@1=*E2#`~&)Xiu=2S zzen<4cmRN%0s#0&T>l;ZcUSsXIE?Bq@IM{v@95t-_6tuy`}6bv#yAyuq