Skip to content

Commit 46f8026

Browse files
hotlongclaude
andcommitted
fix(rest): /import keeps reading a year-first date (2026/7/15), on a real day, stored padded (#20534)
Per the maintainer ruling on the card: YYYY/M/D and YYYY-M-D with the same separator, optionally a space and a zone-naive H:MM[:SS], are read beside ISO 8601 and the export shape. The day must exist, the clock is 0..23 / 00..59, the day is stored as the padded ISO day and a clock is a wall clock read as the export shape's is. No zone, fraction or T in this form. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb52d76 commit 46f8026

1 file changed

Lines changed: 63 additions & 14 deletions

File tree

‎packages/rest/src/import-coerce.ts‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,10 @@ const TIME_OF_DAY = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
290290
* spelling is refused, never guessed: `07/15/2026` and `15 July 2026` went to
291291
* `new Date(s)`, which reads them in the SERVER PROCESS's zone (the same cell
292292
* stored as `2026-07-15T14:00Z` on a New York host and `…T02:00Z` on a
293-
* Shanghai one, a `date` a day apart) and reads `07/08/2026` month-first; a
294-
* year-first spelling that is not ISO (`2026/7/15`, `2026-7-15`) is refused
295-
* with them. A space before a zone (`2026-07-15 10:00Z`) is refused as the
296-
* write door refuses it.
293+
* Shanghai one, a `date` a day apart) and reads `07/08/2026` month-first. A
294+
* space before a zone (`2026-07-15 10:00Z`) is refused as the write door
295+
* refuses it. The one other text shape read at all is the year-first date,
296+
* {@link YEAR_FIRST_CELL}.
297297
*/
298298
const ISO_TEMPORAL_CELL =
299299
/^(\d{4})-(\d{2})-(\d{2})(?:(T| )(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
@@ -359,6 +359,54 @@ function readIsoTemporalCell(s: string): IsoTemporalCell | undefined {
359359
return { day, wall };
360360
}
361361

362+
/**
363+
* [#20534, maintainer ruling] The year-first date a spreadsheet writes, after
364+
* trimming: `YYYY/M/D` or `YYYY-M-D` — a four-digit year, a one- or two-digit
365+
* month and day, the SAME separator in both places — optionally followed by
366+
* one space and a zone-naive `H:MM` or `H:MM:SS` with a one- or two-digit hour.
367+
* `2026/7/15`, `2026/07/15`, `2026-7-15`, `2026/7/15 9:00`,
368+
* `2026/08/01 06:00:00`, `2026-07-15 9:00`.
369+
*
370+
* It is Excel's default short date in zh-CN and ja-JP, and a CSV saved from
371+
* Excel writes the text it displays. The year comes first, so there is no
372+
* field order to guess and no zone to read: it names one day and one wall
373+
* clock on every host. No zone, no fraction and no `T` separator are read in
374+
* this form, and a mixed separator (`2026/7-15`) is not this form. A
375+
* month-first or day-first date (`07/15/2026`, `15/07/2026`) and a two-digit
376+
* year (`26/7/15`) stay refused.
377+
*/
378+
const YEAR_FIRST_CELL = /^(\d{4})([/-])(\d{1,2})\2(\d{1,2})(?: (\d{1,2}):(\d{2})(?::(\d{2}))?)?$/;
379+
380+
/**
381+
* Read a trimmed cell as a {@link YEAR_FIRST_CELL} shape, or `undefined`. The
382+
* rules every other admitted cell keeps: the day must exist
383+
* ({@link namesRealCalendarDay}, never rolled over), the hour runs 0..23 and
384+
* the minute and second 00..59 (`24:00` is refused), and the day is the padded
385+
* ISO `YYYY-MM-DD` (`2026/7/15` → `2026-07-15`). A clock is a wall clock, read
386+
* exactly as the export shape's is.
387+
*/
388+
function readYearFirstCell(s: string): IsoTemporalCell | undefined {
389+
const m = YEAR_FIRST_CELL.exec(s);
390+
if (!m) return undefined;
391+
const [, y, , mo, d, hh, mi, ss] = m;
392+
const month = Number(mo);
393+
const date = Number(d);
394+
if (!namesRealCalendarDay(Number(y), month, date)) return undefined;
395+
const day = `${y}-${pad2(month)}-${pad2(date)}`;
396+
if (hh === undefined) return { day };
397+
const wall = {
398+
year: Number(y),
399+
month,
400+
day: date,
401+
hour: Number(hh),
402+
minute: Number(mi),
403+
second: ss ? Number(ss) : 0,
404+
millisecond: 0,
405+
};
406+
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) return undefined;
407+
return { day, wall };
408+
}
409+
362410
/** The `HH:MM:SS` UTC clock of an instant. */
363411
function utcClock(t: Date): string {
364412
return `${pad2(t.getUTCHours())}:${pad2(t.getUTCMinutes())}:${pad2(t.getUTCSeconds())}`;
@@ -376,19 +424,20 @@ function utcClock(t: Date): string {
376424
* ## Which text is read at all (#20534)
377425
*
378426
* A text cell is read only in an {@link ISO_TEMPORAL_CELL} shape — ISO 8601,
379-
* or the export's own `YYYY-MM-DD HH:mm:ss` — and only on a calendar day that
427+
* or the export's own `YYYY-MM-DD HH:mm:ss` — or as a year-first date
428+
* ({@link YEAR_FIRST_CELL}: `2026/7/15`, `2026/7/15 9:00`, stored padded, the
429+
* clock read exactly as the export shape's), and only on a calendar day that
380430
* exists. A `time` cell may also be a bare `HH:MM` / `HH:MM:SS`. Every other
381431
* cell is refused on every branch; nothing reaches `new Date(s)`:
382432
*
383-
* - **an impossible day** (`2026-02-30`, `2026-02-29`, `2026-04-31`, in any
384-
* of the shapes) — `Date.UTC` and `Date.parse` rolled it into the next
385-
* month, so a `datetime` was stored as March 2 and a `date` given in the
386-
* `T…Z` spelling likewise; never rolled over now;
433+
* - **an impossible day** (`2026-02-30`, `2026-02-29`, `2026-04-31`,
434+
* `2026/2/30`, in any of the shapes) — `Date.UTC` and `Date.parse` rolled it
435+
* into the next month, so a `datetime` was stored as March 2 and a `date`
436+
* given in the `T…Z` spelling likewise; never rolled over now;
387437
* - **a locale or prose spelling** (`07/15/2026 10:00`, `07/08/2026`,
388-
* `15 July 2026`, `2026/7/15`, `2026-7-15`) — `new Date(s)` read it in the
389-
* server process's zone and month-first, so the stored instant, and a
390-
* `date`'s day, were properties of the deployment host; no zone and no
391-
* field order is guessed now;
438+
* `15 July 2026`) — `new Date(s)` read it in the server process's zone and
439+
* month-first, so the stored instant, and a `date`'s day, were properties
440+
* of the deployment host; no zone and no field order is guessed now;
392441
* - **a number** (`2026`, an Excel serial) — `new Date(String(n))` read it
393442
* as a year, in the process zone.
394443
*
@@ -456,7 +505,7 @@ export function parseDateCell(
456505

457506
if (kind === 'time' && TIME_OF_DAY.test(s)) return s.length === 5 ? `${s}:00` : s;
458507

459-
const cell = readIsoTemporalCell(s);
508+
const cell = readIsoTemporalCell(s) ?? readYearFirstCell(s);
460509
if (!cell) return undefined;
461510

462511
if (cell.wall) {

0 commit comments

Comments
 (0)