diff --git a/web/src/lib/components/fields/FieldEditor.datePicker.svelte.test.ts b/web/src/lib/components/fields/FieldEditor.datePicker.svelte.test.ts index 01b0db71a..6b020852c 100644 --- a/web/src/lib/components/fields/FieldEditor.datePicker.svelte.test.ts +++ b/web/src/lib/components/fields/FieldEditor.datePicker.svelte.test.ts @@ -141,3 +141,37 @@ describe('date picker opens from a FOCUSED input (BUG-2858)', () => { expect(document.activeElement).toBe(input); }); }); + +// BUG-3225: an RFC3339 value, which the server accepts for a date field, +// rendered "Invalid Date" because a midnight was appended to the WHOLE string. +describe('a date field holding an RFC3339 timestamp (BUG-3225)', () => { + const label = (c: HTMLElement) => c.querySelector('.date-label')?.textContent?.trim(); + const dateInput = (c: HTMLElement) => c.querySelector('input[type="date"]') as HTMLInputElement; + + it('shows the day as written, and preselects it in the picker', () => { + const { container } = render(FieldEditor, { + props: { field, value: '2026-09-26T23:30:00-05:00', onchange: vi.fn(), itemId: 'item-A' }, + }); + expect(label(container)).toBe('Sep 26, 2026'); + expect(dateInput(container).value).toBe('2026-09-26'); + }); + + it('read-only shows the same day', () => { + const { container } = render(FieldEditor, { + props: { field, value: '2026-09-26T10:00:00Z', onchange: vi.fn(), itemId: 'item-A', readonly: true }, + }); + expect(container.textContent).toContain('Sep 26, 2026'); + expect(container.textContent).not.toContain('Invalid Date'); + }); + + it('control: a bare date is unchanged', () => { + const { container } = render(FieldEditor, { props: { field, value: '2026-09-26', onchange: vi.fn(), itemId: 'item-A' } }); + expect(label(container)).toBe('Sep 26, 2026'); + expect(dateInput(container).value).toBe('2026-09-26'); + }); + + it('a string that is not a date shows as itself, not "Invalid Date"', () => { + const { container } = render(FieldEditor, { props: { field, value: 'next week', onchange: vi.fn(), itemId: 'item-A' } }); + expect(label(container)).toBe('next week'); + }); +}); diff --git a/web/src/lib/components/fields/FieldEditor.svelte b/web/src/lib/components/fields/FieldEditor.svelte index f1f43df87..13fd78dec 100644 --- a/web/src/lib/components/fields/FieldEditor.svelte +++ b/web/src/lib/components/fields/FieldEditor.svelte @@ -757,13 +757,27 @@ handlers — onchange is never called. dateTriggerEl?.focus(); } + /** + * The calendar day a stored date names, as written: `YYYY-MM-DD` itself, or + * the day part of an RFC3339 timestamp, which the server also accepts for a + * date field (BUG-3225). No timezone conversion: `2026-09-26T23:30:00-05:00` + * names the 26th, the day its writer wrote. `null` when the string does not + * start with a day. + */ + function dateDay(dateStr: string): string | null { + const m = /^(\d{4}-\d{2}-\d{2})(?:$|T)/.exec(dateStr); + return m ? m[1] : null; + } + function formatDate(dateStr: string): string { - try { - const d = new Date(dateStr + 'T00:00:00'); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - } catch { - return dateStr; - } + // Appending a midnight to the whole string turned every RFC3339 value + // into "Invalid Date" (BUG-3225). And `toLocaleDateString` answers + // "Invalid Date" rather than throwing, so the old `catch` never ran: an + // unparseable string now shows as itself. + const day = dateDay(dateStr); + const d = day ? new Date(day + 'T00:00:00') : null; + if (!d || isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } // ── Select dropdown state ────────────────────────────────────────────── @@ -1607,7 +1621,7 @@ handlers — onchange is never called. bind:this={dateInputEl} class="date-hidden-input" type="date" - value={value ?? ''} + value={value ? (dateDay(value) ?? '') : ''} onchange={handleDateInput} onkeydown={handleDateKeydown} onfocus={() => (dateInputFocused = true)}