@open-mercato/ui 0.6.6-develop.5588.1.a8f6c51d1f → 0.6.6-develop.5594.1.30cd738303

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.
@@ -4,9 +4,16 @@ import { apiCall } from "../utils/apiCall.js";
4
4
  import { useAppEvent } from "../injection/useAppEvent.js";
5
5
  import { subscribeProgressUpdate } from "@open-mercato/shared/lib/frontend/progressEvents";
6
6
  import { applyLocalProgressUpdate, isLocalProgressJob } from "./useProgressPoll.js";
7
+ const SSE_PROGRESS_SYNC_INTERVAL = 5e3;
7
8
  function isVisibleProgressJob(job) {
8
9
  return job.meta?.hiddenFromTopBar !== true;
9
10
  }
11
+ function isActiveStatus(status) {
12
+ return status === "pending" || status === "running";
13
+ }
14
+ function isTerminalStatus(status) {
15
+ return status === "completed" || status === "failed" || status === "cancelled";
16
+ }
10
17
  function upsertJob(list, job) {
11
18
  if (!isVisibleProgressJob(job)) {
12
19
  return list.filter((item) => item.id !== job.id);
@@ -28,11 +35,11 @@ function useProgressSse() {
28
35
  );
29
36
  if (result.ok && result.result) {
30
37
  setActiveJobs((prev) => [
31
- ...prev.filter((job) => isLocalProgressJob(job) && (job.status === "pending" || job.status === "running")),
38
+ ...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),
32
39
  ...result.result.active.filter(isVisibleProgressJob)
33
40
  ]);
34
41
  setRecentlyCompleted((prev) => [
35
- ...prev.filter((job) => isLocalProgressJob(job) && (job.status === "completed" || job.status === "failed")),
42
+ ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),
36
43
  ...result.result.recentlyCompleted.filter(isVisibleProgressJob)
37
44
  ].slice(0, 10));
38
45
  setError(null);
@@ -49,6 +56,30 @@ function useProgressSse() {
49
56
  React.useEffect(() => {
50
57
  void fetchJobs();
51
58
  }, [fetchJobs]);
59
+ React.useEffect(() => {
60
+ let interval = setInterval(() => {
61
+ void fetchJobs();
62
+ }, SSE_PROGRESS_SYNC_INTERVAL);
63
+ const onVisibilityChange = () => {
64
+ if (document.hidden) {
65
+ if (interval) {
66
+ clearInterval(interval);
67
+ interval = null;
68
+ }
69
+ } else {
70
+ void fetchJobs();
71
+ if (interval) clearInterval(interval);
72
+ interval = setInterval(() => {
73
+ void fetchJobs();
74
+ }, SSE_PROGRESS_SYNC_INTERVAL);
75
+ }
76
+ };
77
+ document.addEventListener("visibilitychange", onVisibilityChange);
78
+ return () => {
79
+ if (interval) clearInterval(interval);
80
+ document.removeEventListener("visibilitychange", onVisibilityChange);
81
+ };
82
+ }, [fetchJobs]);
52
83
  React.useEffect(() => {
53
84
  return subscribeProgressUpdate((detail) => {
54
85
  applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted);
@@ -63,23 +94,30 @@ function useProgressSse() {
63
94
  void fetchJobs();
64
95
  return;
65
96
  }
97
+ const status = payload.status ?? "running";
98
+ const job = {
99
+ id: jobId,
100
+ jobType: payload.jobType ?? "progress",
101
+ name: payload.name ?? payload.jobType ?? "Progress job",
102
+ description: payload.description ?? null,
103
+ meta: payload.meta && typeof payload.meta === "object" ? payload.meta : null,
104
+ status,
105
+ progressPercent: payload.progressPercent ?? 0,
106
+ processedCount: payload.processedCount ?? 0,
107
+ totalCount: payload.totalCount ?? null,
108
+ etaSeconds: payload.etaSeconds ?? null,
109
+ cancellable: payload.cancellable ?? false,
110
+ startedAt: payload.startedAt ?? null,
111
+ finishedAt: payload.finishedAt ?? null,
112
+ errorMessage: payload.errorMessage ?? null
113
+ };
114
+ if (isTerminalStatus(status)) {
115
+ setActiveJobs((prev) => prev.filter((item) => item.id !== jobId));
116
+ setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10));
117
+ return;
118
+ }
66
119
  setActiveJobs(
67
- (prev) => upsertJob(prev, {
68
- id: jobId,
69
- jobType: payload.jobType ?? "progress",
70
- name: payload.name ?? payload.jobType ?? "Progress job",
71
- description: payload.description ?? null,
72
- meta: payload.meta && typeof payload.meta === "object" ? payload.meta : null,
73
- status: payload.status ?? "running",
74
- progressPercent: payload.progressPercent ?? 0,
75
- processedCount: payload.processedCount ?? 0,
76
- totalCount: payload.totalCount ?? null,
77
- etaSeconds: payload.etaSeconds ?? null,
78
- cancellable: payload.cancellable ?? false,
79
- startedAt: payload.startedAt ?? null,
80
- finishedAt: payload.finishedAt ?? null,
81
- errorMessage: payload.errorMessage ?? null
82
- })
120
+ (prev) => upsertJob(prev, job)
83
121
  );
84
122
  },
85
123
  [fetchJobs]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/progress/useProgressSse.ts"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'\nimport type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'\nimport { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'\n\nfunction isVisibleProgressJob(job: ProgressJobDto): boolean {\n return job.meta?.hiddenFromTopBar !== true\n}\n\nfunction upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {\n if (!isVisibleProgressJob(job)) {\n return list.filter((item) => item.id !== job.id)\n }\n const next = [job, ...list.filter((item) => item.id !== job.id)]\n return next.sort(\n (a, b) =>\n new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()\n - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),\n )\n}\n\nexport function useProgressSse(): UseProgressPollResult {\n const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])\n const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchJobs = React.useCallback(async () => {\n try {\n const result = await apiCall<{ active: ProgressJobDto[]; recentlyCompleted: ProgressJobDto[] }>(\n '/api/progress/active',\n )\n if (result.ok && result.result) {\n setActiveJobs((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && (job.status === 'pending' || job.status === 'running')),\n ...result.result!.active.filter(isVisibleProgressJob),\n ])\n setRecentlyCompleted((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && (job.status === 'completed' || job.status === 'failed')),\n ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),\n ].slice(0, 10))\n setError(null)\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch progress')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n return subscribeProgressUpdate((detail) => {\n applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)\n })\n }, [])\n\n useAppEvent(\n 'progress.job.updated',\n (event) => {\n const payload = event.payload as Partial<ProgressJobDto> & { jobId?: string }\n const jobId = payload?.jobId\n if (!jobId) {\n void fetchJobs()\n return\n }\n setActiveJobs((prev) =>\n upsertJob(prev, {\n id: jobId,\n jobType: payload.jobType ?? 'progress',\n name: payload.name ?? payload.jobType ?? 'Progress job',\n description: payload.description ?? null,\n meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,\n status: (payload.status as ProgressJobDto['status']) ?? 'running',\n progressPercent: payload.progressPercent ?? 0,\n processedCount: payload.processedCount ?? 0,\n totalCount: payload.totalCount ?? null,\n etaSeconds: payload.etaSeconds ?? null,\n cancellable: payload.cancellable ?? false,\n startedAt: payload.startedAt ?? null,\n finishedAt: payload.finishedAt ?? null,\n errorMessage: payload.errorMessage ?? null,\n }),\n )\n },\n [fetchJobs],\n )\n\n useAppEvent('progress.job.created', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.started', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.completed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.failed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.cancelled', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('om:bridge:reconnected', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n const onFocus = () => {\n void fetchJobs()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchJobs])\n\n return { activeJobs, recentlyCompleted, isLoading, error, refresh }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall } from '../utils/apiCall'\nimport { useAppEvent } from '../injection/useAppEvent'\nimport { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'\nimport type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'\nimport { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'\n\nconst SSE_PROGRESS_SYNC_INTERVAL = 5000\n\nfunction isVisibleProgressJob(job: ProgressJobDto): boolean {\n return job.meta?.hiddenFromTopBar !== true\n}\n\nfunction isActiveStatus(status: ProgressJobDto['status']): boolean {\n return status === 'pending' || status === 'running'\n}\n\nfunction isTerminalStatus(status: ProgressJobDto['status']): boolean {\n return status === 'completed' || status === 'failed' || status === 'cancelled'\n}\n\nfunction upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {\n if (!isVisibleProgressJob(job)) {\n return list.filter((item) => item.id !== job.id)\n }\n const next = [job, ...list.filter((item) => item.id !== job.id)]\n return next.sort(\n (a, b) =>\n new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()\n - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),\n )\n}\n\nexport function useProgressSse(): UseProgressPollResult {\n const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])\n const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n const fetchJobs = React.useCallback(async () => {\n try {\n const result = await apiCall<{ active: ProgressJobDto[]; recentlyCompleted: ProgressJobDto[] }>(\n '/api/progress/active',\n )\n if (result.ok && result.result) {\n setActiveJobs((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),\n ...result.result!.active.filter(isVisibleProgressJob),\n ])\n setRecentlyCompleted((prev) => [\n ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),\n ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),\n ].slice(0, 10))\n setError(null)\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch progress')\n } finally {\n setIsLoading(false)\n }\n }, [])\n\n const refresh = React.useCallback(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n let interval: ReturnType<typeof setInterval> | null = setInterval(() => {\n void fetchJobs()\n }, SSE_PROGRESS_SYNC_INTERVAL)\n\n const onVisibilityChange = () => {\n if (document.hidden) {\n if (interval) {\n clearInterval(interval)\n interval = null\n }\n } else {\n void fetchJobs()\n if (interval) clearInterval(interval)\n interval = setInterval(() => {\n void fetchJobs()\n }, SSE_PROGRESS_SYNC_INTERVAL)\n }\n }\n\n document.addEventListener('visibilitychange', onVisibilityChange)\n return () => {\n if (interval) clearInterval(interval)\n document.removeEventListener('visibilitychange', onVisibilityChange)\n }\n }, [fetchJobs])\n\n React.useEffect(() => {\n return subscribeProgressUpdate((detail) => {\n applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)\n })\n }, [])\n\n useAppEvent(\n 'progress.job.updated',\n (event) => {\n const payload = event.payload as Partial<ProgressJobDto> & { jobId?: string }\n const jobId = payload?.jobId\n if (!jobId) {\n void fetchJobs()\n return\n }\n const status = (payload.status as ProgressJobDto['status']) ?? 'running'\n const job: ProgressJobDto = {\n id: jobId,\n jobType: payload.jobType ?? 'progress',\n name: payload.name ?? payload.jobType ?? 'Progress job',\n description: payload.description ?? null,\n meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,\n status,\n progressPercent: payload.progressPercent ?? 0,\n processedCount: payload.processedCount ?? 0,\n totalCount: payload.totalCount ?? null,\n etaSeconds: payload.etaSeconds ?? null,\n cancellable: payload.cancellable ?? false,\n startedAt: payload.startedAt ?? null,\n finishedAt: payload.finishedAt ?? null,\n errorMessage: payload.errorMessage ?? null,\n }\n\n if (isTerminalStatus(status)) {\n setActiveJobs((prev) => prev.filter((item) => item.id !== jobId))\n setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10))\n return\n }\n\n setActiveJobs((prev) =>\n upsertJob(prev, job),\n )\n },\n [fetchJobs],\n )\n\n useAppEvent('progress.job.created', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.started', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.completed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.failed', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('progress.job.cancelled', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n useAppEvent('om:bridge:reconnected', () => {\n void fetchJobs()\n }, [fetchJobs])\n\n React.useEffect(() => {\n const onFocus = () => {\n void fetchJobs()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchJobs])\n\n return { activeJobs, recentlyCompleted, isLoading, error, refresh }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.6-develop.5588.1.a8f6c51d1f",
3
+ "version": "0.6.6-develop.5594.1.30cd738303",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -154,13 +154,13 @@
154
154
  "remark-gfm": "^4.0.1"
155
155
  },
156
156
  "peerDependencies": {
157
- "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f",
157
+ "@open-mercato/shared": "0.6.6-develop.5594.1.30cd738303",
158
158
  "react": ">=18.0.0",
159
159
  "react-dom": ">=18.0.0",
160
160
  "react-is": ">=18.0.0"
161
161
  },
162
162
  "devDependencies": {
163
- "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f",
163
+ "@open-mercato/shared": "0.6.6-develop.5594.1.30cd738303",
164
164
  "@testing-library/dom": "^10.4.1",
165
165
  "@testing-library/jest-dom": "^6.9.1",
166
166
  "@testing-library/react": "^16.3.1",
@@ -0,0 +1,105 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react'
2
+ import type { ProgressJobDto } from '../progress/useProgressPoll'
3
+
4
+ const mockApiCall = jest.fn()
5
+ jest.mock('../utils/apiCall', () => ({
6
+ apiCall: (...args: unknown[]) => mockApiCall(...args),
7
+ }))
8
+
9
+ const mockAppEventHandlers = new Map<string, Array<(event: { payload?: unknown }) => void>>()
10
+ jest.mock('../injection/useAppEvent', () => ({
11
+ useAppEvent: (eventId: string, handler: (event: { payload?: unknown }) => void) => {
12
+ const handlers = mockAppEventHandlers.get(eventId) ?? []
13
+ handlers.push(handler)
14
+ mockAppEventHandlers.set(eventId, handlers)
15
+ },
16
+ }))
17
+
18
+ jest.mock('@open-mercato/shared/lib/frontend/progressEvents', () => ({
19
+ subscribeProgressUpdate: jest.fn(() => jest.fn()),
20
+ }))
21
+
22
+ import { useProgressSse } from '../progress/useProgressSse'
23
+
24
+ const runningJob: ProgressJobDto = {
25
+ id: 'job-1',
26
+ jobType: 'search.reindex.vector',
27
+ name: 'Search vector reindex',
28
+ description: 'Vector reindex catalog:catalog_product_variant (queued)',
29
+ status: 'running',
30
+ progressPercent: 0,
31
+ processedCount: 0,
32
+ totalCount: 0,
33
+ cancellable: true,
34
+ startedAt: '2026-06-15T16:37:01.382Z',
35
+ finishedAt: null,
36
+ errorMessage: null,
37
+ }
38
+
39
+ const completedJob: ProgressJobDto = {
40
+ ...runningJob,
41
+ status: 'completed',
42
+ progressPercent: 100,
43
+ finishedAt: '2026-06-15T16:37:01.391Z',
44
+ }
45
+
46
+ function mockProgressResponse(active: ProgressJobDto[], recentlyCompleted: ProgressJobDto[] = []) {
47
+ return {
48
+ ok: true,
49
+ result: {
50
+ active,
51
+ recentlyCompleted,
52
+ },
53
+ }
54
+ }
55
+
56
+ describe('useProgressSse', () => {
57
+ afterEach(() => {
58
+ jest.useRealTimers()
59
+ jest.clearAllMocks()
60
+ mockAppEventHandlers.clear()
61
+ })
62
+
63
+ it('removes a job from activeJobs when an SSE update carries a terminal status', async () => {
64
+ mockApiCall.mockResolvedValue(mockProgressResponse([runningJob]))
65
+ const { result } = renderHook(() => useProgressSse())
66
+
67
+ await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
68
+
69
+ act(() => {
70
+ for (const handler of mockAppEventHandlers.get('progress.job.updated') ?? []) {
71
+ handler({ payload: { ...completedJob, jobId: completedJob.id } })
72
+ }
73
+ })
74
+
75
+ expect(result.current.activeJobs).toHaveLength(0)
76
+ expect(result.current.recentlyCompleted[0]).toEqual(expect.objectContaining({
77
+ id: 'job-1',
78
+ status: 'completed',
79
+ }))
80
+ })
81
+
82
+ it('periodically reconciles active jobs in SSE mode when completion events are missed', async () => {
83
+ jest.useFakeTimers()
84
+ mockApiCall
85
+ .mockResolvedValueOnce(mockProgressResponse([runningJob]))
86
+ .mockResolvedValueOnce(mockProgressResponse([], [completedJob]))
87
+
88
+ const { result } = renderHook(() => useProgressSse())
89
+
90
+ await waitFor(() => expect(result.current.activeJobs).toHaveLength(1))
91
+
92
+ await act(async () => {
93
+ jest.advanceTimersByTime(5000)
94
+ await Promise.resolve()
95
+ })
96
+
97
+ await waitFor(() => {
98
+ expect(result.current.activeJobs).toHaveLength(0)
99
+ expect(result.current.recentlyCompleted[0]).toEqual(expect.objectContaining({
100
+ id: 'job-1',
101
+ status: 'completed',
102
+ }))
103
+ })
104
+ })
105
+ })
@@ -6,10 +6,20 @@ import { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progr
6
6
  import type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'
7
7
  import { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'
8
8
 
9
+ const SSE_PROGRESS_SYNC_INTERVAL = 5000
10
+
9
11
  function isVisibleProgressJob(job: ProgressJobDto): boolean {
10
12
  return job.meta?.hiddenFromTopBar !== true
11
13
  }
12
14
 
15
+ function isActiveStatus(status: ProgressJobDto['status']): boolean {
16
+ return status === 'pending' || status === 'running'
17
+ }
18
+
19
+ function isTerminalStatus(status: ProgressJobDto['status']): boolean {
20
+ return status === 'completed' || status === 'failed' || status === 'cancelled'
21
+ }
22
+
13
23
  function upsertJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {
14
24
  if (!isVisibleProgressJob(job)) {
15
25
  return list.filter((item) => item.id !== job.id)
@@ -35,11 +45,11 @@ export function useProgressSse(): UseProgressPollResult {
35
45
  )
36
46
  if (result.ok && result.result) {
37
47
  setActiveJobs((prev) => [
38
- ...prev.filter((job) => isLocalProgressJob(job) && (job.status === 'pending' || job.status === 'running')),
48
+ ...prev.filter((job) => isLocalProgressJob(job) && isActiveStatus(job.status)),
39
49
  ...result.result!.active.filter(isVisibleProgressJob),
40
50
  ])
41
51
  setRecentlyCompleted((prev) => [
42
- ...prev.filter((job) => isLocalProgressJob(job) && (job.status === 'completed' || job.status === 'failed')),
52
+ ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),
43
53
  ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),
44
54
  ].slice(0, 10))
45
55
  setError(null)
@@ -59,6 +69,33 @@ export function useProgressSse(): UseProgressPollResult {
59
69
  void fetchJobs()
60
70
  }, [fetchJobs])
61
71
 
72
+ React.useEffect(() => {
73
+ let interval: ReturnType<typeof setInterval> | null = setInterval(() => {
74
+ void fetchJobs()
75
+ }, SSE_PROGRESS_SYNC_INTERVAL)
76
+
77
+ const onVisibilityChange = () => {
78
+ if (document.hidden) {
79
+ if (interval) {
80
+ clearInterval(interval)
81
+ interval = null
82
+ }
83
+ } else {
84
+ void fetchJobs()
85
+ if (interval) clearInterval(interval)
86
+ interval = setInterval(() => {
87
+ void fetchJobs()
88
+ }, SSE_PROGRESS_SYNC_INTERVAL)
89
+ }
90
+ }
91
+
92
+ document.addEventListener('visibilitychange', onVisibilityChange)
93
+ return () => {
94
+ if (interval) clearInterval(interval)
95
+ document.removeEventListener('visibilitychange', onVisibilityChange)
96
+ }
97
+ }, [fetchJobs])
98
+
62
99
  React.useEffect(() => {
63
100
  return subscribeProgressUpdate((detail) => {
64
101
  applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)
@@ -74,23 +111,32 @@ export function useProgressSse(): UseProgressPollResult {
74
111
  void fetchJobs()
75
112
  return
76
113
  }
114
+ const status = (payload.status as ProgressJobDto['status']) ?? 'running'
115
+ const job: ProgressJobDto = {
116
+ id: jobId,
117
+ jobType: payload.jobType ?? 'progress',
118
+ name: payload.name ?? payload.jobType ?? 'Progress job',
119
+ description: payload.description ?? null,
120
+ meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,
121
+ status,
122
+ progressPercent: payload.progressPercent ?? 0,
123
+ processedCount: payload.processedCount ?? 0,
124
+ totalCount: payload.totalCount ?? null,
125
+ etaSeconds: payload.etaSeconds ?? null,
126
+ cancellable: payload.cancellable ?? false,
127
+ startedAt: payload.startedAt ?? null,
128
+ finishedAt: payload.finishedAt ?? null,
129
+ errorMessage: payload.errorMessage ?? null,
130
+ }
131
+
132
+ if (isTerminalStatus(status)) {
133
+ setActiveJobs((prev) => prev.filter((item) => item.id !== jobId))
134
+ setRecentlyCompleted((prev) => upsertJob(prev, job).slice(0, 10))
135
+ return
136
+ }
137
+
77
138
  setActiveJobs((prev) =>
78
- upsertJob(prev, {
79
- id: jobId,
80
- jobType: payload.jobType ?? 'progress',
81
- name: payload.name ?? payload.jobType ?? 'Progress job',
82
- description: payload.description ?? null,
83
- meta: (payload.meta && typeof payload.meta === 'object') ? payload.meta as Record<string, unknown> : null,
84
- status: (payload.status as ProgressJobDto['status']) ?? 'running',
85
- progressPercent: payload.progressPercent ?? 0,
86
- processedCount: payload.processedCount ?? 0,
87
- totalCount: payload.totalCount ?? null,
88
- etaSeconds: payload.etaSeconds ?? null,
89
- cancellable: payload.cancellable ?? false,
90
- startedAt: payload.startedAt ?? null,
91
- finishedAt: payload.finishedAt ?? null,
92
- errorMessage: payload.errorMessage ?? null,
93
- }),
139
+ upsertJob(prev, job),
94
140
  )
95
141
  },
96
142
  [fetchJobs],