Skip to content

fix(timezone): compute instance .tz() offset without host DST - #3174

Merged
iamkun merged 2 commits into
iamkun:devfrom
Moumouls:fix/timezone-instance-tz-offset
Aug 15, 2026
Merged

fix(timezone): compute instance .tz() offset without host DST#3174
iamkun merged 2 commits into
iamkun:devfrom
Moumouls:fix/timezone-instance-tz-offset

Conversation

@Moumouls

Copy link
Copy Markdown
Contributor

Summary

Fixes incorrect timezone offsets when converting an instant with instance .tz() on a host that observes DST.

This is the minimal follow-up to #3169, as requested by @iamkun: only the original tzOffset() change in proto.tz, no wall-clock / set / add / startOf refactor.

Bug: America/Guadeloupe is a fixed UTC−4 zone (no DST). On hosts such as Europe/Paris or Europe/London, around the EU spring-forward Sunday, dayjs(instant).tz('America/Guadeloupe') could report −03:00 instead of −04:00. Adding one day and converting again could then land on the same calendar day. The same host-DST leak also affects DST-observing target zones when their calendar is out of sync with the host (US vs EU mid-March / late October, southern-hemisphere summer).

Root cause: proto.tz derived the offset from:

const target = date.toLocaleString('en-US', { timeZone })
const diff = Math.round((date - new Date(target)) / 1000 / 60)
const offset = (-Math.round(date.getTimezoneOffset() / 15) * 15) - diff

Both new Date(target) and getTimezoneOffset() depend on the host timezone.

The static helper dayjs.tz(string, zone) already used tzOffset via Intl.DateTimeFormat#formatToParts (host-independent). Instance .tz() did not.

Fix: reuse the existing tzOffset(+date, timezone) helper in proto.tz for the offset. Keep wall-clock construction via toLocaleString and the rest of the method unchanged (utcOffset, keepLocalTime, $timezone). No changes to the utc plugin or core parse/$set.

Related: #1260
Follow-up: #3169 (calendar arithmetic set / add / startOf on zoned instances)

Tests added

  • Fixed-offset America/Guadeloupe around EU spring DST (offset −240, moment parity, +1 calendar day)
  • DST-observing target zones across host DST (NY/Paris mid-March and late-October desync, Sydney southern summer)
  • keepLocalTime between DST-desynced zones (NY → Paris)
  • Conversion/parse cases that pass without the later refactor: fall-back overlap, distinct 01:30 instants, host fall-back conversion, Lord Howe 30-minute DST, London winter UTC+0, host-local keepLocalTime, quarter-hour / date-line offsets, diff across target DST

Host matrix for test-tz-plugin: Europe/Paris, Europe/London, America/New_York, Pacific/Auckland.

Calendar-arithmetic DST cases (startOf / set / add / host spring-forward gap via instance .tz()) stay in #3169.

Test plan

  • TZ=Europe/Paris npm run test-tz-plugin
  • TZ=Europe/London npm run test-tz-plugin
  • TZ=America/New_York npm run test-tz-plugin
  • TZ=Pacific/Auckland npm run test-tz-plugin

Credits

This PR was prepared with Cursor and assistance from Grok 4.5 High.

Use tzOffset (Intl formatToParts) in proto.tz instead of
toLocaleString + new Date + getTimezoneOffset, so fixed-offset
zones like America/Guadeloupe keep UTC-4 across host spring DST.
Add iamkun's requested conversion tests (NY/Paris desync, keepLocalTime,
New_York/Auckland hosts) plus passing parse/convert cases from iamkun#3169.
@Moumouls

Copy link
Copy Markdown
Contributor Author

@iamkun we should be good to go so here

@Moumouls

Moumouls commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

One test from #3169 is intentionally not in this PR: keepLocalTime when converting UTC → America/Guadeloupe around the EU spring-forward Sunday (2050-03-27).

That case still fails without the later refactor. proto.tz now uses tzOffset() for the offset, but the wall-clock string still comes from toLocaleString and is then parsed as host local time. On Europe/Paris / Europe/London, that hour is skipped, so keepLocalTime: true lands on 05:00 instead of 04:00.

