From 46263e85eb69ab5f85e77f1ba4e3a8911f1cdd1a Mon Sep 17 00:00:00 2001 From: Michal Michalowski Date: Tue, 31 Mar 2026 15:11:04 +0200 Subject: [PATCH] fix(fields): fix NETWORKDAYS for reverse dates and time components [CLK-563323] formulajs NETWORKDAYS has two bugs: - When start > end, the day-counting loop never runs, returning wrong results instead of a negative working-day count - Date objects with time-of-day produce fractional/off-by-one results due to Math.round on the ms difference Fix by stripping time from Date inputs and computing the forward result then negating when the initial call returns <= 0. --- src/clickup/formulajsProxy.ts | 22 +++++++++++++-- .../parsing/formula/date-time.test.js | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/clickup/formulajsProxy.ts b/src/clickup/formulajsProxy.ts index 86795d5..792b7cb 100644 --- a/src/clickup/formulajsProxy.ts +++ b/src/clickup/formulajsProxy.ts @@ -32,8 +32,26 @@ const overrides = { hasNil(startDate, endDate) ? Number.NaN : formulajs.YEARFRAC(startDate, endDate, basis), WORKDAY: (startDate: unknown, days: unknown, holidays: unknown) => hasNil(startDate) ? Number.NaN : formulajs.WORKDAY(startDate, nilToZero(days), holidays), - NETWORKDAYS: (startDate: unknown, endDate: unknown, holidays: unknown) => - hasNil(startDate, endDate) ? Number.NaN : formulajs.NETWORKDAYS(startDate, endDate, holidays), + NETWORKDAYS: (startDate: unknown, endDate: unknown, holidays: unknown) => { + if (hasNil(startDate, endDate)) return Number.NaN; + // Strip time from Date objects. Excel/Sheets ignores time-of-day, but formulajs + // uses Math.round on the ms difference which produces fractional results and + // off-by-one errors when Date objects have time components. Strings and serial + // numbers are unaffected — formulajs.parseDate handles those correctly. + const stripTime = (v: unknown): unknown => + v instanceof Date ? new Date(v.getFullYear(), v.getMonth(), v.getDate()) : v; + const start = stripTime(startDate); + const end = stripTime(endDate); + // formulajs NETWORKDAYS doesn't handle start > end: the day-counting loop + // never runs when days <= 0, so weekends/holidays aren't subtracted. + // Fix by computing the forward result and negating. + const result = formulajs.NETWORKDAYS(start, end, holidays); + if (typeof result === 'number' && result <= 0) { + const reverse = formulajs.NETWORKDAYS(end, start, holidays); + if (typeof reverse === 'number' && reverse > 0) return -reverse; + } + return result; + }, }; export const formulajsProxy = new Proxy(formulajs, { diff --git a/test/integration/parsing/formula/date-time.test.js b/test/integration/parsing/formula/date-time.test.js index 4721b1c..af5c62a 100644 --- a/test/integration/parsing/formula/date-time.test.js +++ b/test/integration/parsing/formula/date-time.test.js @@ -118,6 +118,34 @@ describe('.parse() date & time formulas', () => { expect(parser.parse('NETWORKDAYS(null, "2013-12-05")')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('NETWORKDAYS("2013-12-05", null)')).toMatchObject({ error: null, result: Number.NaN }); expect(parser.parse('NETWORKDAYS(null, null)')).toMatchObject({ error: null, result: Number.NaN }); + + // Forward: Aug 7 (Wed) to Aug 9 (Fri) — 3 working days inclusive + expect(parser.parse('NETWORKDAYS("8/7/2024", "8/9/2024")')).toMatchObject({ error: null, result: 3 }); + + // Reverse direction (start > end) — should return negative of forward count + expect(parser.parse('NETWORKDAYS("2013-12-05", "2013-12-04")')).toMatchObject({ error: null, result: -2 }); + expect(parser.parse('NETWORKDAYS("2013-12-05", "2013-11-04")')).toMatchObject({ error: null, result: -24 }); + expect(parser.parse('NETWORKDAYS("3/1/2013", "10/1/2012", [\'11/22/2012\'])')).toMatchObject({ + error: null, + result: -109, + }); + }); + + // Time components in Date objects should be ignored (Excel/Sheets behavior). + // ClickUp field values are Date objects that may include time-of-day. + it.each([new Parser(), ClickUpParser.create()])('NETWORKDAYS with time components', (parser) => { + const vars = {}; + parser.on('callVariable', (name, done) => done(vars[name])); + + // Aug 7 (Wed) 23:00 → Aug 9 (Fri) 01:00: should be 3, not 2 + vars.start = new Date(2024, 7, 7, 23, 0, 0); + vars.end = new Date(2024, 7, 9, 1, 0, 0); + expect(parser.parse('NETWORKDAYS(start, end)')).toMatchObject({ error: null, result: 3 }); + + // Aug 7 (Wed) 01:00 → Aug 9 (Fri) 23:00: should be 3, not ~2.9 + vars.start = new Date(2024, 7, 7, 1, 0, 0); + vars.end = new Date(2024, 7, 9, 23, 0, 0); + expect(parser.parse('NETWORKDAYS(start, end)')).toMatchObject({ error: null, result: 3 }); }); it.each([new Parser(), ClickUpParser.create()])('NOW', (parser) => {