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.
closestYear()picks the right available date but serializes it wrongly, returning the day before, for anyone whose timezone is ahead of UTC.Cause
_parseYYYYMMDDStr()(src/utils.js:37) buildsnew Date(year, month - 1, day), which is midnight in the local timezone.closestYear()then returnsclosestAvailYearDate.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:451uses the result as an as_of:const closestAsOf = closestYear(pickedDate, availableAsOfs). Because the returned string is shifted, it is not a member ofavailableAsOfsat all (see theincludes()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:12already covers this and fails today. Three of its six cases pass only by accident: when the input is itself inavailableYears,closestYear()early-returns the input string unchanged (src/utils.js:10) and never round-trips throughDate. The three cases that do exercise the conversion all fail, each by exactly one day.Observed across timezones:
Suggested fix
Format from the local components (
getFullYear()/getMonth()/getDate()) rather thantoISOString(), so the string matches the timezone theDatewas built in. Parsing as UTC throughout would work equally well, as long as parse and format agree.