@iamkun

iamkun commented Aug 15, 2026

Copy link
Copy Markdown
Owner

LGTM, THX

@iamkun
iamkun merged commit e27ee80 into iamkun:dev Aug 15, 2026
1 check passed
iamkun added a commit that referenced this pull request Aug 16, 2026
* chore: update doc

* chore: update doc

* chore: update doc

* fix(plugin): timezone compute instance .tz() offset without host DST (#3174)

* fix(timezone): compute .tz() offset without host DST

Use tzOffset (Intl formatToParts) in proto.tz instead of
toLocaleString + new Date + getTimezoneOffset, so fixed-offset
zones like America/Guadeloupe keep UTC-4 across host spring DST.

* test(timezone): cover DST-observing zones and host TZ matrix

Add iamkun's requested conversion tests (NY/Paris desync, keepLocalTime,
New_York/Auckland hosts) plus passing parse/convert cases from #3169.

---------

Co-authored-by: Antoine Cormouls <contact.antoine.cormouls@gmail.com>
github-actions Bot pushed a commit that referenced this pull request Aug 16, 2026
## [1.11.22](v1.11.21...v1.11.22) (2026-08-16)

### Bug Fixes

* **plugin:** timezone compute instance .tz() offset without host DST ([#3174](#3174)) ([e27ee80](e27ee80)), closes [#3169](#3169)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.11.22 🎉

The release is available on:

Your semantic-release bot 📦🚀

guillermoscript added a commit to guillermoscript/calistenia-app that referenced this pull request Aug 27, 2026
…) en Hermes (#660)

* fix(core): la app no arrancaba en Android — dayjs 1.11.22+ rompe .tz() en Hermes

`RangeError: Date value out of bounds` al abrir la v1.12.1 (vc37): el bump de
dayjs 1.11.21 → 1.11.23 (#657) cambió el cálculo de offset del plugin timezone
(iamkun/dayjs#3174) a parsear `Intl.DateTimeFormat().formatToParts`, que en
Hermes devuelve NaN → `todayStr()` = «Invalid Date» → `computeCurrentStreak`
hacía `new Date(NaN).toISOString()` dentro del WorkoutProvider y tumbaba el
árbol entero. Web (V8) no lo sufre.

- dayjs pineado a 1.11.21 (core, web, mcp-server + override de pnpm).
- `todayStrIn` cae a la hora local si el plugin devuelve algo que no es
  YYYY-MM-DD, y `computeCurrentStreak` devuelve 0 con un `today` inválido en
  vez de lanzar. Test añadido.
- Release 1.12.2 / vc38 (hotfix sobre 1.12.1, que nunca salió de internal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxJW1Ydsfa6uwQ4VCCFgmC

* chore(deps): el override de dayjs va en pnpm-workspace.yaml (pnpm 10 ignora package.json#pnpm)

* fix(release): build:aab purga el bundle JS generado antes de gradle

createBundleReleaseJsAndAssets solo mira los fuentes de apps/mobile: con cambios
solo en packages/core queda UP-TO-DATE y el AAB sale con el bundle viejo (le
pasó al hotfix de dayjs de la v1.12.2).

* fix(core): un startDate inválido rehidratado de caché ya no tumba la Home

Segundo crash de la v1.12.2 en dispositivo: la vc37 (dayjs roto) había
persistido settings.startDate = «Invalid Date» en localStorage y en la caché de
React Query; al rehidratarlo, `diffDays(todayStr(), 'Invalid Date')` lanzaba
`RangeError: Invalid time value` — `dayjs.tz(str, tz)` con una cadena
imparseable no devuelve un dayjs inválido, LANZA desde formatToParts.

- ensureStartDate repara un startDate que no sea YYYY-MM-DD.
- tzDate: addDaysIn/diffDaysIn/localMidnightAsUTCIn/utcToLocalDateStrIn parsean
  primero en UTC y devuelven un valor neutro (0 / '' / la entrada) con aviso en
  vez de lanzar. Tests nuevos.
- PERSIST_BUSTER v3 para desechar la caché persistida por vc37.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxJW1Ydsfa6uwQ4VCCFgmC

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants