Skip to content

fix(sheets): read date-only strings as local calendar dates - #136

Merged
mariyadavydova merged 5 commits into
agentsea:mainfrom
hey-research-lab:fix/sheets-local-date-parsing
Oct 26, 2026
Merged

mariyadavydova merged 5 commits into
agentsea:mainfrom
hey-research-lab:fix/sheets-local-date-parsing

Conversation

@hey-research-lab

Copy link
Copy Markdown
Contributor

Contribution track

  • Reproducible defect

What was wrong

parseDate and parseDateTime in packages/office-sheets/src/formula/functions-date.ts (around lines 893-944 on main) call new Date(str.v). JavaScript parses a date-only ISO string such as "2024-03-15" as UTC midnight. The date functions then read that value with local getters (getDate, getDay, getMonth) and write results with formatDate, which also uses local getters. DATE() returns YYYY-MM-DD strings in the same form, so every date that flows through a formula gets parsed this way.

In any time zone west of UTC, each such date ends up one day early:

  • =DAY("2024-03-15") returns 14
  • =WEEKDAY("2024-03-17") returns 7, not 1
  • =EDATE("2024-03-15",-1) returns 2024-02-14
  • =DATEVALUE("2024-03-15") returns 2024-03-14
  • =DAY(DATE(2024,3,15)) returns 14

The existing suite catches this, but only when the zone is set. With TZ=America/New_York or TZ=America/Los_Angeles, 8 tests in test/formula/formula.test.ts fail (DAY, WEEKDAY, EDATE, NETWORKDAYS, DATEVALUE, WEEKNUM, ISOWEEKNUM, WORKDAY). They pass under UTC and east of it, which is likely why the bug went unnoticed.

Three related spots had the same mixed UTC/local handling:

  • The NETWORKDAYS.INTL and WORKDAY.INTL holiday sets were keyed with toISOString(), the UTC date.
  • YEARFRAC bases 0 and 4 read getUTC* fields.
  • WEEKNUM divided a millisecond difference by a 7-day week. Across a daylight-saving change that span is an hour short, so =WEEKNUM("2024-03-17") returned 11 in New York instead of 12.

How it was verified

Formula tests (vitest --run test/formula), before and after:

                       before                  after
TZ=UTC                 571 passed              571 passed
TZ=America/New_York    8 failed | 563 passed   571 passed
TZ=America/Los_Angeles 8 failed | 563 passed   571 passed
TZ=Pacific/Auckland    571 passed              571 passed
TZ=Asia/Kolkata        —                       571 passed

The new test/formula/date-timezone.test.ts sets the zone itself with vi.stubEnv('TZ', ...), so it fails on any machine without the fix: 8 failed | 4 passed before (New York and Los Angeles), 12 passed after.

The whole @nautilo/office-sheets suite was also run under UTC, New York and Auckland, before and after the change. No test fails after the change that passed before. The remaining failures in this environment are files that need workspace dependencies I didn't install (importers, view tests, package-entry tests), and they're identical in both runs. tsc --noEmit -p tsconfig.json shows no errors in the changed files. The error count is the same before and after, and all of those errors come from the uninstalled dependencies.

What changed

  • New exported parseDateString(text). It builds a YYYY-MM-DD string with the local Date constructor and passes any other string to new Date(text) as before. A month outside 1-12 or a day outside 1-31 is still invalid, and day overflow (2024-02-30) still rolls over, as it did before.
  • parseDate and parseDateTime use it.
  • The NETWORKDAYS.INTL and WORKDAY.INTL holiday sets parse with parseDateString and key by formatDate (the local calendar date), and the loops compare the same way.
  • YEARFRAC bases 0 and 4 read local fields, matching the other date functions.
  • WEEKNUM counts whole calendar days from 1 January using Date.UTC on the local fields, so a DST change can't shift the week.
  • The financial yearFrac helper rounds its day count, the same way the other financial day counts already do.
  • docs/office-engines/CHANGES.md has a short entry for the change.

Trust and compatibility

No change to identity, permissions, data movement, providers or APIs. Formula results change only where they were off by a day in zones west of UTC, or across a DST boundary in WEEKNUM.

Author checks

  • I understand and can explain every changed line.

hey-research-lab and others added 2 commits October 19, 2026 13:39
parseDate used new Date(text), which parses a date-only ISO string such
as "2024-03-15" as UTC midnight, while the date functions read the
result with local getters and format it with formatDate. West of UTC
every such date moved back a day: DAY("2024-03-15") returned 14,
WEEKDAY, EDATE, DATEVALUE, NETWORKDAYS, WORKDAY and DATE() round trips
were off by one, and eight existing formula tests fail under
TZ=America/New_York.

Date-only strings are now built with the local Date constructor. The
holiday sets of NETWORKDAYS.INTL and WORKDAY.INTL are keyed by the local
calendar date, YEARFRAC reads local fields, WEEKNUM counts whole days so
a DST change cannot shift the week, and the financial yearFrac rounds
its day count. A new test runs these in zones on both sides of UTC.
@mariyadavydova

Copy link
Copy Markdown
Contributor

Thanks for the contribution! The original off-by-one problem is reproducible, and this fixes the common cases. The added time-zone tests are helpful too.

