@open-mercato/ui 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/CrudForm.js +45 -21
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +7 -6
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/SectionHeader.js +17 -8
- package/dist/backend/SectionHeader.js.map +2 -2
- package/dist/backend/injection/InjectedField.js +55 -9
- package/dist/backend/injection/InjectedField.js.map +2 -2
- package/dist/backend/utils/nav.js +34 -4
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/primitives/date-format.js +68 -1
- package/dist/primitives/date-format.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +40 -9
- package/src/backend/DataTable.tsx +11 -15
- package/src/backend/SectionHeader.tsx +28 -5
- package/src/backend/__tests__/CrudForm.localeNumber.test.tsx +159 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.dateCells.test.tsx +87 -0
- package/src/backend/__tests__/SectionHeader.test.tsx +105 -0
- package/src/backend/__tests__/nav-utils.test.ts +180 -0
- package/src/backend/injection/InjectedField.tsx +69 -9
- package/src/backend/injection/__tests__/InjectedField.localeNumber.test.tsx +94 -0
- package/src/backend/utils/nav.ts +75 -5
- package/src/primitives/__tests__/date-format.test.ts +185 -0
- package/src/primitives/date-format.ts +145 -0
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { pl } from 'date-fns/locale/pl'
|
|
1
2
|
import {
|
|
2
3
|
deriveDateDisplayFormat,
|
|
4
|
+
formatDisplayDate,
|
|
5
|
+
formatDisplayDateTime,
|
|
3
6
|
formatWithPublicDateFormat,
|
|
4
7
|
normalizeDateFormatPattern,
|
|
8
|
+
resolveDisplayDateTimeFormat,
|
|
5
9
|
resolvePublicDateFormat,
|
|
6
10
|
resolvePublicDateTimeFormat,
|
|
11
|
+
toDateInputValue,
|
|
12
|
+
toUtcDateInputValue,
|
|
7
13
|
} from '../date-format'
|
|
8
14
|
|
|
9
15
|
const ORIGINAL_ENV = { ...process.env }
|
|
@@ -41,3 +47,182 @@ describe('date display format helpers', () => {
|
|
|
41
47
|
expect(value).toBe('2026-05-09 10:30')
|
|
42
48
|
})
|
|
43
49
|
})
|
|
50
|
+
|
|
51
|
+
describe('display value helpers', () => {
|
|
52
|
+
// Local midnight is `00:00` in every zone, so asserting the time pins the parse without the
|
|
53
|
+
// runner being in a particular zone. A UTC parse reads `02:00` in Europe/Warsaw.
|
|
54
|
+
it('parses a date-only value as local midnight, so the stored day survives the viewer timezone', () => {
|
|
55
|
+
expect(formatDisplayDateTime('2026-07-01', 'pl')).toBe('1 lip 2026, 00:00')
|
|
56
|
+
expect(formatDisplayDate('2026-07-01', 'pl')).toBe('1 lip 2026')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// With no env override the display pattern is the locale's own, and the month name comes from
|
|
60
|
+
// the resolved date-fns locale — so this agrees with `ReturnsSection` / `ShipmentsSection`,
|
|
61
|
+
// which render `9 cze 2026` beside it. A bare string locale is enough; callers hold one.
|
|
62
|
+
// Formatting through `Intl` rather than a derived pattern, because a two-branch "day first or
|
|
63
|
+
// not" heuristic cannot express either of these: `ko` writes the year first, `de` is numeric and
|
|
64
|
+
// dotted. Both are locales this repo ships, and this path feeds every backend table.
|
|
65
|
+
it.each([
|
|
66
|
+
['en', 'Jul 1, 2026'],
|
|
67
|
+
['pl', '1 lip 2026'],
|
|
68
|
+
['es', '1 jul 2026'],
|
|
69
|
+
['de', '01.07.2026'],
|
|
70
|
+
['ko', '2026. 7. 1.'],
|
|
71
|
+
])('renders %s correctly, including the locales a pattern heuristic gets wrong', (locale, expected) => {
|
|
72
|
+
expect(formatDisplayDate('2026-07-01', locale)).toBe(expected)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// `en` is `defaultLocale` (`packages/shared/src/lib/i18n/config.ts`), so this is what most
|
|
76
|
+
// deployments actually render. `timeStyle: 'short'` moves it from the locale-neutral
|
|
77
|
+
// `2026-07-01 00:00` to a 12-hour clock — the widest-reach consequence of this change, and the
|
|
78
|
+
// one a reader is most likely to mistake for a bug. Pinned so it is a decision, not a side effect.
|
|
79
|
+
// Operators who want the old shape set `NEXT_PUBLIC_OM_DATE_TIME_FORMAT=yyyy-MM-dd HH:mm`.
|
|
80
|
+
it('renders the default locale on a 12-hour clock, not the previous ISO-like shape', () => {
|
|
81
|
+
expect(formatDisplayDateTime('2026-07-01', 'en')).toBe('Jul 1, 2026, 12:00 AM')
|
|
82
|
+
expect(formatDisplayDate('2026-07-01', 'en')).toBe('Jul 1, 2026')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('lets an env pattern override the locale, so a deployment can pin one shape', () => {
|
|
86
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'yyyy-MM-dd'
|
|
87
|
+
expect(formatDisplayDate('2026-07-01', 'ko')).toBe('2026-07-01')
|
|
88
|
+
expect(formatDisplayDate('2026-07-01', 'de')).toBe('2026-07-01')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('keeps the instant for an offset-carrying value', () => {
|
|
92
|
+
expect(formatDisplayDateTime('2026-07-01T09:30:00Z', 'pl')).toBe(
|
|
93
|
+
new Intl.DateTimeFormat('pl', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
|
94
|
+
new Date('2026-07-01T09:30:00Z'),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('accepts a Date as well as a string', () => {
|
|
100
|
+
expect(formatDisplayDate(new Date(2026, 6, 1), 'pl')).toBe('1 lip 2026')
|
|
101
|
+
expect(formatDisplayDateTime(new Date(2026, 6, 1, 9, 30), 'pl')).toBe('1 lip 2026, 09:30')
|
|
102
|
+
expect(formatDisplayDate(new Date('not-a-date'), 'pl')).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('tolerates surrounding whitespace, as DataTable does', () => {
|
|
106
|
+
expect(formatDisplayDate(' 2026-07-01 ', 'pl')).toBe('1 lip 2026')
|
|
107
|
+
expect(formatDisplayDate(' ', 'pl')).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('honours the configured env formats', () => {
|
|
111
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
|
|
112
|
+
process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT = 'dd.MM.yyyy HH:mm'
|
|
113
|
+
expect(formatDisplayDate('2026-07-01')).toBe('01.07.2026')
|
|
114
|
+
// Compared against the formatter rather than a literal: `09:30Z` falls on 30 June at UTC-10
|
|
115
|
+
// and further west, so a hardcoded `01.07.2026` would fail there.
|
|
116
|
+
expect(formatDisplayDateTime('2026-07-01T09:30:00Z')).toBe(
|
|
117
|
+
formatWithPublicDateFormat(new Date('2026-07-01T09:30:00Z'), 'dd.MM.yyyy HH:mm'),
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns null on empty or unparsable input', () => {
|
|
122
|
+
expect(formatDisplayDate(null)).toBeNull()
|
|
123
|
+
expect(formatDisplayDate('')).toBeNull()
|
|
124
|
+
expect(formatDisplayDate('not-a-date')).toBeNull()
|
|
125
|
+
expect(formatDisplayDateTime(undefined)).toBeNull()
|
|
126
|
+
expect(formatDisplayDateTime('not-a-date')).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('resolveDisplayDateTimeFormat', () => {
|
|
131
|
+
// DataTable resolves its cells through this, so the date vars must stay in the chain and stay
|
|
132
|
+
// bare — appending `HH:mm` to a caller's date-only pattern would change every existing table.
|
|
133
|
+
it('falls back through the date vars, used bare', () => {
|
|
134
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
|
|
135
|
+
expect(resolveDisplayDateTimeFormat()).toBe('dd.MM.yyyy')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('prefers the date-time vars', () => {
|
|
139
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
|
|
140
|
+
process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT = 'dd.MM.yyyy HH:mm'
|
|
141
|
+
expect(resolveDisplayDateTimeFormat()).toBe('dd.MM.yyyy HH:mm')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reports no override when nothing is configured, so the caller falls to Intl', () => {
|
|
145
|
+
expect(resolveDisplayDateTimeFormat()).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('toDateInputValue', () => {
|
|
150
|
+
// `new Date(x).toISOString().slice(0, 10)` yields the UTC day, so a just-past-midnight timestamp
|
|
151
|
+
// east of UTC names the previous day. Asserted against the Date's own local getters rather than a
|
|
152
|
+
// literal, so the contract holds in every runner zone — including UTC, where the two coincide.
|
|
153
|
+
it('yields the local calendar day, not the UTC one', () => {
|
|
154
|
+
// Constructed from local components, so it is local July 2 in every zone — the
|
|
155
|
+
// zone-independence is in the construction, not in the assertion.
|
|
156
|
+
const justAfterLocalMidnight = new Date(2026, 6, 2, 0, 30)
|
|
157
|
+
|
|
158
|
+
expect(toDateInputValue(justAfterLocalMidnight)).toBe('2026-07-02')
|
|
159
|
+
expect(toDateInputValue('2026-07-01')).toBe('2026-07-01')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('ignores the configured display format, since the input requires yyyy-MM-dd', () => {
|
|
163
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
|
|
164
|
+
expect(toDateInputValue('2026-07-01')).toBe('2026-07-01')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('returns null when there is nothing to show', () => {
|
|
168
|
+
expect(toDateInputValue(null)).toBeNull()
|
|
169
|
+
expect(toDateInputValue('not-a-date')).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// `toDateInputValue` and `toUtcDateInputValue` differ by exactly one day for half the planet, so
|
|
174
|
+
// which one a field gets is a correctness question, not a style one.
|
|
175
|
+
//
|
|
176
|
+
// In a UTC runner the two are the same function, so these cases cannot fail there — and CI runs
|
|
177
|
+
// in UTC. `jest.config.base.cjs` therefore pins the whole suite to `America/New_York`, so the frame
|
|
178
|
+
// decision is enforced everywhere rather than only on a runner that happens to sit west of UTC.
|
|
179
|
+
// Setting `process.env.TZ` inside this file would NOT work: jest gives each test file a sandboxed
|
|
180
|
+
// copy of `process.env`, so the assignment never reaches V8's timezone cache.
|
|
181
|
+
describe('toUtcDateInputValue', () => {
|
|
182
|
+
// The shape that actually arrives from the API for a date-only column: the editor submits a bare
|
|
183
|
+
// `yyyy-MM-dd`, `z.coerce.date()` stores UTC midnight, the route returns it with a `Z`.
|
|
184
|
+
const STORED_DATE_ONLY = '2026-07-01T00:00:00.000Z'
|
|
185
|
+
|
|
186
|
+
it('round-trips a date-only value stored as UTC midnight', () => {
|
|
187
|
+
expect(toUtcDateInputValue(STORED_DATE_ONLY)).toBe('2026-07-01')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('reads the frame the value was written in, whatever the viewer zone', () => {
|
|
191
|
+
expect(toUtcDateInputValue(STORED_DATE_ONLY)).toBe(
|
|
192
|
+
new Date(STORED_DATE_ONLY).toISOString().slice(0, 10),
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// A bare day names itself. Parsing it into an instant first — by either reading — moves it in
|
|
197
|
+
// some zone, which is how the first version of this helper was wrong.
|
|
198
|
+
it('passes a bare date-only string through untouched', () => {
|
|
199
|
+
expect(toUtcDateInputValue('2026-07-01')).toBe('2026-07-01')
|
|
200
|
+
expect(toUtcDateInputValue(' 2026-07-01 ')).toBe('2026-07-01')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('returns null when there is nothing to show', () => {
|
|
204
|
+
expect(toUtcDateInputValue(null)).toBeNull()
|
|
205
|
+
expect(toUtcDateInputValue('not-a-date')).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// A row that renders a stored date-only column and also seeds an editor from it must name one day.
|
|
210
|
+
// `PaymentsSection` renders `receivedAt` while its Edit dialog seeds from `receivedAt.slice(0, 10)`
|
|
211
|
+
// — the UTC day — so the rendered day has to be derived the same way, or one row contradicts itself.
|
|
212
|
+
describe('rendering a stored date-only column', () => {
|
|
213
|
+
const STORED = '2026-07-01T00:00:00.000Z'
|
|
214
|
+
|
|
215
|
+
it('agrees with an editor that seeds from the UTC day', () => {
|
|
216
|
+
expect(toUtcDateInputValue(STORED)).toBe(STORED.slice(0, 10))
|
|
217
|
+
expect(formatDisplayDate(toUtcDateInputValue(STORED), 'pl')).toBe('1 lip 2026')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('still honours the configured display format', () => {
|
|
221
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
|
|
222
|
+
expect(formatDisplayDate(toUtcDateInputValue(STORED))).toBe('01.07.2026')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('renders nothing when the column is empty', () => {
|
|
226
|
+
expect(formatDisplayDate(toUtcDateInputValue(null))).toBeNull()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { format as formatDateFns } from 'date-fns/format'
|
|
2
|
+
import { parseISO } from 'date-fns/parseISO'
|
|
2
3
|
import type { Locale } from 'date-fns/locale'
|
|
3
4
|
|
|
4
5
|
type LocaleLike = Locale | string | null | undefined
|
|
@@ -58,3 +59,147 @@ export function formatWithPublicDateFormat(date: Date, format: string, locale?:
|
|
|
58
59
|
return null
|
|
59
60
|
}
|
|
60
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The configured pattern for a date rendered as static text, or `null` when none is set.
|
|
65
|
+
*
|
|
66
|
+
* `null` is the interesting case: with no override the format comes from `Intl`, which knows the
|
|
67
|
+
* ordering and month naming of every locale. A pattern heuristic does not — it can only ask
|
|
68
|
+
* "day first or not", which is wrong for `ko` (year first) and for `de` (numeric, dotted). Those
|
|
69
|
+
* are two of the five locales this repo ships, and this resolver feeds every backend table.
|
|
70
|
+
*
|
|
71
|
+
* ISO stays the contract for machine-facing values — `toDateInputValue`, storage, serialization —
|
|
72
|
+
* never for a label.
|
|
73
|
+
*
|
|
74
|
+
* A pattern is a deliberate operator pin: it wins over the locale entirely, and month tokens render
|
|
75
|
+
* in English on that path — `NEXT_PUBLIC_OM_DATE_FORMAT=d MMM yyyy` gives `1 Jul 2026` to a `pl`
|
|
76
|
+
* tenant, not `1 lip 2026`. Set a numeric pattern, or leave it unset and let `Intl` localize.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveDisplayDateFormat(): string | null {
|
|
79
|
+
return (
|
|
80
|
+
normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
|
|
81
|
+
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* As `resolveDisplayDateFormat`, for a value that carries a real time of day.
|
|
87
|
+
*
|
|
88
|
+
* The date vars are in the chain, and used bare, because a caller that sets only a date pattern has
|
|
89
|
+
* said how a date should look and said nothing about time — appending `HH:mm` would invent a
|
|
90
|
+
* requirement. This is the precedence `resolvePublicDateTimeFormat` already uses.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveDisplayDateTimeFormat(): string | null {
|
|
93
|
+
return (
|
|
94
|
+
normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT)
|
|
95
|
+
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_TIME_FORMAT)
|
|
96
|
+
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
|
|
97
|
+
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* **ISO-8601 input only.** `parseISO` rejects shapes `new Date` would have accepted — `'July 1, 2026'`,
|
|
103
|
+
* `'2026/07/01'` — so those now return `null` and the caller renders its empty label. Every in-repo
|
|
104
|
+
* caller passes ISO; `DataTable.tryParseDate` keeps a `new Date` fallback because its input is
|
|
105
|
+
* arbitrary column data, while these helpers' is not.
|
|
106
|
+
*
|
|
107
|
+
* `parseISO`, not `new Date`: a bare `yyyy-MM-dd` names a calendar day, and `new Date` reads it as
|
|
108
|
+
* UTC midnight — which `format` then renders as the PREVIOUS day in every zone west of UTC. On a
|
|
109
|
+
* date-only string `parseISO` returns local midnight of the stored day, so the day survives; a value
|
|
110
|
+
* carrying a time or an offset keeps its instant either way.
|
|
111
|
+
*/
|
|
112
|
+
function parseDisplayValue(value: string | Date | null | undefined): Date | null {
|
|
113
|
+
if (value == null) return null
|
|
114
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
115
|
+
const trimmed = value.trim()
|
|
116
|
+
if (!trimmed) return null
|
|
117
|
+
const parsed = parseISO(trimmed)
|
|
118
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function intlLocale(locale?: LocaleLike): string | undefined {
|
|
122
|
+
if (!locale) return undefined
|
|
123
|
+
return typeof locale === 'string' ? locale : locale.code
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DISPLAY_STYLES = {
|
|
127
|
+
date: { dateStyle: 'medium' },
|
|
128
|
+
datetime: { dateStyle: 'medium', timeStyle: 'short' },
|
|
129
|
+
} as const satisfies Record<string, Intl.DateTimeFormatOptions>
|
|
130
|
+
|
|
131
|
+
// Constructing an `Intl.DateTimeFormat` costs ~40x using one (Node 24: ~31 µs vs ~0.8 µs), and these
|
|
132
|
+
// helpers render every date cell AND its tooltip in `DataTable` — two constructions per cell, so a
|
|
133
|
+
// 100-row page with two date columns would pay 400 of them on every sort, filter and keystroke.
|
|
134
|
+
// A formatter is immutable and the key space is the locales a deployment ships, so one instance per
|
|
135
|
+
// (locale, style) is kept for the process. The style name is the key, so the two cannot desync.
|
|
136
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>()
|
|
137
|
+
|
|
138
|
+
function getFormatter(locale: string | undefined, style: keyof typeof DISPLAY_STYLES): Intl.DateTimeFormat {
|
|
139
|
+
const key = `${locale ?? ''}|${style}`
|
|
140
|
+
const cached = formatterCache.get(key)
|
|
141
|
+
if (cached) return cached
|
|
142
|
+
const created = new Intl.DateTimeFormat(locale, DISPLAY_STYLES[style])
|
|
143
|
+
formatterCache.set(key, created)
|
|
144
|
+
return created
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Render a date for display, or `null` when there is nothing valid to show. */
|
|
148
|
+
export function formatDisplayDate(value: string | Date | null | undefined, locale?: LocaleLike): string | null {
|
|
149
|
+
const parsed = parseDisplayValue(value)
|
|
150
|
+
if (!parsed) return null
|
|
151
|
+
const pattern = resolveDisplayDateFormat()
|
|
152
|
+
if (pattern) return formatWithPublicDateFormat(parsed, pattern)
|
|
153
|
+
return getFormatter(intlLocale(locale), 'date').format(parsed)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Render a timestamp for display, or `null` when there is nothing valid to show. */
|
|
157
|
+
export function formatDisplayDateTime(value: string | Date | null | undefined, locale?: LocaleLike): string | null {
|
|
158
|
+
const parsed = parseDisplayValue(value)
|
|
159
|
+
if (!parsed) return null
|
|
160
|
+
const pattern = resolveDisplayDateTimeFormat()
|
|
161
|
+
if (pattern) return formatWithPublicDateFormat(parsed, pattern)
|
|
162
|
+
return getFormatter(intlLocale(locale), 'datetime').format(parsed)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The **local** calendar day of a real instant, as the `yyyy-MM-dd` an `<input type="date">` requires.
|
|
167
|
+
*
|
|
168
|
+
* Not `new Date(value).toISOString().slice(0, 10)`: `toISOString` converts to UTC first, so east of
|
|
169
|
+
* UTC an evening timestamp yields the previous day. Rendering that through `formatDisplayDate` is
|
|
170
|
+
* faithful to a day that was already wrong.
|
|
171
|
+
*
|
|
172
|
+
* **Precondition: `value` must be a real instant** — a moment that happened, whose local day is the
|
|
173
|
+
* one a human would name. It must NOT be a date-only value that some write path stored as UTC
|
|
174
|
+
* midnight: reading that back locally names the PREVIOUS day west of UTC, which is the mirror image
|
|
175
|
+
* of the bug above. Use `toUtcDateInputValue` for those; see its note for how to tell them apart.
|
|
176
|
+
*/
|
|
177
|
+
export function toDateInputValue(value: string | Date | null | undefined): string | null {
|
|
178
|
+
const parsed = parseDisplayValue(value)
|
|
179
|
+
return parsed ? formatWithPublicDateFormat(parsed, 'yyyy-MM-dd') : null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The **UTC** calendar day of a value, as the `yyyy-MM-dd` an `<input type="date">` requires.
|
|
184
|
+
*
|
|
185
|
+
* For a column that stores a date-only value as UTC midnight — a bare `yyyy-MM-dd` submitted by a
|
|
186
|
+
* date input, coerced with `z.coerce.date()` and returned as `…T00:00:00.000Z`. The stored instant
|
|
187
|
+
* carries no local meaning, so the round trip only closes if it is read back in the same frame it
|
|
188
|
+
* was written in: UTC.
|
|
189
|
+
*
|
|
190
|
+
* Which helper a field needs is decided by its WRITE path, not by its type. If the value can be set
|
|
191
|
+
* from a date input, it is a UTC day; if it is only ever stamped from a clock, it is an instant.
|
|
192
|
+
*/
|
|
193
|
+
export function toUtcDateInputValue(value: string | Date | null | undefined): string | null {
|
|
194
|
+
if (value == null) return null
|
|
195
|
+
if (typeof value === 'string') {
|
|
196
|
+
const trimmed = value.trim()
|
|
197
|
+
if (!trimmed) return null
|
|
198
|
+
// A bare calendar day is already the answer. Parsing it into an instant first — by either
|
|
199
|
+
// reading — can only move it, since there is no zone in which it was meant.
|
|
200
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed
|
|
201
|
+
const parsed = new Date(trimmed)
|
|
202
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10)
|
|
203
|
+
}
|
|
204
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString().slice(0, 10)
|
|
205
|
+
}
|