@@ -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 */
298298const 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. */
363411function 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