@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/AiAssistantLauncher.js +11 -10
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +218 -83
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiIcon.js +1 -1
- package/dist/ai/AiIcon.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +4 -3
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/useAiChat.js +98 -2
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/DataTable.js +4 -13
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/primitives/date-format.js +53 -0
- package/dist/primitives/date-format.js.map +7 -0
- package/dist/primitives/date-picker.js +3 -26
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/date-range-picker.js +3 -22
- package/dist/primitives/date-range-picker.js.map +2 -2
- package/package.json +4 -4
- package/src/ai/AiAssistantLauncher.tsx +11 -10
- package/src/ai/AiChat.tsx +200 -26
- package/src/ai/AiIcon.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +356 -0
- package/src/ai/__tests__/AiChat.test.tsx +44 -2
- package/src/ai/parts/FieldDiffCard.tsx +4 -3
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +24 -0
- package/src/ai/parts/types.ts +3 -0
- package/src/ai/useAiChat.ts +154 -1
- package/src/backend/DataTable.tsx +12 -22
- package/src/primitives/__tests__/date-format.test.ts +43 -0
- package/src/primitives/date-format.ts +60 -0
- package/src/primitives/date-picker.tsx +5 -17
- package/src/primitives/date-range-picker.tsx +3 -13
package/src/ai/useAiChat.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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 ?
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
57
|
-
|
|
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 {
|