@open-mercato/ui 0.6.4-develop.3944.1.4100aa7fbe → 0.6.4-develop.3962.1.70f30e284c

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 (51) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js +4 -2
  3. package/dist/ai/AiChat.js.map +2 -2
  4. package/dist/ai/AiChatSessions.js +30 -9
  5. package/dist/ai/AiChatSessions.js.map +2 -2
  6. package/dist/ai/AiDock.js +6 -1
  7. package/dist/ai/AiDock.js.map +2 -2
  8. package/dist/ai/conversation-store.js +2 -1
  9. package/dist/ai/conversation-store.js.map +2 -2
  10. package/dist/ai/useAiChat.js +15 -4
  11. package/dist/ai/useAiChat.js.map +2 -2
  12. package/dist/backend/charts/BarChart.js +19 -76
  13. package/dist/backend/charts/BarChart.js.map +2 -2
  14. package/dist/backend/charts/BarChartImpl.js +91 -0
  15. package/dist/backend/charts/BarChartImpl.js.map +7 -0
  16. package/dist/backend/charts/LineChart.js +18 -91
  17. package/dist/backend/charts/LineChart.js.map +2 -2
  18. package/dist/backend/charts/LineChartImpl.js +112 -0
  19. package/dist/backend/charts/LineChartImpl.js.map +7 -0
  20. package/dist/backend/charts/PieChart.js +17 -62
  21. package/dist/backend/charts/PieChart.js.map +2 -2
  22. package/dist/backend/charts/PieChartImpl.js +76 -0
  23. package/dist/backend/charts/PieChartImpl.js.map +7 -0
  24. package/dist/backend/dashboard/DashboardScreen.js +9 -1
  25. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  26. package/dist/backend/dashboard/widgetRegistry.js +18 -2
  27. package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
  28. package/dist/backend/injection/useInjectionDataWidgets.js +12 -2
  29. package/dist/backend/injection/useInjectionDataWidgets.js.map +2 -2
  30. package/package.json +3 -3
  31. package/src/ai/AiChat.tsx +10 -0
  32. package/src/ai/AiChatSessions.tsx +54 -9
  33. package/src/ai/AiDock.tsx +11 -0
  34. package/src/ai/__tests__/AiChat.conversation.test.tsx +97 -0
  35. package/src/ai/__tests__/AiChatSessions.test.tsx +228 -0
  36. package/src/ai/__tests__/AiDock.test.tsx +31 -1
  37. package/src/ai/__tests__/conversation-store.test.ts +74 -1
  38. package/src/ai/conversation-store.ts +18 -2
  39. package/src/ai/useAiChat.ts +35 -4
  40. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +53 -0
  41. package/src/backend/charts/BarChart.tsx +21 -72
  42. package/src/backend/charts/BarChartImpl.tsx +99 -0
  43. package/src/backend/charts/LineChart.tsx +23 -85
  44. package/src/backend/charts/LineChartImpl.tsx +120 -0
  45. package/src/backend/charts/PieChart.tsx +19 -60
  46. package/src/backend/charts/PieChartImpl.tsx +82 -0
  47. package/src/backend/dashboard/DashboardScreen.tsx +9 -1
  48. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +45 -0
  49. package/src/backend/dashboard/__tests__/widgetRegistry.test.ts +60 -0
  50. package/src/backend/dashboard/widgetRegistry.ts +23 -5
  51. package/src/backend/injection/useInjectionDataWidgets.ts +18 -2
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, screen, waitFor } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import {
9
+ emitOrganizationScopeChanged,
10
+ } from '@open-mercato/shared/lib/frontend/organizationEvents'
11
+ import {
12
+ AiChatSessionsProvider,
13
+ LEGACY_STORAGE_KEY,
14
+ useAiChatSessions,
15
+ } from '../AiChatSessions'
16
+
17
+ jest.mock('../conversation-store', () => {
18
+ const actual = jest.requireActual('../conversation-store') as Record<string, unknown>
19
+ return {
20
+ ...actual,
21
+ listAiServerConversations: jest.fn(async () => null),
22
+ createAiServerConversation: jest.fn(async () => null),
23
+ updateAiServerConversation: jest.fn(async () => null),
24
+ }
25
+ })
26
+
27
+ import {
28
+ listAiServerConversations,
29
+ createAiServerConversation,
30
+ } from '../conversation-store'
31
+
32
+ const listMock = listAiServerConversations as jest.MockedFunction<typeof listAiServerConversations>
33
+ const createMock = createAiServerConversation as jest.MockedFunction<typeof createAiServerConversation>
34
+
35
+ const KEY_PREFIX = 'om-ai-chat-sessions-v1'
36
+
37
+ function scopedKey(tenantId: string | null, organizationId: string | null): string {
38
+ return `${KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`
39
+ }
40
+
41
+ function CreateSessionButton({ agentId }: { agentId: string }) {
42
+ const sessions = useAiChatSessions()
43
+ return (
44
+ <button type="button" onClick={() => sessions.createSession(agentId)}>
45
+ Create session
46
+ </button>
47
+ )
48
+ }
49
+
50
+ function SessionCount({ agentId }: { agentId: string }) {
51
+ const sessions = useAiChatSessions()
52
+ const open = sessions.getOpenSessions(agentId)
53
+ return <span data-testid="session-count">{open.length}</span>
54
+ }
55
+
56
+ function Harness({ agentId }: { agentId: string }) {
57
+ return (
58
+ <AiChatSessionsProvider>
59
+ <CreateSessionButton agentId={agentId} />
60
+ <SessionCount agentId={agentId} />
61
+ </AiChatSessionsProvider>
62
+ )
63
+ }
64
+
65
+ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
66
+ beforeEach(() => {
67
+ window.localStorage.clear()
68
+ listMock.mockReset()
69
+ listMock.mockResolvedValue(null)
70
+ createMock.mockReset()
71
+ createMock.mockResolvedValue(null)
72
+ // Reset scope to a known starting point. The module-level state in
73
+ // organizationEvents persists across tests in the same file.
74
+ act(() => {
75
+ emitOrganizationScopeChanged({ tenantId: 'T1', organizationId: 'O1' })
76
+ })
77
+ })
78
+
79
+ afterEach(() => {
80
+ window.localStorage.clear()
81
+ })
82
+
83
+ it('does not read or write the legacy unscoped key', async () => {
84
+ window.localStorage.setItem(
85
+ LEGACY_STORAGE_KEY,
86
+ JSON.stringify({
87
+ sessions: [
88
+ {
89
+ id: 'legacy-session',
90
+ agentId: 'assistant',
91
+ conversationId: 'legacy-conv',
92
+ createdAt: 1,
93
+ lastUsedAt: 1,
94
+ status: 'open',
95
+ },
96
+ ],
97
+ activeByAgent: { assistant: 'legacy-session' },
98
+ }),
99
+ )
100
+
101
+ renderWithProviders(<Harness agentId="assistant" />)
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByTestId('session-count').textContent).toBe('0')
105
+ })
106
+
107
+ expect(window.localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull()
108
+ })
109
+
110
+ it('writes new sessions to the tenant/org scoped key', async () => {
111
+ renderWithProviders(<Harness agentId="assistant" />)
112
+
113
+ act(() => {
114
+ screen.getByRole('button', { name: 'Create session' }).click()
115
+ })
116
+
117
+ await waitFor(() => {
118
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
119
+ })
120
+
121
+ const scoped = window.localStorage.getItem(scopedKey('T1', 'O1'))
122
+ expect(scoped).not.toBeNull()
123
+ const parsed = JSON.parse(scoped!) as { sessions: Array<{ agentId: string }> }
124
+ expect(parsed.sessions).toHaveLength(1)
125
+ expect(parsed.sessions[0].agentId).toBe('assistant')
126
+
127
+ expect(window.localStorage.getItem(scopedKey('T2', 'O2'))).toBeNull()
128
+ })
129
+
130
+ it('rehydrates from the new scope on scope change (no flash of previous-scope data)', async () => {
131
+ // Seed BOTH scopes' buckets with distinct session counts so we can
132
+ // observe the swap.
133
+ window.localStorage.setItem(
134
+ scopedKey('T1', 'O1'),
135
+ JSON.stringify({
136
+ sessions: [
137
+ {
138
+ id: 's1',
139
+ agentId: 'assistant',
140
+ conversationId: 'c1',
141
+ createdAt: 1,
142
+ lastUsedAt: 1,
143
+ status: 'open',
144
+ },
145
+ {
146
+ id: 's2',
147
+ agentId: 'assistant',
148
+ conversationId: 'c2',
149
+ createdAt: 2,
150
+ lastUsedAt: 2,
151
+ status: 'open',
152
+ },
153
+ ],
154
+ activeByAgent: { assistant: 's1' },
155
+ }),
156
+ )
157
+ window.localStorage.setItem(
158
+ scopedKey('T2', 'O2'),
159
+ JSON.stringify({
160
+ sessions: [
161
+ {
162
+ id: 's3',
163
+ agentId: 'assistant',
164
+ conversationId: 'c3',
165
+ createdAt: 3,
166
+ lastUsedAt: 3,
167
+ status: 'open',
168
+ },
169
+ ],
170
+ activeByAgent: { assistant: 's3' },
171
+ }),
172
+ )
173
+
174
+ renderWithProviders(<Harness agentId="assistant" />)
175
+
176
+ await waitFor(() => {
177
+ expect(screen.getByTestId('session-count').textContent).toBe('2')
178
+ })
179
+
180
+ act(() => {
181
+ emitOrganizationScopeChanged({ tenantId: 'T2', organizationId: 'O2' })
182
+ })
183
+
184
+ await waitFor(() => {
185
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
186
+ })
187
+
188
+ // T1/O1 bucket must remain untouched after the swap.
189
+ const t1Raw = window.localStorage.getItem(scopedKey('T1', 'O1'))
190
+ expect(t1Raw).not.toBeNull()
191
+ const t1Parsed = JSON.parse(t1Raw!) as { sessions: unknown[] }
192
+ expect(t1Parsed.sessions).toHaveLength(2)
193
+ })
194
+
195
+ it('refires the server conversation list on scope change', async () => {
196
+ renderWithProviders(<Harness agentId="assistant" />)
197
+
198
+ await waitFor(() => {
199
+ expect(listMock).toHaveBeenCalledTimes(1)
200
+ })
201
+
202
+ act(() => {
203
+ emitOrganizationScopeChanged({ tenantId: 'T2', organizationId: 'O2' })
204
+ })
205
+
206
+ await waitFor(() => {
207
+ expect(listMock).toHaveBeenCalledTimes(2)
208
+ })
209
+ })
210
+
211
+ it('falls back to the no-tenant/no-org bucket when scope is unresolved', async () => {
212
+ act(() => {
213
+ emitOrganizationScopeChanged({ tenantId: null, organizationId: null })
214
+ })
215
+
216
+ renderWithProviders(<Harness agentId="assistant" />)
217
+
218
+ act(() => {
219
+ screen.getByRole('button', { name: 'Create session' }).click()
220
+ })
221
+
222
+ await waitFor(() => {
223
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
224
+ })
225
+
226
+ expect(window.localStorage.getItem(scopedKey(null, null))).not.toBeNull()
227
+ })
228
+ })
@@ -11,11 +11,21 @@ import { AiDockProvider, useAiDock, type AiDockedAssistant } from '../AiDock'
11
11
  jest.mock('../AiChat', () => {
12
12
  const ReactModule = require('react') as typeof import('react')
13
13
  return {
14
- AiChat: ({ agent, defaultCompactFooter }: { agent: string; defaultCompactFooter?: boolean }) =>
14
+ AiChat: ({
15
+ agent,
16
+ defaultCompactFooter,
17
+ onConversationNotFound,
18
+ }: {
19
+ agent: string
20
+ defaultCompactFooter?: boolean
21
+ onConversationNotFound?: () => void
22
+ }) =>
15
23
  ReactModule.createElement('div', {
16
24
  'data-testid': 'ai-chat',
17
25
  'data-agent': agent,
18
26
  'data-default-compact-footer': defaultCompactFooter ? 'true' : 'false',
27
+ 'data-has-conversation-not-found-handler': typeof onConversationNotFound === 'function' ? 'true' : 'false',
28
+ onClick: () => onConversationNotFound?.(),
19
29
  }),
20
30
  }
21
31
  })
@@ -132,4 +142,24 @@ describe('<AiDockProvider>', () => {
132
142
  expect(screen.getByTestId('ai-chat')).toHaveAttribute('data-default-compact-footer', 'true')
133
143
  })
134
144
  })
145
+
146
+ it('wires onConversationNotFound so a stale session closes (no perpetual blank chat)', async () => {
147
+ renderWithProviders(<Harness />)
148
+
149
+ fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
150
+
151
+ const chat = await waitFor(() => screen.getByTestId('ai-chat'))
152
+ expect(chat).toHaveAttribute('data-has-conversation-not-found-handler', 'true')
153
+
154
+ // Fire the not-found callback via the mocked AiChat's onClick.
155
+ fireEvent.click(chat)
156
+
157
+ // After closeSession, the active session disappears; DockedChatBody
158
+ // schedules ensureSession in an effect, so for one tick the AiChat
159
+ // surface should be torn down before the new session mounts.
160
+ await waitFor(() => {
161
+ const next = screen.queryByTestId('ai-chat')
162
+ if (next === chat) throw new Error('stale chat surface should have unmounted')
163
+ })
164
+ })
135
165
  })
@@ -1,4 +1,4 @@
1
- import { listAiServerConversations } from '../conversation-store'
1
+ import { listAiServerConversations, loadAiServerTranscript } from '../conversation-store'
2
2
 
3
3
  describe('AI conversation store', () => {
4
4
  const originalFetch = global.fetch
@@ -26,4 +26,77 @@ describe('AI conversation store', () => {
26
26
  }),
27
27
  )
28
28
  })
29
+
30
+ describe('loadAiServerTranscript', () => {
31
+ it('returns ok=true with parsed data on 200', async () => {
32
+ const payload = {
33
+ conversation: {
34
+ conversationId: 'conv-1',
35
+ agentId: 'agent-1',
36
+ title: null,
37
+ status: 'open',
38
+ visibility: 'private',
39
+ pageContext: null,
40
+ createdAt: '2026-05-28T00:00:00Z',
41
+ updatedAt: '2026-05-28T00:00:00Z',
42
+ lastMessageAt: null,
43
+ importedFromLocalAt: null,
44
+ isOwner: true,
45
+ },
46
+ messages: [],
47
+ nextCursor: null,
48
+ }
49
+ global.fetch = jest.fn(async () => new Response(JSON.stringify(payload), { status: 200 })) as unknown as typeof fetch
50
+
51
+ const result = await loadAiServerTranscript('conv-1')
52
+
53
+ expect(result).toEqual({ ok: true, data: payload })
54
+ })
55
+
56
+ it('returns ok=false notFound=true on 404 (stale conversationId from previous scope)', async () => {
57
+ global.fetch = jest.fn(async () => new Response('{"error":"not found"}', { status: 404 })) as unknown as typeof fetch
58
+
59
+ const result = await loadAiServerTranscript('conv-from-old-scope')
60
+
61
+ expect(result).toEqual({ ok: false, notFound: true })
62
+ })
63
+
64
+ it('returns ok=false notFound=false on 503 (transient transport failure)', async () => {
65
+ global.fetch = jest.fn(async () => new Response('{"error":"upstream"}', { status: 503 })) as unknown as typeof fetch
66
+
67
+ const result = await loadAiServerTranscript('conv-1')
68
+
69
+ expect(result).toEqual({ ok: false, notFound: false })
70
+ })
71
+
72
+ it('returns ok=false notFound=false on 403 (no scope membership)', async () => {
73
+ global.fetch = jest.fn(async () => new Response('{"error":"forbidden"}', { status: 403 })) as unknown as typeof fetch
74
+
75
+ const result = await loadAiServerTranscript('conv-1')
76
+
77
+ expect(result).toEqual({ ok: false, notFound: false })
78
+ })
79
+
80
+ it('returns ok=false notFound=false when fetch throws', async () => {
81
+ global.fetch = jest.fn(async () => {
82
+ throw new TypeError('Failed to fetch')
83
+ }) as unknown as typeof fetch
84
+
85
+ const result = await loadAiServerTranscript('conv-1')
86
+
87
+ expect(result).toEqual({ ok: false, notFound: false })
88
+ })
89
+
90
+ it('forwards the limit option as a query string', async () => {
91
+ const fetchMock = jest.fn(async () => new Response('{}', { status: 404 }))
92
+ global.fetch = fetchMock as unknown as typeof fetch
93
+
94
+ await loadAiServerTranscript('conv-1', { limit: 25 })
95
+
96
+ expect(fetchMock).toHaveBeenCalledWith(
97
+ '/api/ai_assistant/ai/conversations/conv-1?limit=25',
98
+ expect.any(Object),
99
+ )
100
+ })
101
+ })
29
102
  })
@@ -50,6 +50,21 @@ export interface AiServerImportResponse {
50
50
  skippedMessageCount: number
51
51
  }
52
52
 
53
+ /**
54
+ * Discriminated result of {@link loadAiServerTranscript}. Callers must
55
+ * distinguish 404 (the conversation does not exist for the current scope —
56
+ * e.g. it belonged to a previous tenant/org) from other failures (network
57
+ * down, server error) because the recovery strategies are opposite:
58
+ * - `notFound: true` → drop the local cache, optionally surface a
59
+ * not-found callback. NEVER import local messages
60
+ * onto the new scope.
61
+ * - `notFound: false` → preserve local state and retry later.
62
+ */
63
+ export type LoadTranscriptResult =
64
+ | { ok: true; data: AiServerTranscriptResponse }
65
+ | { ok: false; notFound: true }
66
+ | { ok: false; notFound: false }
67
+
53
68
  function readString(value: unknown): string | null {
54
69
  return typeof value === 'string' && value.length > 0 ? value : null
55
70
  }
@@ -147,13 +162,14 @@ export async function updateAiServerConversation(
147
162
  export async function loadAiServerTranscript(
148
163
  conversationId: string,
149
164
  options: { limit?: number } = {},
150
- ): Promise<AiServerTranscriptResponse | null> {
165
+ ): Promise<LoadTranscriptResult> {
151
166
  const params = new URLSearchParams()
152
167
  params.set('limit', String(options.limit ?? 100))
153
168
  const result = await requestJson<AiServerTranscriptResponse>(
154
169
  `${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}?${params.toString()}`,
155
170
  )
156
- return result.ok ? result.data : null
171
+ if (result.ok) return { ok: true, data: result.data }
172
+ return { ok: false, notFound: result.status === 404 }
157
173
  }
158
174
 
159
175
  export async function importAiLocalConversation(input: {
@@ -118,6 +118,15 @@ export interface UseAiChatInput {
118
118
  * configured default (no override sent).
119
119
  */
120
120
  modelOverride?: string | null
121
+ /**
122
+ * Called when the server returns 404 for the active `conversationId`.
123
+ * Used by hosts to remove a stale session entry (e.g. the AI dock
124
+ * closes the session tab) when a conversation no longer exists for the
125
+ * current tenant/org scope. A 404 here distinguishes a missing
126
+ * conversation from a transient transport failure — the hook never
127
+ * imports local messages onto the new scope when this fires.
128
+ */
129
+ onConversationNotFound?: () => void
121
130
  }
122
131
 
123
132
  export interface AiChatErrorEnvelope {
@@ -779,7 +788,7 @@ async function readErrorEnvelope(response: Response): Promise<AiChatErrorEnvelop
779
788
  }
780
789
 
781
790
  export function useAiChat(input: UseAiChatInput): UseAiChatResult {
782
- const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput, providerOverride, modelOverride } = input
791
+ const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput, providerOverride, modelOverride, onConversationNotFound } = input
783
792
 
784
793
  // Minted once on mount when the caller does not supply a conversationId.
785
794
  // The ref keeps the id stable across re-renders and is reused for every
@@ -925,10 +934,11 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
925
934
  const localCandidate = readPersistedSession(agent, persistKey)
926
935
 
927
936
  async function hydrateFromServer(): Promise<void> {
928
- const transcript = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
937
+ const transcriptResult = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
929
938
  if (cancelled || latestStatusRef.current !== 'idle') return
930
939
 
931
- if (transcript) {
940
+ if (transcriptResult.ok) {
941
+ const transcript = transcriptResult.data
932
942
  if (typeof transcript.conversation.isOwner === 'boolean') {
933
943
  setIsOwner(transcript.conversation.isOwner)
934
944
  }
@@ -942,8 +952,25 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
942
952
  }
943
953
  }
944
954
 
955
+ // 404 — the conversation does not exist for the current tenant/org
956
+ // scope (e.g. the user switched scope and the stale conversationId
957
+ // came from the previous bucket's localStorage). Self-heal: drop
958
+ // the local cache, signal the host so it can prune its session
959
+ // registry, and NEVER fall through to importAiLocalConversation —
960
+ // that would silently write the previous scope's messages onto the
961
+ // new scope's server.
962
+ if (!transcriptResult.ok && transcriptResult.notFound) {
963
+ updateMessages([])
964
+ clearPersistedSession(agent, persistKey)
965
+ onConversationNotFoundRef.current?.()
966
+ return
967
+ }
968
+
945
969
  if (!localCandidate || localCandidate.messages.length === 0) {
946
- if (!transcript) {
970
+ if (!transcriptResult.ok) {
971
+ // Transient transport failure (network down, 5xx, …). Mint a
972
+ // new server-side record optimistically; the next idle hydrate
973
+ // will reconcile.
947
974
  void createAiServerConversation({
948
975
  agentId: agent,
949
976
  conversationId: effectiveConversationId,
@@ -991,6 +1018,10 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
991
1018
  React.useEffect(() => {
992
1019
  onErrorRef.current = onError
993
1020
  }, [onError])
1021
+ const onConversationNotFoundRef = React.useRef(onConversationNotFound)
1022
+ React.useEffect(() => {
1023
+ onConversationNotFoundRef.current = onConversationNotFound
1024
+ }, [onConversationNotFound])
994
1025
 
995
1026
  const emitError = React.useCallback((envelope: AiChatErrorEnvelope) => {
996
1027
  if (mountedRef.current) {
@@ -47,4 +47,57 @@ describe('heavy libraries are lazy-loaded', () => {
47
47
  expect(source).not.toMatch(/from\s+['"]remark-gfm['"]/)
48
48
  expect(source).toMatch(/markdownToPlainText/)
49
49
  })
50
+
51
+ it('BarChart wrapper does not statically import recharts', () => {
52
+ const source = read('packages/ui/src/backend/charts/BarChart.tsx')
53
+ expect(source).not.toMatch(/^[^\n/]*import\s+[^'\n]+from\s+['"]recharts['"]/m)
54
+ expect(source).toMatch(/next\/dynamic/)
55
+ expect(source).toMatch(/import\(['"]\.\/BarChartImpl['"]\)/)
56
+ })
57
+
58
+ it('LineChart wrapper does not statically import recharts', () => {
59
+ const source = read('packages/ui/src/backend/charts/LineChart.tsx')
60
+ expect(source).not.toMatch(/^[^\n/]*import\s+[^'\n]+from\s+['"]recharts['"]/m)
61
+ expect(source).toMatch(/next\/dynamic/)
62
+ expect(source).toMatch(/import\(['"]\.\/LineChartImpl['"]\)/)
63
+ })
64
+
65
+ it('PieChart wrapper does not statically import recharts', () => {
66
+ const source = read('packages/ui/src/backend/charts/PieChart.tsx')
67
+ expect(source).not.toMatch(/^[^\n/]*import\s+[^'\n]+from\s+['"]recharts['"]/m)
68
+ expect(source).toMatch(/next\/dynamic/)
69
+ expect(source).toMatch(/import\(['"]\.\/PieChartImpl['"]\)/)
70
+ })
71
+
72
+ it('recharts is only imported by the chart *Impl modules', () => {
73
+ const impls = ['BarChartImpl', 'LineChartImpl', 'PieChartImpl']
74
+ for (const name of impls) {
75
+ const source = read(`packages/ui/src/backend/charts/${name}.tsx`)
76
+ expect(source).toMatch(/from\s+['"]recharts['"]/)
77
+ }
78
+ })
79
+
80
+ it('WorkflowGraph wrapper has only type-only @xyflow/react imports', () => {
81
+ const source = read(
82
+ 'packages/core/src/modules/workflows/components/WorkflowGraph.tsx',
83
+ )
84
+ expect(source).not.toMatch(
85
+ /^[^\n/]*import\s+(?!type\b)[^'\n]+from\s+['"]@xyflow\/react['"]/m,
86
+ )
87
+ expect(source).toMatch(/next\/dynamic/)
88
+ expect(source).toMatch(/import\(['"]\.\/WorkflowGraphImpl['"]\)/)
89
+ })
90
+
91
+ it('WorkflowGraphImpl owns the @xyflow/react runtime import and CSS', () => {
92
+ const source = read(
93
+ 'packages/core/src/modules/workflows/components/WorkflowGraphImpl.tsx',
94
+ )
95
+ expect(source).toMatch(/from\s+['"]@xyflow\/react['"]/)
96
+ expect(source).toMatch(/['"]@xyflow\/react\/dist\/style\.css['"]/)
97
+ })
98
+
99
+ it('globals.css no longer eagerly imports @xyflow/react styles', () => {
100
+ const source = read('apps/mercato/src/app/globals.css')
101
+ expect(source).not.toMatch(/@xyflow\/react\/dist\/style\.css/)
102
+ })
50
103
  })
@@ -1,18 +1,8 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import {
5
- BarChart as RechartsBarChart,
6
- Bar,
7
- XAxis,
8
- YAxis,
9
- CartesianGrid,
10
- Tooltip,
11
- Legend,
12
- ResponsiveContainer,
13
- } from 'recharts'
4
+ import dynamic from 'next/dynamic'
14
5
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
15
- import { ChartTooltipContent, resolveChartColor } from './ChartUtils'
16
6
 
17
7
  export type BarChartDataItem = Record<string, string | number | null | undefined>
18
8
 
@@ -43,6 +33,15 @@ function defaultValueFormatter(value: number): string {
43
33
  return value.toLocaleString(undefined, { maximumFractionDigits: 2 })
44
34
  }
45
35
 
36
+ const BarChartImpl = dynamic(() => import('./BarChartImpl'), {
37
+ ssr: false,
38
+ loading: () => (
39
+ <div className="flex h-40 sm:h-48 items-center justify-center">
40
+ <Spinner className="h-6 w-6 text-muted-foreground" />
41
+ </div>
42
+ ),
43
+ })
44
+
46
45
  export function BarChart({
47
46
  title,
48
47
  data,
@@ -59,10 +58,6 @@ export function BarChart({
59
58
  emptyMessage = 'No data available',
60
59
  categoryLabels,
61
60
  }: BarChartProps) {
62
- const getBarColor = (idx: number): string => {
63
- return resolveChartColor(colors?.[idx], idx)
64
- }
65
-
66
61
  const hasWrapper = !!title
67
62
  const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className
68
63
 
@@ -99,66 +94,20 @@ export function BarChart({
99
94
  )
100
95
  }
101
96
 
102
- const isHorizontal = layout === 'horizontal'
103
- const chartHeight = isHorizontal ? Math.max(200, data.length * 28) : 200
104
-
105
- const chartContent = (
106
- <ResponsiveContainer width="100%" height={chartHeight}>
107
- <RechartsBarChart
108
- data={data}
109
- layout={isHorizontal ? 'vertical' : 'horizontal'}
110
- margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
111
- >
112
- {showGridLines && (
113
- <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
114
- )}
115
- <XAxis
116
- type={isHorizontal ? 'number' : 'category'}
117
- dataKey={isHorizontal ? undefined : index}
118
- tickFormatter={isHorizontal ? valueFormatter : undefined}
119
- tick={{ fontSize: 11 }}
120
- />
121
- <YAxis
122
- type={isHorizontal ? 'category' : 'number'}
123
- dataKey={isHorizontal ? index : undefined}
124
- tickFormatter={isHorizontal ? undefined : valueFormatter}
125
- width={isHorizontal ? 90 : 50}
126
- interval={0}
127
- tick={{ fontSize: 10 }}
128
- />
129
- <Tooltip
130
- content={
131
- <ChartTooltipContent
132
- valueFormatter={valueFormatter}
133
- categoryLabels={categoryLabels}
134
- labelFormatter={(label, payload) => {
135
- const entry = payload?.[0] as { payload?: BarChartDataItem } | undefined
136
- const item = entry?.payload
137
- return item?.[index] ? String(item[index]) : label
138
- }}
139
- />
140
- }
141
- cursor={{ fill: 'hsl(var(--muted))', opacity: 0.2 }}
142
- />
143
- {showLegend && categories.length > 1 && (
144
- <Legend verticalAlign="top" height={36} />
145
- )}
146
- {categories.map((category, idx) => (
147
- <Bar
148
- key={category}
149
- dataKey={category}
150
- fill={getBarColor(idx)}
151
- radius={isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]}
152
- />
153
- ))}
154
- </RechartsBarChart>
155
- </ResponsiveContainer>
156
- )
157
-
158
97
  return (
159
98
  <div className={wrapperClass}>
160
99
  {title && <h3 className="mb-4 text-base font-medium text-card-foreground">{title}</h3>}
161
- {chartContent}
100
+ <BarChartImpl
101
+ data={data}
102
+ index={index}
103
+ categories={categories}
104
+ colors={colors}
105
+ layout={layout}
106
+ valueFormatter={valueFormatter}
107
+ showLegend={showLegend}
108
+ showGridLines={showGridLines}
109
+ categoryLabels={categoryLabels}
110
+ />
162
111
  </div>
163
112
  )
164
113
  }