@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.
@@ -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
+ }