Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/datagrid-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-13
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading