diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 5dc605b003..80ab3147a7 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where exporting a date column to Excel could write the previous calendar day, or add a time that is not shown in the grid. Exported dates and times now match what the grid displays, regardless of the time zone. + ## [3.11.3] - 2026-07-27 ### Added diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/.openspec.yaml b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/.openspec.yaml new file mode 100644 index 0000000000..b6b2d1f67c --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/design.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/design.md new file mode 100644 index 0000000000..f5da6e9d19 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/design.md @@ -0,0 +1,83 @@ +## Context + +The Excel export path for a Data Grid 2 column is: + +- `cell-readers.ts` → `readChunk()` builds, per row, an array of `ExcelCell` objects (`{ t, v, z }`). +- `Export_To_Excel.js` (in `@mendix/data-widgets`) feeds those arrays to `utils.aoa_to_sheet` / `utils.sheet_add_aoa`, then `writeFileXLSX`. +- The SheetJS build used is bundled as `packages/modules/data-widgets/src/javascriptsource/datawidgets/actions/xlsx-export-tools.js` (132 KB, minified, exports only `utils` and `writeFileXLSX`). + +The decisive finding came from a throwaway node harness that imported that exact bundle and inspected the resulting `xl/worksheets/sheet1.xml`: + +| input to SheetJS | conversion used | serial for local-midnight `2007-01-01` | +| ------------------------------------------ | ---------------- | -------------------------------------- | +| raw `Date` via `aoa_to_sheet` | **local** fields | `39083` — correct | +| cell object `{ t: "d", v: Date }` at write | **UTC** fields | `39082.958333333336` — wrong | + +`sheet_add_aoa` treats a non-`Date` object as an already-built cell and assigns it through untouched, so conversion is deferred to `write_ws_xml_cell`, which takes the UTC path. The harness reproduced the customer's baseline serial to the digit — `39082.958333333336`, and `…916666` for both summer rows — confirming the loop matched the reported failure rather than something nearby. + +Given that, the `Date` the widget receives must be **local-anchored**: only a local-anchored `2007-01-01 00:00` yields `39082.9583` under UTC-field conversion (a UTC-anchored one yields `39083`). That is consistent with the grid rendering `1/1/2007` correctly, and it means nothing upstream of the widget — not the date picker, not the client's `EditableValue` — applies a spurious shift. The `Localize = OFF` detail in the report is a red herring; the same defect applies to localized attributes. + +## Goals / Non-Goals + +**Goals:** + +- An exported `t: "d"` cell carries the same wall clock the grid displays, for both date-only and time-bearing export formats. +- Correct under any session UTC offset, including fractional offsets and DST transitions. +- Regression coverage that cannot silently pass because of the host machine's timezone. + +**Non-Goals:** + +- Not patching or upgrading the bundled SheetJS, and not changing `Export_To_Excel.js`. The inconsistency is upstream behavior; the widget adapts to the documented-by-experiment contract of the API it calls. +- Not switching the export to raw `Date` values (the local-field path). That would discard the per-cell `z` format the `3.11.0`/`3.11.3` work introduced, since `aoa_to_sheet` assigns its own default date format to raw dates. +- Not addressing WC-3536's boolean complaint (product decision) or its long-number complaint (already fixed in `3.11.3`). +- Not changing how the grid itself renders dates. + +## Decisions + +1. **Re-anchor local fields onto UTC at the point a `Date` enters the export, via `toExcelWallClock()`.** + Establishes a single invariant for the rest of the module: _every `Date` reaching `excelDate()` is UTC-anchored, and its UTC fields are the wall clock Excel must show._ `stripTime()` therefore keeps its existing UTC getters unchanged and simply gains a doc comment stating the precondition. This keeps the diff small and makes the two branches (`stripTime` vs. keep-time) correct by the same rule. + Alternative considered and rejected: subtract `getTimezoneOffset()` in milliseconds. Rejected as it is the same operation expressed less legibly, and it invites the classic error of using the offset of the wrong instant across a DST boundary. + +2. **Apply the same treatment to the time-bearing branch, not just `stripTime()`.** + `hasTimeComponent(format)` previously passed the raw value straight through, exporting `13:35` for a `14:35` value. The bug is in the shared conversion, not in the truncation, so both branches must be anchored. Fixing only the reported (date-only) symptom would have left a second wrong-time defect in place. + +3. **`customContent` date strings get a zone-aware parse (`parseExportDate()`), not a blanket re-anchor.** + ECMAScript parses date-only ISO forms (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`) as **UTC**, but ISO forms carrying a time and no offset as **local**. A blanket re-anchor is therefore wrong for `"2024-06-15"`: it would produce `14-Jun-2024` in every negative-offset zone — a regression introduced by the fix itself, caught before landing. `parseExportDate()` re-anchors only strings that JS parsed locally; strings with an explicit `Z`/`±hh:mm`, and date-only ISO strings, are passed through as-is. + +4. **Timezone-agnostic tests via local `Date` construction, not a pinned `process.env.TZ`.** + Inputs are built with `new Date(2007, 0, 1)` and asserted against `new Date(Date.UTC(2007, 0, 1))`, which holds under every offset by construction. Pinning `TZ` in `jest.setup.ts` was rejected because it is package-global and would change the environment for unrelated date-filter specs. The suite was instead executed under eight zones — `Europe/Amsterdam`, `America/New_York`, `America/Anchorage`, `Pacific/Kiritimati` (+14), `Pacific/Niue` (−11), `Asia/Kathmandu` (+5:45), `Australia/Lord_Howe` (+10:30, 30-minute DST), `UTC` — as the guard against a TZ-sensitive green. + +5. **Five pre-existing tests were rewritten, not relaxed.** + They fed `new Date("2024-06-15T10:30:00Z")` — a UTC instant — into the _attribute_ reader, a shape the Mendix client never produces, and their expectations encoded the buggy UTC truncation. They passed in `Europe/Amsterdam` but failed under `Pacific/Kiritimati` and `Pacific/Niue` once the fix was in, because `10:30Z` falls on a different local date there. Their inputs are now local-constructed to match how the client supplies values. No assertion was weakened. + +## Risks / Trade-offs + +- [Risk] A future SheetJS upgrade changes the `t: "d"` write-path conversion to use local fields, at which point the re-anchoring would itself introduce an offset. + → Mitigation: the invariant is documented at `toExcelWallClock()` with the reason. The timezone-agnostic tests fail loudly under any host offset if the contract flips, rather than passing on a `UTC` CI box and breaking for customers. + +- [Risk] `parseExportDate()` cannot know the intended wall clock of a `customContent` string that names a non-UTC offset (`2007-01-01T00:00:00+05:00`); it keeps the instant, so the cell shows the UTC wall clock rather than the `+05:00` one. + → Mitigation: preserves existing behavior for such strings, so no regression. Unlikely in practice — the export-value expression is user-authored text and typically zoneless or already formatted. + +- [Trade-off] Exported dates now differ from previous releases for every non-UTC session. That is the point of the fix, but it means a customer comparing old and new exports sees every date column shift. + → Mitigation: called out in the changelog as a fix rather than a silent change. + +- [Note] `exportType = "default"` on a _Date and time_ attribute whose formatter config is not `custom` yields `format === undefined`, so `hasTimeComponent()` is false and the time is stripped. Pre-existing behavior, unchanged here, and out of scope for WC-3536 — recorded as an Open Question. + +## Migration Plan + +No data migration (pure in-memory conversion; no persisted format, XML property, or public API change). + +1. Add the failing `timezone handling` tests at the `cell-readers` seam; confirm RED (7 failures matching the reported symptom). +2. Implement `toExcelWallClock()` and route the `attribute` date branch through it. +3. Add `parseExportDate()` for the `customContent` branch, including the date-only-ISO guard. +4. Make the five TZ-fragile pre-existing tests local-anchored. +5. Run the full `datagrid-web` suite under the eight timezones above. +6. Rebuild the widget into a Mendix 10.24.16 project and verify the produced `.xlsx` serials directly. +7. Add the `CHANGELOG.md` entry. Version bump happens at release time per repo convention. +8. Rollback: revert the single commit/PR; nothing persisted to unwind. + +## Open Questions + +- Should the boolean export type keep writing a typed Excel boolean (`TRUE`/`FALSE`) or switch to text matching the grid's `Yes`/`No`? Excel renders a boolean cell as `TRUE`/`FALSE` by definition, so matching the grid means giving up the boolean type. Referred to the PM on WC-3536; deliberately not implemented either way here. +- Should `exportType = "default"` on a _Date and time_ attribute preserve the time component? Currently it strips it whenever the formatter config is not `custom`. Not part of the reported issue; deferred. +- Verified against the reporter's own app (Mendix 10.24.16, `DataGrid2Issues` module): the three date rows exported as `39083` / `38076` / `41155` — `01-Jan-2007`, `30-Mar-2004`, `03-Sep-2012` — matching the grid, with no time component and no serial fraction. diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/proposal.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/proposal.md new file mode 100644 index 0000000000..beb57536d5 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/proposal.md @@ -0,0 +1,46 @@ +## Why + +Exporting a Data Grid 2 date column to Excel writes the wrong wall clock. For a value the grid renders as `1/1/2007`, the exported cell holds Excel serial `39082` — `31-Dec-2006`, one full calendar day early. Before the `3.11.0`/`3.11.3` export work the same cell held `39082.958333` (`31-Dec-2006 23:00`), so users saw a stray time instead; the day shift was always present, the earlier serial fraction merely masked which day it landed on. Reported as WC-3536 against a customer app on Mendix 10.24.14, on an attribute with `Localize = OFF`, where no session-timezone conversion is meant to occur at all. + +The offset is the session's UTC offset and tracks DST (23:00 in Amsterdam winter, 22:00 in summer), which made this look like a localization bug in the Mendix client or the date picker. It is not: the shift is introduced entirely inside the widget's own export path. + +## Root Cause + +SheetJS is internally inconsistent about which fields of a JS `Date` represent the sheet's wall clock: + +- A **raw `Date`** passed through `utils.aoa_to_sheet` is converted using the `Date`'s **local** fields, producing the correct serial. +- A **cell object** (`{ t: "d", v: Date }`) defers conversion to write time, where the `Date`'s **UTC** fields are read instead. + +`cell-readers.ts` builds cell objects, so the export takes the UTC path. The Mendix client hands the widget a local-anchored `Date` — its _local_ fields are the stored value, which is why the grid renders correctly — so reading the UTC fields yields `2006-12-31 23:00`. `stripTime()` then truncated on those same UTC fields, turning the stray hour into the previous calendar day. + +Two consequences of the same cause, both confirmed: + +1. Date-only formats (`dd-MMM-yyyy`) export the previous day for any midnight value, i.e. for all date-only data. A non-midnight value (`14:35`) lands on the correct day, which is the signature of UTC truncation rather than a plain off-by-one. +2. Time-bearing formats (`dd-MMM-yyyy hh:mm`) skip `stripTime()` via `hasTimeComponent()` and export the time shifted by the offset — `13:35` for a `14:35` value. Not in the original report; found while diagnosing. + +`Localize = OFF` is incidental. Localized attributes were affected identically. + +## What Changes + +- Date values are re-anchored onto UTC from their **local** fields before becoming an Excel cell, so a `t: "d"` cell carries exactly the wall clock the grid displays — independent of the session offset and of DST. +- `stripTime()` keeps operating on UTC fields and now documents that it requires a UTC-anchored input. +- `customContent` date strings are parsed into the same UTC-anchored form. Strings that name a zone, and date-only ISO strings (which ECMAScript defines as UTC), are already anchored and are left alone; only genuinely local-parsed strings are re-anchored. +- Regression coverage that is timezone-agnostic by construction, so it holds under any host offset or DST rule. + +## Capabilities + +### New Capabilities + +- `datagrid-excel-export-dates`: the wall clock a Data Grid 2 date column writes into an exported Excel cell, and its relationship to what the grid displays. + +### Modified Capabilities + +_None — no existing `openspec/specs/` capability spec documents Data Grid 2 Excel export date behavior, so this is captured as a new capability rather than a delta._ + +## Impact + +- `packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts` — the fix site. New `toExcelWallClock()` helper; `parseExportDate()` for the `customContent` path; `attribute` and `customContent` date branches route through them. +- `packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts` — new `timezone handling` block; five pre-existing tests made timezone-agnostic (see design). +- `packages/pluggableWidgets/datagrid-web/CHANGELOG.md` — user-facing fix entry. +- No XML/property schema changes. No changes to the `Export_To_Excel` JS action or the bundled SheetJS in `@mendix/data-widgets`. No changes to shared packages. +- WC-3536 additionally reports two non-date complaints that this change deliberately does **not** address: long-number precision (already fixed in Data Widgets `3.11.3`) and `TRUE`/`FALSE` vs `Yes`/`No` for boolean columns (a product decision, not a defect — see Open Questions). diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/specs/datagrid-excel-export-dates/spec.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/specs/datagrid-excel-export-dates/spec.md new file mode 100644 index 0000000000..ed66650985 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/specs/datagrid-excel-export-dates/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: An exported date cell carries the wall clock the grid displays + +When a Data Grid 2 column holding a date value is exported to Excel, the resulting cell SHALL represent the same wall clock the grid renders for that value. The session's UTC offset SHALL NOT appear in the exported cell, whether as a shifted calendar day or as a time component the grid does not show. + +This SHALL hold for every session UTC offset, including offsets that are not a whole number of hours, and for values on either side of a daylight-saving transition. Whether the attribute is configured as localized or non-localized SHALL NOT affect the exported wall clock. + +#### Scenario: Midnight value with a date-only export format + +- **WHEN** an attribute column whose value the grid renders as `1/1/2007` is exported with the export format `dd-MMM-yyyy` +- **THEN** the exported cell holds `01-Jan-2007`, on the same calendar day the grid shows, and carries no time component + +#### Scenario: Midnight value across a daylight-saving boundary + +- **WHEN** values the grid renders as `3/30/2004` and `9/3/2012` — one in each DST state for the session — are exported with the export format `dd-MMM-yyyy` +- **THEN** both export on their own calendar day, `30-Mar-2004` and `03-Sep-2012`, with no dependence on which offset was in effect + +#### Scenario: Non-midnight value with a date-only export format + +- **WHEN** a value the grid renders as `1/1/2007 14:35` is exported with the export format `dd-MMM-yyyy` +- **THEN** the exported cell holds `01-Jan-2007` — the time is dropped, and the calendar day is the one the grid shows + +#### Scenario: Value with a time-bearing export format + +- **WHEN** a value the grid renders as `1/1/2007 14:35` is exported with the export format `dd-MMM-yyyy hh:mm` +- **THEN** the exported cell holds `01-Jan-2007 14:35` — the displayed time is preserved exactly, not shifted by the session offset + +#### Scenario: Default export type on a date attribute + +- **WHEN** a date attribute column is exported with export type `Default`, so the format is taken from the attribute's own formatter +- **THEN** the exported cell is on the calendar day the grid shows, on the same terms as an explicit date export format + +### Requirement: Custom content date strings export on the day the string names + +When a column with custom content is exported with export type `Date`, the exported cell SHALL represent the wall clock named by the export value string, regardless of how ECMAScript resolves that string's timezone. + +A string carrying an explicit zone, and a date-only ISO string (which ECMAScript defines as UTC), SHALL be taken at the instant it resolves to. Any other string — which ECMAScript parses in the browser's local time — SHALL be re-anchored so its named wall clock survives into the cell. + +#### Scenario: Zoneless date and time string + +- **WHEN** the export value is `2007-01-01T00:00:00` and the export format is `dd-MMM-yyyy` +- **THEN** the exported cell holds `01-Jan-2007` + +#### Scenario: Date-only ISO string + +- **WHEN** the export value is `2007-01-01` and the export format is `dd-MMM-yyyy` +- **THEN** the exported cell holds `01-Jan-2007`, including in sessions at a negative UTC offset + +#### Scenario: String with an explicit zone + +- **WHEN** the export value is `2007-01-01T00:00:00Z` and the export format is `dd-MMM-yyyy` +- **THEN** the exported cell holds `01-Jan-2007` + +#### Scenario: Locale-style date string + +- **WHEN** the export value is `1/1/2007` and the export format is `dd-MMM-yyyy` +- **THEN** the exported cell holds `01-Jan-2007` + +#### Scenario: Unparseable string + +- **WHEN** the export value cannot be parsed as a date +- **THEN** the value is exported as a text cell, unchanged, rather than as a date cell diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/tasks.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/tasks.md new file mode 100644 index 0000000000..1299f2b9fc --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/fix-excel-export-date-timezone/tasks.md @@ -0,0 +1,46 @@ +## 1. Establish a feedback loop and confirm the root cause + +- [x] 1.1 Build a node harness that imports the real bundled SheetJS (`packages/modules/data-widgets/src/javascriptsource/datawidgets/actions/xlsx-export-tools.js`), writes a workbook, and reads the serials back out of `xl/worksheets/sheet1.xml`. Capture the write via a `globalThis.saveAs` shim, since the bundle has no `fs` binding and `writeFileXLSX` otherwise throws `cannot save file`. +- [x] 1.2 Confirm the harness reproduces the reported failure exactly, not something nearby: baseline serial `39082.958333333336` for a local-midnight `2007-01-01`, and `…916666` for both summer rows. +- [x] 1.3 Establish which fields SheetJS reads: a raw `Date` through `aoa_to_sheet` converts on **local** fields (`39083`, correct); a `{ t: "d", v: Date }` cell object converts at write time on **UTC** fields (`39082.9583`, wrong). +- [x] 1.4 Confirm from the differential that the `Date` the widget receives is local-anchored, so no layer upstream of the widget applies a shift and `Localize = OFF` is not implicated. +- [x] 1.5 Confirm the predicted discriminator: a non-midnight (`14:35`) value lands on the correct day while midnight values shift back — the signature of UTC truncation rather than a plain off-by-one. + +## 2. Regression tests first (`cell-readers.spec.ts`) + +- [x] 2.1 Add a `timezone handling` describe block whose inputs are built with the local `Date` constructor and whose expectations are `Date.UTC(...)`, making every case timezone-agnostic by construction. +- [x] 2.2 Cover midnight values in both DST states (`2007-01-01`, `2004-03-30`, `2012-09-03`) with a date-only export format. +- [x] 2.3 Cover a non-midnight value with a date-only format, and the same value with a time-bearing format (`dd-MMM-yyyy hh:mm`). +- [x] 2.4 Cover `exportType = "default"` on a date attribute. +- [x] 2.5 Cover the `customContent` date paths: zoneless date-and-time, zoneless afternoon time, date-only ISO, explicit-zone, and locale-style strings. +- [x] 2.6 Confirm the suite is RED before the fix — 7 failures, each matching the reported symptom (`Expected 2007-01-01T00:00:00.000Z / Received 2006-12-31T00:00:00.000Z`). + +## 3. Implement the fix (`cell-readers.ts`) + +- [x] 3.1 Add `toExcelWallClock(date)`, re-anchoring the `Date`'s local fields onto UTC, with a comment explaining the SheetJS contract and why the raw value cannot be passed through. +- [x] 3.2 Document on `stripTime()` that it expects a UTC-anchored input; leave its UTC getters unchanged. +- [x] 3.3 Route the `attribute` date branch through `toExcelWallClock()` for **both** the `stripTime` and the `hasTimeComponent` paths, so time-bearing formats stop exporting a shifted time. +- [x] 3.4 Add `parseExportDate(value)` for the `customContent` branch, re-anchoring only strings ECMAScript parsed locally. +- [x] 3.5 Guard date-only ISO forms (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`) in `parseExportDate()` — ECMAScript parses them as UTC, so re-anchoring them would export the previous day in every negative-offset session. Verify the `EXPLICIT_ZONE` pattern does not false-match a bare `YYYY-MM-DD`. + +## 4. Verify + +- [x] 4.1 Rewrite the five pre-existing tests that fed `new Date("2024-06-15T10:30:00Z")` into the attribute reader so their inputs are local-constructed; their expectations encoded the buggy UTC truncation and broke under extreme offsets. Do not weaken any assertion. +- [x] 4.2 Run the full `datagrid-web` suite under `Europe/Amsterdam`, `America/New_York`, `America/Anchorage`, `Pacific/Kiritimati`, `Pacific/Niue`, `Asia/Kathmandu`, `Australia/Lord_Howe` and `UTC` — 235/235 green in each. +- [x] 4.3 Re-run the Task 1 harness against the fixed conversion: `39083` / `38076` / `41155` for the date-only cases and `39083.60763888889` for the time-bearing case. +- [x] 4.4 Rebuild the widget into the reporter's app on Mendix 10.24.16 and confirm the deployed bundle actually replaced the old one before trusting the export. +- [x] 4.5 Export from the running app and dump the `.xlsx`: three date cells at `39083` / `38076` / `41155`, format `dd-MMM-yyyy`, flagged date-only with no time component. +- [x] 4.6 Confirm eslint and `tsc --noEmit` are clean for both changed files. +- [x] 4.7 Confirm no debug instrumentation was left behind (the diagnosis used an external harness under `/tmp`, so nothing was added to `src/`). + +## 5. Release hygiene + +- [x] 5.1 Add a `CHANGELOG.md` entry for `datagrid-web` under `[Unreleased]`, describing the user-facing behavior only. +- [x] 5.2 Confirm no XML/property schema change is needed (none — the fix is internal to `cell-readers.ts`). +- [x] 5.3 Confirm no change is needed in `@mendix/data-widgets` (`Export_To_Excel.js` and the bundled SheetJS are untouched). +- [ ] 5.4 Confirm the `datagrid-web` patch version bump is present at release time, per repo convention that bumps happen at release rather than during development. + +## 6. Out of scope for this change (WC-3536 also covers these) + +- [x] 6.1 Long-number precision — already fixed in Data Widgets `3.11.3`; re-confirmed in the verification export (19-digit account numbers exact, as text cells). +- [ ] 6.2 Boolean `TRUE`/`FALSE` vs the grid's `Yes`/`No` — product decision referred to the PM on WC-3536. Not implemented in either direction here. diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 22955724ba..e607c9a285 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -83,7 +83,8 @@ describe("cell-readers", () => { }); it("uses attribute date formatter when exportType is default", () => { - const testDate = new Date("2024-06-15T10:30:00Z"); + // Local time, the way the Mendix client hands over a DateTime value. + const testDate = new Date(2024, 5, 15, 10, 30, 0); const attr = listAttribute(() => testDate) as any; attr.formatter = { type: "datetime", config: { type: "custom", pattern: "dd/MM/yyyy" } }; const col = column("Created", c => { @@ -98,7 +99,8 @@ describe("cell-readers", () => { }); it("returns no format for default datetime with non-custom config", () => { - const testDate = new Date("2024-06-15T10:30:00Z"); + // Local time, the way the Mendix client hands over a DateTime value. + const testDate = new Date(2024, 5, 15, 10, 30, 0); const attr = listAttribute(() => testDate) as any; attr.formatter = { type: "datetime", config: { type: "date" } }; const col = column("Created", c => { @@ -172,7 +174,8 @@ describe("cell-readers", () => { }); it("exports date attribute with format as date cell", () => { - const testDate = new Date("2024-06-15T10:30:00Z"); + // Local time, the way the Mendix client hands over a DateTime value. + const testDate = new Date(2024, 5, 15, 10, 30, 0); const col = column("Created", c => { c.showContentAs = "attribute"; c.attribute = listAttribute(() => testDate); @@ -186,7 +189,8 @@ describe("cell-readers", () => { }); it("exports date attribute without format using default date format", () => { - const testDate = new Date("2024-06-15T10:30:00Z"); + // Local time, the way the Mendix client hands over a DateTime value. + const testDate = new Date(2024, 5, 15, 10, 30, 0); const col = column("Created", c => { c.showContentAs = "attribute"; c.attribute = listAttribute(() => testDate); @@ -474,7 +478,8 @@ describe("cell-readers", () => { describe("date time stripping", () => { it("strips time from attribute date when format has no time components", () => { - const testDate = new Date("2024-06-15T10:30:00Z"); + // Local time, the way the Mendix client hands over a DateTime value. + const testDate = new Date(2024, 5, 15, 10, 30, 0); const col = column("DateOnly", c => { c.showContentAs = "attribute"; c.attribute = listAttribute(() => testDate); @@ -488,7 +493,8 @@ describe("cell-readers", () => { }); it("preserves time in attribute date when format has time components", () => { - const testDate = new Date("2024-06-15T10:30:00Z"); + // Constructed in local time, the way the Mendix client hands over a DateTime value. + const testDate = new Date(2024, 5, 15, 10, 30, 0); const col = column("DateTime", c => { c.showContentAs = "attribute"; c.attribute = listAttribute(() => testDate); @@ -497,7 +503,7 @@ describe("cell-readers", () => { }); const cell = readSingleCell(col); expect(cell.t).toBe("d"); - expect(cell.v).toEqual(testDate); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15, 10, 30, 0))); }); it("strips time from customContent date when format has no time components", () => { @@ -524,4 +530,76 @@ describe("cell-readers", () => { expect(cell.v).toEqual(new Date("2024-06-15T10:30:00Z")); }); }); + + /** + * SheetJS reads the UTC fields of a `t: "d"` cell value as the wall clock to store in the + * sheet, so every exported date must be UTC-anchored on the wall clock the grid displays. + * These cases are timezone-agnostic on purpose: the inputs are built with the local `Date` + * constructor, so they hold regardless of the offset (or DST rule) the test host runs in. + */ + describe("timezone handling", () => { + it.each([ + ["winter", 2007, 0, 1], + ["summer", 2004, 2, 30], + ["summer", 2012, 8, 3] + ])("exports a %s midnight date on the same calendar day as the grid", (_season, year, month, day) => { + const col = column("BirthDate", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Date(year, month, day)); + c.exportType = "date"; + c.exportDateFormat = dynamic.available("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.v).toEqual(new Date(Date.UTC(year, month, day))); + }); + + it("exports a non-midnight date on the same calendar day as the grid", () => { + const col = column("BirthDate", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Date(2007, 0, 1, 14, 35)); + c.exportType = "date"; + c.exportDateFormat = dynamic.available("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.v).toEqual(new Date(Date.UTC(2007, 0, 1))); + }); + + it("keeps the displayed time when the format has time components", () => { + const col = column("BirthDate", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Date(2007, 0, 1, 14, 35)); + c.exportType = "date"; + c.exportDateFormat = dynamic.available("dd-mmm-yyyy hh:mm"); + }); + const cell = readSingleCell(col); + expect(cell.v).toEqual(new Date(Date.UTC(2007, 0, 1, 14, 35))); + }); + + it("exports a default-formatted date on the same calendar day as the grid", () => { + const col = column("BirthDate", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Date(2007, 0, 1)); + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.v).toEqual(new Date(Date.UTC(2007, 0, 1))); + }); + + it.each([ + ["zoneless date and time", "2007-01-01T00:00:00"], + ["zoneless afternoon time", "2007-01-01T14:35:00"], + ["date-only ISO string (UTC by spec)", "2007-01-01"], + ["string with an explicit zone", "2007-01-01T00:00:00Z"], + ["locale-style date", "1/1/2007"] + ])("exports a customContent %s on the day the string names", (_label, value) => { + const col = column("BirthDate", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => value); + c.exportType = "date"; + c.exportDateFormat = dynamic.available("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.v).toEqual(new Date(Date.UTC(2007, 0, 1))); + }); + }); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index 7f3382c6ee..16306a7a0d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -99,10 +99,53 @@ function hasTimeComponent(format: string): boolean { return /[hs]/i.test(stripped); } +/** + * SheetJS turns a `t: "d"` cell into a sheet serial by reading the `Date`'s **UTC** fields, while + * the grid renders the same value using its local fields. A `Date` handed over by the Mendix client + * is local-anchored (`new Date(2007, 0, 1)`), so passing it through unchanged exports the session's + * UTC offset as a stray time — and, once the time is stripped, the previous calendar day. + * + * Re-anchoring the local fields onto UTC makes the cell carry exactly the wall clock the grid shows, + * independent of the offset or DST rule in effect. + */ +function toExcelWallClock(date: Date): Date { + return new Date( + Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() + ) + ); +} + +/** Expects a UTC-anchored date, as produced by {@link toExcelWallClock}. */ function stripTime(date: Date): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } +const EXPLICIT_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i; +/** Date-only ISO forms (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`), which ECMAScript parses as UTC. */ +const DATE_ONLY_ISO = /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/; + +/** + * Parses an exported date string into a UTC-anchored date. A string that names a zone — or a + * date-only ISO string, which ECMAScript defines as UTC — already resolves to an instant whose UTC + * fields are the wall clock it asked for. Anything else is parsed by the browser in local time, so + * its fields need re-anchoring. + */ +function parseExportDate(value: string): Date | undefined { + const parsed = new Date(value); + if (isNaN(parsed.getTime())) { + return undefined; + } + const trimmed = value.trim(); + return EXPLICIT_ZONE.test(trimmed) || DATE_ONLY_ISO.test(trimmed) ? parsed : toExcelWallClock(parsed); +} + const MAX_SAFE_SIGNIFICANT_DIGITS = 15; function countSignificantDigits(value: Big): number { @@ -165,7 +208,8 @@ const readers: ReadersByType = { }); if (value instanceof Date) { - const dateValue = format && hasTimeComponent(format) ? value : stripTime(value); + const wallClock = toExcelWallClock(value); + const dateValue = format && hasTimeComponent(format) ? wallClock : stripTime(wallClock); return excelDate(dateValue, format); } @@ -218,8 +262,8 @@ const readers: ReadersByType = { } if (exportType === "date" && value !== "") { - const parsed = new Date(value); - if (!isNaN(parsed.getTime())) { + const parsed = parseExportDate(value); + if (parsed) { const dateValue = format && hasTimeComponent(format) ? parsed : stripTime(parsed); return excelDate(dateValue, format); }