From 6c29869ce1f678dc79025c1b189151bfbedfb5c8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 17:08:12 +0800 Subject: [PATCH 01/39] fix: harden calendar API requests --- frontend/lib/api.test.ts | 68 ++ frontend/lib/api.ts | 69 +- frontend/package-lock.json | 1798 +++++++++++++++++++++++++++++++++--- frontend/package.json | 10 +- frontend/vitest.config.ts | 15 + frontend/vitest.setup.ts | 1 + 6 files changed, 1840 insertions(+), 121 deletions(-) create mode 100644 frontend/lib/api.test.ts create mode 100644 frontend/vitest.config.ts create mode 100644 frontend/vitest.setup.ts diff --git a/frontend/lib/api.test.ts b/frontend/lib/api.test.ts new file mode 100644 index 0000000..e2bc6b3 --- /dev/null +++ b/frontend/lib/api.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError, fetchApi } from "./api"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchApi", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("attaches an abort signal so stalled production requests can time out", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true })); + vi.stubGlobal("fetch", fetchMock); + + await fetchApi("/health"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it("clears stale credentials when refresh is rejected", async () => { + localStorage.setItem("token", "expired-access"); + localStorage.setItem("refresh_token", "expired-refresh"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(401, { detail: "expired" })) + .mockResolvedValueOnce(jsonResponse(401, { detail: "invalid refresh" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchApi("/events")).rejects.toBeInstanceOf(ApiError); + + expect(localStorage.getItem("token")).toBeNull(); + expect(localStorage.getItem("refresh_token")).toBeNull(); + }); + + it("uses a plain-text server error instead of a generic message", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response("Service is warming up", { + status: 503, + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + + await expect(fetchApi("/events")).rejects.toMatchObject({ + status: 503, + message: "Service is warming up", + }); + }); + + it("returns null for successful empty responses", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); + + await expect(fetchApi("/events/one", { method: "DELETE" })).resolves.toBeNull(); + }); +}); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 8974641..d910b15 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,4 +1,5 @@ const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api').replace(/\/+$/, ''); +const DEFAULT_TIMEOUT_MS = 15_000; type JsonRecord = Record; @@ -35,6 +36,39 @@ type AuthTokenResponse = { refresh_token?: string; }; +function clearSession(): void { + localStorage.removeItem('token'); + localStorage.removeItem('refresh_token'); + window.dispatchEvent(new CustomEvent('timeora:auth-expired')); +} + +async function responseData(response: Response): Promise { + if (response.status === 204) return null; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json().catch(() => ({})); + } + const text = await response.text().catch(() => ''); + return text ? { detail: text } : {}; +} + +function requestSignal(external?: AbortSignal | null): { + signal: AbortSignal; + dispose: () => void; +} { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + const abortFromExternal = () => controller.abort(external?.reason); + external?.addEventListener('abort', abortFromExternal, { once: true }); + return { + signal: controller.signal, + dispose: () => { + window.clearTimeout(timeout); + external?.removeEventListener('abort', abortFromExternal); + }, + }; +} + async function refreshAccessToken(): Promise { const refreshToken = localStorage.getItem('refresh_token'); if (!refreshToken) return false; @@ -44,14 +78,19 @@ async function refreshAccessToken(): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }); - if (!resp.ok) return false; + if (!resp.ok) { + clearSession(); + return false; + } const data = await resp.json() as AuthTokenResponse; if (data.access_token) { localStorage.setItem('token', data.access_token); if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token); return true; } + clearSession(); } catch { + clearSession(); return false; } return false; @@ -71,10 +110,26 @@ export async function fetchApi( } if (token) headers.set('Authorization', `Bearer ${token}`); - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers, - }); + const timeout = requestSignal(options.signal); + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + signal: timeout.signal, + }); + } catch (error: unknown) { + if (timeout.signal.aborted) { + throw new ApiError(408, { detail: 'The request took too long. Please try again.' }, 'Request timed out'); + } + throw new ApiError( + 0, + { detail: error instanceof Error ? error.message : 'Network request failed' }, + 'Network request failed', + ); + } finally { + timeout.dispose(); + } if (response.status === 401 && retry && !endpoint.includes('/auth/')) { const refreshed = await refreshAccessToken(); @@ -82,7 +137,7 @@ export async function fetchApi( } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); + const errorData = await responseData(response); throw new ApiError(response.status, errorData, 'An error occurred while fetching data'); } @@ -90,7 +145,7 @@ export async function fetchApi( return null as T; } - return response.json() as Promise; + return responseData(response) as Promise; } export type ParseResult = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ee8df6..7d19c40 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,15 +29,27 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.10", + "jsdom": "^29.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -51,6 +63,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -525,6 +588,159 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -936,6 +1152,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "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 + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1883,84 +2117,20 @@ "node": ">=12.4.0" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, - "license": "MIT" - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "license": "MIT", - "engines": { - "node": ">=18" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -1971,13 +2141,13 @@ "android" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -1988,13 +2158,13 @@ "darwin" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -2005,13 +2175,13 @@ "darwin" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -2022,13 +2192,13 @@ "freebsd" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -2039,47 +2209,433 @@ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -2188,6 +2744,107 @@ "tailwindcss": "4.3.2" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "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 + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -2274,6 +2931,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2950,6 +3633,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3252,6 +4048,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3345,6 +4151,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -3539,6 +4355,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3836,6 +4662,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3862,6 +4709,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3958,6 +4819,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4073,6 +4941,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4105,6 +4984,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -4201,6 +5088,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4353,6 +5253,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4852,6 +5759,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4918,6 +5835,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5228,6 +6155,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5583,6 +6525,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5663,6 +6618,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6069,6 +7034,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -6349,6 +7321,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "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.3", + "@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.5", + "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" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6848,6 +7871,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6867,6 +7901,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -6962,6 +8003,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7332,6 +8383,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7578,6 +8643,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7629,6 +8707,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7808,9 +8893,47 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -7992,6 +9115,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8135,6 +9272,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8247,6 +9418,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8610,6 +9794,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8653,6 +9844,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8662,6 +9860,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -8914,6 +10119,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8976,6 +10194,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.31.11", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", @@ -9039,6 +10264,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9087,6 +10329,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9108,6 +10380,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9508,6 +10806,248 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9612,6 +11152,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9644,6 +11201,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 45a83f5..c10b8d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -30,12 +31,17 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.10", + "jsdom": "^29.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..e81e338 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + restoreMocks: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; From 021877e9215ddf5b80439d446f874f6ec885f16f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 17:12:28 +0800 Subject: [PATCH 02/39] feat: add rich calendar event details --- backend/app/core/ics_export.py | 9 +++- backend/app/core/ics_import.py | 6 +++ backend/app/data_access.py | 15 +++++- backend/app/models.py | 52 ++++++++++++++++++- backend/app/routers/events.py | 5 ++ backend/app/supabase_store.py | 10 ++++ backend/schema.sql | 5 ++ backend/tests/test_event_details.py | 49 +++++++++++++++++ frontend/app/dashboard/page.tsx | 25 +++++++++ frontend/components/calendar/EventDialog.tsx | 15 ++++++ frontend/lib/api.ts | 5 ++ .../20260706120000_event_details.sql | 31 +++++++++++ 12 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_event_details.py create mode 100644 supabase/migrations/20260706120000_event_details.sql diff --git a/backend/app/core/ics_export.py b/backend/app/core/ics_export.py index ecf41bc..3bc65f8 100644 --- a/backend/app/core/ics_export.py +++ b/backend/app/core/ics_export.py @@ -63,6 +63,8 @@ def generate_ics( uid = ev.get("id", str(uuid4())) title = _escape(ev.get("title", "Untitled")) participants = ev.get("participants", "") + description = ev.get("description", "") + location_url = ev.get("location_url") lines.append("BEGIN:VEVENT") lines.append(f"UID:{uid}") @@ -70,8 +72,13 @@ def generate_ics( lines.append(f"DTSTART:{_format_dt(ev_date, ev_time)}") lines.append(f"DTEND:{_format_dt(end_dt.date(), end_dt.time())}") lines.append(f"SUMMARY:{title}") + description_parts = [description] if description else [] if participants: - lines.append(f"DESCRIPTION:Participants: {_escape(participants)}") + description_parts.append(f"Participants: {participants}") + if description_parts: + lines.append(f"DESCRIPTION:{_escape(chr(10).join(description_parts))}") + if location_url: + lines.append(f"URL:{location_url}") lines.append("END:VEVENT") lines.append("END:VCALENDAR") diff --git a/backend/app/core/ics_import.py b/backend/app/core/ics_import.py index 96ee890..c776b9a 100644 --- a/backend/app/core/ics_import.py +++ b/backend/app/core/ics_import.py @@ -183,6 +183,10 @@ def parse_ics( categories = properties.get("CATEGORIES", [({}, "")])[0][1] category = categories.split(",", 1)[0].strip().lower() or None recurrence = properties.get("RRULE", [({}, "")])[0][1] + description = _unescape( + properties.get("DESCRIPTION", [({}, "")])[0][1] + ).strip() + location_url = properties.get("URL", [({}, "")])[0][1].strip() or None events.append( EventCreate( @@ -193,6 +197,8 @@ def parse_ics( participants=", ".join(attendees), recurrence_rule=_recurrence_rule(recurrence), category=category, + description=description, + location_url=location_url, external_ids={"ics": uid} if uid else {}, ) ) diff --git a/backend/app/data_access.py b/backend/app/data_access.py index ec46cce..cfff1ca 100644 --- a/backend/app/data_access.py +++ b/backend/app/data_access.py @@ -48,6 +48,11 @@ def _row_to_event(row) -> EventResponse: participants=row.get("participants", ""), recurrence_rule=row.get("recurrence_rule"), category=row.get("category"), + description=row.get("description") or "", + location_url=row.get("location_url"), + priority=row.get("priority") or "normal", + tags=list(row.get("tags") or []), + reminder_minutes=row.get("reminder_minutes"), external_ids=external_ids, sync_status=row.get("sync_status") or "not_synced", last_synced_at=row.get("last_synced_at"), @@ -223,9 +228,10 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: """ INSERT INTO events ( user_id, title, date, start_time, duration_minutes, - participants, recurrence_rule, category, external_ids + participants, recurrence_rule, category, description, + location_url, priority, tags, reminder_minutes, external_ids ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING * """, user_id, @@ -236,6 +242,11 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: body.participants, body.recurrence_rule, body.category, + body.description, + body.location_url, + body.priority, + body.tags, + body.reminder_minutes, json.dumps(body.external_ids), ) return _row_to_event(row) diff --git a/backend/app/models.py b/backend/app/models.py index 699d86c..a2742fc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,36 @@ from datetime import date as Date, datetime as DateTime, time as Time from typing import Literal -from pydantic import BaseModel, Field +from urllib.parse import urlparse + +from pydantic import BaseModel, Field, field_validator + + +def _normalize_tags(value: list[str] | None) -> list[str] | None: + if value is None: + return None + normalized: list[str] = [] + seen: set[str] = set() + for raw_tag in value: + tag = raw_tag.strip() + key = tag.casefold() + if not tag or key in seen: + continue + seen.add(key) + normalized.append(tag) + return normalized + + +def _validate_location_url(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + return None + parsed = urlparse(normalized) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("location_url must use http or https") + return normalized class EventCreate(BaseModel): @@ -12,8 +41,16 @@ class EventCreate(BaseModel): participants: str = "" recurrence_rule: str | None = None category: str | None = None + description: str = Field("", max_length=5000) + location_url: str | None = Field(None, max_length=2048) + priority: Literal["low", "normal", "important"] = "normal" + tags: list[str] = Field(default_factory=list, max_length=20) + reminder_minutes: int | None = Field(None, ge=0, le=10080) external_ids: dict[str, str] = Field(default_factory=dict) + _location_url = field_validator("location_url")(_validate_location_url) + _tags = field_validator("tags")(_normalize_tags) + class EventUpdate(BaseModel): title: str | None = Field(None, min_length=1, max_length=200) @@ -23,6 +60,14 @@ class EventUpdate(BaseModel): participants: str | None = None recurrence_rule: str | None = None category: str | None = None + description: str | None = Field(None, max_length=5000) + location_url: str | None = Field(None, max_length=2048) + priority: Literal["low", "normal", "important"] | None = None + tags: list[str] | None = Field(None, max_length=20) + reminder_minutes: int | None = Field(None, ge=0, le=10080) + + _location_url = field_validator("location_url")(_validate_location_url) + _tags = field_validator("tags")(_normalize_tags) class EventResponse(BaseModel): @@ -35,6 +80,11 @@ class EventResponse(BaseModel): participants: str recurrence_rule: str | None = None category: str | None = None + description: str = "" + location_url: str | None = None + priority: Literal["low", "normal", "important"] = "normal" + tags: list[str] = Field(default_factory=list) + reminder_minutes: int | None = None external_ids: dict[str, str] = Field(default_factory=dict) sync_status: str = "not_synced" last_synced_at: DateTime | None = None diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 9beb37b..2056b8d 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -48,6 +48,11 @@ def _expand_events( participants=event.participants, recurrence_rule=event.recurrence_rule, category=event.category, + description=event.description, + location_url=event.location_url, + priority=event.priority, + tags=event.tags, + reminder_minutes=event.reminder_minutes, external_ids=event.external_ids, sync_status=event.sync_status, last_synced_at=event.last_synced_at, diff --git a/backend/app/supabase_store.py b/backend/app/supabase_store.py index b50a746..6005352 100644 --- a/backend/app/supabase_store.py +++ b/backend/app/supabase_store.py @@ -59,6 +59,11 @@ def _row_to_event(row: dict) -> EventResponse: participants=row.get("participants") or "", recurrence_rule=row.get("recurrence_rule"), category=row.get("category"), + description=row.get("description") or "", + location_url=row.get("location_url"), + priority=row.get("priority") or "normal", + tags=list(row.get("tags") or []), + reminder_minutes=row.get("reminder_minutes"), external_ids=row.get("external_ids") or {}, sync_status=row.get("sync_status") or "not_synced", last_synced_at=row.get("last_synced_at"), @@ -212,6 +217,11 @@ async def create_event(user_id: str, body: EventCreate) -> EventResponse: "participants": body.participants, "recurrence_rule": body.recurrence_rule, "category": body.category, + "description": body.description, + "location_url": body.location_url, + "priority": body.priority, + "tags": body.tags, + "reminder_minutes": body.reminder_minutes, "external_ids": body.external_ids, } ) diff --git a/backend/schema.sql b/backend/schema.sql index 73e85fd..40dc42b 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS events ( participants TEXT DEFAULT '', recurrence_rule TEXT DEFAULT NULL, category TEXT DEFAULT NULL, + description TEXT NOT NULL DEFAULT '', + location_url TEXT DEFAULT NULL, + priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'important')), + tags TEXT[] NOT NULL DEFAULT '{}', + reminder_minutes INTEGER DEFAULT NULL CHECK (reminder_minutes IS NULL OR (reminder_minutes >= 0 AND reminder_minutes <= 10080)), deleted_at TIMESTAMPTZ DEFAULT NULL, external_ids JSONB NOT NULL DEFAULT '{}'::jsonb, sync_status event_sync_status NOT NULL DEFAULT 'not_synced', diff --git a/backend/tests/test_event_details.py b/backend/tests/test_event_details.py new file mode 100644 index 0000000..a75d5c4 --- /dev/null +++ b/backend/tests/test_event_details.py @@ -0,0 +1,49 @@ +import unittest +from datetime import date, time + +from pydantic import ValidationError + +from app.models import EventCreate, EventUpdate + + +class TestEventDetails(unittest.TestCase): + def _event(self, **overrides): + values = { + "title": "Product Sync", + "date": date(2026, 7, 6), + "start_time": time(14, 0), + "duration_minutes": 60, + } + values.update(overrides) + return EventCreate(**values) + + def test_normalizes_rich_event_fields(self): + event = self._event( + description="Roadmap review", + location_url="https://zoom.us/j/123", + priority="important", + tags=[" Work ", "planning", "work", ""], + reminder_minutes=15, + ) + + self.assertEqual(event.description, "Roadmap review") + self.assertEqual(event.location_url, "https://zoom.us/j/123") + self.assertEqual(event.priority, "important") + self.assertEqual(event.tags, ["Work", "planning"]) + self.assertEqual(event.reminder_minutes, 15) + + def test_rejects_unsafe_location_url(self): + with self.assertRaises(ValidationError): + self._event(location_url="javascript:alert(1)") + + def test_update_normalizes_tags(self): + update = EventUpdate(tags=["Important", " important ", "Client"]) + self.assertEqual(update.tags, ["Important", "Client"]) + + def test_rejects_excessive_reminder(self): + with self.assertRaises(ValidationError): + self._event(reminder_minutes=10081) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 4916f77..e5ed362 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -43,6 +43,11 @@ type CalendarExtendedProps = { participants?: string; recurrence_rule?: string | null; category?: string | null; + description?: string; + location_url?: string | null; + priority?: "low" | "normal" | "important"; + tags?: string[]; + reminder_minutes?: number | null; }; function initialDateRange(): DateRange { @@ -75,6 +80,11 @@ function toCalendarEvents(events: ApiEvent[]): EventInput[] { participants: event.participants || "", recurrence_rule: event.recurrence_rule || null, category: event.category || null, + description: event.description || "", + location_url: event.location_url || null, + priority: event.priority || "normal", + tags: event.tags || [], + reminder_minutes: event.reminder_minutes ?? null, }, }; }); @@ -387,6 +397,11 @@ export default function DashboardPage() { participants: extendedProps.participants || "", recurrence_rule: extendedProps.recurrence_rule || null, category: extendedProps.category || null, + description: extendedProps.description || "", + location_url: extendedProps.location_url || null, + priority: extendedProps.priority || "normal", + tags: extendedProps.tags || [], + reminder_minutes: extendedProps.reminder_minutes ?? null, }); setConflictData(null); setIsDialogOpen(true); @@ -452,6 +467,11 @@ export default function DashboardPage() { participants: data.participants, recurrence_rule: data.recurrence_rule || null, category: data.category || null, + description: data.description, + location_url: data.location_url || null, + priority: data.priority, + tags: data.tags, + reminder_minutes: data.reminder_minutes ?? null, }), }); } else { @@ -465,6 +485,11 @@ export default function DashboardPage() { participants: data.participants, recurrence_rule: data.recurrence_rule || null, category: data.category || null, + description: data.description, + location_url: data.location_url || null, + priority: data.priority, + tags: data.tags, + reminder_minutes: data.reminder_minutes ?? null, }), }); } diff --git a/frontend/components/calendar/EventDialog.tsx b/frontend/components/calendar/EventDialog.tsx index 2c26060..6ee7367 100644 --- a/frontend/components/calendar/EventDialog.tsx +++ b/frontend/components/calendar/EventDialog.tsx @@ -32,6 +32,11 @@ export type EventData = { participants: string; recurrence_rule?: string | null; category?: string | null; + description: string; + location_url?: string | null; + priority: "low" | "normal" | "important"; + tags: string[]; + reminder_minutes?: number | null; }; interface EventDialogProps { @@ -53,6 +58,11 @@ function defaultEventData(initialData: Partial | null): Partial; sync_status?: string; last_synced_at?: string | null; diff --git a/supabase/migrations/20260706120000_event_details.sql b/supabase/migrations/20260706120000_event_details.sql new file mode 100644 index 0000000..4d88fc1 --- /dev/null +++ b/supabase/migrations/20260706120000_event_details.sql @@ -0,0 +1,31 @@ +ALTER TABLE public.events + ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS location_url TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal', + ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS reminder_minutes INTEGER DEFAULT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'events_priority_check' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT events_priority_check + CHECK (priority IN ('low', 'normal', 'important')); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'events_reminder_minutes_check' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT events_reminder_minutes_check + CHECK ( + reminder_minutes IS NULL + OR (reminder_minutes >= 0 AND reminder_minutes <= 10080) + ); + END IF; +END +$$; + +CREATE INDEX IF NOT EXISTS idx_events_tags ON public.events USING GIN (tags); From 45a8a33c0ff44127a7c7530e973627904dac445c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 17:15:10 +0800 Subject: [PATCH 03/39] feat: add native calendar AI tools --- backend/app/core/assistant_tools.py | 70 +++++++++++++++ backend/app/models.py | 6 ++ backend/app/routers/assistant.py | 131 +++++++++++++++++----------- backend/tests/test_assistant.py | 83 ++++++++++++++++++ 4 files changed, 239 insertions(+), 51 deletions(-) create mode 100644 backend/app/core/assistant_tools.py create mode 100644 backend/tests/test_assistant.py diff --git a/backend/app/core/assistant_tools.py b/backend/app/core/assistant_tools.py new file mode 100644 index 0000000..2b816b2 --- /dev/null +++ b/backend/app/core/assistant_tools.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import date, time + +from fastapi import HTTPException, status + +from app import data_access +from app.models import AssistantRequest, EventCreate, EventUpdate + + +def _time_value(value: str | None) -> time | None: + if not value: + return None + parts = value.split(":") + return time( + int(parts[0]), + int(parts[1]), + int(parts[2]) if len(parts) > 2 else 0, + ) + + +async def execute_calendar_tool(user_id: str, body: AssistantRequest): + action = body.action + if not action: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="action is required when confirm=true", + ) + + if action == "create": + if not body.event_data: + raise HTTPException(status_code=400, detail="event_data is required for create") + return "create", await data_access.create_event( + user_id, + EventCreate.model_validate(body.event_data), + ) + + if not body.event_id: + raise HTTPException(status_code=400, detail="event_id is required for this action") + event_id = body.event_id.split("_")[0] + + if action in {"cancel", "delete"}: + await data_access.delete_event(event_id, user_id) + return "cancel", {"event_id": event_id, "deleted": True} + + if action == "reschedule": + new_time = _time_value(body.new_time) + if not body.new_date or new_time is None: + raise HTTPException( + status_code=400, + detail="new_date and new_time are required for reschedule", + ) + updated = await data_access.update_event( + event_id, + user_id, + EventUpdate(date=date.fromisoformat(body.new_date[:10]), start_time=new_time), + ) + return "reschedule", updated + + if action in {"edit", "update"}: + if not body.event_data: + raise HTTPException(status_code=400, detail="event_data is required for update") + updated = await data_access.update_event( + event_id, + user_id, + EventUpdate.model_validate(body.event_data), + ) + return "update", updated + + raise HTTPException(status_code=400, detail=f"Unknown action: {action}") diff --git a/backend/app/models.py b/backend/app/models.py index a2742fc..46285dd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -174,6 +174,9 @@ class AssistantRequest(BaseModel): action: str | None = None new_date: str | None = None new_time: str | None = None + selected_event_id: str | None = None + context_event_id: str | None = None + event_data: dict | None = None class AssistantResponse(BaseModel): @@ -182,6 +185,9 @@ class AssistantResponse(BaseModel): message: str requires_confirmation: bool = False executed: bool = False + clarification: dict | None = None + events: list[dict] = Field(default_factory=list) + suggested_actions: list[str] = Field(default_factory=list) class InsightAction(BaseModel): diff --git a/backend/app/routers/assistant.py b/backend/app/routers/assistant.py index 7357e4d..a140755 100644 --- a/backend/app/routers/assistant.py +++ b/backend/app/routers/assistant.py @@ -13,8 +13,8 @@ from app.auth import get_current_user from app import data_access -from app.core import nlparser, conflicts as conflicts_engine -from app.models import AssistantRequest, AssistantResponse, EventUpdate +from app.core import assistant_tools, nlparser, conflicts as conflicts_engine +from app.models import AssistantRequest, AssistantResponse router = APIRouter() @@ -82,8 +82,10 @@ async def assistant(body: AssistantRequest, user: dict = Depends(get_current_use if intent == "create": return AssistantResponse( intent="create", - result=parsed, - message="Use the parse endpoint to create events.", + result={"tool": "create", "event_data": parsed}, + message=f"Create \"{parsed.get('title', 'Event')}\"? Confirm to add it to your calendar.", + requires_confirmation=True, + suggested_actions=["confirm", "edit"], ) if intent == "query": @@ -93,10 +95,10 @@ async def assistant(body: AssistantRequest, user: dict = Depends(get_current_use return await _handle_find_slot(user, parsed) if intent == "cancel": - return await _handle_cancel(user, parsed) + return await _handle_cancel(user, parsed, body.selected_event_id or body.context_event_id) if intent == "reschedule": - return await _handle_reschedule(user, parsed) + return await _handle_reschedule(user, parsed, body.selected_event_id or body.context_event_id) return AssistantResponse( intent=intent, @@ -106,48 +108,22 @@ async def assistant(body: AssistantRequest, user: dict = Depends(get_current_use async def _execute_confirmed(user: dict, body: AssistantRequest) -> AssistantResponse: - if not body.event_id or not body.action: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="event_id and action are required when confirm=true", - ) - - base_id = body.event_id.split("_")[0] - - if body.action == "cancel": - await data_access.delete_event(base_id, user["id"]) - return AssistantResponse( - intent="cancel", - result={"event_id": base_id, "deleted": True}, - message="Event cancelled.", - executed=True, - ) - - if body.action == "reschedule": - if not body.new_date or not body.new_time: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="new_date and new_time are required for reschedule", - ) - new_time = _parse_time_value(body.new_time) - if new_time is None: - raise HTTPException(status_code=400, detail="Invalid new_time") - updated = await data_access.update_event( - base_id, - user["id"], - EventUpdate( - date=date.fromisoformat(body.new_date[:10]), - start_time=new_time, - ), - ) - return AssistantResponse( - intent="reschedule", - result=_event_to_dict(updated), - message=f"Rescheduled to {body.new_date} {body.new_time}.", - executed=True, - ) - - raise HTTPException(status_code=400, detail=f"Unknown action: {body.action}") + intent, result = await assistant_tools.execute_calendar_tool(user["id"], body) + serialized = _event_to_dict(result) + messages = { + "create": "Event added to your calendar.", + "cancel": "Event cancelled.", + "reschedule": f"Event rescheduled to {body.new_date} {body.new_time}.", + "update": "Event updated.", + } + return AssistantResponse( + intent=intent, + result=serialized, + message=messages[intent], + executed=True, + events=[serialized] if isinstance(serialized, dict) and not serialized.get("deleted") else [], + suggested_actions=["open_event"] if intent != "cancel" else [], + ) async def _handle_query(user: dict, parsed: dict) -> AssistantResponse: @@ -175,6 +151,8 @@ async def _handle_query(user: dict, parsed: dict) -> AssistantResponse: intent="query", result=matching, message=f"Found {len(matching)} event(s) on {target_date.isoformat()}.", + events=matching, + suggested_actions=["open_event", "find_free_slot"], ) @@ -209,9 +187,45 @@ async def _handle_find_slot(user: dict, parsed: dict) -> AssistantResponse: ) -async def _handle_cancel(user: dict, parsed: dict) -> AssistantResponse: +def _clarification(matches: list[dict], action: str) -> AssistantResponse: + choices = [ + { + "id": item["id"], + "title": item.get("title", "Event"), + "date": item.get("date"), + "start_time": item.get("start_time"), + } + for item in matches + ] + return AssistantResponse( + intent=action, + result={"events": matches}, + message="I found more than one matching event. Which one did you mean?", + clarification={ + "type": "event_selection", + "prompt": "Which event did you mean?", + "choices": choices, + }, + events=matches, + suggested_actions=["select_event"], + ) + + +def _selected_match(matches: list[dict], selected_event_id: str | None) -> list[dict]: + if not selected_event_id: + return matches + base_id = selected_event_id.split("_")[0] + return [item for item in matches if str(item.get("id", "")).split("_")[0] == base_id] + + +async def _handle_cancel( + user: dict, + parsed: dict, + selected_event_id: str | None = None, +) -> AssistantResponse: all_events = await data_access.list_events(user["id"]) matches = _find_matches(all_events, parsed) + matches = _selected_match(matches, selected_event_id) if not matches: return AssistantResponse( @@ -220,6 +234,9 @@ async def _handle_cancel(user: dict, parsed: dict) -> AssistantResponse: message=f"No event matching '{parsed.get('title', '')}' found to cancel.", ) + if len(matches) > 1: + return _clarification(matches, "cancel") + primary = matches[0] return AssistantResponse( intent="cancel", @@ -230,12 +247,19 @@ async def _handle_cancel(user: dict, parsed: dict) -> AssistantResponse: }, message=f"Cancel \"{primary.get('title', 'Event')}\"? Confirm to proceed.", requires_confirmation=True, + events=matches, + suggested_actions=["confirm", "cancel"], ) -async def _handle_reschedule(user: dict, parsed: dict) -> AssistantResponse: +async def _handle_reschedule( + user: dict, + parsed: dict, + selected_event_id: str | None = None, +) -> AssistantResponse: all_events = await data_access.list_events(user["id"]) matches = _find_matches(all_events, parsed) + matches = _selected_match(matches, selected_event_id) if not matches: return AssistantResponse( @@ -244,6 +268,9 @@ async def _handle_reschedule(user: dict, parsed: dict) -> AssistantResponse: message=f"No event matching '{parsed.get('title', '')}' found to reschedule.", ) + if len(matches) > 1: + return _clarification(matches, "reschedule") + primary = matches[0] new_date = parsed.get("date") new_time = parsed.get("start_time") @@ -261,4 +288,6 @@ async def _handle_reschedule(user: dict, parsed: dict) -> AssistantResponse: f"{new_date} {new_time}? Confirm to proceed." ), requires_confirmation=True, - ) \ No newline at end of file + events=matches, + suggested_actions=["confirm", "cancel"], + ) diff --git a/backend/tests/test_assistant.py b/backend/tests/test_assistant.py new file mode 100644 index 0000000..8ba3d35 --- /dev/null +++ b/backend/tests/test_assistant.py @@ -0,0 +1,83 @@ +import unittest +from unittest.mock import AsyncMock, patch + +from app.models import AssistantRequest, EventResponse +from app.routers import assistant + + +def event(event_id: str, title: str, hour: int) -> EventResponse: + return EventResponse( + id=event_id, + user_id="user-1", + title=title, + date="2026-07-06", + start_time=f"{hour:02d}:00:00", + duration_minutes=60, + participants="", + ) + + +class TestAssistantClarification(unittest.IsolatedAsyncioTestCase): + async def test_ambiguous_cancel_returns_choices(self): + events = [event("one", "Team Sync", 10), event("two", "Team Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_cancel( + {"id": "user-1"}, + {"title": "Team Sync", "date": None}, + ) + + self.assertFalse(response.requires_confirmation) + self.assertEqual(response.clarification["type"], "event_selection") + self.assertEqual([choice["id"] for choice in response.clarification["choices"]], ["one", "two"]) + + async def test_selected_ambiguous_event_can_be_confirmed(self): + events = [event("one", "Team Sync", 10), event("two", "Team Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_cancel( + {"id": "user-1"}, + {"title": "Team Sync", "date": None}, + selected_event_id="two", + ) + + self.assertTrue(response.requires_confirmation) + self.assertEqual(response.result["primary_event_id"], "two") + + async def test_query_exposes_structured_events(self): + events = [event("one", "Product Sync", 14)] + with patch.object(assistant.data_access, "list_events", AsyncMock(return_value=events)): + response = await assistant._handle_query( + {"id": "user-1"}, + {"date": "2026-07-06"}, + ) + + self.assertEqual(response.events[0]["title"], "Product Sync") + self.assertIn("open_event", response.suggested_actions) + + +class TestAssistantNativeTools(unittest.IsolatedAsyncioTestCase): + async def test_confirmed_create_uses_calendar_data_access(self): + created = event("new", "Planning", 9) + body = AssistantRequest( + confirm=True, + action="create", + event_data={ + "title": "Planning", + "date": "2026-07-07", + "start_time": "09:00:00", + "duration_minutes": 30, + }, + ) + with patch.object( + assistant.data_access, + "create_event", + AsyncMock(return_value=created), + ) as create_event: + response = await assistant._execute_confirmed({"id": "user-1"}, body) + + self.assertTrue(response.executed) + self.assertEqual(response.intent, "create") + self.assertEqual(create_event.await_args.args[0], "user-1") + + +if __name__ == "__main__": + unittest.main() From 03f613560716fb8afaa5117e442bd7a1b93ef235 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 17:57:34 +0800 Subject: [PATCH 04/39] feat: add calendar AI chat and reminders --- frontend/app/dashboard/page.tsx | 201 ++++-------- frontend/app/globals.css | 10 +- .../components/NotificationCenter.test.tsx | 28 ++ frontend/components/NotificationCenter.tsx | 151 +++++++++ .../assistant/AssistantPanel.test.tsx | 77 +++++ .../components/assistant/AssistantPanel.tsx | 307 ++++++++++++++++++ .../assistant/ClarificationCard.tsx | 35 ++ .../components/calendar/EventActions.test.tsx | 53 +++ frontend/components/calendar/EventActions.tsx | 81 +++++ frontend/components/calendar/EventDialog.tsx | 99 +++++- frontend/components/calendar/EventPreview.tsx | 62 ++++ .../components/calendar/WeeklyCalendar.tsx | 75 ++++- frontend/components/ui/badge.tsx | 52 +++ frontend/components/ui/context-menu.tsx | 271 ++++++++++++++++ frontend/components/ui/drawer.tsx | 228 +++++++++++++ frontend/components/ui/dropdown-menu.tsx | 268 +++++++++++++++ frontend/components/ui/hover-card.tsx | 51 +++ frontend/components/ui/sonner.tsx | 49 +++ frontend/components/ui/textarea.tsx | 18 + frontend/lib/api.ts | 25 +- frontend/lib/reminders.test.ts | 69 ++++ frontend/lib/reminders.ts | 154 +++++++++ frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/vitest.config.ts | 1 + frontend/vitest.setup.ts | 17 + 26 files changed, 2224 insertions(+), 170 deletions(-) create mode 100644 frontend/components/NotificationCenter.test.tsx create mode 100644 frontend/components/NotificationCenter.tsx create mode 100644 frontend/components/assistant/AssistantPanel.test.tsx create mode 100644 frontend/components/assistant/AssistantPanel.tsx create mode 100644 frontend/components/assistant/ClarificationCard.tsx create mode 100644 frontend/components/calendar/EventActions.test.tsx create mode 100644 frontend/components/calendar/EventActions.tsx create mode 100644 frontend/components/calendar/EventPreview.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/context-menu.tsx create mode 100644 frontend/components/ui/drawer.tsx create mode 100644 frontend/components/ui/dropdown-menu.tsx create mode 100644 frontend/components/ui/hover-card.tsx create mode 100644 frontend/components/ui/sonner.tsx create mode 100644 frontend/components/ui/textarea.tsx create mode 100644 frontend/lib/reminders.test.ts create mode 100644 frontend/lib/reminders.ts diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index e5ed362..3d0c831 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -3,27 +3,28 @@ import { useCallback, useEffect, useState, useRef } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { LogOut, Calendar as CalendarIcon, Brain, Download, Settings, User } from "lucide-react"; +import { LogOut, Brain, Download, Settings, User } from "lucide-react"; import { WeeklyCalendar } from "@/components/calendar/WeeklyCalendar"; import { EventDialog, EventData, ConflictData } from "@/components/calendar/EventDialog"; import { fetchApi, ApiError, type ApiEvent, - type AssistantResult, restoreEvent, fetchEventsExpanded, exportIcs, - executeAssistant, applyBlockFocusTime, applySpreadLoad, } from "@/lib/api"; import { format } from "date-fns"; -import { CommandBar } from "@/components/CommandBar"; import { callAssistant } from "@/lib/api"; +import { AssistantPanel } from "@/components/assistant/AssistantPanel"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; import { InsightsPanel } from "@/components/InsightsPanel"; import { AvailabilityHeatmap } from "@/components/AvailabilityHeatmap"; import { TodayAgenda } from "@/components/TodayAgenda"; +import { NotificationCenter } from "@/components/NotificationCenter"; import { ThemeToggle } from "@/components/theme-toggle"; import { motion, AnimatePresence } from "framer-motion"; import type { @@ -112,15 +113,8 @@ export default function DashboardPage() { const [isSaving, setIsSaving] = useState(false); const [conflictData, setConflictData] = useState(null); const [assistantToast, setAssistantToast] = useState(null); - const [assistantConfirm, setAssistantConfirm] = useState<{ - eventId: string; - action: "cancel" | "reschedule"; - title: string; - newDate?: string; - newTime?: string; - message: string; - } | null>(null); - const [confirmingAssistant, setConfirmingAssistant] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(false); + const [assistantContext, setAssistantContext] = useState(null); const [undoDelete, setUndoDelete] = useState<{ id: string; title: string } | null>(null); const [dateRange, setDateRange] = useState(INITIAL_DATE_RANGE); const [exporting, setExporting] = useState(false); @@ -234,7 +228,7 @@ export default function DashboardPage() { URL.revokeObjectURL(url); setAssistantToast("Calendar exported — timeora.ics downloaded."); } catch (err: unknown) { - alert(errorMessage(err, "Failed to export calendar")); + toast.error(errorMessage(err, "Failed to export calendar")); } finally { setExporting(false); } @@ -273,7 +267,7 @@ export default function DashboardPage() { setAssistantToast(result.message); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to block focus time")); + toast.error(errorMessage(err, "Failed to block focus time")); } }; @@ -283,7 +277,7 @@ export default function DashboardPage() { setAssistantToast(result.message); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to spread load")); + toast.error(errorMessage(err, "Failed to spread load")); } }; @@ -302,6 +296,16 @@ export default function DashboardPage() { } }; + const openAssistant = useCallback((context?: EventData | null) => { + setAssistantContext(context ?? null); + setAssistantOpen(true); + }, []); + + const handleAssistantOpenChange = useCallback((open: boolean) => { + setAssistantOpen(open); + if (!open) setAssistantContext(null); + }, []); + const handleEventCategoryChange = async (eventId: string, category: string) => { try { const baseId = eventId.split("_")[0]; @@ -311,76 +315,7 @@ export default function DashboardPage() { }); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to change category")); - } - }; - - const handleParsedNL = ( - parsedData: Partial, - meta?: { source?: string; warnings?: string[] } - ) => { - setSelectedEvent(parsedData); - setConflictData(null); - setAssistantToast( - meta?.source === "fallback" - ? "Parsed offline — please verify date and time before saving." - : null - ); - setIsDialogOpen(true); - }; - - const handleAssistant = (result: AssistantResult) => { - if (result.requires_confirmation && result.result && typeof result.result === "object") { - const r = result.result as Record; - const eventId = r.primary_event_id as string | undefined; - if (eventId) { - setAssistantConfirm({ - eventId, - action: result.intent === "reschedule" ? "reschedule" : "cancel", - title: (r.primary_title as string) || "Event", - newDate: r.new_date as string | undefined, - newTime: r.new_time as string | undefined, - message: result.message, - }); - setAssistantToast(null); - return; - } - } - - setAssistantConfirm(null); - setAssistantToast(result.message); - if (result.intent === "find_slot" && Array.isArray(result.result) && result.result.length > 0) { - const first = result.result[0] as { start_time?: string; reason?: string }; - if (first.start_time) { - setAssistantToast( - `${result.message} First slot: ${first.start_time}${first.reason ? ` — ${first.reason}` : ""}` - ); - } - } - }; - - const handleConfirmAssistant = async () => { - if (!assistantConfirm) return; - setConfirmingAssistant(true); - try { - const result = await executeAssistant({ - event_id: assistantConfirm.eventId, - action: assistantConfirm.action, - new_date: assistantConfirm.newDate, - new_time: assistantConfirm.newTime, - }); - const { eventId, title, action } = assistantConfirm; - setAssistantConfirm(null); - setAssistantToast(result.message); - if (action === "cancel") { - setUndoDelete({ id: eventId, title }); - } - refreshAll(); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Failed to execute command"; - alert(message); - } finally { - setConfirmingAssistant(false); + toast.error(errorMessage(err, "Failed to change category")); } }; @@ -389,7 +324,7 @@ export default function DashboardPage() { if (!event.start) return; const extendedProps = event.extendedProps as CalendarExtendedProps; setSelectedEvent({ - id: event.id, + id: event.id.split("_")[0], title: event.title, date: format(event.start, "yyyy-MM-dd"), start_time: format(event.start, "HH:mm:ss"), @@ -414,7 +349,7 @@ export default function DashboardPage() { const start = rawStart instanceof Date ? rawStart : new Date(rawStart as string | number); const ext = (calEvent.extendedProps || {}) as CalendarExtendedProps; setSelectedEvent({ - id: calEvent.id as string, + id: String(calEvent.id).split("_")[0], title: calEvent.title as string, date: format(start, "yyyy-MM-dd"), start_time: format(start, "HH:mm:ss"), @@ -422,6 +357,11 @@ export default function DashboardPage() { participants: ext.participants || "", recurrence_rule: ext.recurrence_rule || null, category: ext.category || null, + description: ext.description || "", + location_url: ext.location_url || null, + priority: ext.priority || "normal", + tags: ext.tags || [], + reminder_minutes: ext.reminder_minutes ?? null, }); setConflictData(null); setIsDialogOpen(true); @@ -437,7 +377,7 @@ export default function DashboardPage() { } const end = event.end || new Date(event.start.getTime() + 60 * 60_000); try { - await fetchApi(`/events/${event.id}`, { + await fetchApi(`/events/${event.id.split("_")[0]}`, { method: "PUT", body: JSON.stringify({ date: format(event.start, "yyyy-MM-dd"), @@ -448,7 +388,7 @@ export default function DashboardPage() { refreshAll(); } catch (err: unknown) { console.error(err); - alert(errorMessage(err, "Failed to move event")); + toast.error(errorMessage(err, "Failed to move event")); arg.revert(); } }; @@ -502,10 +442,10 @@ export default function DashboardPage() { if (isConflictData(detail)) { setConflictData(detail); } else { - alert("The selected time conflicts with another event"); + toast.error("The selected time conflicts with another event"); } } else { - alert(errorMessage(err, "Failed to save event")); + toast.error(errorMessage(err, "Failed to save event")); } } finally { setIsSaving(false); @@ -516,14 +456,15 @@ export default function DashboardPage() { if (!confirm("Are you sure you want to delete this event?")) return; setIsSaving(true); try { - await fetchApi(`/events/${id}`, { + const baseId = id.split("_")[0]; + await fetchApi(`/events/${baseId}`, { method: "DELETE", }); setIsDialogOpen(false); - setUndoDelete({ id, title: title || "Event" }); + setUndoDelete({ id: baseId, title: title || "Event" }); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to delete event")); + toast.error(errorMessage(err, "Failed to delete event")); } finally { setIsSaving(false); } @@ -536,7 +477,7 @@ export default function DashboardPage() { setUndoDelete(null); refreshAll(); } catch (err: unknown) { - alert(errorMessage(err, "Failed to restore event")); + toast.error(errorMessage(err, "Failed to restore event")); } }; @@ -568,10 +509,10 @@ export default function DashboardPage() { {/* Center: Command Bar Trigger (Desktop) - More prominent */}
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))} + onClick={() => openAssistant()} className="hidden md:flex items-center gap-3 bg-white dark:bg-zinc-900 border border-slate-200/70 dark:border-white/10 hover:border-violet-400/60 focus-within:border-violet-500 shadow-sm hover:shadow-md rounded-2xl px-4 py-2.5 w-full max-w-md cursor-pointer transition-all select-none group" role="button" - aria-label="Open AI Command Bar (⌘K)" + aria-label="Open AI calendar chat (⌘K)" >
@@ -590,8 +531,9 @@ export default function DashboardPage() { @@ -684,38 +626,6 @@ export default function DashboardPage() {
- {assistantConfirm && ( - - {assistantConfirm.message} -
- - -
-
- )} {assistantToast && (

No events yet

- Start by using the AI Command Bar (⌘K) or the buttons above.
+ Start by using the AI calendar chat (⌘K) or the buttons above.
Try: “Jadwalkan meeting besok jam 10 selama 30 menit”

-
)} @@ -815,6 +725,11 @@ export default function DashboardPage() { onDatesChange={handleDatesChange} onAddEventClick={handleAddEventClick} onEventCategoryChange={handleEventCategoryChange} + onEventEdit={handleAgendaEventClick} + onEventDelete={(event) => { + if (event.id) void handleDeleteEvent(event.id, event.title); + }} + onEventAskAI={(event) => openAssistant(event)} />
@@ -838,6 +753,8 @@ export default function DashboardPage() {
+ + {/* Sidebar Tab Control */}
{[ @@ -936,14 +853,20 @@ export default function DashboardPage() { {/* Mobile Floating Action Button - High visibility for Command Bar */} - + refreshAll()} + contextEvent={assistantContext} + /> +
); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 296e2d9..8b0229d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -123,9 +123,17 @@ } body { @apply bg-background text-foreground; + position: relative; + min-width: 0; + overflow-x: hidden; + min-height: 100dvh; + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); } html { @apply font-sans; + min-width: 0; + overflow-x: hidden; } } @@ -361,4 +369,4 @@ .dark .fc .fc-timegrid-now-indicator-line { border-color: #a78bfa !important; -} \ No newline at end of file +} diff --git a/frontend/components/NotificationCenter.test.tsx b/frontend/components/NotificationCenter.test.tsx new file mode 100644 index 0000000..6396578 --- /dev/null +++ b/frontend/components/NotificationCenter.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import type { EventInput } from "@fullcalendar/core"; +import { describe, expect, it } from "vitest"; + +import { NotificationCenter } from "./NotificationCenter"; + +function dueSoonEvent(): EventInput { + const start = new Date(); + start.setMinutes(start.getMinutes() + 14); + return { + id: "event-1", + title: "Product Sync", + start, + extendedProps: { + participants: "team@example.com", + reminder_minutes: 15, + }, + }; +} + +describe("NotificationCenter", () => { + it("shows an in-app reminder fallback when browser notifications are unavailable", async () => { + render(); + + expect(await screen.findByText("Product Sync")).toBeVisible(); + expect(screen.getByText(/Starts at/)).toBeVisible(); + }); +}); diff --git a/frontend/components/NotificationCenter.tsx b/frontend/components/NotificationCenter.tsx new file mode 100644 index 0000000..a491a6d --- /dev/null +++ b/frontend/components/NotificationCenter.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { EventInput } from "@fullcalendar/core"; +import { Bell, BellOff, X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + createReminderScheduler, + requestReminderPermission, + type ReminderDelivery, + type ReminderEvent, + type ReminderPermissionState, +} from "@/lib/reminders"; + +function readPermission(): ReminderPermissionState { + if (typeof Notification === "undefined") return "unsupported"; + return Notification.permission; +} + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} + +function toLocalDate(value: Date): string { + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`; +} + +function toLocalTime(value: Date): string { + return `${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; +} + +function toReminderEvent(event: EventInput): ReminderEvent | null { + const ext = (event.extendedProps || {}) as Record; + if (ext.reminder_minutes === null || ext.reminder_minutes === undefined) { + return null; + } + if (!event.id || !event.title || !event.start) return null; + + const start = event.start instanceof Date ? event.start : new Date(event.start as string | number); + if (Number.isNaN(start.getTime())) return null; + + return { + id: String(event.id), + title: String(event.title), + date: toLocalDate(start), + start_time: toLocalTime(start), + participants: typeof ext.participants === "string" ? ext.participants : "", + reminder_minutes: Number(ext.reminder_minutes), + }; +} + +export function NotificationCenter({ events }: { events: EventInput[] }) { + const [permission, setPermission] = useState(() => readPermission()); + const [fallbacks, setFallbacks] = useState([]); + const schedulerRef = useRef | null>(null); + + const reminderEvents = useMemo( + () => events.map(toReminderEvent).filter((event): event is ReminderEvent => event !== null), + [events], + ); + + useEffect(() => { + schedulerRef.current = createReminderScheduler({ + fallback: (delivery) => { + setFallbacks((current) => { + if (current.some((item) => item.key === delivery.key)) return current; + return [delivery, ...current].slice(0, 4); + }); + }, + }); + + return () => schedulerRef.current?.cancel(); + }, []); + + useEffect(() => { + schedulerRef.current?.schedule(reminderEvents); + }, [reminderEvents]); + + const handleEnableNotifications = async () => { + const nextPermission = await requestReminderPermission(); + setPermission(nextPermission); + schedulerRef.current?.schedule(reminderEvents); + }; + + const dismiss = (key: string) => { + setFallbacks((current) => current.filter((item) => item.key !== key)); + }; + + const statusLabel = permission === "granted" + ? "Notifications on" + : permission === "default" + ? "Notifications optional" + : "In-app reminders"; + + return ( +
+
+
+ + {permission === "granted" ? : } + +
+

Reminders

+

{statusLabel}

+
+
+ {permission === "default" ? ( + + ) : ( + {reminderEvents.length} + )} +
+ + {permission !== "granted" ? ( +

+ Timeora will show reminders here when browser notifications are unavailable or blocked. +

+ ) : null} + + {fallbacks.length ? ( +
+ {fallbacks.map((reminder) => ( +
+
+

{reminder.title}

+

{reminder.body}

+
+ +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/components/assistant/AssistantPanel.test.tsx b/frontend/components/assistant/AssistantPanel.test.tsx new file mode 100644 index 0000000..6ab5839 --- /dev/null +++ b/frontend/components/assistant/AssistantPanel.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { callAssistant, executeAssistant } from "@/lib/api"; +import { AssistantPanel } from "./AssistantPanel"; + +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { + ...actual, + callAssistant: vi.fn(), + executeAssistant: vi.fn(), + }; +}); + +const callAssistantMock = vi.mocked(callAssistant); +const executeAssistantMock = vi.mocked(executeAssistant); + +describe("AssistantPanel", () => { + beforeEach(() => { + callAssistantMock.mockReset(); + executeAssistantMock.mockReset(); + }); + + it("submits from a visible Send button and renders feedback", async () => { + const user = userEvent.setup(); + callAssistantMock.mockResolvedValue({ + intent: "query", + result: [], + events: [], + suggested_actions: [], + message: "I found 3 events today.", + }); + render(); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Apa jadwal saya hari ini?"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(callAssistantMock).toHaveBeenCalledWith("Apa jadwal saya hari ini?", undefined); + expect(await screen.findByText("I found 3 events today.")).toBeVisible(); + }); + + it("renders clarification choices and resubmits with the selected event", async () => { + const user = userEvent.setup(); + callAssistantMock + .mockResolvedValueOnce({ + intent: "cancel", + result: { events: [] }, + message: "Which event did you mean?", + clarification: { + type: "event_selection", + prompt: "Which team meeting did you mean?", + choices: [ + { id: "one", title: "Marketing Sync", start_time: "10:00:00" }, + { id: "two", title: "Product Sync", start_time: "14:00:00" }, + ], + }, + }) + .mockResolvedValueOnce({ + intent: "cancel", + result: { primary_event_id: "two", primary_title: "Product Sync" }, + message: "Cancel Product Sync?", + requires_confirmation: true, + }); + render(); + + await user.type(screen.getByPlaceholderText("Ask or type a message…"), "Batalkan team sync"); + await user.click(screen.getByRole("button", { name: "Send" })); + await user.click(await screen.findByRole("button", { name: /Product Sync/ })); + + expect(callAssistantMock).toHaveBeenLastCalledWith("Batalkan team sync", { + selected_event_id: "two", + }); + expect(await screen.findByRole("button", { name: "Confirm action" })).toBeVisible(); + }); +}); diff --git a/frontend/components/assistant/AssistantPanel.tsx b/frontend/components/assistant/AssistantPanel.tsx new file mode 100644 index 0000000..c096544 --- /dev/null +++ b/frontend/components/assistant/AssistantPanel.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { FormEvent, useEffect, useRef, useState } from "react"; +import { Bot, Check, RotateCcw, Send, Sparkles, X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; + +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { Textarea } from "@/components/ui/textarea"; +import { + callAssistant, + executeAssistant, + type AssistantExecuteParams, + type AssistantResult, +} from "@/lib/api"; +import type { EventData } from "@/components/calendar/EventDialog"; +import { ClarificationCard } from "./ClarificationCard"; + +type Message = { + id: string; + role: "user" | "assistant"; + text: string; + requestText?: string; + result?: AssistantResult; +}; + +function useMobile(): boolean { + const [mobile, setMobile] = useState(false); + useEffect(() => { + const query = window.matchMedia("(max-width: 767px)"); + const update = () => setMobile(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + return mobile; +} + +function executionParams(result: AssistantResult): AssistantExecuteParams | null { + if (!result.requires_confirmation || !result.result || typeof result.result !== "object") return null; + const data = result.result as Record; + if (result.intent === "create") { + return { + action: "create", + event_data: data.event_data as Record, + }; + } + if (result.intent === "cancel") { + return { + action: "cancel", + event_id: data.primary_event_id as string, + }; + } + if (result.intent === "reschedule") { + return { + action: "reschedule", + event_id: data.primary_event_id as string, + new_date: data.new_date as string, + new_time: data.new_time as string, + }; + } + return null; +} + +export function AssistantPanel({ + open, + onOpenChange, + onEventsChanged, + contextEvent, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onEventsChanged: () => void; + contextEvent?: EventData | null; +}) { + const [query, setQuery] = useState(""); + const [messages, setMessages] = useState([]); + const [pending, setPending] = useState(false); + const [retryText, setRetryText] = useState(null); + const scrollRef = useRef(null); + const isMobile = useMobile(); + const reduceMotion = useReducedMotion(); + + useEffect(() => { + const listener = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onOpenChange(!open); + } + if (event.key === "Escape" && open) onOpenChange(false); + }; + document.addEventListener("keydown", listener); + return () => document.removeEventListener("keydown", listener); + }, [onOpenChange, open]); + + useEffect(() => { + scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" }); + }, [messages, pending, reduceMotion]); + + const submit = async ( + text: string, + options?: { selected_event_id?: string; context_event_id?: string }, + addUserMessage = true, + ) => { + const normalized = text.trim(); + if (!normalized || pending) return; + if (addUserMessage) { + setMessages((current) => [...current, { id: crypto.randomUUID(), role: "user", text: normalized }]); + } + setPending(true); + setRetryText(null); + try { + const result = await callAssistant(normalized, options); + setMessages((current) => [ + ...current, + { + id: crypto.randomUUID(), + role: "assistant", + text: result.message, + requestText: normalized, + result, + }, + ]); + setQuery(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Timeora could not process that request."; + setMessages((current) => [...current, { id: crypto.randomUUID(), role: "assistant", text: message }]); + setRetryText(normalized); + } finally { + setPending(false); + } + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + void submit( + query, + contextEvent?.id ? { context_event_id: contextEvent.id } : undefined, + ); + }; + + const confirmResult = async (result: AssistantResult) => { + const params = executionParams(result); + if (!params || pending) return; + setPending(true); + try { + const executed = await executeAssistant(params); + setMessages((current) => [ + ...current, + { id: crypto.randomUUID(), role: "assistant", text: executed.message, result: executed }, + ]); + onEventsChanged(); + } catch (error: unknown) { + setMessages((current) => [ + ...current, + { + id: crypto.randomUUID(), + role: "assistant", + text: error instanceof Error ? error.message : "The calendar action failed.", + }, + ]); + } finally { + setPending(false); + } + }; + + const content = ( +
+
+
+ + + +
+

Ask your calendar

+

Query, create, and reschedule safely

+
+
+ +
+ +
+ {messages.length === 0 ? ( +
+ +
+

Ask directly about your calendar

+

Try “Apa jadwal saya hari ini?” or “Pindahkan Product Sync ke jam 3.”

+
+
+ ) : null} + + + {messages.map((message) => ( + +

{message.text}

+ {message.result?.events?.length ? ( +
+ {message.result.events.map((event) => ( +
+

{event.title}

+

{event.date} · {event.start_time.slice(0, 5)}

+
+ ))} +
+ ) : null} + {message.result?.clarification && message.requestText ? ( + void submit(message.requestText as string, { selected_event_id: eventId }, false)} + /> + ) : null} + {message.result && executionParams(message.result) ? ( +
+ + +
+ ) : null} +
+ ))} +
+ + {pending ? ( +
+ {[0, 1, 2].map((dot) => ( + + ))} +
+ ) : null} +
+ +
+ {retryText ? ( + + ) : null} +
+