@open-mercato/ui 0.6.2-develop.3406.1.2b403f40da → 0.6.2-develop.3446.1.bd060c6017

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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiAssistantLauncher.js +11 -10
  3. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  4. package/dist/ai/AiChat.js +218 -83
  5. package/dist/ai/AiChat.js.map +2 -2
  6. package/dist/ai/AiIcon.js +1 -1
  7. package/dist/ai/AiIcon.js.map +2 -2
  8. package/dist/ai/parts/FieldDiffCard.js +4 -3
  9. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  10. package/dist/ai/useAiChat.js +98 -2
  11. package/dist/ai/useAiChat.js.map +2 -2
  12. package/dist/backend/DataTable.js +4 -13
  13. package/dist/backend/DataTable.js.map +2 -2
  14. package/dist/primitives/date-format.js +53 -0
  15. package/dist/primitives/date-format.js.map +7 -0
  16. package/dist/primitives/date-picker.js +3 -26
  17. package/dist/primitives/date-picker.js.map +2 -2
  18. package/dist/primitives/date-range-picker.js +3 -22
  19. package/dist/primitives/date-range-picker.js.map +2 -2
  20. package/package.json +4 -4
  21. package/src/ai/AiAssistantLauncher.tsx +11 -10
  22. package/src/ai/AiChat.tsx +200 -26
  23. package/src/ai/AiIcon.tsx +1 -1
  24. package/src/ai/__tests__/AiChat.task-plan.test.tsx +356 -0
  25. package/src/ai/__tests__/AiChat.test.tsx +44 -2
  26. package/src/ai/parts/FieldDiffCard.tsx +4 -3
  27. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +24 -0
  28. package/src/ai/parts/types.ts +3 -0
  29. package/src/ai/useAiChat.ts +154 -1
  30. package/src/backend/DataTable.tsx +12 -22
  31. package/src/primitives/__tests__/date-format.test.ts +43 -0
  32. package/src/primitives/date-format.ts +60 -0
  33. package/src/primitives/date-picker.tsx +5 -17
  34. package/src/primitives/date-range-picker.tsx +3 -13
@@ -20,6 +20,7 @@ export interface AiChatMessageFile {
20
20
  export interface AiChatToolCallSnapshot {
21
21
  id: string
22
22
  toolName: string
23
+ caption?: string
23
24
  state: 'pending' | 'complete' | 'error'
24
25
  input?: unknown
25
26
  output?: unknown
@@ -34,6 +35,25 @@ export interface AiChatMessageUiPart {
34
35
  key: string
35
36
  }
36
37
 
38
+ /**
39
+ * Snapshot of a single task in the visible agent task plan
40
+ * (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
41
+ * streamed as additive `data-agent-task-plan` / `data-agent-task-update`
42
+ * SSE chunks and rendered above raw tool-call rows in `<AiChat>`.
43
+ *
44
+ * `source: 'runtime'` tasks are derived from tool lifecycle events by the
45
+ * agent runtime. `source: 'agent'` tasks are emitted through the reserved
46
+ * `meta.update_task_plan` helper and sanitized before they reach the client.
47
+ */
48
+ export interface AiAgentTaskSnapshot {
49
+ id: string
50
+ label: string
51
+ state: 'pending' | 'running' | 'done' | 'failed' | 'skipped'
52
+ detail?: string
53
+ source: 'runtime' | 'agent'
54
+ toolCallId?: string
55
+ }
56
+
37
57
  export interface AiChatMessage {
38
58
  id: string
39
59
  role: 'user' | 'assistant'
@@ -52,6 +72,12 @@ export interface AiChatMessage {
52
72
  * the UI-part registry never surfaces.
53
73
  */
54
74
  uiParts?: AiChatMessageUiPart[]
75
+ /**
76
+ * Client-local visible task plan derived from streamed
77
+ * `data-agent-task-plan` snapshots and `data-agent-task-update` deltas.
78
+ * Not persisted in any chat storage payload.
79
+ */
80
+ taskPlan?: AiAgentTaskSnapshot[]
55
81
  }
56
82
 
57
83
  export interface UseAiChatInput {
@@ -294,11 +320,78 @@ interface AssistantBuilderState {
294
320
  reasoning: string
295
321
  reasoningStreaming: boolean
296
322
  toolCalls: AiChatToolCallSnapshot[]
323
+ toolCallCaptions: Record<string, string>
324
+ internalToolCallIds: string[]
297
325
  uiParts: AiChatMessageUiPart[]
326
+ taskPlan: AiAgentTaskSnapshot[]
298
327
  }
299
328
 
300
329
  function createBuilder(): AssistantBuilderState {
301
- return { text: '', reasoning: '', reasoningStreaming: false, toolCalls: [], uiParts: [] }
330
+ return {
331
+ text: '',
332
+ reasoning: '',
333
+ reasoningStreaming: false,
334
+ toolCalls: [],
335
+ toolCallCaptions: {},
336
+ internalToolCallIds: [],
337
+ uiParts: [],
338
+ taskPlan: [],
339
+ }
340
+ }
341
+
342
+ const VALID_TASK_STATES: ReadonlySet<AiAgentTaskSnapshot['state']> = new Set([
343
+ 'pending',
344
+ 'running',
345
+ 'done',
346
+ 'failed',
347
+ 'skipped',
348
+ ])
349
+ const VALID_TASK_SOURCES: ReadonlySet<AiAgentTaskSnapshot['source']> = new Set(['runtime', 'agent'])
350
+
351
+ function coerceTaskSnapshot(raw: unknown): AiAgentTaskSnapshot | null {
352
+ if (!raw || typeof raw !== 'object') return null
353
+ const value = raw as Record<string, unknown>
354
+ const id = typeof value.id === 'string' ? value.id.trim() : ''
355
+ if (!id) return null
356
+ const label = typeof value.label === 'string' ? value.label : ''
357
+ if (!label) return null
358
+ const state = VALID_TASK_STATES.has(value.state as AiAgentTaskSnapshot['state'])
359
+ ? (value.state as AiAgentTaskSnapshot['state'])
360
+ : 'pending'
361
+ const source = VALID_TASK_SOURCES.has(value.source as AiAgentTaskSnapshot['source'])
362
+ ? (value.source as AiAgentTaskSnapshot['source'])
363
+ : 'runtime'
364
+ const detail = typeof value.detail === 'string' && value.detail.length > 0 ? value.detail : undefined
365
+ const toolCallId =
366
+ typeof value.toolCallId === 'string' && value.toolCallId.length > 0 ? value.toolCallId : undefined
367
+ return { id, label, state, source, detail, toolCallId }
368
+ }
369
+
370
+ const TERMINAL_TASK_STATES: ReadonlySet<AiAgentTaskSnapshot['state']> = new Set([
371
+ 'done',
372
+ 'failed',
373
+ 'skipped',
374
+ ])
375
+
376
+ function mergeTaskSnapshot(
377
+ current: AiAgentTaskSnapshot[],
378
+ incoming: AiAgentTaskSnapshot,
379
+ ): AiAgentTaskSnapshot[] {
380
+ const idx = current.findIndex((task) => task.id === incoming.id)
381
+ if (idx === -1) return [...current, incoming]
382
+ const prior = current[idx]
383
+ // Stream ordering safeguard: once a task reaches a terminal state we keep
384
+ // it terminal so a late "running" event cannot revert it (spec §Risks —
385
+ // "Stream ordering bugs cause stale statuses").
386
+ const nextState = TERMINAL_TASK_STATES.has(prior.state) ? prior.state : incoming.state
387
+ const merged: AiAgentTaskSnapshot = {
388
+ ...prior,
389
+ ...incoming,
390
+ state: nextState,
391
+ }
392
+ const next = current.slice()
393
+ next[idx] = merged
394
+ return next
302
395
  }
303
396
 
304
397
  /**
@@ -421,6 +514,7 @@ function updateToolCall(
421
514
  const next: AiChatToolCallSnapshot = {
422
515
  id,
423
516
  toolName: patch.toolName ?? 'tool',
517
+ caption: patch.caption ?? state.toolCallCaptions[id],
424
518
  state: patch.state ?? 'pending',
425
519
  input: patch.input,
426
520
  output: patch.output,
@@ -432,6 +526,7 @@ function updateToolCall(
432
526
  const merged: AiChatToolCallSnapshot = {
433
527
  ...current,
434
528
  toolName: patch.toolName ?? current.toolName,
529
+ caption: patch.caption ?? current.caption,
435
530
  state: patch.state ?? current.state,
436
531
  input: patch.input !== undefined ? patch.input : current.input,
437
532
  output: patch.output !== undefined ? patch.output : current.output,
@@ -442,11 +537,40 @@ function updateToolCall(
442
537
  return { ...state, toolCalls: nextCalls }
443
538
  }
444
539
 
540
+ function updateToolCallCaption(
541
+ state: AssistantBuilderState,
542
+ toolCallId: string | undefined,
543
+ caption: string,
544
+ ): AssistantBuilderState {
545
+ if (!toolCallId || !caption) return state
546
+ const toolCallCaptions = { ...state.toolCallCaptions, [toolCallId]: caption }
547
+ const idx = state.toolCalls.findIndex((entry) => entry.id === toolCallId)
548
+ if (idx === -1) return { ...state, toolCallCaptions }
549
+ const nextCalls = state.toolCalls.slice()
550
+ nextCalls[idx] = { ...nextCalls[idx], caption }
551
+ return { ...state, toolCallCaptions, toolCalls: nextCalls }
552
+ }
553
+
445
554
  function displayToolName(toolName: unknown): string | undefined {
446
555
  if (typeof toolName !== 'string') return undefined
447
556
  return toolName.replace(/__/g, '.')
448
557
  }
449
558
 
559
+ function isInternalTaskPlanTool(toolName: unknown): boolean {
560
+ return displayToolName(toolName) === 'meta.update_task_plan'
561
+ }
562
+
563
+ function markInternalToolCall(state: AssistantBuilderState, toolCallId: unknown): AssistantBuilderState {
564
+ const id = String(toolCallId ?? '')
565
+ if (!id || state.internalToolCallIds.includes(id)) return state
566
+ return { ...state, internalToolCallIds: [...state.internalToolCallIds, id] }
567
+ }
568
+
569
+ function isInternalToolCallId(state: AssistantBuilderState, toolCallId: unknown): boolean {
570
+ const id = String(toolCallId ?? '')
571
+ return id.length > 0 && state.internalToolCallIds.includes(id)
572
+ }
573
+
450
574
  function applyChunk(
451
575
  state: AssistantBuilderState,
452
576
  chunk: { type: string; [key: string]: unknown },
@@ -469,11 +593,13 @@ function applyChunk(
469
593
  case 'reasoning-end':
470
594
  return { ...state, reasoningStreaming: false }
471
595
  case 'tool-input-start':
596
+ if (isInternalTaskPlanTool(chunk.toolName)) return markInternalToolCall(state, chunk.toolCallId)
472
597
  return updateToolCall(state, String(chunk.toolCallId ?? ''), {
473
598
  toolName: displayToolName(chunk.toolName),
474
599
  state: 'pending',
475
600
  })
476
601
  case 'tool-input-available':
602
+ if (isInternalTaskPlanTool(chunk.toolName)) return markInternalToolCall(state, chunk.toolCallId)
477
603
  return updateToolCall(state, String(chunk.toolCallId ?? ''), {
478
604
  toolName: displayToolName(chunk.toolName),
479
605
  input: chunk.input,
@@ -481,6 +607,7 @@ function applyChunk(
481
607
  })
482
608
  case 'tool-output-available': {
483
609
  const toolCallId = String(chunk.toolCallId ?? '')
610
+ if (isInternalToolCallId(state, toolCallId)) return state
484
611
  const next = updateToolCall(state, toolCallId, {
485
612
  output: chunk.output,
486
613
  state: 'complete',
@@ -504,12 +631,14 @@ function applyChunk(
504
631
  return { ...next, uiParts: merged }
505
632
  }
506
633
  case 'tool-output-error':
634
+ if (isInternalToolCallId(state, chunk.toolCallId)) return state
507
635
  return updateToolCall(state, String(chunk.toolCallId ?? ''), {
508
636
  state: 'error',
509
637
  errorMessage:
510
638
  typeof chunk.errorText === 'string' ? chunk.errorText : 'Tool error',
511
639
  })
512
640
  case 'tool-input-error':
641
+ if (isInternalTaskPlanTool(chunk.toolName)) return markInternalToolCall(state, chunk.toolCallId)
513
642
  return updateToolCall(state, String(chunk.toolCallId ?? ''), {
514
643
  toolName: displayToolName(chunk.toolName),
515
644
  input: chunk.input,
@@ -517,6 +646,29 @@ function applyChunk(
517
646
  errorMessage:
518
647
  typeof chunk.errorText === 'string' ? chunk.errorText : 'Tool error',
519
648
  })
649
+ case 'data-agent-task-plan': {
650
+ // Initial / replacement plan snapshot. The agent runtime emits this
651
+ // when the first task in a turn becomes visible. Subsequent
652
+ // `data-agent-task-update` events patch individual tasks.
653
+ const rawTasks = Array.isArray(chunk.tasks) ? chunk.tasks : []
654
+ const coerced = rawTasks
655
+ .map(coerceTaskSnapshot)
656
+ .filter((task): task is AiAgentTaskSnapshot => task !== null)
657
+ if (coerced.length === 0) return state
658
+ return coerced.reduce(
659
+ (nextState, task) => updateToolCallCaption(nextState, task.toolCallId, task.label),
660
+ { ...state, taskPlan: coerced },
661
+ )
662
+ }
663
+ case 'data-agent-task-update': {
664
+ const incoming = coerceTaskSnapshot(chunk.task)
665
+ if (!incoming) return state
666
+ return updateToolCallCaption(
667
+ { ...state, taskPlan: mergeTaskSnapshot(state.taskPlan, incoming) },
668
+ incoming.toolCallId,
669
+ incoming.label,
670
+ )
671
+ }
520
672
  default:
521
673
  return state
522
674
  }
@@ -533,6 +685,7 @@ function mergeAssistantMessage(
533
685
  reasoningStreaming: state.reasoning ? state.reasoningStreaming : undefined,
534
686
  toolCalls: state.toolCalls.length > 0 ? state.toolCalls : undefined,
535
687
  uiParts: state.uiParts.length > 0 ? state.uiParts : undefined,
688
+ taskPlan: state.taskPlan.length > 0 ? state.taskPlan : undefined,
536
689
  }
537
690
  }
538
691
 
@@ -34,6 +34,7 @@ import { apiCall } from './utils/apiCall'
34
34
  import { raiseCrudError } from './utils/serverErrors'
35
35
  import { PerspectiveSidebar } from './PerspectiveSidebar'
36
36
  import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
37
+ import { formatWithPublicDateFormat, normalizeDateFormatPattern } from '../primitives/date-format'
37
38
  import { cn } from '@open-mercato/shared/lib/utils'
38
39
  import { useT } from '@open-mercato/shared/lib/i18n/context'
39
40
  import { flash } from './FlashMessages'
@@ -1327,26 +1328,15 @@ export function DataTable<T>({
1327
1328
  return <RowActions items={injectedItems} />
1328
1329
  }, [injectedRowActions, rowActions, router, t])
1329
1330
 
1330
- // Date formatting setup
1331
- const DATE_FORMAT = (process.env.NEXT_PUBLIC_DATE_FORMAT || 'YYYY-MM-DD HH:mm') as string
1332
-
1333
- const pad2 = (n: number) => (n < 10 ? `0${n}` : String(n))
1334
- const simpleFormat = (d: Date, fmt: string) => {
1335
- // Supports tokens: YYYY, MM, DD, HH, mm, ss
1336
- const YYYY = String(d.getFullYear())
1337
- const MM = pad2(d.getMonth() + 1)
1338
- const DD = pad2(d.getDate())
1339
- const HH = pad2(d.getHours())
1340
- const mm = pad2(d.getMinutes())
1341
- const ss = pad2(d.getSeconds())
1342
- return fmt
1343
- .replace(/YYYY/g, YYYY)
1344
- .replace(/MM/g, MM)
1345
- .replace(/DD/g, DD)
1346
- .replace(/HH/g, HH)
1347
- .replace(/mm/g, mm)
1348
- .replace(/ss/g, ss)
1349
- }
1331
+ // Date formatting setup. The OM-prefixed env vars are the new public contract;
1332
+ // NEXT_PUBLIC_DATE_FORMAT remains supported for existing apps.
1333
+ const DATE_FORMAT = (
1334
+ normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT)
1335
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_TIME_FORMAT)
1336
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
1337
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
1338
+ ?? 'yyyy-MM-dd HH:mm'
1339
+ )
1350
1340
 
1351
1341
  const tryParseDate = (v: unknown): Date | null => {
1352
1342
  if (v == null) return null
@@ -2787,7 +2777,7 @@ export function DataTable<T>({
2787
2777
  if (isDateCol) {
2788
2778
  const raw = cell.getValue() as any
2789
2779
  const d = tryParseDate(raw)
2790
- content = d ? simpleFormat(d, DATE_FORMAT) : (raw as any)
2780
+ content = d ? (formatWithPublicDateFormat(d, DATE_FORMAT) ?? raw) : (raw as any)
2791
2781
  } else {
2792
2782
  content = flexRender(cell.column.columnDef.cell, cell.getContext())
2793
2783
  }
@@ -2810,7 +2800,7 @@ export function DataTable<T>({
2810
2800
  tooltipText = metaTooltipContent(row.original)
2811
2801
  } else if (isDateCol && cellValue != null) {
2812
2802
  const parsedDate = tryParseDate(cellValue)
2813
- tooltipText = parsedDate ? simpleFormat(parsedDate, DATE_FORMAT) : String(cellValue)
2803
+ tooltipText = parsedDate ? (formatWithPublicDateFormat(parsedDate, DATE_FORMAT) ?? String(cellValue)) : String(cellValue)
2814
2804
  } else {
2815
2805
  tooltipText = cellValue != null ? String(cellValue) : undefined
2816
2806
  }
@@ -0,0 +1,43 @@
1
+ import {
2
+ deriveDateDisplayFormat,
3
+ formatWithPublicDateFormat,
4
+ normalizeDateFormatPattern,
5
+ resolvePublicDateFormat,
6
+ resolvePublicDateTimeFormat,
7
+ } from '../date-format'
8
+
9
+ const ORIGINAL_ENV = { ...process.env }
10
+
11
+ afterEach(() => {
12
+ process.env = { ...ORIGINAL_ENV }
13
+ })
14
+
15
+ describe('date display format helpers', () => {
16
+ it('derives a system-style fallback from the locale family', () => {
17
+ expect(deriveDateDisplayFormat('en')).toBe('MMM d, yyyy')
18
+ expect(deriveDateDisplayFormat('pl')).toBe('d MMM yyyy')
19
+ })
20
+
21
+ it('normalizes legacy uppercase tokens for date-fns', () => {
22
+ expect(normalizeDateFormatPattern('YYYY-MM-DD HH:mm')).toBe('yyyy-MM-dd HH:mm')
23
+ })
24
+
25
+ it('treats system-like env values as unset', () => {
26
+ process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'system'
27
+ expect(resolvePublicDateFormat('en')).toBe('MMM d, yyyy')
28
+ })
29
+
30
+ it('uses OM-prefixed env formats before legacy env formats', () => {
31
+ process.env.NEXT_PUBLIC_DATE_FORMAT = 'YYYY/MM/DD'
32
+ process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
33
+ process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT = 'dd.MM.yyyy HH:mm'
34
+
35
+ expect(resolvePublicDateFormat('en')).toBe('dd.MM.yyyy')
36
+ expect(resolvePublicDateTimeFormat('en')).toBe('dd.MM.yyyy HH:mm')
37
+ })
38
+
39
+ it('formats with normalized date-fns patterns', () => {
40
+ const value = formatWithPublicDateFormat(new Date(2026, 4, 9, 10, 30), 'yyyy-MM-dd HH:mm')
41
+ expect(value).toBe('2026-05-09 10:30')
42
+ })
43
+ })
@@ -0,0 +1,60 @@
1
+ import { format as formatDateFns } from 'date-fns'
2
+ import type { Locale } from 'date-fns'
3
+
4
+ type LocaleLike = Locale | string | null | undefined
5
+
6
+ const DAY_FIRST_LOCALE_CODES = new Set([
7
+ 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
8
+ ])
9
+
10
+ const SYSTEM_FORMAT_VALUES = new Set(['auto', 'default', 'locale', 'system'])
11
+
12
+ function getLocaleCode(locale?: LocaleLike): string {
13
+ if (!locale) return ''
14
+ if (typeof locale === 'string') return locale.split('-')[0]?.toLowerCase() ?? ''
15
+ return locale.code?.split('-')[0]?.toLowerCase() ?? ''
16
+ }
17
+
18
+ export function normalizeDateFormatPattern(value: string | null | undefined): string | null {
19
+ const trimmed = value?.trim()
20
+ if (!trimmed) return null
21
+ if (SYSTEM_FORMAT_VALUES.has(trimmed.toLowerCase())) return null
22
+ return trimmed
23
+ .replace(/YYYY/g, 'yyyy')
24
+ .replace(/YY/g, 'yy')
25
+ .replace(/DD/g, 'dd')
26
+ }
27
+
28
+ export function deriveDateDisplayFormat(locale?: LocaleLike): string {
29
+ const code = getLocaleCode(locale)
30
+ return code && DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy' : 'MMM d, yyyy'
31
+ }
32
+
33
+ export function resolvePublicDateFormat(locale?: LocaleLike, explicitFormat?: string | null): string {
34
+ return (
35
+ normalizeDateFormatPattern(explicitFormat)
36
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
37
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
38
+ ?? deriveDateDisplayFormat(locale)
39
+ )
40
+ }
41
+
42
+ export function resolvePublicDateTimeFormat(locale?: LocaleLike, explicitFormat?: string | null): string {
43
+ const dateFormat = deriveDateDisplayFormat(locale)
44
+ return (
45
+ normalizeDateFormatPattern(explicitFormat)
46
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT)
47
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_TIME_FORMAT)
48
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
49
+ ?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
50
+ ?? `${dateFormat} HH:mm`
51
+ )
52
+ }
53
+
54
+ export function formatWithPublicDateFormat(date: Date, format: string, locale?: Locale): string | null {
55
+ try {
56
+ return formatDateFns(date, format, locale ? { locale } : undefined)
57
+ } catch {
58
+ return null
59
+ }
60
+ }
@@ -1,7 +1,6 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { format } from 'date-fns'
5
4
  import type { Locale } from 'date-fns'
6
5
  import { CalendarIcon } from 'lucide-react'
7
6
  import { cn } from '@open-mercato/shared/lib/utils'
@@ -11,6 +10,7 @@ import { Calendar } from './calendar'
11
10
  import { Button } from './button'
12
11
  import { LinkButton } from './link-button'
13
12
  import { TimeInput } from '../backend/inputs/TimeInput'
13
+ import { formatWithPublicDateFormat, resolvePublicDateFormat, resolvePublicDateTimeFormat } from './date-format'
14
14
 
15
15
  export type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'
16
16
 
@@ -62,16 +62,6 @@ export type DatePickerProps = {
62
62
  'aria-describedby'?: string
63
63
  }
64
64
 
65
- const DAY_FIRST_LOCALE_CODES = new Set([
66
- 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
67
- ])
68
-
69
- function deriveDisplayFormat(locale: Locale | undefined, withTime: boolean): string {
70
- const code = locale?.code?.split('-')[0]?.toLowerCase() ?? ''
71
- const dateFmt = code && DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy' : 'MMM d, yyyy'
72
- return withTime ? `${dateFmt} HH:mm` : dateFmt
73
- }
74
-
75
65
  function extractTime(date: Date): string {
76
66
  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
77
67
  }
@@ -129,7 +119,9 @@ export function DatePicker({
129
119
  if (open) setDraft(value ?? null)
130
120
  }, [open, value])
131
121
 
132
- const resolvedFormat = displayFormat ?? deriveDisplayFormat(locale, withTime)
122
+ const resolvedFormat = withTime
123
+ ? resolvePublicDateTimeFormat(locale, displayFormat)
124
+ : resolvePublicDateFormat(locale, displayFormat)
133
125
  const placeholderText = placeholder ?? (
134
126
  withTime
135
127
  ? t('ui.dateTimePicker.placeholder', 'Pick date and time')
@@ -138,11 +130,7 @@ export function DatePicker({
138
130
 
139
131
  const formattedValue = React.useMemo(() => {
140
132
  if (!value) return null
141
- try {
142
- return format(value, resolvedFormat, locale ? { locale } : undefined)
143
- } catch {
144
- return null
145
- }
133
+ return formatWithPublicDateFormat(value, resolvedFormat, locale)
146
134
  }, [value, resolvedFormat, locale])
147
135
 
148
136
  const handleDaySelect = React.useCallback(
@@ -1,7 +1,6 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { format } from 'date-fns'
5
4
  import type { Locale } from 'date-fns'
6
5
  import { CalendarIcon } from 'lucide-react'
7
6
  import { cn } from '@open-mercato/shared/lib/utils'
@@ -15,6 +14,7 @@ import {
15
14
  defaultDateRangePresets,
16
15
  type DateRangePresetItem,
17
16
  } from './date-picker-helpers'
17
+ import { formatWithPublicDateFormat, resolvePublicDateFormat } from './date-format'
18
18
 
19
19
  export type { DateRangePresetItem } from './date-picker-helpers'
20
20
 
@@ -43,19 +43,9 @@ export type DateRangePickerProps = {
43
43
  'aria-describedby'?: string
44
44
  }
45
45
 
46
- const DAY_FIRST_LOCALE_CODES = new Set([
47
- 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
48
- ])
49
-
50
- function deriveDateFormat(locale?: Locale): string {
51
- const code = locale?.code?.split('-')[0]?.toLowerCase() ?? ''
52
- return code && DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy' : 'MMM d, yyyy'
53
- }
54
-
55
46
  function defaultFormatRange(range: DateRange, locale?: Locale): string {
56
- const fmt = deriveDateFormat(locale)
57
- const opts = locale ? { locale } : undefined
58
- return `${format(range.start, fmt, opts)} – ${format(range.end, fmt, opts)}`
47
+ const fmt = resolvePublicDateFormat(locale)
48
+ return `${formatWithPublicDateFormat(range.start, fmt, locale) ?? ''} – ${formatWithPublicDateFormat(range.end, fmt, locale) ?? ''}`
59
49
  }
60
50
 
61
51
  function toRDPRange(range: DateRange | null | undefined): RDPRange | undefined {