I tested the branch with main merged locally and found a few things to address before merging:

  1. Business-day counting across midnight DST changes. In America/Santiago, NETWORKDAYS("2019-09-06","2019-09-09") returns 1 instead of 2. NETWORKDAYS.INTL has the same issue. The iterator carries a DST-adjusted hour into subsequent days, causing the final day to fall outside the timestamp comparison. Comparing calendar dates rather than timestamps should avoid this.

  2. Financial day rounding changes timestamp behavior. For example:

    =ACCRINTM("2024-01-01T00:00:00","2024-01-01T11:59:59",0.1,1000,3)
    

    now returns 0, while changing the end time by one second to 12:00:00 returns a full day’s interest. Could we make date-only arithmetic DST-safe while preserving the existing behavior for timestamp inputs?

  3. YEARFRAC becomes dependent on the evaluator’s time zone for explicit timestamps.

    =YEARFRAC("2024-02-29T23:30:00-08:00","2024-03-31T23:30:00-08:00",0)
    

    returns approximately 0.0833333 in UTC and 0.0861111 in Los Angeles. Previously these matched. Bases 0 and 4 both need coverage that distinguishes date-only values from timestamps.

  4. The unused-code gate fails on the new export. bun run lint:unused flags parseDateString. Since its callers are within the same module, removing export should resolve this.

  5. One small leap-year edge case: in UTC, DAY("0000-02-29") returns 1 instead of 29. The constructor initially interprets year zero as 1900, normalizes the leap day to March 1, and only then applies setFullYear. Setting the year, month and day together should preserve the intended date.

The existing formula suite passes across the eight time zones I tested; these cases seem to fall outside its current coverage. Thanks again for tracking down the original issue and including a clear reproduction!

- NETWORKDAYS and NETWORKDAYS.INTL compare calendar days, so a day with
  no local midnight (America/Santiago, 2019-09-08) no longer drops the
  last day of the range.
- Financial day counts round only between two date-only values; a
  timestamp keeps its time of day as before.
- YEARFRAC bases 0 and 4 read a date-only value by its calendar day and
  a timestamp by its UTC day as before, so an explicit offset gives the
  same result in every zone.
- parseDateString is module-private again (lint:unused).
- A date-only value sets year, month and day together, so 0000-02-29
  keeps its leap day.

The time-zone tests now also run in UTC, America/Santiago and
Asia/Kolkata and cover each case.
@hey-research-lab

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, @mariyadavydova. All five are fixed in dc95cca, on top of a merge of current main. Each case is a new test in test/formula/date-timezone.test.ts, which now also runs in UTC, America/Santiago and Asia/Kolkata. The new tests failed before the fix (14 failed, 40 passed) and pass after it.

  1. NETWORKDAYS and NETWORKDAYS.INTL now compare calendar days, not timestamps. The range check and the direction both use a local calendar day number, so the 01:00 carried past Santiago's missing midnight no longer drops the last day. Test: "counts business days across a midnight daylight saving change" (your 2019-09-06 to 2019-09-09 case, both directions, both functions). One side effect: a timestamp pair is now counted by date as well, so NETWORKDAYS("2024-03-11T15:00","2024-03-15T10:00") returns 5 where it returned 4 before.
  2. The financial yearFrac helper only rounds when both inputs are date-only values. Timestamps keep their exact elapsed time, as before. Tests: "keeps the time of day of timestamps in financial day counts" (your ACCRINTM 11:59:59 and 12:00:00 cases) and "counts whole days between date-only values in financial day counts" (across the New York and Santiago DST changes).
  3. YEARFRAC bases 0 and 4 read a date-only value by its calendar day and a timestamp by its UTC day, as before, so your -08:00 example gives 30/360 in every zone. parseDateString records which dates it read from YYYY-MM-DD text, and isDateOnlyValue exposes that to YEARFRAC and the financial helper. Test: "reads YEARFRAC 30/360 dates from date-only values and timestamps" covers bases 0 and 4 with both kinds of input. 2024-02-29 to 2024-03-31 under basis 4 is 31/360 as a date-only value and 30/360 as your timestamp, so the test tells the two apart. Timestamps without an offset also keep the old UTC reading. I didn't change that here.
  4. parseDateString is no longer exported. bun run lint:unused passes.
  5. Year, month and day are now set together through setFullYear(year, month - 1, day). Test: "keeps a leap day in years the Date constructor maps to 19xx" (DAY and MONTH of 0000-02-29, plus 0099-02-29 still rolling over to 1 March).

What I ran:

  • vitest --run for all of @nautilo/office-sheets (84 files, 1713 tests, all passing) under TZ=UTC, America/New_York, America/Los_Angeles, America/Santiago, Asia/Kolkata and Pacific/Auckland
  • vitest --run test/formula under Europe/London, Australia/Lord_Howe and America/Sao_Paulo
  • eslint . and tsc --noEmit in the package
  • bun run lint:unused at the root

@mariyadavydova
mariyadavydova merged commit d1df6fd into agentsea:main Oct 26, 2026
9 checks passed
@mariyadavydova

Copy link
Copy Markdown
Contributor

Thank you, @hey-research-lab !

@hey-research-lab

Copy link
Copy Markdown
Contributor Author

Thanks for the quick review and merge, @mariyadavydova!

We're a small team building HEY (heyresearch.xyz), a public record of who's actually shipping on Robinhood Chain, and we found this while reading Nautilo's code. If the team is open to a quick mention from @nautiloapp on X, it would mean a lot to us. Either way, happy to help with more fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants