Skip to content

closestYear() returns a date one day early in timezones ahead of UTC #71

Description

@annakrystalli

closestYear() picks the right available date but serializes it wrongly, returning the day before, for anyone whose timezone is ahead of UTC.

$ TZ=Europe/Berlin node -e "import('./src/utils.js').then(({closestYear}) => {
    const avail = ['2020-03-14','2020-03-21','2021-08-07','2023-03-04','2023-03-11'];
    console.log(closestYear('2023-03-12', avail));  // '2023-03-10', expected '2023-03-11'
    console.log(avail.includes(closestYear('2023-03-12', avail)));  // false
  })"

Cause

_parseYYYYMMDDStr() (src/utils.js:37) builds new Date(year, month - 1, day), which is midnight in the local timezone. closestYear() then returns closestAvailYearDate.toISOString().split('T')[0] (src/utils.js:28), which is the UTC calendar date. East of UTC, local midnight falls on the previous day in UTC, so the returned string is one day early. West of UTC and at UTC itself there is no shift, which is why this has gone unnoticed.

The comparison logic is fine: every date goes through the same local-midnight parse, so the deltas are consistent and the nearest available date is selected correctly. Only the final serialization is wrong.

Impact

src/predtimechart.js:451 uses the result as an as_of: const closestAsOf = closestYear(pickedDate, availableAsOfs). Because the returned string is shifted, it is not a member of availableAsOfs at all (see the includes() check above), so picking a date that isn't exactly on an available as_of snaps to one that doesn't exist.

Test coverage

test/utils.js:12 already covers this and fails today. Three of its six cases pass only by accident: when the input is itself in availableYears, closestYear() early-returns the input string unchanged (src/utils.js:10) and never round-trips through Date. The three cases that do exercise the conversion all fail, each by exactly one day.

Observed across timezones:

TZ result
UTC, Atlantic/Azores, America/New_York, America/Los_Angeles pass
Europe/London, Europe/Berlin, Asia/Tokyo, Australia/Sydney fail

Suggested fix

Format from the local components (getFullYear() / getMonth() / getDate()) rather than toISOString(), so the string matches the timezone the Date was built in. Parsing as UTC throughout would work equally well, as long as parse and format agree.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions