From 6d77651cd6f8e0bec656d16bfc91057761dbb1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 09:02:28 -0400 Subject: [PATCH 1/9] feat(@helm/web): add type-safe API client and test infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.ts wraps fetch with typed ApiResult — ok/error discriminated union, network errors caught and returned (never throw to caller). Imports Product from @helm/shared and WorkflowStage from @helm/workflow; defines ItemState and WorkflowEvent locally mirroring apps/api response shapes. 6 tests covering success, HTTP error, and network error paths. Adds vitest + jsdom + @testing-library/react test infrastructure. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package.json | 16 +- apps/web/src/lib/api.test.ts | 79 ++++ apps/web/src/lib/api.ts | 74 ++++ apps/web/src/test-setup.ts | 1 + apps/web/vite.config.ts | 5 + pnpm-lock.yaml | 728 ++++++++++++++++++++++++++++++++++- 6 files changed, 895 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/api.test.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/test-setup.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5efed4b..90a7b39 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,19 +6,29 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "echo 'no tests yet'", + "test": "vitest run", "lint": "eslint src/" }, "dependencies": { + "@helm/shared": "workspace:*", + "@helm/workflow": "workspace:*", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.15.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^4.1.6", + "jsdom": "^29.1.1", "tailwindcss": "^4.3.0", - "vite": "^5.4.21" + "vite": "^5.4.21", + "vitest": "^2.1.9" } } diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts new file mode 100644 index 0000000..1c4a116 --- /dev/null +++ b/apps/web/src/lib/api.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { api } from './api.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function mockOk(body: unknown): Response { + return { + ok: true, + status: 200, + json: () => Promise.resolve(body), + } as unknown as Response; +} + +function mockErr(status: number, body: unknown): Response { + return { + ok: false, + status, + statusText: `Error ${status}`, + json: () => Promise.resolve(body), + } as unknown as Response; +} + +afterEach(() => vi.clearAllMocks()); + +describe('api.getProduct', () => { + it('returns ok result on success', async () => { + const product = { helm_version: '0', product: { slug: 'helm', name: 'Helm' } }; + mockFetch.mockResolvedValueOnce(mockOk(product)); + const result = await api.getProduct(); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.product.slug).toBe('helm'); + }); + + it('returns http error on non-200', async () => { + mockFetch.mockResolvedValueOnce(mockErr(404, { error: 'Not found' })); + const result = await api.getProduct(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('http'); + expect((result.error as { status: number }).status).toBe(404); + } + }); + + it('returns network error on fetch throw', async () => { + mockFetch.mockRejectedValueOnce(new Error('Failed to fetch')); + const result = await api.getProduct(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('network'); + expect(result.error.message).toBe('Failed to fetch'); + } + }); +}); + +describe('api.listItems', () => { + it('returns array of items', async () => { + const items = [{ externalId: 'issue_1', currentStage: 'discovery' }]; + mockFetch.mockResolvedValueOnce(mockOk(items)); + const result = await api.listItems(); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toHaveLength(1); + }); +}); + +describe('api.getItem', () => { + it('URL-encodes the externalId', async () => { + mockFetch.mockResolvedValueOnce(mockOk({ externalId: 'issue_1' })); + await api.getItem('issue_1'); + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('issue_1'); + }); + + it('returns http error on 404', async () => { + mockFetch.mockResolvedValueOnce(mockErr(404, { error: 'Not found' })); + const result = await api.getItem('issue_999'); + expect(result.ok).toBe(false); + }); +}); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..59a1102 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,74 @@ +import type { Product } from '@helm/shared'; +import type { WorkflowStage } from '@helm/workflow'; + +// ── Types mirroring apps/api/src/services/types.ts ─────────────────────────── +// These match the JSON shape returned by GET /api/items and GET /api/items/:id. +// Keep in sync when the API response shape changes. + +export type WorkflowEvent = { + fromStage: WorkflowStage | null; + toStage: WorkflowStage; + /** 'agent:spec-writer', 'human:lhpaul', 'webhook:github-projects', etc. */ + triggeredBy: string; + /** ISO 8601 */ + at: string; + note?: string; +}; + +export type ItemState = { + externalId: string; + productSlug: string; + currentStage: WorkflowStage; + /** Always has at least one entry (the creation event, fromStage = null). */ + history: WorkflowEvent[]; + createdAt: string; + updatedAt: string; +}; + +export type { Product }; + +// ── Error types ─────────────────────────────────────────────────────────────── + +export type ApiError = + | { type: 'network'; message: string } + | { type: 'http'; status: number; message: string }; + +export type ApiResult = { ok: true; data: T } | { ok: false; error: ApiError }; + +// ── Fetch helper ────────────────────────────────────────────────────────────── + +const BASE_URL = (import.meta as { env?: { VITE_API_URL?: string } }).env?.VITE_API_URL ?? ''; + +async function fetchJson(path: string): Promise> { + try { + const res = await fetch(`${BASE_URL}${path}`); + if (!res.ok) { + const body = (await res.json().catch(() => ({ error: res.statusText }))) as { + error?: string; + }; + return { + ok: false, + error: { type: 'http', status: res.status, message: body.error ?? res.statusText }, + }; + } + const data = (await res.json()) as T; + return { ok: true, data }; + } catch (err) { + return { + ok: false, + error: { + type: 'network', + message: err instanceof Error ? err.message : 'Network error', + }, + }; + } +} + +// ── API client ──────────────────────────────────────────────────────────────── + +export const api = { + getProduct: (): Promise> => fetchJson('/api/product'), + listItems: (): Promise> => fetchJson('/api/items'), + getItem: (id: string): Promise> => + fetchJson(`/api/items/${encodeURIComponent(id)}`), +}; diff --git a/apps/web/src/test-setup.ts b/apps/web/src/test-setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/apps/web/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 811d9fd..295c694 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -14,4 +14,9 @@ export default defineConfig({ }, }, }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a260cb9..9969a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.9 - version: 2.1.9(@types/node@25.7.0)(lightningcss@1.32.0) + version: 2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0) apps/api: dependencies: @@ -69,31 +69,61 @@ importers: apps/web: dependencies: + '@helm/shared': + specifier: workspace:* + version: link:../../packages/shared + '@helm/workflow': + specifier: workspace:* + version: link:../../packages/workflow react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^7.15.1 + version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@5.4.21(@types/node@25.7.0)(lightningcss@1.32.0)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/react': specifier: ^18.3.28 version: 18.3.28 '@types/react-dom': specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) + '@types/react-router-dom': + specifier: ^5.3.3 + version: 5.3.3 '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@5.4.21(@types/node@25.7.0)(lightningcss@1.32.0)) + '@vitest/coverage-v8': + specifier: ^4.1.6 + version: 4.1.6(vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 tailwindcss: specifier: ^4.3.0 version: 4.3.0 vite: specifier: ^5.4.21 version: 5.4.21(@types/node@25.7.0)(lightningcss@1.32.0) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0) packages/adapters: dependencies: @@ -115,7 +145,7 @@ importers: version: 22.19.19 vitest: specifier: ^2.1.9 - version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + version: 2.1.9(@types/node@22.19.19)(jsdom@29.1.1)(lightningcss@1.32.0) packages/shared: dependencies: @@ -143,6 +173,24 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -214,6 +262,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -226,6 +278,50 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -402,6 +498,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -682,6 +787,35 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@turbo/darwin-64@2.9.12': resolution: {integrity: sha512-eu3eFRmE9NjgZ0wPdRJ44l+LGSeIky+tz5ZQd8zQkw/Yqi+BM7wq+8nbabeoiVUcICi/IZweMOKl/MCmkrd1+g==} cpu: [x64] @@ -712,6 +846,9 @@ packages: cpu: [arm64] os: [win32] + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -730,6 +867,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/history@4.7.11': + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -747,6 +887,12 @@ packages: peerDependencies: '@types/react': ^18.0.0 + '@types/react-router-dom@5.3.3': + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + + '@types/react-router@5.1.20': + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} @@ -815,6 +961,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + peerDependencies: + '@vitest/browser': 4.1.6 + vitest: 4.1.6 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -832,6 +987,9 @@ packages: '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} @@ -844,6 +1002,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -861,6 +1022,10 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -869,6 +1034,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -876,10 +1045,20 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -892,6 +1071,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -970,13 +1152,28 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -986,6 +1183,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -993,10 +1193,20 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.353: resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} @@ -1007,6 +1217,10 @@ packages: resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1186,6 +1400,13 @@ packages: resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -1211,6 +1432,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1231,6 +1456,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1238,10 +1466,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1249,6 +1492,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1379,12 +1631,30 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1400,6 +1670,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1425,6 +1699,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -1449,6 +1726,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1501,6 +1781,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1510,14 +1794,42 @@ packages: peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@7.15.1: + resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.1: + resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1534,6 +1846,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1546,6 +1862,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1579,6 +1898,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -1595,6 +1917,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1603,6 +1929,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1632,14 +1961,33 @@ packages: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1665,6 +2013,10 @@ packages: undici-types@7.21.0: resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -1738,6 +2090,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1756,6 +2124,13 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1773,6 +2148,28 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1862,6 +2259,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -1885,6 +2284,36 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2000,6 +2429,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2213,6 +2644,40 @@ snapshots: tailwindcss: 4.3.0 vite: 5.4.21(@types/node@25.7.0)(lightningcss@1.32.0) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@turbo/darwin-64@2.9.12': optional: true @@ -2231,6 +2696,8 @@ snapshots: '@turbo/windows-arm64@2.9.12': optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -2256,6 +2723,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/history@4.7.11': {} + '@types/json-schema@7.0.15': {} '@types/node@22.19.19': @@ -2273,6 +2742,17 @@ snapshots: dependencies: '@types/react': 18.3.28 + '@types/react-router-dom@5.3.3': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.28 + '@types/react-router': 5.1.20 + + '@types/react-router@5.1.20': + dependencies: + '@types/history': 4.7.11 + '@types/react': 18.3.28 + '@types/react@18.3.28': dependencies: '@types/prop-types': 15.7.15 @@ -2381,6 +2861,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.6(vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0) + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -2388,6 +2882,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19)(lightningcss@1.32.0) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.7.0)(lightningcss@1.32.0))': dependencies: '@vitest/spy': 2.1.9 @@ -2400,6 +2902,10 @@ snapshots: dependencies: tinyrainbow: 1.2.0 + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -2421,6 +2927,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2438,24 +2950,44 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} baseline-browser-mapping@2.10.29: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 @@ -2529,24 +3061,48 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.353: {} emoji-regex@10.6.0: {} @@ -2556,6 +3112,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + environment@1.1.0: {} es-module-lexer@1.7.0: {} @@ -2748,6 +3306,14 @@ snapshots: hono@4.12.18: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + human-signals@5.0.0: {} husky@9.1.7: {} @@ -2763,6 +3329,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@4.0.0: {} @@ -2777,18 +3345,61 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@3.0.0: {} isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.6 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2905,14 +3516,30 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.3.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + + mdn-data@2.27.1: {} + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -2924,6 +3551,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -2944,6 +3573,8 @@ snapshots: dependencies: path-key: 4.0.0 + obug@2.1.1: {} + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -2973,6 +3604,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3005,6 +3640,12 @@ snapshots: prettier@3.8.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} react-dom@18.3.1(react@18.3.1): @@ -3013,12 +3654,35 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-refresh@0.17.0: {} + react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} restore-cursor@5.1.0: @@ -3059,6 +3723,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -3067,6 +3735,8 @@ snapshots: semver@7.8.0: {} + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3093,6 +3763,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + string-argv@0.3.2: {} string-width@7.2.0: @@ -3107,12 +3779,18 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -3134,12 +3812,28 @@ snapshots: tinyrainbow@1.2.0: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3164,6 +3858,8 @@ snapshots: undici-types@7.21.0: optional: true + undici@7.25.0: {} + universal-user-agent@7.0.3: {} update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -3232,10 +3928,10 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.32.0 - vitest@2.1.9(@types/node@22.19.19)(lightningcss@1.32.0): + vitest@2.1.9(@types/node@22.19.19)(jsdom@29.1.1)(lightningcss@1.32.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.7.0)(lightningcss@1.32.0)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)(lightningcss@1.32.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -3256,6 +3952,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 + jsdom: 29.1.1 transitivePeerDependencies: - less - lightningcss @@ -3267,7 +3964,7 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@25.7.0)(lightningcss@1.32.0): + vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.7.0)(lightningcss@1.32.0)) @@ -3291,6 +3988,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.7.0 + jsdom: 29.1.1 transitivePeerDependencies: - less - lightningcss @@ -3302,6 +4000,22 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3319,6 +4033,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@2.9.0: {} From b7c924dea342011c908cb416fba4078de9fec5b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 09:03:09 -0400 Subject: [PATCH 2/9] feat(@helm/web): add usePolling hook and Kanban board view usePolling(fetcher, intervalMs) fetches immediately then polls via setInterval; stable fetcherRef avoids re-creating the effect on re-renders; intervalMs=null fetches once. Kanban renders dynamic columns from product.workflow.stages_enabled, filters items per stage, shows relative time and item count badges. Items error shown inline; product error shown full-page. Polling every 5s with cleanup on unmount. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/hooks/usePolling.ts | 59 ++++++++++++++++ apps/web/src/views/Kanban.tsx | 112 +++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 apps/web/src/hooks/usePolling.ts create mode 100644 apps/web/src/views/Kanban.tsx diff --git a/apps/web/src/hooks/usePolling.ts b/apps/web/src/hooks/usePolling.ts new file mode 100644 index 0000000..a9087b3 --- /dev/null +++ b/apps/web/src/hooks/usePolling.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ApiResult } from '../lib/api.js'; + +export type PollingState = { + data: T | null; + error: string | null; + loading: boolean; +}; + +/** + * Fetches immediately, then polls at `intervalMs`. + * Pass `intervalMs = null` to fetch once and skip polling. + * The fetcher reference is kept stable via a ref so callers can pass + * inline arrow functions without causing the effect to re-run. + */ +export function usePolling( + fetcher: () => Promise>, + intervalMs: number | null = 5_000, +): PollingState { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + + useEffect(() => { + let cancelled = false; + + const doFetch = async () => { + const result = await fetcherRef.current(); + if (cancelled) return; + if (result.ok) { + setData(result.data); + setError(null); + } else { + setError(result.error.message); + } + setLoading(false); + }; + + void doFetch(); + + if (intervalMs === null) + return () => { + cancelled = true; + }; + + const id = setInterval(() => { + void doFetch(); + }, intervalMs); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [intervalMs]); + + return { data, error, loading }; +} diff --git a/apps/web/src/views/Kanban.tsx b/apps/web/src/views/Kanban.tsx new file mode 100644 index 0000000..145a5fc --- /dev/null +++ b/apps/web/src/views/Kanban.tsx @@ -0,0 +1,112 @@ +import { useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import type { ItemState } from '../lib/api.js'; +import type { WorkflowStage } from '@helm/workflow'; +import { usePolling } from '../hooks/usePolling.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function ItemCard({ item }: { item: ItemState }) { + return ( + +

{item.externalId}

+

{item.externalId}

+

{relativeTime(item.createdAt)}

+ + ); +} + +function KanbanColumn({ stage, items }: { stage: WorkflowStage; items: ItemState[] }) { + return ( +
+
+

{stage}

+ + {items.length} + +
+
+ {items.map((item) => ( + + ))} + {items.length === 0 &&

} +
+
+ ); +} + +// ── Main view ───────────────────────────────────────────────────────────────── + +export function Kanban() { + const fetchProduct = useCallback(() => api.getProduct(), []); + const fetchItems = useCallback(() => api.listItems(), []); + + const { data: product, error: productError, loading } = usePolling(fetchProduct, null); + const { data: items, error: itemsError } = usePolling(fetchItems, 5_000); + + if (loading) { + return ( +
+

Loading…

+
+ ); + } + + if (productError) { + return ( +
+

{productError}

+
+ ); + } + + const stages = product?.workflow.stages_enabled ?? []; + const allItems = items ?? []; + + return ( +
+ {/* Header */} +
+
+

+ Helm + {product && ( + + / {product.product.name} + + )} +

+ {itemsError &&

⚠ {itemsError}

} +
+
+ + {/* Board */} +
+ {stages.map((stage) => ( + i.currentStage === stage)} + /> + ))} +
+
+ ); +} From 7943da1c24163b089b7abc8d6d1ac3332e33c40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 09:03:47 -0400 Subject: [PATCH 3/9] feat(@helm/web): add ItemDetail view and React Router wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ItemDetail polls GET /api/items/:id every 5s, renders stage badge, full transition history table (newest first), and back-to-board link. Error state shown full-page. App.tsx replaces the WebSocket placeholder with BrowserRouter + two routes: / → Kanban, /items/:id → ItemDetail. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/App.tsx | 26 +++---- apps/web/src/views/ItemDetail.tsx | 111 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/views/ItemDetail.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 80cbd1a..b249c67 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,22 +1,14 @@ -import { useEffect, useState } from 'react'; - -type WsStatus = 'connecting' | 'connected' | 'disconnected'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { Kanban } from './views/Kanban.js'; +import { ItemDetail } from './views/ItemDetail.js'; export default function App() { - const [status, setStatus] = useState('connecting'); - - useEffect(() => { - const ws = new WebSocket('ws://localhost:5173/ws'); - ws.onopen = () => setStatus('connected'); - ws.onclose = () => setStatus('disconnected'); - ws.onerror = () => setStatus('disconnected'); - return () => ws.close(); - }, []); - return ( -
-

Helm

-

{status}

-
+ + + } /> + } /> + + ); } diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx new file mode 100644 index 0000000..66b60dd --- /dev/null +++ b/apps/web/src/views/ItemDetail.tsx @@ -0,0 +1,111 @@ +import { useCallback } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import type { WorkflowEvent } from '../lib/api.js'; +import { usePolling } from '../hooks/usePolling.js'; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'short', + }); +} + +function StageBadge({ stage }: { stage: string }) { + return ( + + {stage} + + ); +} + +function HistoryRow({ event, index }: { event: WorkflowEvent; index: number }) { + return ( + + + {event.fromStage ?? created} + + → + + {event.toStage} + + {formatDate(event.at)} + {event.triggeredBy} + {event.note && {event.note}} + + ); +} + +export function ItemDetail() { + const { id } = useParams<{ id: string }>(); + + const fetcher = useCallback(() => api.getItem(id ?? ''), [id]); + const { data: item, error, loading } = usePolling(fetcher, 5_000); + + if (loading) { + return ( +
+

Loading…

+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ + ← Back to board + +
+
+ ); + } + + if (!item) return null; + + return ( +
+ {/* Header */} +
+ + ← Back to board + +
+
+

{item.externalId}

+

{item.externalId}

+
+ +
+
+ + {/* History table */} +
+

Transition history

+
+ + + + + + + + + + + {[...item.history].reverse().map((event, i) => ( + + ))} + +
From + ToWhenActor
+
+
+
+ ); +} From 35c3382449d05fa07298b8e28b820ccc8d2b3bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 09:04:17 -0400 Subject: [PATCH 4/9] test(@helm/web): add usePolling hook tests with vi.useFakeTimers 6 tests: initial loading state, re-fetch on interval tick, interval cleanup on unmount, error on fetch failure, error cleared on recovery, and intervalMs=null fetch-once behaviour. Uses vi.useFakeTimers() to control setInterval without real time delays. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/hooks/usePolling.test.ts | 128 ++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/web/src/hooks/usePolling.test.ts diff --git a/apps/web/src/hooks/usePolling.test.ts b/apps/web/src/hooks/usePolling.test.ts new file mode 100644 index 0000000..c0e4878 --- /dev/null +++ b/apps/web/src/hooks/usePolling.test.ts @@ -0,0 +1,128 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePolling } from './usePolling.js'; +import type { ApiResult } from '../lib/api.js'; + +function ok(data: T): ApiResult { + return { ok: true, data }; +} +function err(message: string): ApiResult { + return { ok: false, error: { type: 'network', message } }; +} + +describe('usePolling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts in loading state and populates data on first fetch', async () => { + const fetcher = vi.fn().mockResolvedValue(ok({ count: 1 })); + + const { result } = renderHook(() => usePolling(fetcher, 5_000)); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ count: 1 }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('re-fetches after each interval tick', async () => { + const fetcher = vi.fn().mockResolvedValue(ok({ count: 1 })); + renderHook(() => usePolling(fetcher, 1_000)); + + await act(async () => { + await Promise.resolve(); + }); + expect(fetcher).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + expect(fetcher).toHaveBeenCalledTimes(2); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('clears the interval on unmount', async () => { + const fetcher = vi.fn().mockResolvedValue(ok({})); + const { unmount } = renderHook(() => usePolling(fetcher, 1_000)); + + await act(async () => { + await Promise.resolve(); + }); + unmount(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(fetcher).toHaveBeenCalledTimes(1); // only the initial fetch + }); + + it('sets error and clears data on fetch failure', async () => { + const fetcher = vi.fn().mockResolvedValue(err('Failed to fetch')); + const { result } = renderHook(() => usePolling(fetcher, 5_000)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.error).toBe('Failed to fetch'); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('clears error when a subsequent poll succeeds', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(err('Network error')) + .mockResolvedValue(ok({ value: 42 })); + + const { result } = renderHook(() => usePolling(fetcher, 1_000)); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.error).toBe('Network error'); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.data).toEqual({ value: 42 }); + }); + + it('fetches once and does not poll when intervalMs is null', async () => { + const fetcher = vi.fn().mockResolvedValue(ok({})); + renderHook(() => usePolling(fetcher, null)); + + await act(async () => { + await Promise.resolve(); + }); + expect(fetcher).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + expect(fetcher).toHaveBeenCalledTimes(1); // still just once + }); +}); From aee85614cea72d34aa9e160a743548b5b094dd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 09:36:05 -0400 Subject: [PATCH 5/9] fix(@helm/web): address 8 CodeRabbit findings on PR #10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove conflicting @types/react-router-dom@5 (RR v7 ships own types) 2. Align @vitest/coverage-v8 to ^2.1.9 (v4 requires Vite 6+) 3. Add rejection test for usePolling (fetcher throws vs returns {ok:false}) 4. Harden usePolling: inFlight guard prevents overlapping requests; try-catch maps thrown errors to error state (never-throw parity) 5. URL-encoding test now uses 'issue/1 with space' to actually exercise encodeURIComponent 6. VITE_API_URL: trim() + truthiness check (empty-string-in-input category) 7. ItemDetail note column: always rendered with "—" fallback so thead/tbody column counts stay consistent 8. Kanban: useMemo groups items by stage once per render instead of running filter() once per stage per render Addresses CodeRabbit review comments on PR #10. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package.json | 3 +- apps/web/src/hooks/usePolling.test.ts | 13 ++ apps/web/src/hooks/usePolling.ts | 32 ++- apps/web/src/lib/api.test.ts | 9 +- apps/web/src/lib/api.ts | 3 +- apps/web/src/views/ItemDetail.tsx | 5 +- apps/web/src/views/Kanban.tsx | 18 +- pnpm-lock.yaml | 314 ++++++++++++++++++-------- 8 files changed, 283 insertions(+), 114 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 90a7b39..4d870d2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,9 +23,8 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.7.0", - "@vitest/coverage-v8": "^4.1.6", + "@vitest/coverage-v8": "^2.1.9", "jsdom": "^29.1.1", "tailwindcss": "^4.3.0", "vite": "^5.4.21", diff --git a/apps/web/src/hooks/usePolling.test.ts b/apps/web/src/hooks/usePolling.test.ts index c0e4878..99725f8 100644 --- a/apps/web/src/hooks/usePolling.test.ts +++ b/apps/web/src/hooks/usePolling.test.ts @@ -110,6 +110,19 @@ describe('usePolling', () => { expect(result.current.data).toEqual({ value: 42 }); }); + it('handles a rejected fetcher promise and sets error state', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('boom')); + const { result } = renderHook(() => usePolling(fetcher, 5_000)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe('boom'); + expect(result.current.data).toBeNull(); + }); + it('fetches once and does not poll when intervalMs is null', async () => { const fetcher = vi.fn().mockResolvedValue(ok({})); renderHook(() => usePolling(fetcher, null)); diff --git a/apps/web/src/hooks/usePolling.ts b/apps/web/src/hooks/usePolling.ts index a9087b3..3efa626 100644 --- a/apps/web/src/hooks/usePolling.ts +++ b/apps/web/src/hooks/usePolling.ts @@ -12,6 +12,12 @@ export type PollingState = { * Pass `intervalMs = null` to fetch once and skip polling. * The fetcher reference is kept stable via a ref so callers can pass * inline arrow functions without causing the effect to re-run. + * + * Guards: + * - `inFlight`: skips a tick if the previous request is still pending, + * preventing overlapping requests and out-of-order state writes. + * - try-catch: maps thrown errors (not just { ok: false } returns) to the + * error state so unhandled rejections never bubble up. */ export function usePolling( fetcher: () => Promise>, @@ -26,17 +32,27 @@ export function usePolling( useEffect(() => { let cancelled = false; + let inFlight = false; const doFetch = async () => { - const result = await fetcherRef.current(); - if (cancelled) return; - if (result.ok) { - setData(result.data); - setError(null); - } else { - setError(result.error.message); + if (inFlight) return; + inFlight = true; + try { + const result = await fetcherRef.current(); + if (cancelled) return; + if (result.ok) { + setData(result.data); + setError(null); + } else { + setError(result.error.message); + } + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'Unexpected polling error'); + } finally { + inFlight = false; + setLoading(false); } - setLoading(false); }; void doFetch(); diff --git a/apps/web/src/lib/api.test.ts b/apps/web/src/lib/api.test.ts index 1c4a116..c509e2f 100644 --- a/apps/web/src/lib/api.test.ts +++ b/apps/web/src/lib/api.test.ts @@ -64,11 +64,12 @@ describe('api.listItems', () => { }); describe('api.getItem', () => { - it('URL-encodes the externalId', async () => { - mockFetch.mockResolvedValueOnce(mockOk({ externalId: 'issue_1' })); - await api.getItem('issue_1'); + it('URL-encodes special characters in externalId', async () => { + const externalId = 'issue/1 with space'; + mockFetch.mockResolvedValueOnce(mockOk({ externalId })); + await api.getItem(externalId); const calledUrl = mockFetch.mock.calls[0]?.[0] as string; - expect(calledUrl).toContain('issue_1'); + expect(calledUrl).toContain(encodeURIComponent(externalId)); }); it('returns http error on 404', async () => { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 59a1102..9eeddf2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -37,7 +37,8 @@ export type ApiResult = { ok: true; data: T } | { ok: false; error: ApiError // ── Fetch helper ────────────────────────────────────────────────────────────── -const BASE_URL = (import.meta as { env?: { VITE_API_URL?: string } }).env?.VITE_API_URL ?? ''; +const rawApiUrl = (import.meta as { env?: { VITE_API_URL?: string } }).env?.VITE_API_URL; +const BASE_URL = rawApiUrl && rawApiUrl.trim() ? rawApiUrl.trim() : ''; async function fetchJson(path: string): Promise> { try { diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx index 66b60dd..4b9f8d2 100644 --- a/apps/web/src/views/ItemDetail.tsx +++ b/apps/web/src/views/ItemDetail.tsx @@ -31,7 +31,9 @@ function HistoryRow({ event, index }: { event: WorkflowEvent; index: number }) { {formatDate(event.at)} {event.triggeredBy} - {event.note && {event.note}} + + {event.note ?? } + ); } @@ -96,6 +98,7 @@ export function ItemDetail() { To When Actor + Note diff --git a/apps/web/src/views/Kanban.tsx b/apps/web/src/views/Kanban.tsx index 145a5fc..c4bc7e9 100644 --- a/apps/web/src/views/Kanban.tsx +++ b/apps/web/src/views/Kanban.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../lib/api.js'; import type { ItemState } from '../lib/api.js'; @@ -80,6 +80,16 @@ export function Kanban() { const stages = product?.workflow.stages_enabled ?? []; const allItems = items ?? []; + const itemsByStage = useMemo(() => { + const grouped = new Map(); + for (const item of allItems) { + const list = grouped.get(item.currentStage) ?? []; + list.push(item); + grouped.set(item.currentStage, list); + } + return grouped; + }, [allItems]); + return (
{/* Header */} @@ -100,11 +110,7 @@ export function Kanban() { {/* Board */}
{stages.map((stage) => ( - i.currentStage === stage)} - /> + ))}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9969a6d..dac6693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,15 +103,12 @@ importers: '@types/react-dom': specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) - '@types/react-router-dom': - specifier: ^5.3.3 - version: 5.3.3 '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@5.4.21(@types/node@25.7.0)(lightningcss@1.32.0)) '@vitest/coverage-v8': - specifier: ^4.1.6 - version: 4.1.6(vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0)) + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0)) jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -176,6 +173,10 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -278,9 +279,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} @@ -527,6 +527,14 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -565,6 +573,10 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -867,9 +879,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/history@4.7.11': - resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -887,12 +896,6 @@ packages: peerDependencies: '@types/react': ^18.0.0 - '@types/react-router-dom@5.3.3': - resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} - - '@types/react-router@5.1.20': - resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} @@ -961,11 +964,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/coverage-v8@4.1.6': - resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} peerDependencies: - '@vitest/browser': 4.1.6 - vitest: 4.1.6 + '@vitest/browser': 2.1.9 + vitest: 2.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -987,9 +990,6 @@ packages: '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - '@vitest/pretty-format@4.1.6': - resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} - '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} @@ -1002,9 +1002,6 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - '@vitest/utils@4.1.6': - resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1056,9 +1053,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.0: - resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1077,6 +1071,9 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -1207,12 +1204,21 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.353: resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.21.3: resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} engines: {node: '>=10.13.0'} @@ -1364,6 +1370,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1385,6 +1395,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1440,6 +1455,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} @@ -1474,17 +1493,21 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1631,6 +1654,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -1645,8 +1671,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -1681,6 +1707,14 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1699,9 +1733,6 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -1722,6 +1753,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1741,6 +1775,10 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1898,17 +1936,26 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -1943,6 +1990,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1961,10 +2012,6 @@ packages: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -2120,6 +2167,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -2150,6 +2205,11 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -2284,7 +2344,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@bcoe/v8-coverage@1.0.2': {} + '@bcoe/v8-coverage@0.2.3': {} '@bramus/specificity@2.4.2': dependencies: @@ -2447,6 +2507,17 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2497,6 +2568,9 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.2.9': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2723,8 +2797,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/history@4.7.11': {} - '@types/json-schema@7.0.15': {} '@types/node@22.19.19': @@ -2742,17 +2814,6 @@ snapshots: dependencies: '@types/react': 18.3.28 - '@types/react-router-dom@5.3.3': - dependencies: - '@types/history': 4.7.11 - '@types/react': 18.3.28 - '@types/react-router': 5.1.20 - - '@types/react-router@5.1.20': - dependencies: - '@types/history': 4.7.11 - '@types/react': 18.3.28 - '@types/react@18.3.28': dependencies: '@types/prop-types': 15.7.15 @@ -2861,19 +2922,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.6(vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0))': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0))': dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.6 - ast-v8-to-istanbul: 1.0.0 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.1 - std-env: 4.1.0 - tinyrainbow: 3.1.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 vitest: 2.1.9(@types/node@25.7.0)(jsdom@29.1.1)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color '@vitest/expect@2.1.9': dependencies: @@ -2902,10 +2967,6 @@ snapshots: dependencies: tinyrainbow: 1.2.0 - '@vitest/pretty-format@4.1.6': - dependencies: - tinyrainbow: 3.1.0 - '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -2927,12 +2988,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@vitest/utils@4.1.6': - dependencies: - '@vitest/pretty-format': 4.1.6 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2972,12 +3027,6 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2993,6 +3042,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -3103,10 +3156,16 @@ snapshots: dom-accessibility-api@0.6.3: {} + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.353: {} emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + enhanced-resolve@5.21.3: dependencies: graceful-fs: 4.2.11 @@ -3285,6 +3344,11 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true @@ -3298,6 +3362,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} graceful-fs@4.2.11: {} @@ -3333,6 +3406,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@4.0.0: {} is-fullwidth-code-point@5.1.0: @@ -3359,14 +3434,26 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jiti@2.7.0: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 - js-tokens@10.0.0: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -3516,6 +3603,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -3528,7 +3617,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.3: + magicast@0.3.5: dependencies: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 @@ -3561,6 +3650,12 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minipass@7.1.3: {} + ms@2.1.3: {} nanoid@3.3.12: {} @@ -3573,8 +3668,6 @@ snapshots: dependencies: path-key: 4.0.0 - obug@2.1.1: {} - onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -3600,6 +3693,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3614,6 +3709,11 @@ snapshots: path-key@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@1.1.2: {} pathval@2.0.1: {} @@ -3763,16 +3863,30 @@ snapshots: std-env@3.10.0: {} - std-env@4.1.0: {} - string-argv@0.3.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -3799,6 +3913,12 @@ snapshots: tapable@2.3.3: {} + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3812,8 +3932,6 @@ snapshots: tinyrainbow@1.2.0: {} - tinyrainbow@3.1.0: {} - tinyspy@3.0.2: {} tldts-core@7.0.30: {} @@ -4027,6 +4145,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 From 7ac157e72c666d160f74efa758108fa772a3bcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 09:42:56 -0400 Subject: [PATCH 6/9] fix(@helm/web): address 3 CodeRabbit findings on PR #10 (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ItemDetail: externalId was shown twice; replace redundant

with productSlug as contextual subtitle (ItemState has no title field) 2. Kanban ItemCard: externalId was shown twice; replace second line with currentStage so the card conveys two distinct pieces of information 3. relativeTime: guard against NaN from malformed ISO strings with isNaN(date.getTime()) → return 'unknown' Addresses CodeRabbit review comments on PR #10. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/views/ItemDetail.tsx | 4 ++-- apps/web/src/views/Kanban.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx index 4b9f8d2..a43f38b 100644 --- a/apps/web/src/views/ItemDetail.tsx +++ b/apps/web/src/views/ItemDetail.tsx @@ -79,8 +79,8 @@ export function ItemDetail() {

-

{item.externalId}

-

{item.externalId}

+

{item.externalId}

+

{item.productSlug}

diff --git a/apps/web/src/views/Kanban.tsx b/apps/web/src/views/Kanban.tsx index c4bc7e9..bfba294 100644 --- a/apps/web/src/views/Kanban.tsx +++ b/apps/web/src/views/Kanban.tsx @@ -8,7 +8,9 @@ import { usePolling } from '../hooks/usePolling.js'; // ── Helpers ─────────────────────────────────────────────────────────────────── function relativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); + const date = new Date(iso); + if (isNaN(date.getTime())) return 'unknown'; + const diff = Date.now() - date.getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; @@ -26,8 +28,8 @@ function ItemCard({ item }: { item: ItemState }) { to={`/items/${item.externalId}`} className="block rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-shadow hover:shadow-md" > -

