diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56ec8bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Blocking gates — these must pass before the app can be deployed. + - name: Test + run: npm test + + - name: Build + run: npm run build + + # Informational for now (the codebase still has pre-existing `any`s to + # clean up); flip to blocking once the lint backlog is cleared. + - name: Lint + run: npm run lint + continue-on-error: true diff --git a/.gitignore b/.gitignore index 5ef6a52..a8f6092 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# --- LinearCal deploy agent: credentials + local tooling, NEVER commit --- +# (the .env* rule above misses files that *end* in .env, like linearcal-agent.env) +*.env +linearcal-agent.env +/deploy-agent/ diff --git a/package-lock.json b/package-lock.json index 5f7a836..4d32b7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,14 +22,72 @@ "react-dom": "19.2.3" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^9", "eslint-config-next": "16.1.1", - "typescript": "^5" + "jsdom": "^29.1.1", + "playwright": "^1.60.0", + "typescript": "^5", + "vitest": "^4.1.8" } }, + "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.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -279,22 +337,175 @@ "node": ">=6.9.0" } }, + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "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.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "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.0.2", + "@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.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "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/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -302,9 +513,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -456,6 +667,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.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1330,6 +1559,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -1358,67 +1597,499 @@ "cross-spawn": "^7.0.6" } }, - "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/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "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.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "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.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "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/@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/@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", + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/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", + "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/@testing-library/dom/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" + }, + "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/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "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" + }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, @@ -2014,6 +2685,145 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "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.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2267,6 +3077,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-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2364,6 +3184,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "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/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2532,7 +3362,17 @@ "svg-pathdata": "^6.0.3" }, "engines": { - "node": ">=10.0.0" + "node": ">=10.0.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": { @@ -2642,6 +3482,20 @@ "utrie": "^1.0.2" } }, + "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/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2664,6 +3518,20 @@ "node": ">= 12" } }, + "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", @@ -2745,6 +3613,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/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2788,12 +3663,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -2811,6 +3696,13 @@ "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" + }, "node_modules/dompurify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", @@ -2863,6 +3755,19 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "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/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -2978,6 +3883,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3474,6 +4386,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", @@ -3484,6 +4406,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3699,6 +4631,21 @@ "node": ">=12.20.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "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", @@ -4133,6 +5080,19 @@ "hermes-estree": "0.25.1" } }, + "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/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -4496,6 +5456,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4716,6 +5683,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", @@ -4768,106 +5786,367 @@ "bin": { "json5": "lib/cli.js" }, - "engines": { - "node": ">=6" + "engines": { + "node": ">=6" + } + }, + "node_modules/jspdf": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", + "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jspdf": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz", - "integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "fast-png": "^6.2.0", - "fflate": "^0.8.1" + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" }, - "optionalDependencies": { - "canvg": "^3.0.11", - "core-js": "^3.6.0", - "dompurify": "^3.2.4", - "html2canvas": "^1.0.0-rc.5" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "CC0-1.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -4916,6 +6195,26 @@ "yallist": "^3.0.2" } }, + "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", + "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", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4925,6 +6224,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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4988,9 +6294,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -5295,6 +6601,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oidc-token-hash": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", @@ -5430,6 +6750,19 @@ "node": ">=6" } }, + "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/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5478,6 +6811,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "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/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -5504,6 +6844,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5794,6 +7166,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5867,8 +7249,42 @@ "bin": { "rimraf": "dist/esm/bin.mjs" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.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.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/run-parallel": { @@ -5970,6 +7386,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -6186,6 +7615,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", @@ -6214,6 +7650,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/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -6224,6 +7667,13 @@ "node": ">=0.1.14" } }, + "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/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6529,6 +7979,13 @@ "node": ">=12.0.0" } }, + "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/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -6559,15 +8016,32 @@ "utrie": "^1.0.2" } }, + "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.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6595,9 +8069,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6607,6 +8081,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.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "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", @@ -6620,6 +8124,32 @@ "node": ">=8.0" } }, + "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.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", @@ -6813,6 +8343,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6920,6 +8460,257 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.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.1.18", + "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/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/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "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.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "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/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -6935,6 +8726,41 @@ "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", "license": "Apache-2.0" }, + "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", @@ -7039,6 +8865,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", @@ -7137,6 +8980,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "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/package.json b/package.json index e1d1f1a..fdfbcf2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "TZ=UTC vitest run", + "test:watch": "TZ=UTC vitest" }, "dependencies": { "@hebcal/core": "^6.0.8", @@ -23,11 +25,18 @@ "react-dom": "19.2.3" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^9", "eslint-config-next": "16.1.1", - "typescript": "^5" + "jsdom": "^29.1.1", + "playwright": "^1.60.0", + "typescript": "^5", + "vitest": "^4.1.8" } } diff --git a/src/app/actions.test.ts b/src/app/actions.test.ts new file mode 100644 index 0000000..d63028d --- /dev/null +++ b/src/app/actions.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the session + the Google I/O layer so we test the action logic in isolation. +vi.mock('next-auth/next', () => ({ getServerSession: vi.fn() })); +vi.mock('@/lib/google-calendar', () => ({ + getEventsForRange: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + createCalendar: vi.fn(), +})); + +import { getServerSession } from 'next-auth/next'; +import * as gcal from '@/lib/google-calendar'; +import { + fetchCalendarEventsAction, + createEventAction, + updateEventAction, + deleteEventAction, + createCalendarAction, +} from './actions'; + +const mockedSession = getServerSession as unknown as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockedSession.mockResolvedValue({ accessToken: 'tok-123' }); +}); + +describe('auth enforcement', () => { + it('throws when there is no session', async () => { + mockedSession.mockResolvedValue(null); + await expect(deleteEventAction('cal', 'evt')).rejects.toThrow(/Unauthorized/); + }); + + it('throws when the session has no access token', async () => { + mockedSession.mockResolvedValue({ user: { name: 'x' } }); + await expect(createEventAction('cal', {})).rejects.toThrow(/Unauthorized/); + }); +}); + +describe('fetchCalendarEventsAction', () => { + it('strips the virtual calendar ids before calling Google', async () => { + (gcal.getEventsForRange as ReturnType).mockResolvedValue([]); + const start = new Date(2024, 0, 1); + const end = new Date(2024, 0, 31); + await fetchCalendarEventsAction(['primary', 'jewish-calendar', 'hebrew-date', 'work'], start, end); + expect(gcal.getEventsForRange).toHaveBeenCalledWith('tok-123', start, end, ['primary', 'work']); + }); +}); + +describe('createEventAction', () => { + it('returns success + data on success', async () => { + (gcal.createEvent as ReturnType).mockResolvedValue({ id: 'new-1' }); + const res = await createEventAction('work@group', { summary: 'X' }); + expect(res).toEqual({ success: true, data: { id: 'new-1' } }); + expect(gcal.createEvent).toHaveBeenCalledWith('tok-123', 'work@group', { summary: 'X' }); + }); + + it('maps a thrown error to { success:false, error }', async () => { + (gcal.createEvent as ReturnType).mockRejectedValue(new Error('Boom')); + const res = await createEventAction('cal', {}); + expect(res).toEqual({ success: false, error: 'Boom' }); + }); +}); + +describe('updateEventAction', () => { + it('passes the calendarId + eventId straight through', async () => { + (gcal.updateEvent as ReturnType).mockResolvedValue({ id: 'evt-1' }); + const res = await updateEventAction('work@group', 'evt-1', { summary: 'Y' }); + expect(res.success).toBe(true); + expect(gcal.updateEvent).toHaveBeenCalledWith('tok-123', 'work@group', 'evt-1', { summary: 'Y' }); + }); + + it('reports failure without throwing', async () => { + (gcal.updateEvent as ReturnType).mockRejectedValue(new Error('404')); + const res = await updateEventAction('cal', 'evt', {}); + expect(res).toEqual({ success: false, error: '404' }); + }); +}); + +describe('deleteEventAction', () => { + it('deletes via the provided calendar id', async () => { + (gcal.deleteEvent as ReturnType).mockResolvedValue({ success: true }); + const res = await deleteEventAction('work@group', 'evt-9'); + expect(res).toEqual({ success: true }); + expect(gcal.deleteEvent).toHaveBeenCalledWith('tok-123', 'work@group', 'evt-9'); + }); +}); + +describe('createCalendarAction', () => { + it('creates a calendar and returns its data', async () => { + (gcal.createCalendar as ReturnType).mockResolvedValue({ id: 'cal-new', summary: 'Trips' }); + const res = await createCalendarAction('Trips'); + expect(res).toEqual({ success: true, data: { id: 'cal-new', summary: 'Trips' } }); + }); +}); diff --git a/src/app/actions.ts b/src/app/actions.ts index 50cb945..2bcbe11 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,85 +1,69 @@ 'use server'; import { getServerSession } from "next-auth/next"; +import { calendar_v3 } from "googleapis"; import { authOptions } from "@/lib/auth"; import { getEventsForRange, createEvent, updateEvent, deleteEvent } from "@/lib/google-calendar"; -export async function fetchCalendarEventsAction(calendarIds: string[], start: Date, end: Date) { - const session = await getServerSession(authOptions); +function messageOf(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} +async function requireAccessToken(): Promise { + const session = await getServerSession(authOptions); if (!session || !session.accessToken) { throw new Error("Unauthorized: No active session or access token"); } + return session.accessToken; +} - // Use provided range +export async function fetchCalendarEventsAction(calendarIds: string[], start: Date, end: Date) { + const accessToken = await requireAccessToken(); const googleCalendarIds = calendarIds.filter(id => id !== 'jewish-calendar' && id !== 'hebrew-date'); - const events = await getEventsForRange(session.accessToken, start, end, googleCalendarIds); - return events; + return getEventsForRange(accessToken, start, end, googleCalendarIds); } - - -export async function createEventAction(calendarId: string, eventData: any) { - const session = await getServerSession(authOptions); - - if (!session || !session.accessToken) { - throw new Error("Unauthorized: No active session or access token"); - } - +export async function createEventAction(calendarId: string, eventData: calendar_v3.Schema$Event) { + const accessToken = await requireAccessToken(); try { - const result = await createEvent(session.accessToken, calendarId, eventData); - return { success: true, data: result }; - } catch (error: any) { + const result = await createEvent(accessToken, calendarId, eventData); + return { success: true as const, data: result }; + } catch (error: unknown) { console.error("Failed to create event:", error); - return { success: false, error: error.message || "Failed to create event" }; + return { success: false as const, error: messageOf(error, "Failed to create event") }; } } -export async function updateEventAction(calendarId: string, eventId: string, eventData: any) { - const session = await getServerSession(authOptions); - - if (!session || !session.accessToken) { - throw new Error("Unauthorized: No active session or access token"); - } - +export async function updateEventAction(calendarId: string, eventId: string, eventData: calendar_v3.Schema$Event) { + const accessToken = await requireAccessToken(); try { - const result = await updateEvent(session.accessToken, calendarId, eventId, eventData); - return { success: true, data: result }; - } catch (error: any) { + const result = await updateEvent(accessToken, calendarId, eventId, eventData); + return { success: true as const, data: result }; + } catch (error: unknown) { console.error("Failed to update event:", error); - return { success: false, error: error.message || "Failed to update event" }; + return { success: false as const, error: messageOf(error, "Failed to update event") }; } } export async function deleteEventAction(calendarId: string, eventId: string) { - const session = await getServerSession(authOptions); - - if (!session || !session.accessToken) { - throw new Error("Unauthorized: No active session or access token"); - } - + const accessToken = await requireAccessToken(); try { - await deleteEvent(session.accessToken, calendarId, eventId); - return { success: true }; - } catch (error: any) { + await deleteEvent(accessToken, calendarId, eventId); + return { success: true as const }; + } catch (error: unknown) { console.error("Failed to delete event:", error); - return { success: false, error: error.message || "Failed to delete event" }; + return { success: false as const, error: messageOf(error, "Failed to delete event") }; } } export async function createCalendarAction(title: string) { - const session = await getServerSession(authOptions); - - if (!session || !session.accessToken) { - throw new Error("Unauthorized: No active session or access token"); - } - + const accessToken = await requireAccessToken(); try { const { createCalendar } = await import("@/lib/google-calendar"); - const result = await createCalendar(session.accessToken, title); - return { success: true, data: result }; - } catch (error: any) { + const result = await createCalendar(accessToken, title); + return { success: true as const, data: result }; + } catch (error: unknown) { console.error("Failed to create calendar:", error); - return { success: false, error: error.message || "Failed to create calendar" }; + return { success: false as const, error: messageOf(error, "Failed to create calendar") }; } } diff --git a/src/app/page.tsx b/src/app/page.tsx index d350386..6e4b1fe 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -49,7 +49,10 @@ export default async function Home({ searchParams }: PageProps) { } - if (!session || !session.accessToken) { + // Show the login screen when there's no session, no token, OR the token + // refresh failed (`session.error`). Without the error check the user would be + // left with a stale/expired token and silent 401s from the Google API. + if (!session || !session.accessToken || session.error) { return (
@@ -58,6 +61,11 @@ export default async function Home({ searchParams }: PageProps) { לוח שנה ליניארי Linear Calendar + {session?.error && ( +

+ ההתחברות פגה — יש להתחבר מחדש. +

+ )}

הדרך היעילה ביותר לנהל את הזמן שלך.
התחבר כדי לצפות ולערוך את יומן Google שלך בתצוגה שנתית. diff --git a/src/components/CalendarHeader.tsx b/src/components/CalendarHeader.tsx index 2d5e0fd..c4c0280 100644 --- a/src/components/CalendarHeader.tsx +++ b/src/components/CalendarHeader.tsx @@ -134,7 +134,7 @@ export default function CalendarHeader({

{/* Force LTR for DatePicker input logic */} d && setPickerStart(d)} + onChange={(d: Date | null) => d && setPickerStart(d)} showMonthYearPicker dateFormat="MMM yyyy" locale="he" @@ -149,7 +149,7 @@ export default function CalendarHeader({
d && setPickerEnd(d)} + onChange={(d: Date | null) => d && setPickerEnd(d)} showMonthYearPicker dateFormat="MMM yyyy" locale="he" diff --git a/src/components/CreateEventModal.test.tsx b/src/components/CreateEventModal.test.tsx new file mode 100644 index 0000000..d1ec4bd --- /dev/null +++ b/src/components/CreateEventModal.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +// Stub the server actions so we can assert how the modal calls them. +vi.mock('@/app/actions', () => ({ + createEventAction: vi.fn(), + updateEventAction: vi.fn(), + deleteEventAction: vi.fn(), +})); + +import { updateEventAction, deleteEventAction } from '@/app/actions'; +import CreateEventModal from './CreateEventModal'; +import type { CalendarListEntry } from '@/lib/google-calendar'; + +const calendars: CalendarListEntry[] = [ + { id: 'primary', summary: 'ראשי', primary: true }, + { id: 'work@group.calendar.google.com', summary: 'עבודה', primary: false }, +]; + +// An all-day event that lives in the NON-default ("work") calendar. +const editEvent = { + id: 'evt-1', + summary: 'פגישה', + description: '', + start: { date: '2024-01-10' }, + end: { date: '2024-01-12' }, // exclusive + _calendarId: 'work@group.calendar.google.com', +}; + +function renderEdit() { + return render( + , + ); +} + +const asMock = (fn: unknown) => fn as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('CreateEventModal edit mode — bug #1: operate on the event\'s own calendar', () => { + it('saves against the event calendar (work), not the default (primary)', async () => { + asMock(updateEventAction).mockResolvedValue({ success: true, data: { id: 'evt-1' } }); + renderEdit(); + + fireEvent.click(screen.getByRole('button', { name: 'שמור' })); + + await waitFor(() => expect(updateEventAction).toHaveBeenCalled()); + const [calendarId, eventId] = asMock(updateEventAction).mock.calls[0]; + expect(calendarId).toBe('work@group.calendar.google.com'); + expect(eventId).toBe('evt-1'); + }); + + it('deletes against the event calendar (work), not the default (primary)', async () => { + window.confirm = vi.fn(() => true); + asMock(deleteEventAction).mockResolvedValue({ success: true }); + renderEdit(); + + fireEvent.click(screen.getByRole('button', { name: 'מחק' })); + + await waitFor(() => expect(deleteEventAction).toHaveBeenCalled()); + expect(asMock(deleteEventAction).mock.calls[0][0]).toBe('work@group.calendar.google.com'); + }); +}); diff --git a/src/components/CreateEventModal.tsx b/src/components/CreateEventModal.tsx index 0e2510a..d305efa 100644 --- a/src/components/CreateEventModal.tsx +++ b/src/components/CreateEventModal.tsx @@ -7,6 +7,12 @@ import { createEventAction, updateEventAction, deleteEventAction } from '@/app/a import { format } from 'date-fns'; import DatePicker from 'react-datepicker'; import "react-datepicker/dist/react-datepicker.css"; +import { + resolveEventCalendarId, + tagEventCalendar, + toInclusiveEndDate, + buildEventPayload, +} from '@/lib/event-form'; interface CreateEventModalProps { isOpen: boolean; @@ -46,15 +52,21 @@ export default function CreateEventModal({ const [colorId, setColorId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); // Initialize state when modal opens useEffect(() => { if (isOpen) { + setErrorMsg(null); if (initialEvent) { // Edit Mode setTitle(initialEvent.summary || ''); setDescription(initialEvent.description || ''); + // Target the calendar the event actually lives in, so save/delete + // hit the right calendar (not just the default visible one). + setSelectedCalendarId(resolveEventCalendarId(initialEvent, defaultCalendarId)); + const s = initialEvent.start.dateTime || initialEvent.start.date; const e = initialEvent.end.dateTime || initialEvent.end.date; @@ -75,11 +87,9 @@ export default function CreateEventModal({ setStartDate(startD); setStartTime('09:00'); // Default - // Initial logic: Google sends exclusive end date for all day (e.g. End is 14th, meaning event is 13th). - // We want to show Inclusive end date in picker (display 13th). - const inclusiveEnd = new Date(endD); - inclusiveEnd.setDate(inclusiveEnd.getDate() - 1); - setEndDate(inclusiveEnd); + // Google sends an exclusive end date for all-day events (end 14th + // means the event covers the 13th). Show the inclusive date. + setEndDate(toInclusiveEndDate(endD)); setEndTime('10:00'); // Default } @@ -107,38 +117,19 @@ export default function CreateEventModal({ if (!selectedCalendarId || !startDate || !endDate) return; setIsSubmitting(true); - - const eventData: any = { - summary: title || '(ללא כותרת)', - description: description, - }; - - if (isAllDay) { - // Full Day Event - eventData.start = { date: format(startDate, 'yyyy-MM-dd') }; - - // Exclusive End Date Logic for Submission - // If user selected 13th, we send 14th. - const endD = new Date(endDate); - endD.setDate(endD.getDate() + 1); - eventData.end = { date: format(endD, 'yyyy-MM-dd') }; - } else { - // Time Based Event - // Construct ISO DateTime strings - // We need to combine the Date object from picker with the Time string. - const sDate = format(startDate, 'yyyy-MM-dd'); - const eDate = format(endDate, 'yyyy-MM-dd'); - - const startDateTime = new Date(`${sDate}T${startTime}:00`); - const endDateTime = new Date(`${eDate}T${endTime}:00`); - - eventData.start = { dateTime: startDateTime.toISOString() }; - eventData.end = { dateTime: endDateTime.toISOString() }; - } - - if (colorId) { - eventData.colorId = colorId; - } + setErrorMsg(null); + + const eventData = buildEventPayload({ + title, + description, + isAllDay, + startDate, + endDate, + startTime, + endTime, + colorId, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); try { let result; @@ -149,14 +140,16 @@ export default function CreateEventModal({ } if (result.success) { - onSaveSuccess(result.data); + // Tag the returned event with its calendar so visibility filtering + // and later edits/deletes resolve to the correct calendar. + onSaveSuccess(tagEventCalendar(result.data ?? {}, selectedCalendarId)); onClose(); } else { - alert('שגיאה בשמירת האירוע: ' + result.error); + setErrorMsg('שגיאה בשמירת האירוע: ' + result.error); } } catch (err) { console.error(err); - alert('אירעה שגיאה לא צפויה.'); + setErrorMsg('אירעה שגיאה לא צפויה. נסה שוב.'); } finally { setIsSubmitting(false); } @@ -166,17 +159,18 @@ export default function CreateEventModal({ if (!confirm('האם אתה בטוח שברצונך למחוק אירוע זה?')) return; setIsDeleting(true); + setErrorMsg(null); try { const result = await deleteEventAction(selectedCalendarId, initialEvent.id); if (result.success) { if (onDeleteSuccess) onDeleteSuccess(initialEvent.id); onClose(); } else { - alert('שגיאה במחיקת האירוע: ' + result.error); + setErrorMsg('שגיאה במחיקת האירוע: ' + result.error); } } catch (err) { console.error(err); - alert('שגיאה במחיקה'); + setErrorMsg('שגיאה במחיקה. נסה שוב.'); } finally { setIsDeleting(false); } @@ -190,6 +184,23 @@ export default function CreateEventModal({
e.stopPropagation()} dir="rtl">

{isEditMode ? 'עריכת אירוע' : 'יצירת אירוע חדש'}

+ {errorMsg && ( +
+ {errorMsg} +
+ )} +
diff --git a/src/components/LinearCalendar.module.css b/src/components/LinearCalendar.module.css index d931565..49419ce 100644 --- a/src/components/LinearCalendar.module.css +++ b/src/components/LinearCalendar.module.css @@ -82,11 +82,19 @@ z-index: 50; } +/* + * Leading/trailing padding cells. These keep every month row a full, + * consistent-width rectangle (so the start/end of each month lines up across + * months) while reading clearly as "not a day": faint dashed outline, no fill, + * no interaction. + */ .dayCell.empty { - border: none; + border: 1px dashed var(--border-color); + border-radius: 6px; background-color: transparent !important; pointer-events: none; box-shadow: none !important; + opacity: 0.35; } .dayCell.today { diff --git a/src/components/LinearCalendar.tsx b/src/components/LinearCalendar.tsx index d781fb4..a8fc826 100644 --- a/src/components/LinearCalendar.tsx +++ b/src/components/LinearCalendar.tsx @@ -3,8 +3,7 @@ import React, { useState, useEffect } from 'react'; import styles from './LinearCalendar.module.css'; import { CalendarEvent } from '@/lib/google-calendar'; -import { signOut } from 'next-auth/react'; -import { format, eachDayOfInterval, isSameDay, addDays, getWeek, parseISO, isSameMonth, endOfMonth, getDay, isToday, differenceInCalendarDays, startOfMonth, addYears, subYears, eachMonthOfInterval, getYear, addMonths } from 'date-fns'; +import { format, eachDayOfInterval, isSameDay, getWeek, parseISO, endOfMonth, getDay, isToday, differenceInCalendarDays, eachMonthOfInterval } from 'date-fns'; import { he } from 'date-fns/locale'; import { useRouter } from 'next/navigation'; @@ -12,14 +11,18 @@ import DayDetailModal from './DayDetailModal'; import CreateEventModal from './CreateEventModal'; import CalendarHeader from './CalendarHeader'; import { CalendarListEntry } from '@/lib/google-calendar'; -import { HebrewCalendar, Event as HebcalEvent, HDate, gematriya, Locale } from '@hebcal/core'; +import { HDate, gematriya, Locale } from '@hebcal/core'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; import { fetchCalendarEventsAction, createCalendarAction } from '@/app/actions'; +import { getJewishHolidays, buildHolidayStylingMap } from '@/lib/holidays'; +import { buildEventLayout } from '@/lib/event-layout'; +import { getContrastColor } from '@/lib/color'; +import { JEWISH_CALENDAR_ID, applyCalendarsParam } from '@/lib/calendar-params'; // Virtual Calendar Definition const JEWISH_CALENDAR: CalendarListEntry = { - id: 'jewish-calendar', + id: JEWISH_CALENDAR_ID, summary: 'לוח שנה עברי', backgroundColor: '#FFB74D', // Orange-ish foregroundColor: '#000000', @@ -35,25 +38,6 @@ interface LinearCalendarProps { eventColors: any; // Google Color definitions } -// Helper to determine text color based on background brightness -const getContrastColor = (hex: string) => { - if (!hex || !hex.startsWith('#')) return '#ffffff'; // Fallback for vars/invalid - // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b); - - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - if (!result) return '#ffffff'; - - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - - // YIQ equation - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - return brightness > 128 ? '#000000' : '#ffffff'; -}; - export default function LinearCalendar({ events: initialEventsProp, startDate, endDate, allCalendars, selectedCalendarIds: initialSelectedIdsProp, eventColors }: LinearCalendarProps) { const router = useRouter(); @@ -95,144 +79,11 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e }); }, [initialEventsProp, initialSelectedIdsProp, allCalendars]); - // Generate Jewish Holidays for all years in range - const jewishHolidays = React.useMemo(() => { - const startYear = getYear(startDate); - const endYear = getYear(endDate); - const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i); - - let allEvents: HebcalEvent[] = []; - - years.forEach(y => { - const options = { - isHebrewYear: false, - il: true, // Israel schedule - locale: 'he', - year: y - }; - allEvents = allEvents.concat(HebrewCalendar.calendar(options)); - }); - - // Filter events by actual date range of the calendar - const startStr = format(startDate, 'yyyy-MM-dd'); - const endStr = format(endDate, 'yyyy-MM-dd'); - - return allEvents - .filter(ev => { - const d = ev.getDate().greg(); - const dStr = format(d, 'yyyy-MM-dd'); - if (dStr < startStr || dStr > endStr) return false; - - const mask = ev.getFlags(); - // Correct Constants based on @hebcal/core definitions: - // CHAG = 1 - // MAJOR_FAST = 16384 - // MINOR_FAST = 256 - // CHOL_HAMOED = 2097152 - // MODERN_HOLIDAY = 8192 - // SPECIAL_SHABBAT = 512 - // ROSH_CHODESH = 128 - // MINOR_HOLIDAY = 524288 - // EREV = 1048576 - - const isChag = (mask & 1) !== 0; - const isCholHamoed = (mask & 2097152) !== 0; - const isMajorFast = (mask & 16384) !== 0; - const isMinorFast = (mask & 256) !== 0; - const isFast = isMajorFast || isMinorFast; - // Modern Holidays (e.g. Yom HaAtzmaut, Yom HaShoah, Family Day) - // User explicitly wants to FILTER OUT minor ones like Family Day. - // User wants "Israeli Major Chagim as Yom Haatzmaut". - const isModern = (mask & 8192) !== 0; - const isErev = (mask & 1048576) !== 0; - - const desc = ev.getDesc(); - - // Check for Major Erev Chag (Half work day) - // Filter strict list of Erevs to avoid Erev Minor holidays or Modern - // We want Erev before full holyday: Rosh Hashana, Yom Kippur, Sukkot, Pesach, Shavuot. - let isMajorErev = false; - if (isErev) { - if ( - desc.startsWith("Erev Rosh Hashana") || - desc.startsWith("Erev Yom Kippur") || - desc.startsWith("Erev Sukkot") || - desc.startsWith("Erev Pesach") || - desc.startsWith("Erev Shavuot") - ) { - isMajorErev = true; - } - } - - // Explicitly check for Shavuot to ensure it acts as Chag (work restriction) - // In case flag is missing or different - const isShavuot = desc.startsWith("Shavuot") && !desc.includes("Isru Chag"); /* Isru Chag is minor? */ - - // Check for other important holidays that might be "Minor" in flags but major for user - const isPurim = desc.includes("Purim"); - const isChanukah = desc.includes("Chanukah"); - const isTuBiShvat = desc.includes("Tu BiShvat"); - const isLagBaOmer = desc.includes("Lag BaOmer"); - - // Specific Allow List for Modern Holidays - let isAllowedModern = false; - if (isModern) { - // Allow Yom HaAtzma'ut - if (desc === "Yom HaAtzma'ut") isAllowedModern = true; - if (desc === "Yom HaZikaron") isAllowedModern = true; - if (desc === "Yom HaShoah") isAllowedModern = true; - } - - if (isChag || isCholHamoed || isFast || isAllowedModern || isMajorErev || isShavuot || isPurim || isChanukah || isTuBiShvat || isLagBaOmer) return true; - return false; - }) - .map(ev => { - const hd = ev.getDate(); - const d = hd.greg(); - const dateStr = format(d, 'yyyy-MM-dd'); - const mask = ev.getFlags(); - const desc = ev.getDesc(); - - const isYomTov = (mask & 1) !== 0; // CHAG - const isCholHamoed = (mask & 2097152) !== 0; - const isMajorFast = (mask & 16384) !== 0; - const isMinorFast = (mask & 256) !== 0; - const isFast = isMajorFast || isMinorFast; - const isErev = (mask & 1048576) !== 0; - - // Yom HaAtzmaut -> treat as Yom Tov for styling (Holiday) - const isYomHaatzmaut = desc === "Yom HaAtzma'ut"; - - // Explicit check for Shavuot - const isShavuot = desc.startsWith("Shavuot") && !desc.includes("Isru Chag"); - - const isPurim = desc.includes("Purim"); - - // Identify Major Erev for styling - let isMajorErev = false; - if (isErev) { - if ( - desc.startsWith("Erev Rosh Hashana") || - desc.startsWith("Erev Yom Kippur") || - desc.startsWith("Erev Sukkot") || - desc.startsWith("Erev Pesach") || - desc.startsWith("Erev Shavuot") - ) { - isMajorErev = true; - } - } - - return { - date: dateStr, - text: ev.render('he'), - isYomTov: isYomTov || isYomHaatzmaut || isShavuot, - // Treat Major Erev as Chol Hamoed for styling (Friday style) - // Treat Purim as Chol Hamoed (Friday style) - Festive but not Yom Tov - isCholHamoed: isCholHamoed || isMajorErev || isPurim, - isFast: isFast - }; - }); - }, [startDate, endDate]); + // Generate Jewish Holidays for all years in range (Israel schedule) + const jewishHolidays = React.useMemo( + () => getJewishHolidays(startDate, endDate), + [startDate, endDate], + ); // Pre-calculate Calendar Colors Map const calendarColorMap = React.useMemo(() => { @@ -610,13 +461,14 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e } } - // 3. Update URL (Background) - const params = new URLSearchParams(window.location.search); - if (newVisibleIds.length > 0) { - params.set('calendars', newVisibleIds.join(',')); - } else { - params.delete('calendars'); - } + // 3. Update URL (Background). Preserve the virtual overlays (Jewish + // holidays / Hebrew date) so toggling a real calendar doesn't drop them + // from the URL — which would make the server round-trip reset them off. + const params = applyCalendarsParam(new URLSearchParams(window.location.search), { + regularIds: newVisibleIds, + showJewishCalendar, + showHebrewDate, + }); router.push(`/?${params.toString()}`, { scroll: false }); }; @@ -692,113 +544,18 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e return ['א׳', 'ב׳', 'ג׳', 'ד׳', 'ה׳', 'ו׳', 'ש׳'][dayIndex]; }); - // Optimization: Create a map of events by date string (YYYY-MM-DD) - // Also process multi-day events to display them as bars + // Build the grid layout (single-day map + multi-day segment tracks) from the + // currently-visible events, plus the holiday styling overlay. const { eventsMap, multiDaySegments, maxTracksPerDay, holidayStylingMap } = React.useMemo(() => { - const map: Record = {}; - const segments: Record = {}; - const tracksCount: Record = {}; - const holidayStylingMap: Record = {}; - // Filter events based on visibility - let activeEvents = allEvents.filter(e => !e._calendarId || visibleCalendarIds.includes(e._calendarId)); - - if (showJewishCalendar) { - // We do NOT add them to activeEvents anymore (User req: "holydays not to appear as calendar events") - - // Populate styling/label map - jewishHolidays.forEach(h => { - const d = h.date; - // If multiple events on same day (e.g. Hanukkah + Shabbat), usually one wins or we combine? - // For now, let's just overwrite or maybe append text? - // Let's overwrite, assuming the last one is most specific or we just take one. - holidayStylingMap[d] = { - text: h.text, - isYomTov: h.isYomTov, - isCholHamoed: h.isCholHamoed, - isFast: h.isFast - }; - }); - } - - // Sort events: multi-day first, then by start time, then by duration (longer first) - const sortedEvents = [...activeEvents].sort((a, b) => { - const startA = new Date(a.start?.dateTime || a.start?.date || 0).getTime(); - const startB = new Date(b.start?.dateTime || b.start?.date || 0).getTime(); + const activeEvents = allEvents.filter(e => !e._calendarId || visibleCalendarIds.includes(e._calendarId)); - const isMultiA = a.end?.date || (a.start?.dateTime && a.end?.dateTime && !isSameDay(parseISO(a.start.dateTime), parseISO(a.end.dateTime))); - const isMultiB = b.end?.date || (b.start?.dateTime && b.end?.dateTime && !isSameDay(parseISO(b.start.dateTime), parseISO(b.end.dateTime))); + const layout = buildEventLayout(activeEvents); - if (isMultiA && !isMultiB) return -1; - if (!isMultiA && isMultiB) return 1; + // Holidays are rendered as cell styling/labels, not as calendar events. + const holidayStylingMap = showJewishCalendar ? buildHolidayStylingMap(jewishHolidays) : {}; - if (startA !== startB) return startA - startB; - - const endA = new Date(a.end?.dateTime || a.end?.date || 0).getTime(); - const endB = new Date(b.end?.dateTime || b.end?.date || 0).getTime(); - return endB - endA; // Longer first - }); - - sortedEvents.forEach(event => { - const startStr = event.start?.dateTime || event.start?.date; - const endStr = event.end?.dateTime || event.end?.date; - - if (!startStr) return; - - const startDate = parseISO(startStr); - const endDate = endStr ? parseISO(endStr) : startDate; - - // Handle exclusive end date for Google full-day events - let realEnd = endDate; - if (event.end?.date) { - // For all-day events, Google uses exclusive end date (e.g. Sep 13). - // We want inclusive end date (Sep 12). - // Subtract 1 day. - realEnd = addDays(endDate, -1); - } - if (realEnd < startDate) realEnd = startDate; - - const daysDifference = Math.ceil((realEnd.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); - const isSingleDay = isSameDay(startDate, realEnd) && !event.start?.date; - - if (isSingleDay) { - const dateKey = format(startDate, 'yyyy-MM-dd'); - if (!map[dateKey]) map[dateKey] = []; - map[dateKey].push(event); - } else { - const days = eachDayOfInterval({ start: startDate, end: realEnd }); - - // Track allocator: find first track index available on ALL days of the event - let track = 0; - while (true) { - let available = true; - for (const day of days) { - const dateKey = format(day, 'yyyy-MM-dd'); - if (segments[dateKey]?.some(s => s.position === track)) { - available = false; - break; - } - } - if (available) break; - track++; - } - - days.forEach((day, index) => { - const dateKey = format(day, 'yyyy-MM-dd'); - if (!segments[dateKey]) segments[dateKey] = []; - segments[dateKey].push({ - event, - isStart: index === 0, - isEnd: index === days.length - 1, - position: track, - colSpan: 1, // Will be calculated dynamically in render - realEnd // Store for render calculation - }); - tracksCount[dateKey] = Math.max(tracksCount[dateKey] || 0, track + 1); - }); - } - }); - return { eventsMap: map, multiDaySegments: segments, maxTracksPerDay: tracksCount, holidayStylingMap }; + return { ...layout, holidayStylingMap }; }, [allEvents, visibleCalendarIds, jewishHolidays, showJewishCalendar]); @@ -816,21 +573,12 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e const newValue = !showJewishCalendar; setShowJewishCalendar(newValue); - // Update URL to persist state - const params = new URLSearchParams(window.location.search); - let urlIds = params.get('calendars')?.split(',').filter(Boolean) || []; - - if (newValue) { - if (!urlIds.includes(JEWISH_CALENDAR.id!)) urlIds.push(JEWISH_CALENDAR.id!); - } else { - urlIds = urlIds.filter(id => id !== JEWISH_CALENDAR.id); - } - - if (urlIds.length > 0) { - params.set('calendars', urlIds.join(',')); - } else { - params.delete('calendars'); - } + // Recombine the whole selection so we never clobber other toggles. + const params = applyCalendarsParam(new URLSearchParams(window.location.search), { + regularIds: visibleCalendarIds, + showJewishCalendar: newValue, + showHebrewDate, + }); router.push(`/?${params.toString()}`, { scroll: false }); }; @@ -838,20 +586,11 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e const newValue = !showHebrewDate; setShowHebrewDate(newValue); - const params = new URLSearchParams(window.location.search); - let urlIds = params.get('calendars')?.split(',').filter(Boolean) || []; - - if (newValue) { - if (!urlIds.includes('hebrew-date')) urlIds.push('hebrew-date'); - } else { - urlIds = urlIds.filter(id => id !== 'hebrew-date'); - } - - if (urlIds.length > 0) { - params.set('calendars', urlIds.join(',')); - } else { - params.delete('calendars'); - } + const params = applyCalendarsParam(new URLSearchParams(window.location.search), { + regularIds: visibleCalendarIds, + showJewishCalendar, + showHebrewDate: newValue, + }); router.push(`/?${params.toString()}`, { scroll: false }); }; @@ -949,7 +688,6 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e {/* Month Rows */} {months.map((month, mIndex) => { - const totalUsedCols = month.padding + month.days.length; return ( {/* Month Label */} @@ -971,13 +709,11 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e const isShabbat = dayOfWeek === 6; // Saturday const isFriday = dayOfWeek === 5; const weekNum = getWeek(day); - const isWeekStart = dayOfWeek === 0; const dayKey = format(day, 'yyyy-MM-dd'); const maxPosition = maxTracksPerDay[dayKey] || 0; const holidayStyle = holidayStylingMap[dayKey]; - const isWeeksStart = index === 0 || getDay(day) === 0; // index 0 might not be Sun, but is visual start const isSunday = getDay(day) === 0; // Hebrew Date Logic (Option C: Milestone Marker) @@ -1078,8 +814,6 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e // Placeholder for empty track or non-visual-start segments if (!seg) return
; - const barColor = getEventColor(seg.event); - // Visual Start: It's the absolute start of the event OR the first day of the displayed month/row // (Assuming 'day' is iterated in order and splits by month) const isVisualStart = seg.isStart || day.getDate() === 1; diff --git a/src/lib/calendar-params.test.ts b/src/lib/calendar-params.test.ts new file mode 100644 index 0000000..7161e45 --- /dev/null +++ b/src/lib/calendar-params.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCalendarsParam, + applyCalendarsParam, + isVirtualCalendarId, + JEWISH_CALENDAR_ID, + HEBREW_DATE_ID, +} from './calendar-params'; + +describe('isVirtualCalendarId', () => { + it('recognizes the virtual overlay ids', () => { + expect(isVirtualCalendarId(JEWISH_CALENDAR_ID)).toBe(true); + expect(isVirtualCalendarId(HEBREW_DATE_ID)).toBe(true); + }); + it('treats real calendar ids as non-virtual', () => { + expect(isVirtualCalendarId('me@example.com')).toBe(false); + expect(isVirtualCalendarId('primary')).toBe(false); + }); +}); + +describe('buildCalendarsParam', () => { + it('keeps only real calendars when no overlay is enabled', () => { + expect( + buildCalendarsParam({ regularIds: ['a', 'b'], showJewishCalendar: false, showHebrewDate: false }), + ).toEqual(['a', 'b']); + }); + + it('appends the enabled overlays after the real calendars', () => { + expect( + buildCalendarsParam({ regularIds: ['a'], showJewishCalendar: true, showHebrewDate: true }), + ).toEqual(['a', JEWISH_CALENDAR_ID, HEBREW_DATE_ID]); + }); + + it('strips virtual ids that leak into regularIds (defensive)', () => { + expect( + buildCalendarsParam({ + regularIds: ['a', JEWISH_CALENDAR_ID, HEBREW_DATE_ID], + showJewishCalendar: false, + showHebrewDate: false, + }), + ).toEqual(['a']); + }); + + // --- Regression for bug #2: toggling a real calendar must NOT drop overlays --- + it('preserves the Jewish-holidays overlay when a real calendar is toggled on', () => { + // User had holidays on; now adds a second calendar "work". + const result = buildCalendarsParam({ + regularIds: ['primary', 'work'], + showJewishCalendar: true, + showHebrewDate: false, + }); + expect(result).toContain(JEWISH_CALENDAR_ID); + expect(result).toContain('work'); + }); + + it('preserves the Hebrew-date overlay when a real calendar is toggled off', () => { + const result = buildCalendarsParam({ + regularIds: ['primary'], + showJewishCalendar: false, + showHebrewDate: true, + }); + expect(result).toContain(HEBREW_DATE_ID); + }); +}); + +describe('applyCalendarsParam', () => { + it('sets the calendars param from the full selection', () => { + const params = new URLSearchParams('year=2026'); + applyCalendarsParam(params, { regularIds: ['primary'], showJewishCalendar: true, showHebrewDate: false }); + expect(params.get('calendars')).toBe(`primary,${JEWISH_CALENDAR_ID}`); + expect(params.get('year')).toBe('2026'); // unrelated params untouched + }); + + it('deletes the calendars param when nothing is selected', () => { + const params = new URLSearchParams('calendars=primary'); + applyCalendarsParam(params, { regularIds: [], showJewishCalendar: false, showHebrewDate: false }); + expect(params.has('calendars')).toBe(false); + }); + + it('round-trips: toggling a real calendar keeps overlays in the URL', () => { + // Simulate the real scenario from the component. + const params = new URLSearchParams(`calendars=primary,${JEWISH_CALENDAR_ID}`); + // handleCalendarToggle would compute newVisibleIds = ['primary', 'work'] + applyCalendarsParam(params, { + regularIds: ['primary', 'work'], + showJewishCalendar: true, // still on in component state + showHebrewDate: false, + }); + const ids = params.get('calendars')!.split(','); + expect(ids).toContain('primary'); + expect(ids).toContain('work'); + expect(ids).toContain(JEWISH_CALENDAR_ID); + }); +}); diff --git a/src/lib/calendar-params.ts b/src/lib/calendar-params.ts new file mode 100644 index 0000000..c985275 --- /dev/null +++ b/src/lib/calendar-params.ts @@ -0,0 +1,60 @@ +/** + * Helpers for managing the set of calendar IDs that drive both data fetching + * and the `calendars` URL search-param. + * + * The calendar selection is made of two kinds of entries: + * - "real" Google calendars (identified by their Google calendar id), and + * - "virtual" calendars that are rendered purely on the client: + * - the Jewish holidays overlay (`jewish-calendar`) + * - the Hebrew date overlay (`hebrew-date`) + * + * All three toggles (real-calendar checkbox, holidays button, Hebrew-date + * button) write to the *same* `calendars` URL param. Historically each toggle + * rebuilt the param from only its own slice of state, so e.g. toggling a real + * calendar would silently drop `jewish-calendar`/`hebrew-date` from the URL, + * and the server round-trip would then reset those toggles to off. + * + * `buildCalendarsParam` is the single source of truth that recombines every + * slice, so no toggle can clobber another. + */ + +export const JEWISH_CALENDAR_ID = 'jewish-calendar'; +export const HEBREW_DATE_ID = 'hebrew-date'; +export const VIRTUAL_CALENDAR_IDS: readonly string[] = [JEWISH_CALENDAR_ID, HEBREW_DATE_ID]; + +export function isVirtualCalendarId(id: string): boolean { + return VIRTUAL_CALENDAR_IDS.includes(id); +} + +export interface CalendarSelection { + /** Ids of the visible real Google calendars. */ + regularIds: string[]; + showJewishCalendar: boolean; + showHebrewDate: boolean; +} + +/** + * Recombine the full calendar selection into the ordered list of ids that + * belongs in the `calendars` URL param. Real calendars come first (virtual ids + * are stripped from the input defensively), followed by any enabled overlays. + */ +export function buildCalendarsParam(selection: CalendarSelection): string[] { + const ids = selection.regularIds.filter(id => !isVirtualCalendarId(id)); + if (selection.showJewishCalendar) ids.push(JEWISH_CALENDAR_ID); + if (selection.showHebrewDate) ids.push(HEBREW_DATE_ID); + return ids; +} + +/** + * Apply the selection onto a URLSearchParams-like object, setting or deleting + * the `calendars` key. Returns the same params for chaining. + */ +export function applyCalendarsParam(params: URLSearchParams, selection: CalendarSelection): URLSearchParams { + const ids = buildCalendarsParam(selection); + if (ids.length > 0) { + params.set('calendars', ids.join(',')); + } else { + params.delete('calendars'); + } + return params; +} diff --git a/src/lib/color.test.ts b/src/lib/color.test.ts new file mode 100644 index 0000000..c99a017 --- /dev/null +++ b/src/lib/color.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { getContrastColor } from './color'; + +describe('getContrastColor', () => { + it('returns white text on dark backgrounds', () => { + expect(getContrastColor('#000000')).toBe('#ffffff'); + expect(getContrastColor('#1a73e8')).toBe('#ffffff'); + expect(getContrastColor('#3d7eff')).toBe('#ffffff'); + }); + + it('returns black text on light backgrounds', () => { + expect(getContrastColor('#ffffff')).toBe('#000000'); + expect(getContrastColor('#FFB74D')).toBe('#000000'); // holiday orange + expect(getContrastColor('#ffff00')).toBe('#000000'); + }); + + it('expands shorthand hex', () => { + expect(getContrastColor('#000')).toBe('#ffffff'); + expect(getContrastColor('#fff')).toBe('#000000'); + }); + + it('falls back to white for CSS vars / invalid input', () => { + expect(getContrastColor('var(--event-bar-bg, #3d7eff)')).toBe('#ffffff'); + expect(getContrastColor('')).toBe('#ffffff'); + expect(getContrastColor('not-a-color')).toBe('#ffffff'); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..247ca1a --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,23 @@ +/** + * Choose a readable text color (black or white) for a given background color, + * using the YIQ brightness formula. Falls back to white for missing/invalid + * input (e.g. CSS variables), so callers can pass `var(--x)` safely. + */ +export function getContrastColor(hex: string): '#000000' | '#ffffff' { + if (!hex || !hex.startsWith('#')) return '#ffffff'; + + // Expand shorthand form (e.g. "#03F" -> "#0033FF") + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + const expanded = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b); + + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(expanded); + if (!result) return '#ffffff'; + + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + + // YIQ equation + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 128 ? '#000000' : '#ffffff'; +} diff --git a/src/lib/date-utils.test.ts b/src/lib/date-utils.test.ts new file mode 100644 index 0000000..56697da --- /dev/null +++ b/src/lib/date-utils.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { getJewishWorkWeek, getYearRangeDates, formatDateHe, WEEK_STARTS_ON } from './date-utils'; +import { getDay, format } from 'date-fns'; + +describe('getJewishWorkWeek', () => { + it('returns Sunday..Friday (6 days) for the containing week', () => { + // 2024-01-10 is a Wednesday. + const week = getJewishWorkWeek(new Date(2024, 0, 10)); + expect(week).toHaveLength(6); + expect(getDay(week[0])).toBe(0); // Sunday + expect(getDay(week[5])).toBe(5); // Friday + }); + + it('starts the week on Sunday (WEEK_STARTS_ON)', () => { + expect(WEEK_STARTS_ON).toBe(0); + }); +}); + +describe('getYearRangeDates', () => { + it('spans roughly 2 months back to 10 months forward', () => { + const dates = getYearRangeDates(new Date(2024, 5, 15)); + const first = dates[0]; + const last = dates[dates.length - 1]; + expect(format(first, 'yyyy-MM')).toBe('2024-04'); // 2 months back + expect(format(last, 'yyyy-MM')).toBe('2025-04'); // 10 months forward + }); +}); + +describe('formatDateHe', () => { + it('formats with the Hebrew locale', () => { + const out = formatDateHe(new Date(2024, 0, 10), 'dd/MM/yyyy'); + expect(out).toBe('10/01/2024'); + }); +}); diff --git a/src/lib/event-form.test.ts b/src/lib/event-form.test.ts new file mode 100644 index 0000000..b82dd06 --- /dev/null +++ b/src/lib/event-form.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveEventCalendarId, + tagEventCalendar, + toInclusiveEndDate, + toExclusiveEndDate, + buildEventPayload, +} from './event-form'; + +describe('resolveEventCalendarId (bug #1: edit/delete target the right calendar)', () => { + it('uses the event\'s own calendar id when present', () => { + expect(resolveEventCalendarId({ _calendarId: 'work@group.calendar.google.com' }, 'primary')).toBe( + 'work@group.calendar.google.com', + ); + }); + + it('falls back when the event has no calendar id', () => { + expect(resolveEventCalendarId({}, 'primary')).toBe('primary'); + expect(resolveEventCalendarId(null, 'primary')).toBe('primary'); + expect(resolveEventCalendarId(undefined, 'primary')).toBe('primary'); + }); +}); + +describe('tagEventCalendar', () => { + it('attaches the calendar id without mutating the original', () => { + const original = { id: 'evt1', summary: 'X' }; + const tagged = tagEventCalendar(original, 'cal-2'); + expect(tagged._calendarId).toBe('cal-2'); + expect(tagged.id).toBe('evt1'); + expect((original as Record)._calendarId).toBeUndefined(); + }); +}); + +describe('all-day inclusive/exclusive end-date conversion', () => { + it('converts Google exclusive end to inclusive display date', () => { + // Event covers the 13th; Google end is the 14th. + const inclusive = toInclusiveEndDate(new Date(2024, 0, 14)); + expect(inclusive.getFullYear()).toBe(2024); + expect(inclusive.getMonth()).toBe(0); + expect(inclusive.getDate()).toBe(13); + }); + + it('converts inclusive display date back to Google exclusive end', () => { + const exclusive = toExclusiveEndDate(new Date(2024, 0, 13)); + expect(exclusive.getDate()).toBe(14); + }); + + it('is an exact round-trip across a month boundary', () => { + const inclusive = new Date(2024, 0, 31); + const back = toInclusiveEndDate(toExclusiveEndDate(inclusive)); + expect(back.getTime()).toBe(inclusive.getTime()); + }); +}); + +describe('buildEventPayload', () => { + it('builds an all-day payload with exclusive end date', () => { + const payload = buildEventPayload({ + title: 'Vacation', + description: 'beach', + isAllDay: true, + startDate: new Date(2024, 5, 10), + endDate: new Date(2024, 5, 12), // inclusive + startTime: '09:00', + endTime: '10:00', + colorId: null, + }); + expect(payload.start).toEqual({ date: '2024-06-10' }); + expect(payload.end).toEqual({ date: '2024-06-13' }); // exclusive (+1) + expect(payload.summary).toBe('Vacation'); + expect(payload).not.toHaveProperty('colorId'); + }); + + it('defaults an empty title to the Hebrew placeholder', () => { + const payload = buildEventPayload({ + title: '', + description: '', + isAllDay: true, + startDate: new Date(2024, 5, 10), + endDate: new Date(2024, 5, 10), + startTime: '09:00', + endTime: '10:00', + colorId: null, + }); + expect(payload.summary).toBe('(ללא כותרת)'); + }); + + it('includes colorId when provided', () => { + const payload = buildEventPayload({ + title: 'X', + description: '', + isAllDay: true, + startDate: new Date(2024, 5, 10), + endDate: new Date(2024, 5, 10), + startTime: '09:00', + endTime: '10:00', + colorId: '5', + }); + expect(payload.colorId).toBe('5'); + }); + + it('builds a timed payload as naive local dateTime + explicit timeZone', () => { + const payload = buildEventPayload({ + title: 'Meeting', + description: '', + isAllDay: false, + startDate: new Date(2024, 0, 15), + endDate: new Date(2024, 0, 15), + startTime: '09:00', + endTime: '10:30', + colorId: null, + timeZone: 'Asia/Jerusalem', + }); + // Unambiguous Google representation: wall-clock time + zone (no UTC math). + expect(payload.start).toEqual({ dateTime: '2024-01-15T09:00:00', timeZone: 'Asia/Jerusalem' }); + expect(payload.end).toEqual({ dateTime: '2024-01-15T10:30:00', timeZone: 'Asia/Jerusalem' }); + expect(payload.start.date).toBeUndefined(); + }); + + it('falls back to a UTC instant when no timezone is supplied (TZ=UTC in tests)', () => { + const payload = buildEventPayload({ + title: 'Meeting', + description: '', + isAllDay: false, + startDate: new Date(2024, 0, 15), + endDate: new Date(2024, 0, 15), + startTime: '09:00', + endTime: '10:30', + colorId: null, + }); + expect(payload.start.dateTime).toBe('2024-01-15T09:00:00.000Z'); + expect(payload.start.timeZone).toBeUndefined(); + }); +}); diff --git a/src/lib/event-form.ts b/src/lib/event-form.ts new file mode 100644 index 0000000..104b4c9 --- /dev/null +++ b/src/lib/event-form.ts @@ -0,0 +1,104 @@ +import { format } from 'date-fns'; + +/** + * Pure helpers behind the create/edit-event modal. + * + * The two important correctness concerns captured here: + * 1. Edits and deletes must target the calendar the event actually lives in + * (`_calendarId`), not whatever calendar happens to be selected by default. + * Using the wrong id makes Google return 404 and the operation silently + * fails for any non-primary calendar. + * 2. Google models all-day end dates as *exclusive* (an event on the 13th ends + * on the 14th). The UI shows *inclusive* dates, so we convert on the way in + * and out. + */ + +/** Calendar an existing event belongs to, falling back when unknown. */ +export function resolveEventCalendarId( + event: { _calendarId?: string } | null | undefined, + fallbackCalendarId: string, +): string { + return event?._calendarId || fallbackCalendarId; +} + +/** Tag an event (e.g. one returned by the Google API) with its calendar id. */ +export function tagEventCalendar( + event: T, + calendarId: string, +): T & { _calendarId: string } { + return { ...event, _calendarId: calendarId }; +} + +/** Google's exclusive all-day end date -> inclusive date shown to the user. */ +export function toInclusiveEndDate(exclusiveEnd: Date): Date { + const d = new Date(exclusiveEnd); + d.setDate(d.getDate() - 1); + return d; +} + +/** Inclusive all-day end date (from the UI) -> Google's exclusive end date. */ +export function toExclusiveEndDate(inclusiveEnd: Date): Date { + const d = new Date(inclusiveEnd); + d.setDate(d.getDate() + 1); + return d; +} + +export interface EventFormValues { + title: string; + description: string; + isAllDay: boolean; + startDate: Date; + endDate: Date; + /** "HH:mm" — used only when not all-day. */ + startTime: string; + endTime: string; + colorId: string | null; + /** IANA timezone for timed events (e.g. "Asia/Jerusalem"). */ + timeZone?: string; +} + +export interface GoogleEventPayload { + summary: string; + description: string; + start: { date?: string; dateTime?: string; timeZone?: string }; + end: { date?: string; dateTime?: string; timeZone?: string }; + colorId?: string; +} + +/** + * Build the request body sent to the Google Calendar API from the modal's form + * state. + * - All-day events use inclusive->exclusive date conversion. + * - Timed events send a *naive* local dateTime plus an explicit `timeZone`, + * which is the unambiguous Google representation (no reliance on the + * runtime's UTC offset). Falls back to a UTC instant if no timezone is given. + */ +export function buildEventPayload(values: EventFormValues): GoogleEventPayload { + const payload: GoogleEventPayload = { + summary: values.title || '(ללא כותרת)', + description: values.description, + start: {}, + end: {}, + }; + + if (values.isAllDay) { + payload.start = { date: format(values.startDate, 'yyyy-MM-dd') }; + payload.end = { date: format(toExclusiveEndDate(values.endDate), 'yyyy-MM-dd') }; + } else { + const sDateTime = `${format(values.startDate, 'yyyy-MM-dd')}T${values.startTime}:00`; + const eDateTime = `${format(values.endDate, 'yyyy-MM-dd')}T${values.endTime}:00`; + if (values.timeZone) { + payload.start = { dateTime: sDateTime, timeZone: values.timeZone }; + payload.end = { dateTime: eDateTime, timeZone: values.timeZone }; + } else { + payload.start = { dateTime: new Date(sDateTime).toISOString() }; + payload.end = { dateTime: new Date(eDateTime).toISOString() }; + } + } + + if (values.colorId) { + payload.colorId = values.colorId; + } + + return payload; +} diff --git a/src/lib/event-layout.test.ts b/src/lib/event-layout.test.ts new file mode 100644 index 0000000..4c979b1 --- /dev/null +++ b/src/lib/event-layout.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { + buildEventLayout, + isMultiDayEvent, + inclusiveEndDate, + sortEventsForLayout, +} from './event-layout'; +import type { CalendarEvent } from './google-calendar'; + +const timed = (id: string, startISO: string, endISO: string, cal = 'primary'): CalendarEvent => ({ + id, + summary: id, + start: { dateTime: startISO }, + end: { dateTime: endISO }, + _calendarId: cal, +}); + +const allDay = (id: string, startDate: string, endDateExclusive: string): CalendarEvent => ({ + id, + summary: id, + start: { date: startDate }, + end: { date: endDateExclusive }, + _calendarId: 'primary', +}); + +describe('isMultiDayEvent', () => { + it('treats all-day events (date-based) as multi-day bars', () => { + expect(isMultiDayEvent(allDay('a', '2024-01-10', '2024-01-11'))).toBe(true); + }); + it('detects a same-day timed event as single-day', () => { + expect(isMultiDayEvent(timed('a', '2024-01-10T09:00:00Z', '2024-01-10T10:00:00Z'))).toBe(false); + }); + it('detects a timed event crossing midnight as multi-day', () => { + expect(isMultiDayEvent(timed('a', '2024-01-10T23:00:00Z', '2024-01-11T01:00:00Z'))).toBe(true); + }); +}); + +describe('inclusiveEndDate (exclusive all-day end normalization)', () => { + it('subtracts a day for all-day events', () => { + const start = new Date(2024, 0, 10); + const end = inclusiveEndDate(allDay('a', '2024-01-10', '2024-01-13'), start); + expect(end.getDate()).toBe(12); // exclusive 13 -> inclusive 12 + }); + it('never returns an end before the start', () => { + const start = new Date(2024, 0, 10); + const end = inclusiveEndDate(allDay('a', '2024-01-10', '2024-01-10'), start); + expect(end.getTime()).toBe(start.getTime()); + }); +}); + +describe('buildEventLayout — single-day events', () => { + it('puts a timed single-day event into eventsMap, not the segments', () => { + const { eventsMap, multiDaySegments } = buildEventLayout([ + timed('m', '2024-01-15T09:00:00Z', '2024-01-15T10:00:00Z'), + ]); + expect(eventsMap['2024-01-15']).toHaveLength(1); + expect(Object.keys(multiDaySegments)).toHaveLength(0); + }); + + it('renders a one-day all-day event as a single capped bar (start AND end)', () => { + const { multiDaySegments, eventsMap } = buildEventLayout([allDay('a', '2024-01-10', '2024-01-11')]); + expect(eventsMap['2024-01-10']).toBeUndefined(); + const seg = multiDaySegments['2024-01-10']; + expect(seg).toHaveLength(1); + expect(seg[0].isStart).toBe(true); + expect(seg[0].isEnd).toBe(true); + }); +}); + +describe('buildEventLayout — multi-day spans', () => { + it('spreads an all-day event across its inclusive days with start/end flags', () => { + const { multiDaySegments, maxTracksPerDay } = buildEventLayout([allDay('trip', '2024-01-10', '2024-01-13')]); + // Covers 10, 11, 12 (13 is exclusive) + expect(Object.keys(multiDaySegments).sort()).toEqual(['2024-01-10', '2024-01-11', '2024-01-12']); + expect(multiDaySegments['2024-01-10'][0].isStart).toBe(true); + expect(multiDaySegments['2024-01-10'][0].isEnd).toBe(false); + expect(multiDaySegments['2024-01-12'][0].isEnd).toBe(true); + expect(multiDaySegments['2024-01-12'][0].isStart).toBe(false); + expect(maxTracksPerDay['2024-01-11']).toBe(1); + }); + + it('allocates separate tracks for overlapping events', () => { + const { multiDaySegments, maxTracksPerDay } = buildEventLayout([ + allDay('A', '2024-01-10', '2024-01-13'), // 10,11,12 + allDay('B', '2024-01-11', '2024-01-14'), // 11,12,13 + ]); + const positionsOn11 = multiDaySegments['2024-01-11'].map(s => s.position).sort(); + expect(positionsOn11).toEqual([0, 1]); + expect(maxTracksPerDay['2024-01-11']).toBe(2); + // Day 10 only has one event -> one track used. + expect(maxTracksPerDay['2024-01-10']).toBe(1); + }); + + it('reuses a freed track for non-overlapping events', () => { + const { multiDaySegments } = buildEventLayout([ + allDay('A', '2024-01-10', '2024-01-12'), // 10,11 + allDay('B', '2024-01-13', '2024-01-15'), // 13,14 — no overlap with A + ]); + expect(multiDaySegments['2024-01-10'][0].position).toBe(0); + expect(multiDaySegments['2024-01-13'][0].position).toBe(0); + }); +}); + +describe('sortEventsForLayout', () => { + it('orders multi-day events before single-day events', () => { + const single = timed('s', '2024-01-10T09:00:00Z', '2024-01-10T10:00:00Z'); + const multi = allDay('m', '2024-01-10', '2024-01-13'); + const sorted = sortEventsForLayout([single, multi]); + expect(sorted[0].id).toBe('m'); + }); +}); diff --git a/src/lib/event-layout.ts b/src/lib/event-layout.ts new file mode 100644 index 0000000..86c3bac --- /dev/null +++ b/src/lib/event-layout.ts @@ -0,0 +1,132 @@ +import { addDays, eachDayOfInterval, format, isSameDay, parseISO } from 'date-fns'; +import type { CalendarEvent } from './google-calendar'; + +/** + * Turns a flat list of calendar events into the data the grid needs: + * - `eventsMap`: timed, single-day events keyed by yyyy-MM-dd (rendered as a + * count badge in the day cell). + * - `multiDaySegments`: per-day segments for multi-day / all-day events, + * laid out into horizontal "tracks" so overlapping events stack instead of + * colliding. + * - `maxTracksPerDay`: how many tracks each day needs (for reserving height). + * + * Note on all-day events: Google reports their end date as *exclusive*, so we + * subtract a day to get the inclusive last day the bar should cover. + */ + +export interface MultiDaySegment { + event: CalendarEvent; + isStart: boolean; + isEnd: boolean; + position: number; + /** Inclusive last day of the event (after exclusive-end normalization). */ + realEnd: Date; +} + +export interface EventLayout { + eventsMap: Record; + multiDaySegments: Record; + maxTracksPerDay: Record; +} + +function eventStart(e: CalendarEvent): string | null | undefined { + return e.start?.dateTime || e.start?.date; +} +function eventEnd(e: CalendarEvent): string | null | undefined { + return e.end?.dateTime || e.end?.date; +} + +/** Is this event multi-day (spans more than one calendar day)? */ +export function isMultiDayEvent(e: CalendarEvent): boolean { + if (e.end?.date) return true; // all-day events are laid out as bars + if (e.start?.dateTime && e.end?.dateTime) { + return !isSameDay(parseISO(e.start.dateTime), parseISO(e.end.dateTime)); + } + return false; +} + +/** Sort: multi-day first, then by start time, then longer-first. */ +export function sortEventsForLayout(events: CalendarEvent[]): CalendarEvent[] { + return [...events].sort((a, b) => { + const startA = new Date(eventStart(a) || 0).getTime(); + const startB = new Date(eventStart(b) || 0).getTime(); + + const isMultiA = isMultiDayEvent(a); + const isMultiB = isMultiDayEvent(b); + if (isMultiA && !isMultiB) return -1; + if (!isMultiA && isMultiB) return 1; + + if (startA !== startB) return startA - startB; + + const endA = new Date(eventEnd(a) || 0).getTime(); + const endB = new Date(eventEnd(b) || 0).getTime(); + return endB - endA; // longer first + }); +} + +/** Inclusive last day for an event, normalizing Google's exclusive all-day end. */ +export function inclusiveEndDate(event: CalendarEvent, start: Date): Date { + const endStr = eventEnd(event); + let end = endStr ? parseISO(endStr) : start; + if (event.end?.date) { + end = addDays(end, -1); + } + if (end < start) end = start; + return end; +} + +export function buildEventLayout(events: CalendarEvent[]): EventLayout { + const eventsMap: Record = {}; + const multiDaySegments: Record = {}; + const maxTracksPerDay: Record = {}; + + const sorted = sortEventsForLayout(events); + + sorted.forEach(event => { + const startStr = eventStart(event); + if (!startStr) return; + + const start = parseISO(startStr); + const realEnd = inclusiveEndDate(event, start); + + // Timed single-day events render as a count badge, not a bar. + const isSingleDay = isSameDay(start, realEnd) && !event.start?.date; + + if (isSingleDay) { + const key = format(start, 'yyyy-MM-dd'); + (eventsMap[key] ||= []).push(event); + return; + } + + const days = eachDayOfInterval({ start, end: realEnd }); + + // Allocate the first track index that is free on *every* day of the event. + let track = 0; + for (;;) { + let available = true; + for (const day of days) { + const key = format(day, 'yyyy-MM-dd'); + if (multiDaySegments[key]?.some(s => s.position === track)) { + available = false; + break; + } + } + if (available) break; + track++; + } + + days.forEach((day, index) => { + const key = format(day, 'yyyy-MM-dd'); + (multiDaySegments[key] ||= []).push({ + event, + isStart: index === 0, + isEnd: index === days.length - 1, + position: track, + realEnd, + }); + maxTracksPerDay[key] = Math.max(maxTracksPerDay[key] || 0, track + 1); + }); + }); + + return { eventsMap, multiDaySegments, maxTracksPerDay }; +} diff --git a/src/lib/google-calendar.ts b/src/lib/google-calendar.ts index 19cfe36..d7d442c 100644 --- a/src/lib/google-calendar.ts +++ b/src/lib/google-calendar.ts @@ -1,34 +1,41 @@ -import { google } from 'googleapis'; -import { startOfDay, endOfDay } from 'date-fns'; +import { google, calendar_v3 } from 'googleapis'; -// Helper to refresh token is now handled in auth.ts via NextAuth rotation. -// Ensure your NextAuth config has 'offline' access_type (already done). +// Token refresh is handled in auth.ts via NextAuth rotation +// (NextAuth config uses access_type 'offline'). +/** Extract a human-readable message from an unknown thrown value. */ +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} -export async function getCalendarList(accessToken: string) { +/** Build an authenticated Calendar API client for a given access token. */ +function calendarClient(accessToken: string): calendar_v3.Calendar { const auth = new google.auth.OAuth2(); auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); + return google.calendar({ version: 'v3', auth }); +} +export async function getCalendarList(accessToken: string) { + const calendar = calendarClient(accessToken); try { - const response = await calendar.calendarList.list({ - minAccessRole: 'reader', - }); + const response = await calendar.calendarList.list({ minAccessRole: 'reader' }); return response.data.items || []; - } catch (error: any) { - console.error('Error fetching calendar list:', error?.message || error); + } catch (error: unknown) { + console.error('Error fetching calendar list:', errorMessage(error)); return []; } } export async function getEventsForRange(accessToken: string, start: Date, end: Date, calendarIds: string[] = ['primary']) { - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); - + const calendar = calendarClient(accessToken); try { const promises = calendarIds.map(async (calendarId) => { - // console.log(`Fetching events for ${calendarId} from ${start.toISOString()} to ${end.toISOString()}`); try { const response = await calendar.events.list({ calendarId, @@ -40,35 +47,31 @@ export async function getEventsForRange(accessToken: string, start: Date, end: D }); return (response.data.items || []).map(event => ({ ...event, - // optimization: tag event with calendarId so frontend knows source color if needed - // (though google event resource usually has colorId, not calendar color directly on event) - _calendarId: calendarId + // Tag the event with its source calendar so the client can + // resolve calendar color and target edits/deletes correctly. + _calendarId: calendarId, })); - } catch (e) { - console.error(`Failed to fetch for calendar ${calendarId}`, e); + } catch (e: unknown) { + console.error(`Failed to fetch for calendar ${calendarId}:`, errorMessage(e)); return []; } }); const results = await Promise.all(promises); return results.flat(); - - } catch (error: any) { - console.error('Error fetching calendar events:', error?.message || error); + } catch (error: unknown) { + console.error('Error fetching calendar events:', errorMessage(error)); return []; } } export async function getEventColors(accessToken: string) { - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); - + const calendar = calendarClient(accessToken); try { const response = await calendar.colors.get(); return response.data.event || {}; - } catch (error: any) { - console.error('Error fetching event colors:', error?.message || error); + } catch (error: unknown) { + console.error('Error fetching event colors:', errorMessage(error)); return {}; } } @@ -94,72 +97,46 @@ export type CalendarListEntry = { }; -export async function createEvent(accessToken: string, calendarId: string, eventDetails: any) { - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); - +export async function createEvent(accessToken: string, calendarId: string, eventDetails: calendar_v3.Schema$Event) { + const calendar = calendarClient(accessToken); try { - const response = await calendar.events.insert({ - calendarId, - requestBody: eventDetails, - }); + const response = await calendar.events.insert({ calendarId, requestBody: eventDetails }); return response.data; - } catch (error: any) { - console.error('Error creating event:', error?.message || error); + } catch (error: unknown) { + console.error('Error creating event:', errorMessage(error)); throw error; } } -export async function updateEvent(accessToken: string, calendarId: string, eventId: string, eventDetails: any) { - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); - +export async function updateEvent(accessToken: string, calendarId: string, eventId: string, eventDetails: calendar_v3.Schema$Event) { + const calendar = calendarClient(accessToken); try { - const response = await calendar.events.patch({ - calendarId, - eventId, - requestBody: eventDetails, - }); + const response = await calendar.events.patch({ calendarId, eventId, requestBody: eventDetails }); return response.data; - } catch (error: any) { - console.error('Error updating event:', error?.message || error); + } catch (error: unknown) { + console.error('Error updating event:', errorMessage(error)); throw error; } } export async function deleteEvent(accessToken: string, calendarId: string, eventId: string) { - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); - + const calendar = calendarClient(accessToken); try { - await calendar.events.delete({ - calendarId, - eventId, - }); + await calendar.events.delete({ calendarId, eventId }); return { success: true }; - } catch (error: any) { - console.error('Error deleting event:', error?.message || error); + } catch (error: unknown) { + console.error('Error deleting event:', errorMessage(error)); throw error; } } export async function createCalendar(accessToken: string, title: string) { - const auth = new google.auth.OAuth2(); - auth.setCredentials({ access_token: accessToken }); - const calendar = google.calendar({ version: 'v3', auth }); - + const calendar = calendarClient(accessToken); try { - const response = await calendar.calendars.insert({ - requestBody: { - summary: title - } - }); + const response = await calendar.calendars.insert({ requestBody: { summary: title } }); return response.data; - } catch (error: any) { - console.error('Error creating calendar:', error?.message || error); + } catch (error: unknown) { + console.error('Error creating calendar:', errorMessage(error)); throw error; } } diff --git a/src/lib/holidays.test.ts b/src/lib/holidays.test.ts new file mode 100644 index 0000000..cf33a2b --- /dev/null +++ b/src/lib/holidays.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { getJewishHolidays, buildHolidayStylingMap } from './holidays'; + +// Israel schedule, civil year 2024. +const holidays = getJewishHolidays(new Date(2024, 0, 1), new Date(2024, 11, 31)); +const map = buildHolidayStylingMap(holidays); + +describe('getJewishHolidays — classification of known 2024 dates', () => { + it('marks Rosh Hashana (2024-10-03) as a full holiday (Yom Tov)', () => { + expect(map['2024-10-03']).toBeDefined(); + expect(map['2024-10-03'].isYomTov).toBe(true); + }); + + it('marks Yom Kippur (2024-10-12) as both Yom Tov and a fast', () => { + expect(map['2024-10-12']).toBeDefined(); + expect(map['2024-10-12'].isYomTov).toBe(true); + expect(map['2024-10-12'].isFast).toBe(true); + }); + + it('marks Pesach I (2024-04-23) as Yom Tov and Chol HaMoed (2024-04-24) accordingly', () => { + expect(map['2024-04-23'].isYomTov).toBe(true); + expect(map['2024-04-24'].isCholHamoed).toBe(true); + expect(map['2024-04-24'].isYomTov).toBe(false); + }); + + it('marks Tisha B\'Av (2024-08-13) as a fast but not a Yom Tov', () => { + expect(map['2024-08-13']).toBeDefined(); + expect(map['2024-08-13'].isFast).toBe(true); + expect(map['2024-08-13'].isYomTov).toBe(false); + }); + + it('renders Yom HaAtzma\'ut (2024-05-14) like a holiday', () => { + expect(map['2024-05-14']).toBeDefined(); + expect(map['2024-05-14'].isYomTov).toBe(true); + }); + + it('shows festive-but-not-Yom-Tov days (Purim, Tu BiShvat, Lag BaOmer, Chanukah)', () => { + expect(map['2024-03-24']).toBeDefined(); // Purim + expect(map['2024-03-24'].isCholHamoed).toBe(true); + expect(map['2024-03-24'].isYomTov).toBe(false); + + expect(map['2024-01-25']).toBeDefined(); // Tu BiShvat + expect(map['2024-05-26']).toBeDefined(); // Lag BaOmer + expect(map['2024-12-26']).toBeDefined(); // Chanukah (2nd candle) + }); + + it('includes a Hebrew label for each holiday', () => { + for (const h of holidays) { + expect(h.text.length).toBeGreaterThan(0); + expect(h.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + it('filters out noise (an ordinary mid-week non-holiday has no entry)', () => { + expect(map['2024-07-10']).toBeUndefined(); + }); +}); + +describe('getJewishHolidays — range filtering', () => { + it('only returns holidays within the requested range', () => { + const narrow = getJewishHolidays(new Date(2024, 11, 1), new Date(2024, 11, 31)); + for (const h of narrow) { + expect(h.date >= '2024-12-01').toBe(true); + expect(h.date <= '2024-12-31').toBe(true); + } + // Chanukah starts late December 2024. + expect(narrow.some(h => h.text.includes('חֲנוּכָּה') || h.date >= '2024-12-25')).toBe(true); + }); + + it('spans multiple Gregorian years when the range crosses a year boundary', () => { + const cross = getJewishHolidays(new Date(2024, 11, 20), new Date(2025, 0, 10)); + expect(cross.some(h => h.date.startsWith('2024-12'))).toBe(true); + expect(cross.some(h => h.date.startsWith('2025-01'))).toBe(true); + }); +}); diff --git a/src/lib/holidays.ts b/src/lib/holidays.ts new file mode 100644 index 0000000..4dc8547 --- /dev/null +++ b/src/lib/holidays.ts @@ -0,0 +1,162 @@ +import { format, getYear } from 'date-fns'; +import { HebrewCalendar, Event as HebcalEvent } from '@hebcal/core'; + +/** + * Jewish holiday overlay used by the linear calendar. + * + * We generate the Israeli (`il: true`) holiday schedule for every Gregorian + * year that the visible range touches, then keep only the days that matter for + * a *work/visual* calendar and classify each for styling: + * - `isYomTov` -> full holiday (rendered like Shabbat / no-work day) + * - `isCholHamoed` -> intermediate / festive (rendered like Friday) + * - `isFast` -> fast day (annotated with an icon) + */ + +// @hebcal/core flag bits (see https://github.com/hebcal/hebcal-es6) +const CHAG = 1; +const MINOR_FAST = 256; +const SPECIAL_SHABBAT = 512; // (kept for reference) +const MODERN_HOLIDAY = 8192; +const MAJOR_FAST = 16384; +const MINOR_HOLIDAY = 524288; // (kept for reference) +const EREV = 1048576; +const CHOL_HAMOED = 2097152; + +void SPECIAL_SHABBAT; +void MINOR_HOLIDAY; + +export interface HolidayInfo { + /** Gregorian date as yyyy-MM-dd. */ + date: string; + /** Localized (Hebrew) label. */ + text: string; + isYomTov: boolean; + isCholHamoed: boolean; + isFast: boolean; +} + +function isMajorErev(desc: string): boolean { + return ( + desc.startsWith('Erev Rosh Hashana') || + desc.startsWith('Erev Yom Kippur') || + desc.startsWith('Erev Sukkot') || + desc.startsWith('Erev Pesach') || + desc.startsWith('Erev Shavuot') + ); +} + +/** Raw hebcal events for every year spanned by [startDate, endDate]. */ +function rawEventsForRange(startDate: Date, endDate: Date): HebcalEvent[] { + const startYear = getYear(startDate); + const endYear = getYear(endDate); + const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i); + + let events: HebcalEvent[] = []; + years.forEach(y => { + events = events.concat( + HebrewCalendar.calendar({ + isHebrewYear: false, + il: true, // Israel schedule + locale: 'he', + year: y, + }), + ); + }); + return events; +} + +/** + * Whether a hebcal event should be surfaced on the calendar overlay. + * Mirrors the product decision to show major chagim, chol hamoed, fasts, + * the major erevs, and a curated set of modern / minor festive days, while + * hiding noise (e.g. "Family Day", Rosh Chodesh). + */ +export function shouldShowHoliday(ev: HebcalEvent): boolean { + const mask = ev.getFlags(); + const desc = ev.getDesc(); + + const isChag = (mask & CHAG) !== 0; + const isCholHamoed = (mask & CHOL_HAMOED) !== 0; + const isFast = (mask & MAJOR_FAST) !== 0 || (mask & MINOR_FAST) !== 0; + const isErev = (mask & EREV) !== 0; + const isModern = (mask & MODERN_HOLIDAY) !== 0; + + const majorErev = isErev && isMajorErev(desc); + const isShavuot = desc.startsWith('Shavuot') && !desc.includes('Isru Chag'); + const isPurim = desc.includes('Purim'); + const isChanukah = desc.includes('Chanukah'); + const isTuBiShvat = desc.includes('Tu BiShvat'); + const isLagBaOmer = desc.includes('Lag BaOmer'); + + let allowedModern = false; + if (isModern) { + if (desc === "Yom HaAtzma'ut") allowedModern = true; + if (desc === 'Yom HaZikaron') allowedModern = true; + if (desc === 'Yom HaShoah') allowedModern = true; + } + + return ( + isChag || + isCholHamoed || + isFast || + allowedModern || + majorErev || + isShavuot || + isPurim || + isChanukah || + isTuBiShvat || + isLagBaOmer + ); +} + +/** Classify a (already-included) hebcal event into styling buckets. */ +export function classifyHoliday(ev: HebcalEvent): HolidayInfo { + const hd = ev.getDate(); + const d = hd.greg(); + const mask = ev.getFlags(); + const desc = ev.getDesc(); + + const isYomTov = (mask & CHAG) !== 0; + const isCholHamoed = (mask & CHOL_HAMOED) !== 0; + const isFast = (mask & MAJOR_FAST) !== 0 || (mask & MINOR_FAST) !== 0; + const isErev = (mask & EREV) !== 0; + + const isYomHaatzmaut = desc === "Yom HaAtzma'ut"; + const isShavuot = desc.startsWith('Shavuot') && !desc.includes('Isru Chag'); + const isPurim = desc.includes('Purim'); + const majorErev = isErev && isMajorErev(desc); + + return { + date: format(d, 'yyyy-MM-dd'), + text: ev.render('he'), + isYomTov: isYomTov || isYomHaatzmaut || isShavuot, + // Major erev + Purim render like a Friday (festive, not a full holiday). + isCholHamoed: isCholHamoed || majorErev || isPurim, + isFast, + }; +} + +/** + * Build the list of holiday styling entries for the given (inclusive) range. + */ +export function getJewishHolidays(startDate: Date, endDate: Date): HolidayInfo[] { + const startStr = format(startDate, 'yyyy-MM-dd'); + const endStr = format(endDate, 'yyyy-MM-dd'); + + return rawEventsForRange(startDate, endDate) + .filter(ev => { + const dStr = format(ev.getDate().greg(), 'yyyy-MM-dd'); + if (dStr < startStr || dStr > endStr) return false; + return shouldShowHoliday(ev); + }) + .map(classifyHoliday); +} + +/** Convenience: build a date(yyyy-MM-dd) -> HolidayInfo map. */ +export function buildHolidayStylingMap(holidays: HolidayInfo[]): Record { + const map: Record = {}; + holidays.forEach(h => { + map[h.date] = h; + }); + return map; +} diff --git a/src/next-auth.d.ts b/src/next-auth.d.ts index 37a0e85..55355fb 100644 --- a/src/next-auth.d.ts +++ b/src/next-auth.d.ts @@ -1,5 +1,5 @@ -import NextAuth from "next-auth"; -import { JWT } from "next-auth/jwt"; +import "next-auth"; +import "next-auth/jwt"; declare module "next-auth" { /** diff --git a/test-date.js b/test-date.js deleted file mode 100644 index 3ac8a55..0000000 --- a/test-date.js +++ /dev/null @@ -1,10 +0,0 @@ - -const { format } = require('date-fns'); -const { he } = require('date-fns/locale'); - -const date = new Date(2023, 8, 3); // Sept 3, 2023 - -console.log('PP:', format(date, 'PP', { locale: he })); -console.log('P:', format(date, 'P', { locale: he })); -console.log('dd/MM/yyyy:', format(date, 'dd/MM/yyyy', { locale: he })); -console.log('EEEE, d MMMM yyyy:', format(date, 'EEEE, d MMMM yyyy', { locale: he })); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..095fdaf --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + // Default to node; component tests opt into jsdom via a + // `// @vitest-environment jsdom` comment at the top of the file. + environment: 'node', + globals: true, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});