{item.externalId}

-

{item.externalId}

+

{item.externalId}

+

{item.currentStage}

{relativeTime(item.createdAt)}

); From 7e98ec87d09b7da5c95910da8e111ab3e60d98fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 10:13:39 -0400 Subject: [PATCH 7/9] fix(@helm/web): address 2 CodeRabbit findings on PR #10 (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. formatDate: guard against malformed ISO strings with Number.isNaN check — same defensive pattern applied to relativeTime in round 2 2. History row key: replace at+toStage with at+fromStage+toStage+ triggeredBy to avoid collisions when two events share the same timestamp and destination stage Addresses CodeRabbit review comments on PR #10. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/views/ItemDetail.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx index a43f38b..171dacb 100644 --- a/apps/web/src/views/ItemDetail.tsx +++ b/apps/web/src/views/ItemDetail.tsx @@ -5,7 +5,9 @@ import type { WorkflowEvent } from '../lib/api.js'; import { usePolling } from '../hooks/usePolling.js'; function formatDate(iso: string): string { - return new Date(iso).toLocaleString(undefined, { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return 'unknown'; + return date.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short', }); @@ -103,7 +105,11 @@ export function ItemDetail() { {[...item.history].reverse().map((event, i) => ( - + ))} From 2f5ef5e4e75200304d4331af10b34683e3342533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 10:24:56 -0400 Subject: [PATCH 8/9] fix(@helm/web): show inline warning on transient poll errors in ItemDetail Full-page error replaced content even when prior data was available, making brief network hiccups look like hard outages. Now: full-page error only on first-load failure (no data yet); inline amber banner when polling fails but last-known data is present. Matches the pattern already used in Kanban (itemsError shown inline without hiding the board). Addresses CodeRabbit review comment on PR #10. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/views/ItemDetail.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx index 171dacb..b59e68b 100644 --- a/apps/web/src/views/ItemDetail.tsx +++ b/apps/web/src/views/ItemDetail.tsx @@ -54,7 +54,9 @@ export function ItemDetail() { ); } - if (error) { + // Full-page error only when there's no prior data — transient poll failures + // show an inline warning so the last-known content stays visible. + if (error && !item) { return (
@@ -90,6 +92,11 @@ export function ItemDetail() { {/* History table */}
+ {error && ( +

+ ⚠ {error} — showing last known data. +

+ )}

Transition history

From 8a04081b136ca9ff27f02a5728901b2c8c6c5c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Herna=CC=81n=20Pau=CC=81l=20Ossando=CC=81n?= Date: Sun, 17 May 2026 10:45:13 -0400 Subject: [PATCH 9/9] fix(@helm/web): guard missing route param in ItemDetail before polling id ?? '' triggered repeated GET /api/items/ failures when the route param was absent. Now: returns {ok:false} immediately if id is falsy and disables the polling interval (intervalMs=null) for the same case, so no network requests are made until a valid id is present. Addresses CodeRabbit review comment on PR #10. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/views/ItemDetail.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/views/ItemDetail.tsx b/apps/web/src/views/ItemDetail.tsx index b59e68b..50cef69 100644 --- a/apps/web/src/views/ItemDetail.tsx +++ b/apps/web/src/views/ItemDetail.tsx @@ -43,8 +43,16 @@ function HistoryRow({ event, index }: { event: WorkflowEvent; index: number }) { export function ItemDetail() { const { id } = useParams<{ id: string }>(); - const fetcher = useCallback(() => api.getItem(id ?? ''), [id]); - const { data: item, error, loading } = usePolling(fetcher, 5_000); + const fetcher = useCallback((): ReturnType => { + if (!id) { + return Promise.resolve({ + ok: false, + error: { type: 'network', message: 'Missing item id in route.' }, + }); + } + return api.getItem(id); + }, [id]); + const { data: item, error, loading } = usePolling(fetcher, id ? 5_000 : null); if (loading) { return (