From 923198f1e8e2559dba2ef8d68699ca1f1f08ca4f Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:08:30 +0000 Subject: [PATCH 01/15] ci: fix network config --- .gitea/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f9f1563..8d16927 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,3 +29,4 @@ jobs: - name: Deploy to dev run: | echo "Build successful - dev deploy would happen via docker" +# CI From 61efea4810007f083e6b2aa5ba53f09207ab96dd Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:17:39 +0000 Subject: [PATCH 02/15] ci: clean up workflows --- .gitea/workflows/ci.yml | 5 ----- .gitea/workflows/deploy-prod.yml | 4 ---- 2 files changed, 9 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8d16927..81bd9f7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,8 +25,3 @@ jobs: - name: Build run: npm run build - - - name: Deploy to dev - run: | - echo "Build successful - dev deploy would happen via docker" -# CI diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 73ffd54..265caf7 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -19,7 +19,3 @@ jobs: - name: Build run: npm run build - - - name: Deploy to production - run: | - echo "Production deploy would happen via docker" From 6f0445287e33c326ca76ab63f55687b6036a5b30 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:19:31 +0000 Subject: [PATCH 03/15] docs: add readme --- README.md | 1 + docker-compose.dev.yml | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 README.md create mode 100644 docker-compose.dev.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd04635 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Pulse Web diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..2a49885 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,14 @@ +networks: + proxy: + external: true + name: services_proxy + +services: + web-dev: + build: . + container_name: pulse-web-dev + restart: always + ports: + - "5174:80" + networks: + - proxy From 7d18a92ad4233d64721d2d619b57821931e71e19 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:24:24 +0000 Subject: [PATCH 04/15] test: webhook delivery --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index dd04635..d65520c 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ # Pulse Web +# test webhook From cdc1a2390e91b149747f2a58b6d252dffcfac1da Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:25:07 +0000 Subject: [PATCH 05/15] test: webhook 3 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d65520c..f00212e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # Pulse Web # test webhook + From f23f7946424ae66fdb05dfe15454e0d221c3c79f Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:25:53 +0000 Subject: [PATCH 06/15] ci: add deploy trigger via curl --- .gitea/workflows/ci.yml | 6 ++++++ .gitea/workflows/deploy-prod.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 81bd9f7..75c4039 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,3 +25,9 @@ jobs: - name: Build run: npm run build + + - name: Trigger Deploy + run: | + curl -s -X POST http://172.18.0.1:9000/deploy \ + -H 'Content-Type: application/json' \ + -d '{"ref":"refs/heads/dev","repository":{"name":"pulse-web"}}' diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 265caf7..4d1f70e 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -19,3 +19,9 @@ jobs: - name: Build run: npm run build + + - name: Trigger Deploy + run: | + curl -s -X POST http://172.18.0.1:9000/deploy \ + -H 'Content-Type: application/json' \ + -d '{"ref":"refs/heads/main","repository":{"name":"pulse-web"}}' From fd2b4fdff7fadade50aec64281d959bcf03fd8ca Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:30:40 +0000 Subject: [PATCH 07/15] ci: clean workflows (deploy via cron) --- .gitea/workflows/ci.yml | 6 ------ .gitea/workflows/deploy-prod.yml | 6 ------ 2 files changed, 12 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 75c4039..81bd9f7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,9 +25,3 @@ jobs: - name: Build run: npm run build - - - name: Trigger Deploy - run: | - curl -s -X POST http://172.18.0.1:9000/deploy \ - -H 'Content-Type: application/json' \ - -d '{"ref":"refs/heads/dev","repository":{"name":"pulse-web"}}' diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 4d1f70e..265caf7 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -19,9 +19,3 @@ jobs: - name: Build run: npm run build - - - name: Trigger Deploy - run: | - curl -s -X POST http://172.18.0.1:9000/deploy \ - -H 'Content-Type: application/json' \ - -d '{"ref":"refs/heads/main","repository":{"name":"pulse-web"}}' From c9047177eec9546a8515d9b2abd8013aa02f977e Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 02:33:04 +0000 Subject: [PATCH 08/15] Add unit tests: auth store, API layer (tasks, habits, savings, profile), vitest config --- package-lock.json | 1739 +++++++++++++++++++++++++++++- package.json | 33 +- src/__tests__/api.test.js | 267 +++++ src/__tests__/auth.store.test.js | 134 +++ src/__tests__/setup.js | 1 + vitest.config.js | 11 + 6 files changed, 2170 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/api.test.js create mode 100644 src/__tests__/auth.store.test.js create mode 100644 src/__tests__/setup.js create mode 100644 vitest.config.js diff --git a/package-lock.json b/package-lock.json index 553f544..99b5815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,16 +26,35 @@ "@storybook/blocks": "^8.5.0", "@storybook/react": "^8.5.0", "@storybook/react-vite": "^8.5.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", + "jsdom": "^28.1.0", "postcss": "^8.4.33", "storybook": "^8.5.0", "tailwindcss": "^3.4.1", - "vite": "^5.0.12" + "vite": "^5.0.12", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -49,6 +68,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -340,6 +417,151 @@ "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.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "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.1.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.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "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/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -782,6 +1004,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "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/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1340,6 +1580,13 @@ "win32" ] }, + "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/@storybook/addon-actions": { "version": "8.6.14", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz", @@ -1885,6 +2132,104 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1930,6 +2275,17 @@ "@babel/types": "^7.28.2" } }, + "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/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1993,6 +2349,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "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/doctrine": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", @@ -2077,6 +2440,90 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2090,6 +2537,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2157,6 +2614,26 @@ "dev": true, "license": "MIT" }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -2273,6 +2750,16 @@ "node": ">=12.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2429,6 +2916,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2540,6 +3037,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2553,6 +3071,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2680,6 +3224,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/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -2708,6 +3266,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/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2788,6 +3353,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2833,6 +3406,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2851,6 +3437,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "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", @@ -2980,6 +3573,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "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/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -3367,6 +3970,57 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3521,6 +4175,13 @@ "node": ">=0.12.0" } }, + "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", @@ -3618,6 +4279,47 @@ "node": ">=12.0.0" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3717,6 +4419,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3743,6 +4456,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoizerific": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", @@ -3811,6 +4531,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -3936,6 +4666,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -3993,6 +4734,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.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", @@ -4044,6 +4798,13 @@ "dev": true, "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4270,6 +5031,55 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-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", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4303,6 +5113,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4545,6 +5365,43 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/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", @@ -4664,6 +5521,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.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4724,6 +5594,13 @@ "node": ">=8" } }, + "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", @@ -4757,6 +5634,20 @@ "node": ">=0.10.0" } }, + "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/storybook": { "version": "8.6.17", "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.17.tgz", @@ -4947,6 +5838,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -5014,6 +5912,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "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", @@ -5031,6 +5946,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "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", @@ -5044,6 +5989,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "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-dedent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", @@ -5097,6 +6068,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unplugin": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", @@ -5698,6 +6679,703 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.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.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/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/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -5705,6 +7383,31 @@ "dev": true, "license": "MIT" }, + "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", @@ -5743,6 +7446,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/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -5863,6 +7583,23 @@ } } }, + "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 28d077c..1e7974e 100644 --- a/package.json +++ b/package.json @@ -11,31 +11,36 @@ "build-storybook": "storybook build" }, "dependencies": { + "@tanstack/react-query": "^5.17.0", + "axios": "^1.6.5", + "clsx": "^2.1.0", + "date-fns": "^3.3.1", + "framer-motion": "^11.0.3", + "lucide-react": "^0.312.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.0", - "@tanstack/react-query": "^5.17.0", - "axios": "^1.6.5", - "zustand": "^4.5.0", - "date-fns": "^3.3.1", - "lucide-react": "^0.312.0", - "clsx": "^2.1.0", - "framer-motion": "^11.0.3", - "recharts": "^2.12.0" + "recharts": "^2.12.0", + "zustand": "^4.5.0" }, "devDependencies": { + "@storybook/addon-essentials": "^8.5.0", + "@storybook/addon-themes": "^8.5.0", + "@storybook/blocks": "^8.5.0", + "@storybook/react": "^8.5.0", + "@storybook/react-vite": "^8.5.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.17", + "jsdom": "^28.1.0", "postcss": "^8.4.33", + "storybook": "^8.5.0", "tailwindcss": "^3.4.1", "vite": "^5.0.12", - "@storybook/react": "^8.5.0", - "@storybook/react-vite": "^8.5.0", - "@storybook/addon-essentials": "^8.5.0", - "@storybook/addon-themes": "^8.5.0", - "@storybook/blocks": "^8.5.0", - "storybook": "^8.5.0" + "vitest": "^4.0.18" } } diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js new file mode 100644 index 0000000..0e79e5d --- /dev/null +++ b/src/__tests__/api.test.js @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../api/client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + }, +})) + +import api from '../api/client' +import { tasksApi } from '../api/tasks' +import { habitsApi } from '../api/habits' +import { savingsApi } from '../api/savings' +import { profileApi } from '../api/profile' + +describe('tasksApi', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should list all tasks', async () => { + api.get.mockResolvedValueOnce({ data: [{ id: 1, title: 'Test' }] }) + const result = await tasksApi.list() + expect(api.get).toHaveBeenCalledWith('tasks') + expect(result).toEqual([{ id: 1, title: 'Test' }]) + }) + + it('should list completed tasks', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await tasksApi.list(true) + expect(api.get).toHaveBeenCalledWith('tasks?completed=true') + }) + + it('should list incomplete tasks', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await tasksApi.list(false) + expect(api.get).toHaveBeenCalledWith('tasks?completed=false') + }) + + it('should get today tasks', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await tasksApi.today() + expect(api.get).toHaveBeenCalledWith('tasks/today') + }) + + it('should create a task', async () => { + const taskData = { title: 'New Task', priority: 1 } + api.post.mockResolvedValueOnce({ data: { id: 1, ...taskData } }) + const result = await tasksApi.create(taskData) + expect(api.post).toHaveBeenCalledWith('tasks', taskData) + expect(result.title).toBe('New Task') + }) + + it('should update a task', async () => { + api.put.mockResolvedValueOnce({ data: { id: 1, title: 'Updated' } }) + await tasksApi.update(1, { title: 'Updated' }) + expect(api.put).toHaveBeenCalledWith('tasks/1', { title: 'Updated' }) + }) + + it('should delete a task', async () => { + api.delete.mockResolvedValueOnce({}) + await tasksApi.delete(1) + expect(api.delete).toHaveBeenCalledWith('tasks/1') + }) + + it('should complete a task', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1, completed: true } }) + await tasksApi.complete(1) + expect(api.post).toHaveBeenCalledWith('tasks/1/complete') + }) + + it('should uncomplete a task', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1, completed: false } }) + await tasksApi.uncomplete(1) + expect(api.post).toHaveBeenCalledWith('tasks/1/uncomplete') + }) + + it('should get a single task', async () => { + api.get.mockResolvedValueOnce({ data: { id: 5, title: 'Task 5' } }) + const result = await tasksApi.get(5) + expect(api.get).toHaveBeenCalledWith('tasks/5') + expect(result.id).toBe(5) + }) +}) + +describe('habitsApi', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should list habits', async () => { + api.get.mockResolvedValueOnce({ data: [{ id: 1, name: 'Exercise' }] }) + const result = await habitsApi.list() + expect(api.get).toHaveBeenCalledWith('/habits') + expect(result).toEqual([{ id: 1, name: 'Exercise' }]) + }) + + it('should create a habit', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1, name: 'Read' } }) + await habitsApi.create({ name: 'Read' }) + expect(api.post).toHaveBeenCalledWith('/habits', { name: 'Read' }) + }) + + it('should update a habit', async () => { + api.put.mockResolvedValueOnce({ data: { id: 1, name: 'Updated' } }) + await habitsApi.update(1, { name: 'Updated' }) + expect(api.put).toHaveBeenCalledWith('/habits/1', { name: 'Updated' }) + }) + + it('should log a habit', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1 } }) + await habitsApi.log(5, { date: '2025-03-01' }) + expect(api.post).toHaveBeenCalledWith('/habits/5/log', { date: '2025-03-01' }) + }) + + it('should log habit with empty data', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1 } }) + await habitsApi.log(5) + expect(api.post).toHaveBeenCalledWith('/habits/5/log', {}) + }) + + it('should get logs with custom days', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await habitsApi.getLogs(5, 60) + expect(api.get).toHaveBeenCalledWith('/habits/5/logs?days=60') + }) + + it('should get overall stats', async () => { + api.get.mockResolvedValueOnce({ data: { total_habits: 5 } }) + await habitsApi.getStats() + expect(api.get).toHaveBeenCalledWith('/habits/stats') + }) + + it('should get single habit stats', async () => { + api.get.mockResolvedValueOnce({ data: { habit_id: 3 } }) + await habitsApi.getHabitStats(3) + expect(api.get).toHaveBeenCalledWith('/habits/3/stats') + }) + + it('should get freezes', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await habitsApi.getFreezes(3) + expect(api.get).toHaveBeenCalledWith('/habits/3/freezes') + }) + + it('should add freeze', async () => { + const freezeData = { start_date: '2025-03-01', end_date: '2025-03-07' } + api.post.mockResolvedValueOnce({ data: { id: 1 } }) + await habitsApi.addFreeze(3, freezeData) + expect(api.post).toHaveBeenCalledWith('/habits/3/freezes', freezeData) + }) + + it('should delete freeze', async () => { + api.delete.mockResolvedValueOnce({}) + await habitsApi.deleteFreeze(3, 10) + expect(api.delete).toHaveBeenCalledWith('/habits/3/freezes/10') + }) + + it('should delete a habit', async () => { + api.delete.mockResolvedValueOnce({}) + await habitsApi.delete(5) + expect(api.delete).toHaveBeenCalledWith('/habits/5') + }) + + it('should delete a log', async () => { + api.delete.mockResolvedValueOnce({}) + await habitsApi.deleteLog(3, 7) + expect(api.delete).toHaveBeenCalledWith('/habits/3/logs/7') + }) +}) + +describe('savingsApi', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should list categories', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await savingsApi.listCategories() + expect(api.get).toHaveBeenCalledWith('/savings/categories') + }) + + it('should get single category', async () => { + api.get.mockResolvedValueOnce({ data: { id: 1 } }) + await savingsApi.getCategory(1) + expect(api.get).toHaveBeenCalledWith('/savings/categories/1') + }) + + it('should create category', async () => { + api.post.mockResolvedValueOnce({ data: { id: 1 } }) + await savingsApi.createCategory({ name: 'Savings' }) + expect(api.post).toHaveBeenCalledWith('/savings/categories', { name: 'Savings' }) + }) + + it('should update category', async () => { + api.put.mockResolvedValueOnce({ data: { id: 1 } }) + await savingsApi.updateCategory(1, { name: 'Updated' }) + expect(api.put).toHaveBeenCalledWith('/savings/categories/1', { name: 'Updated' }) + }) + + it('should delete category', async () => { + api.delete.mockResolvedValueOnce({}) + await savingsApi.deleteCategory(1) + expect(api.delete).toHaveBeenCalledWith('/savings/categories/1') + }) + + it('should create transaction', async () => { + const data = { category_id: 1, amount: 1000, type: 'deposit', date: '2025-03-01' } + api.post.mockResolvedValueOnce({ data: { id: 1 } }) + await savingsApi.createTransaction(data) + expect(api.post).toHaveBeenCalledWith('/savings/transactions', data) + }) + + it('should list transactions with category filter', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await savingsApi.listTransactions(5, 50, 10) + expect(api.get).toHaveBeenCalledWith('/savings/transactions?limit=50&offset=10&category_id=5') + }) + + it('should list transactions without category filter', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await savingsApi.listTransactions(null) + expect(api.get).toHaveBeenCalledWith('/savings/transactions?limit=100&offset=0') + }) + + it('should get stats', async () => { + api.get.mockResolvedValueOnce({ data: { total_balance: 5000 } }) + await savingsApi.getStats() + expect(api.get).toHaveBeenCalledWith('/savings/stats') + }) + + it('should get members', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await savingsApi.getMembers(1) + expect(api.get).toHaveBeenCalledWith('/savings/categories/1/members') + }) + + it('should add member', async () => { + api.post.mockResolvedValueOnce({ data: [] }) + await savingsApi.addMember(1, 42) + expect(api.post).toHaveBeenCalledWith('/savings/categories/1/members', { user_id: 42 }) + }) + + it('should get recurring plans', async () => { + api.get.mockResolvedValueOnce({ data: [] }) + await savingsApi.getRecurringPlans(1) + expect(api.get).toHaveBeenCalledWith('/savings/categories/1/recurring-plans') + }) +}) + +describe('profileApi', () => { + beforeEach(() => vi.clearAllMocks()) + + it('should get profile', async () => { + api.get.mockResolvedValueOnce({ data: { username: 'test' } }) + const result = await profileApi.get() + expect(api.get).toHaveBeenCalledWith('/profile') + expect(result.username).toBe('test') + }) + + it('should update profile', async () => { + api.put.mockResolvedValueOnce({ data: { username: 'updated' } }) + const result = await profileApi.update({ username: 'updated' }) + expect(api.put).toHaveBeenCalledWith('/profile', { username: 'updated' }) + expect(result.username).toBe('updated') + }) +}) diff --git a/src/__tests__/auth.store.test.js b/src/__tests__/auth.store.test.js new file mode 100644 index 0000000..21320bf --- /dev/null +++ b/src/__tests__/auth.store.test.js @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../api/client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + }, +})) + +import api from '../api/client' +import { useAuthStore } from '../store/auth' + +describe('useAuthStore', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + isLoading: true, + isAuthenticated: false, + }) + localStorage.clear() + vi.clearAllMocks() + }) + + it('should have correct initial state', () => { + const state = useAuthStore.getState() + expect(state.user).toBeNull() + expect(state.isLoading).toBe(true) + expect(state.isAuthenticated).toBe(false) + }) + + it('should login successfully', async () => { + const mockResponse = { + data: { + user: { id: 1, email: 'test@test.com', username: 'test' }, + access_token: 'access-123', + refresh_token: 'refresh-123', + }, + } + api.post.mockResolvedValueOnce(mockResponse) + + await useAuthStore.getState().login('test@test.com', 'password') + + expect(api.post).toHaveBeenCalledWith('/auth/login', { + email: 'test@test.com', + password: 'password', + }) + expect(localStorage.getItem('access_token')).toBe('access-123') + expect(localStorage.getItem('refresh_token')).toBe('refresh-123') + expect(useAuthStore.getState().isAuthenticated).toBe(true) + expect(useAuthStore.getState().user).toEqual(mockResponse.data.user) + }) + + it('should register successfully', async () => { + const mockResponse = { + data: { + user: { id: 2, email: 'new@test.com', username: 'newuser' }, + access_token: 'access-456', + refresh_token: 'refresh-456', + }, + } + api.post.mockResolvedValueOnce(mockResponse) + + await useAuthStore.getState().register('new@test.com', 'newuser', 'password123') + + expect(api.post).toHaveBeenCalledWith('/auth/register', { + email: 'new@test.com', + username: 'newuser', + password: 'password123', + }) + expect(useAuthStore.getState().isAuthenticated).toBe(true) + expect(localStorage.getItem('access_token')).toBe('access-456') + }) + + it('should logout and clear tokens', () => { + localStorage.setItem('access_token', 'old-token') + localStorage.setItem('refresh_token', 'old-refresh') + useAuthStore.setState({ user: { id: 1 }, isAuthenticated: true }) + + useAuthStore.getState().logout() + + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + expect(useAuthStore.getState().user).toBeNull() + expect(useAuthStore.getState().isAuthenticated).toBe(false) + }) + + it('should initialize with no token', async () => { + await useAuthStore.getState().initialize() + + expect(useAuthStore.getState().isLoading).toBe(false) + expect(useAuthStore.getState().isAuthenticated).toBe(false) + }) + + it('should initialize with valid token', async () => { + localStorage.setItem('access_token', 'valid-token') + api.get.mockResolvedValueOnce({ + data: { id: 1, email: 'test@test.com', username: 'test' }, + }) + + await useAuthStore.getState().initialize() + + expect(api.get).toHaveBeenCalledWith('/auth/me') + expect(useAuthStore.getState().isAuthenticated).toBe(true) + expect(useAuthStore.getState().isLoading).toBe(false) + expect(useAuthStore.getState().user.email).toBe('test@test.com') + }) + + it('should handle invalid token on initialize', async () => { + localStorage.setItem('access_token', 'invalid-token') + api.get.mockRejectedValueOnce(new Error('401')) + + await useAuthStore.getState().initialize() + + expect(useAuthStore.getState().isAuthenticated).toBe(false) + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) + + it('should handle login error', async () => { + api.post.mockRejectedValueOnce(new Error('Invalid credentials')) + + await expect( + useAuthStore.getState().login('bad@test.com', 'wrong') + ).rejects.toThrow('Invalid credentials') + + expect(useAuthStore.getState().isAuthenticated).toBe(false) + }) +}) diff --git a/src/__tests__/setup.js b/src/__tests__/setup.js new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/src/__tests__/setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..3fa0d06 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: './src/__tests__/setup.js', + }, +}) From 0ec0eede7655b51db603f4b6cdaf61726a98f09a Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:22:35 +0000 Subject: [PATCH 09/15] feat: add finance module UI - Finance.jsx: main page with tabs (Dashboard/Transactions/Analytics) - FinanceDashboard: balance card, top categories, pie chart, daily line chart - TransactionList: filtered list with search, type/category filters - AddTransactionModal: bottom sheet with quick templates - FinanceAnalytics: bar chart, donut chart, monthly trend - finance.js: API layer - Navigation: added Wallet icon for Finance - App.jsx: added /finance route Design matches Storybook mockups (glassmorphism, Deep Teal palette) --- src/App.jsx | 9 + src/api/finance.js | 47 ++++ src/components/Navigation.jsx | 3 +- .../finance/AddTransactionModal.jsx | 197 +++++++++++++++ src/components/finance/FinanceAnalytics.jsx | 235 ++++++++++++++++++ src/components/finance/FinanceDashboard.jsx | 201 +++++++++++++++ src/components/finance/TransactionList.jsx | 170 +++++++++++++ src/pages/Finance.jsx | 75 ++++++ 8 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 src/api/finance.js create mode 100644 src/components/finance/AddTransactionModal.jsx create mode 100644 src/components/finance/FinanceAnalytics.jsx create mode 100644 src/components/finance/FinanceDashboard.jsx create mode 100644 src/components/finance/TransactionList.jsx create mode 100644 src/pages/Finance.jsx diff --git a/src/App.jsx b/src/App.jsx index 3a2c923..5f817ee 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import ResetPassword from "./pages/ResetPassword" import ForgotPassword from "./pages/ForgotPassword" import Stats from "./pages/Stats" import Settings from "./pages/Settings" +import Finance from "./pages/Finance" function ProtectedRoute({ children }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -124,6 +125,14 @@ export default function App() { } /> + + + + } + /> { + const res = await client.get('finance/categories') + return res.data + }, + createCategory: async (data) => { + const res = await client.post('finance/categories', data) + return res.data + }, + updateCategory: async (id, data) => { + const res = await client.put(`finance/categories/${id}`, data) + return res.data + }, + deleteCategory: async (id) => { + await client.delete(`finance/categories/${id}`) + }, + + // Transactions + listTransactions: async (params = {}) => { + const res = await client.get('finance/transactions', { params }) + return res.data + }, + createTransaction: async (data) => { + const res = await client.post('finance/transactions', data) + return res.data + }, + updateTransaction: async (id, data) => { + const res = await client.put(`finance/transactions/${id}`, data) + return res.data + }, + deleteTransaction: async (id) => { + await client.delete(`finance/transactions/${id}`) + }, + + // Summary & Analytics + getSummary: async (params = {}) => { + const res = await client.get('finance/summary', { params }) + return res.data + }, + getAnalytics: async (params = {}) => { + const res = await client.get('finance/analytics', { params }) + return res.data + }, +} diff --git a/src/components/Navigation.jsx b/src/components/Navigation.jsx index 0a5f869..fa24bea 100644 --- a/src/components/Navigation.jsx +++ b/src/components/Navigation.jsx @@ -1,5 +1,5 @@ import { NavLink } from "react-router-dom" -import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Settings } from "lucide-react" +import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Wallet, Settings } from "lucide-react" import clsx from "clsx" export default function Navigation() { @@ -9,6 +9,7 @@ export default function Navigation() { { to: "/tasks", icon: CheckSquare, label: "Задачи" }, { to: "/stats", icon: BarChart3, label: "Статистика" }, { to: "/savings", icon: PiggyBank, label: "Накопления" }, + { to: "/finance", icon: Wallet, label: "Финансы" }, { to: "/settings", icon: Settings, label: "Настройки" }, ] diff --git a/src/components/finance/AddTransactionModal.jsx b/src/components/finance/AddTransactionModal.jsx new file mode 100644 index 0000000..6ac3751 --- /dev/null +++ b/src/components/finance/AddTransactionModal.jsx @@ -0,0 +1,197 @@ +import { useState, useEffect } from "react" +import { financeApi } from "../../api/finance" + +const quickTemplates = [ + { description: "Продукты", categoryName: "Еда", amount: 2000 }, + { description: "Такси", categoryName: "Транспорт", amount: 400 }, + { description: "Кофе", categoryName: "Еда", amount: 350 }, + { description: "Обед", categoryName: "Еда", amount: 600 }, + { description: "Метро", categoryName: "Транспорт", amount: 57 }, +] + +export default function AddTransactionModal({ onClose, onSaved }) { + const [type, setType] = useState("expense") + const [categories, setCategories] = useState([]) + const [categoryId, setCategoryId] = useState(null) + const [amount, setAmount] = useState("") + const [description, setDescription] = useState("") + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)) + const [saving, setSaving] = useState(false) + + useEffect(() => { + financeApi.listCategories().then(setCategories).catch(console.error) + }, []) + + const cats = categories.filter((c) => c.type === type) + + const applyTemplate = (t) => { + setDescription(t.description) + setAmount(String(t.amount)) + const found = categories.find( + (c) => c.name === t.categoryName && c.type === "expense" + ) + if (found) setCategoryId(found.id) + } + + const handleSubmit = async () => { + if (!amount || !categoryId) return + setSaving(true) + try { + await financeApi.createTransaction({ + type, + category_id: categoryId, + amount: parseFloat(amount), + description, + date, + }) + onSaved() + } catch (e) { + console.error(e) + alert("Ошибка при сохранении") + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()} + > +
+

+ Новая запись +

+ +
+ {/* Type toggle */} +
+ {[ + ["expense", "Расход"], + ["income", "Доход"], + ].map(([k, l]) => ( + + ))} +
+ + {/* Quick templates */} + {type === "expense" && ( +
+

+ Быстрые шаблоны +

+
+ {quickTemplates.map((t, i) => ( + + ))} +
+
+ )} + + {/* Amount */} +
+ +
+ setAmount(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none" + placeholder="0" + /> + + ₽ + +
+
+ + {/* Description */} +
+ + setDescription(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + placeholder="Что купили?" + /> +
+ + {/* Category */} +
+ +
+ {cats.map((c) => ( + + ))} +
+
+ + {/* Date */} +
+ + setDate(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + /> +
+ + {/* Submit */} + +
+
+
+ ) +} diff --git a/src/components/finance/FinanceAnalytics.jsx b/src/components/finance/FinanceAnalytics.jsx new file mode 100644 index 0000000..8f4c14c --- /dev/null +++ b/src/components/finance/FinanceAnalytics.jsx @@ -0,0 +1,235 @@ +import { useState, useEffect } from "react" +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, LineChart, Line, +} from "recharts" +import { financeApi } from "../../api/finance" + +const COLORS = [ + "#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444", + "#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6", + "#64748b", "#a855f7", "#78716c", +] + +const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽" + +const MONTH_NAMES = [ + "", "Янв", "Фев", "Мар", "Апр", "Май", "Июн", + "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек", +] + +export default function FinanceAnalytics() { + const [analytics, setAnalytics] = useState(null) + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const now = new Date() + Promise.all([ + financeApi.getAnalytics({ months: 6 }), + financeApi.getSummary({ month: now.getMonth() + 1, year: now.getFullYear() }), + ]) + .then(([a, s]) => { + setAnalytics(a) + setSummary(s) + }) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+ ) + } + + if (!analytics || !summary) return null + + const expenseCategories = (summary.by_category || []).filter( + (c) => c.type === "expense" + ) + const barData = expenseCategories.map((c) => ({ + name: c.category_emoji + " " + c.category_name, + value: c.amount, + })) + const pieData = barData + + const monthlyData = (analytics.monthly_trend || []).map((m) => ({ + month: MONTH_NAMES[parseInt(m.month.slice(5))] || m.month, + income: m.income, + expense: m.expense, + })) + + const comp = analytics.comparison_prev_month + const diffPct = Math.abs(Math.round(comp.diff_percent)) + const isUp = comp.diff_percent > 0 + + return ( +
+ {/* Summary cards */} +
+
+

+ Всего расходов +

+

+ {fmt(summary.total_expense)} +

+

+ {isUp ? "↑" : "↓"} {diffPct}% vs пред. месяц +

+
+
+

+ В среднем / день +

+

+ {fmt(Math.round(analytics.avg_daily_expense))} +

+
+
+ + {/* Bar chart */} + {barData.length > 0 && ( +
+

+ Расходы по категориям +

+
+ + + v / 1000 + "к"} + /> + + fmt(v)} /> + + {barData.map((_, i) => ( + + ))} + + + +
+
+ )} + + {/* Donut chart */} + {pieData.length > 0 && ( +
+

+ Доля категорий +

+
+
+ + + + {pieData.map((_, i) => ( + + ))} + + fmt(v)} /> + + +
+
+ {pieData.map((c, i) => ( +
+
+ + {c.name} + + + {Math.round( + (c.value / summary.total_expense) * 100 + )} + % + +
+ ))} +
+
+
+ )} + + {/* Monthly trend */} + {monthlyData.length > 0 && ( +
+

+ Тренд по месяцам +

+
+ + + + v / 1000 + "к"} + /> + fmt(v)} /> + + + + +
+
+
+
+ Доходы +
+
+
+ Расходы +
+
+
+ )} +
+ ) +} diff --git a/src/components/finance/FinanceDashboard.jsx b/src/components/finance/FinanceDashboard.jsx new file mode 100644 index 0000000..8b711be --- /dev/null +++ b/src/components/finance/FinanceDashboard.jsx @@ -0,0 +1,201 @@ +import { useState, useEffect } from "react" +import { + PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, +} from "recharts" +import { financeApi } from "../../api/finance" + +const COLORS = [ + "#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444", + "#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6", + "#64748b", "#a855f7", "#78716c", +] + +const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽" + +export default function FinanceDashboard() { + const [summary, setSummary] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const now = new Date() + financeApi + .getSummary({ month: now.getMonth() + 1, year: now.getFullYear() }) + .then(setSummary) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+ ))} +
+ ) + } + + if (!summary || (summary.total_income === 0 && summary.total_expense === 0)) { + return ( +
+ 📊 +

+ Нет данных +

+

+ Добавьте первую транзакцию +

+
+ ) + } + + const expenseCategories = summary.by_category.filter((c) => c.type === "expense") + const pieData = expenseCategories.map((c) => ({ + name: c.category_emoji + " " + c.category_name, + value: c.amount, + })) + const dailyData = summary.daily.map((d) => ({ + day: d.date.slice(8, 10), + amount: d.amount, + })) + + return ( +
+ {/* Balance Card */} +
+

Баланс за месяц

+

{fmt(summary.balance)}

+
+
+

Доходы

+

+ +{fmt(summary.total_income)} +

+
+
+

Расходы

+

+ -{fmt(summary.total_expense)} +

+
+
+
+ + {/* Top Categories */} + {expenseCategories.length > 0 && ( +
+

+ Топ расходов +

+
+ {expenseCategories.slice(0, 5).map((c, i) => ( +
+ + {c.category_emoji} + +
+
+ + {c.category_name} + + + {fmt(c.amount)} + +
+
+
+
+
+
+ ))} +
+
+ )} + + {/* Donut Chart */} + {pieData.length > 0 && ( +
+

+ По категориям +

+
+
+ + + + {pieData.map((_, i) => ( + + ))} + + fmt(v)} /> + + +
+
+ {pieData.slice(0, 6).map((c, i) => ( +
+
+ + {c.name} + + + {Math.round( + (c.value / summary.total_expense) * 100 + )} + % + +
+ ))} +
+
+
+ )} + + {/* Daily Line Chart */} + {dailyData.length > 0 && ( +
+

+ Расходы по дням +

+
+ + + + v / 1000 + "к"} + /> + fmt(v)} /> + + + +
+
+ )} +
+ ) +} diff --git a/src/components/finance/TransactionList.jsx b/src/components/finance/TransactionList.jsx new file mode 100644 index 0000000..b2f90be --- /dev/null +++ b/src/components/finance/TransactionList.jsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from "react" +import { financeApi } from "../../api/finance" + +const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽" + +const formatDate = (d) => { + const dt = new Date(d) + return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long" }) +} + +export default function TransactionList({ onAdd }) { + const [transactions, setTransactions] = useState([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState("all") + const [catFilter, setCatFilter] = useState(null) + const [search, setSearch] = useState("") + + useEffect(() => { + Promise.all([ + financeApi.listCategories(), + financeApi.listTransactions({ + month: new Date().getMonth() + 1, + year: new Date().getFullYear(), + limit: 100, + }), + ]) + .then(([cats, txs]) => { + setCategories(cats || []) + setTransactions(txs || []) + }) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + const filtered = transactions.filter((t) => { + if (filter !== "all" && t.type !== filter) return false + if (catFilter && t.category_id !== catFilter) return false + if (search && !t.description.toLowerCase().includes(search.toLowerCase())) + return false + return true + }) + + const grouped = filtered.reduce((acc, t) => { + const d = t.date.slice(0, 10) + ;(acc[d] = acc[d] || []).push(t) + return acc + }, {}) + + const handleDelete = async (id) => { + if (!confirm("Удалить транзакцию?")) return + await financeApi.deleteTransaction(id) + setTransactions((txs) => txs.filter((t) => t.id !== id)) + } + + if (loading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+ ))} +
+ ) + } + + return ( +
+ {/* Search */} + setSearch(e.target.value)} + /> + + {/* Type filter */} +
+ {[ + ["all", "Все"], + ["income", "Доходы"], + ["expense", "Расходы"], + ].map(([k, l]) => ( + + ))} +
+ + {/* Category filter */} +
+ + {categories.map((c) => ( + + ))} +
+ + {/* Transaction groups */} + {Object.keys(grouped).length === 0 ? ( +
+ 🔍 +

Ничего не найдено

+
+ ) : ( + Object.entries(grouped).map(([date, txs]) => ( +
+

+ {formatDate(date)} +

+
+ {txs.map((t) => ( +
handleDelete(t.id)} + > + {t.category_emoji} +
+

+ {t.description || t.category_name} +

+

+ {t.category_emoji} {t.category_name} +

+
+ + {t.type === "income" ? "+" : "-"} + {fmt(t.amount)} + +
+ ))} +
+
+ )) + )} +
+ ) +} diff --git a/src/pages/Finance.jsx b/src/pages/Finance.jsx new file mode 100644 index 0000000..8d849d3 --- /dev/null +++ b/src/pages/Finance.jsx @@ -0,0 +1,75 @@ +import { useState } from "react" +import Navigation from "../components/Navigation" +import FinanceDashboard from "../components/finance/FinanceDashboard" +import TransactionList from "../components/finance/TransactionList" +import FinanceAnalytics from "../components/finance/FinanceAnalytics" +import AddTransactionModal from "../components/finance/AddTransactionModal" + +const tabs = [ + { key: "dashboard", label: "Обзор", icon: "📊" }, + { key: "transactions", label: "Транзакции", icon: "📋" }, + { key: "analytics", label: "Аналитика", icon: "📈" }, +] + +export default function Finance() { + const [activeTab, setActiveTab] = useState("dashboard") + const [showAdd, setShowAdd] = useState(false) + const [refreshKey, setRefreshKey] = useState(0) + + const refresh = () => setRefreshKey((k) => k + 1) + + return ( +
+
+
+
+

+ 💰 Финансы +

+
+ +
+
+ {tabs.map((t) => ( + + ))} +
+
+ +
+ {activeTab === "dashboard" && } + {activeTab === "transactions" && ( + setShowAdd(true)} /> + )} + {activeTab === "analytics" && } +
+ + {showAdd && ( + setShowAdd(false)} + onSaved={() => { + setShowAdd(false) + refresh() + }} + /> + )} + + +
+ ) +} From 8baddf19149c9aeb2c5a14995f4122292ee22658 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:34:59 +0000 Subject: [PATCH 10/15] feat: unified navigation hub + categories tab + mobile scroll fix - Navigation: 4 items (Home, Tracker, Finance, Settings) - Tracker page: tabs for Habits, Tasks, Stats - Finance: added Categories tab (CRUD) - AddTransactionModal: fixed mobile scroll with sticky button - Home: added finance balance widget - Legacy routes (/habits, /tasks, /stats) redirect to /tracker --- src/App.jsx | 30 ++- src/components/Navigation.jsx | 16 +- .../finance/AddTransactionModal.jsx | 220 ++++++++-------- src/components/finance/CategoriesManager.jsx | 238 ++++++++++++++++++ src/pages/Finance.jsx | 7 +- src/pages/Habits.jsx | 10 +- src/pages/Home.jsx | 27 ++ src/pages/Stats.jsx | 8 +- src/pages/Tasks.jsx | 10 +- src/pages/Tracker.jsx | 52 ++++ 10 files changed, 478 insertions(+), 140 deletions(-) create mode 100644 src/components/finance/CategoriesManager.jsx create mode 100644 src/pages/Tracker.jsx diff --git a/src/App.jsx b/src/App.jsx index 5f817ee..4168e33 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import ForgotPassword from "./pages/ForgotPassword" import Stats from "./pages/Stats" import Settings from "./pages/Settings" import Finance from "./pages/Finance" +import Tracker from "./pages/Tracker" function ProtectedRoute({ children }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -93,11 +94,20 @@ export default function App() { } /> + + + + } + /> + {/* Legacy routes redirect to tracker */} - + } /> @@ -105,7 +115,15 @@ export default function App() { path="/tasks" element={ - + + + } + /> + + } /> @@ -117,14 +135,6 @@ export default function App() { } /> - - - - } - /> clsx( - "flex flex-col items-center gap-0.5 px-1.5 py-1.5 rounded-xl transition-all", + "flex flex-col items-center gap-0.5 px-3 py-2 rounded-xl transition-all", isActive ? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" ) } > - - {label} + + {label} ))}
diff --git a/src/components/finance/AddTransactionModal.jsx b/src/components/finance/AddTransactionModal.jsx index 6ac3751..f281309 100644 --- a/src/components/finance/AddTransactionModal.jsx +++ b/src/components/finance/AddTransactionModal.jsx @@ -59,128 +59,138 @@ export default function AddTransactionModal({ onClose, onSaved }) { onClick={onClose} >
e.stopPropagation()} > -
-

- Новая запись -

+ {/* Header - fixed */} +
+
+

+ Новая запись +

+
-
- {/* Type toggle */} -
- {[ - ["expense", "Расход"], - ["income", "Доход"], - ].map(([k, l]) => ( - - ))} -
+ {/* Scrollable content */} +
+
+ {/* Type toggle */} +
+ {[ + ["expense", "Расход"], + ["income", "Доход"], + ].map(([k, l]) => ( + + ))} +
- {/* Quick templates */} - {type === "expense" && ( + {/* Quick templates */} + {type === "expense" && ( +
+

+ Быстрые шаблоны +

+
+ {quickTemplates.map((t, i) => ( + + ))} +
+
+ )} + + {/* Amount */}
-

- Быстрые шаблоны -

-
- {quickTemplates.map((t, i) => ( + +
+ setAmount(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none" + placeholder="0" + /> + + ₽ + +
+
+ + {/* Description */} +
+ + setDescription(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + placeholder="Что купили?" + /> +
+ + {/* Category */} +
+ +
+ {cats.map((c) => ( ))}
- )} - {/* Amount */} -
- -
+ {/* Date */} +
+ setAmount(e.target.value)} - className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none" - placeholder="0" + type="date" + value={date} + onChange={(e) => setDate(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" /> - - ₽ -
+
- {/* Description */} -
- - setDescription(e.target.value)} - className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" - placeholder="Что купили?" - /> -
- - {/* Category */} -
- -
- {cats.map((c) => ( - - ))} -
-
- - {/* Date */} -
- - setDate(e.target.value)} - className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" - /> -
- - {/* Submit */} + {/* Sticky submit button */} +
+ ))} +
+ +
+ + {/* Category list */} +
+ {cats.length === 0 ? ( +
+

Нет категорий

+ +
+ ) : cats.map(c => { + const pct = c.budget > 0 ? Math.min(Math.round((c.spent || 0) / c.budget * 100), 100) : 0 + const isOver = c.budget > 0 && (c.spent || 0) > c.budget * 0.9 + return ( +
+
+
+ {c.emoji} +
+
+
+ {c.name} + {c.budget > 0 && ( + + {fmt(c.spent || 0)} / {fmt(c.budget)} + + )} +
+ {c.budget > 0 && ( +
+
70 ? "bg-yellow-500" : "bg-primary-500"}`} + style={{ width: pct + "%" }} /> +
+ )} +
+
+ + +
+
+
+ ) + })} +
+ + {/* Add/Edit Modal */} + {showModal && ( +
setShowModal(false)}> +
e.stopPropagation()}> +
+

+ {editing ? "Редактировать категорию" : "Новая категория"} +

+
+ {/* Emoji + Name */} +
+ + setName(e.target.value)} + className="flex-1 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + placeholder="Название категории" /> +
+ + {/* Emoji picker */} + {showEmojiPicker && ( +
+ {EMOJI_OPTIONS.map(e => ( + + ))} +
+ )} + + {/* Type toggle */} +
+ {[["expense","Расход"],["income","Доход"]].map(([k,l]) => ( + + ))} +
+ + {/* Budget */} +
+ +
+ setBudget(e.target.value)} + className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none" + placeholder="0" /> + +
+
+ + {/* Save */} + +
+
+
+ )} +
+ ) +} diff --git a/src/pages/Finance.jsx b/src/pages/Finance.jsx index 8d849d3..e27dd04 100644 --- a/src/pages/Finance.jsx +++ b/src/pages/Finance.jsx @@ -3,12 +3,14 @@ import Navigation from "../components/Navigation" import FinanceDashboard from "../components/finance/FinanceDashboard" import TransactionList from "../components/finance/TransactionList" import FinanceAnalytics from "../components/finance/FinanceAnalytics" +import CategoriesManager from "../components/finance/CategoriesManager" import AddTransactionModal from "../components/finance/AddTransactionModal" const tabs = [ { key: "dashboard", label: "Обзор", icon: "📊" }, { key: "transactions", label: "Транзакции", icon: "📋" }, { key: "analytics", label: "Аналитика", icon: "📈" }, + { key: "categories", label: "Категории", icon: "🏷️" }, ] export default function Finance() { @@ -34,12 +36,12 @@ export default function Finance() { +
-
+
{tabs.map((t) => (
{showAdd && ( diff --git a/src/pages/Habits.jsx b/src/pages/Habits.jsx index ffe7486..4c3fd3d 100644 --- a/src/pages/Habits.jsx +++ b/src/pages/Habits.jsx @@ -10,7 +10,7 @@ import EditHabitModal from '../components/EditHabitModal' import Navigation from '../components/Navigation' import clsx from 'clsx' -export default function Habits() { +export default function Habits({ embedded = false }) { const [showCreateModal, setShowCreateModal] = useState(false) const [editingHabit, setEditingHabit] = useState(null) const [showArchived, setShowArchived] = useState(false) @@ -67,8 +67,8 @@ export default function Habits() { const archivedList = habits.filter(h => h.is_archived) return ( -
-
+
+ {!embedded &&

Мои привычки

@@ -79,7 +79,7 @@ export default function Habits() { Новая
-
+
}
{isLoading ? ( @@ -168,7 +168,7 @@ export default function Habits() { )}
- + {!embedded && } setShowCreateModal(false)} /> setEditingHabit(null)} habit={editingHabit} />
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 294b140..d623cd2 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -6,6 +6,7 @@ import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, i import { ru } from 'date-fns/locale' import { habitsApi } from '../api/habits' import { tasksApi } from '../api/tasks' +import { financeApi } from '../api/finance' import { useAuthStore } from '../store/auth' import Navigation from '../components/Navigation' import CreateTaskModal from '../components/CreateTaskModal' @@ -96,6 +97,11 @@ export default function Home() { queryFn: tasksApi.today, }) + + const { data: financeSummary } = useQuery({ + queryKey: ["finance-summary"], + queryFn: () => financeApi.getSummary(), + }) useEffect(() => { if (habits.length > 0) { loadTodayLogs() @@ -301,6 +307,27 @@ export default function Home() { )} {/* Tasks */} + + {/* Finance Summary */} + {financeSummary && ( + +

💰 Баланс

+
+
+

+{(financeSummary.total_income || 0).toLocaleString("ru-RU")} ₽

+

Доходы

+
+
+

-{(financeSummary.total_expense || 0).toLocaleString("ru-RU")} ₽

+

Расходы

+
+
+

= 0 ? "text-primary-500" : "text-red-500")}>{(financeSummary.balance || 0).toLocaleString("ru-RU")} ₽

+

Баланс

+
+
+
+ )} {(activeTasks.length > 0 || !tasksLoading) && (
diff --git a/src/pages/Stats.jsx b/src/pages/Stats.jsx index a6baefe..2a0a47c 100644 --- a/src/pages/Stats.jsx +++ b/src/pages/Stats.jsx @@ -147,7 +147,7 @@ const SectionHeader = ({ icon: Icon, title, subtitle }) => (
) -export default function Stats() { +export default function Stats({ embedded = false }) { const [selectedHabitId, setSelectedHabitId] = useState(null) const [allHabitLogs, setAllHabitLogs] = useState({}) const [allHabitStats, setAllHabitStats] = useState({}) @@ -356,7 +356,7 @@ export default function Stats() {
-
+ {!embedded &&
@@ -371,7 +371,7 @@ export default function Stats() {
-
+
}
@@ -689,7 +689,7 @@ export default function Stats() { )}
- + {!embedded && }
) } diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index 2f2a4d8..59594cc 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -32,7 +32,7 @@ function formatDueDate(dateStr) { return format(date, 'd MMM', { locale: ru }) } -export default function Tasks() { +export default function Tasks({ embedded = false }) { const [showCreate, setShowCreate] = useState(false) const [editingTask, setEditingTask] = useState(null) const [filter, setFilter] = useState('active') @@ -68,8 +68,8 @@ export default function Tasks() { } return ( -
-
+
+ {!embedded &&

Задачи

@@ -99,7 +99,7 @@ export default function Tasks() { ))}
-
+
}
{isLoading ? ( @@ -145,7 +145,7 @@ export default function Tasks() { )}
- + {!embedded && } setShowCreate(false)} /> setEditingTask(null)} task={editingTask} />
diff --git a/src/pages/Tracker.jsx b/src/pages/Tracker.jsx new file mode 100644 index 0000000..81cbbea --- /dev/null +++ b/src/pages/Tracker.jsx @@ -0,0 +1,52 @@ +import { useState, lazy, Suspense } from "react" +import Navigation from "../components/Navigation" + +// Import pages as components (they render their own content but we strip their Navigation) +import HabitsContent from "./Habits" +import TasksContent from "./Tasks" +import StatsContent from "./Stats" + +const tabs = [ + { key: "habits", label: "Привычки", icon: "🎯" }, + { key: "tasks", label: "Задачи", icon: "✅" }, + { key: "stats", label: "Статистика", icon: "📊" }, +] + +export default function Tracker() { + const [activeTab, setActiveTab] = useState("habits") + + return ( +
+
+
+

+ 📊 Трекер +

+
+
+ {tabs.map((t) => ( + + ))} +
+
+ +
+ {activeTab === "habits" && } + {activeTab === "tasks" && } + {activeTab === "stats" && } +
+ + +
+ ) +} From 7fd9314440b353d724ca906ca74558b137b1c3d9 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:35:15 +0000 Subject: [PATCH 11/15] fix: Stats embedded mode outer div --- src/pages/Stats.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Stats.jsx b/src/pages/Stats.jsx index 2a0a47c..88c715b 100644 --- a/src/pages/Stats.jsx +++ b/src/pages/Stats.jsx @@ -349,7 +349,7 @@ export default function Stats({ embedded = false }) { }, [heatmapData]) return ( -
+
{/* Gradient Background */}
From dfee7e246ee3535e33f6b23f152c767608c72d7a Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:37:56 +0000 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20restore=20Savings=20(=D0=9D=D0=B0?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F)=20in=20naviga?= =?UTF-8?q?tion=20as=205th=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Navigation.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Navigation.jsx b/src/components/Navigation.jsx index e234e56..cf10175 100644 --- a/src/components/Navigation.jsx +++ b/src/components/Navigation.jsx @@ -1,5 +1,5 @@ import { NavLink } from "react-router-dom" -import { Home, BarChart3, Wallet, Settings } from "lucide-react" +import { Home, BarChart3, Wallet, PiggyBank, Settings } from "lucide-react" import clsx from "clsx" export default function Navigation() { @@ -7,6 +7,7 @@ export default function Navigation() { { to: "/", icon: Home, label: "Главная" }, { to: "/tracker", icon: BarChart3, label: "Трекер" }, { to: "/finance", icon: Wallet, label: "Финансы" }, + { to: "/savings", icon: PiggyBank, label: "Накопления" }, { to: "/settings", icon: Settings, label: "Настройки" }, ] @@ -21,15 +22,15 @@ export default function Navigation() { end={to === "/"} className={({ isActive }) => clsx( - "flex flex-col items-center gap-0.5 px-3 py-2 rounded-xl transition-all", + "flex flex-col items-center gap-0.5 px-2 py-2 rounded-xl transition-all", isActive ? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" ) } > - - {label} + + {label} ))}
From c898c0063c98ed3c37ba13a54137f147c95920a3 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:39:04 +0000 Subject: [PATCH 13/15] feat: hide Finance nav for non-owner users (Savings visible to all) --- src/components/Navigation.jsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/Navigation.jsx b/src/components/Navigation.jsx index cf10175..7b68ec6 100644 --- a/src/components/Navigation.jsx +++ b/src/components/Navigation.jsx @@ -1,15 +1,21 @@ import { NavLink } from "react-router-dom" import { Home, BarChart3, Wallet, PiggyBank, Settings } from "lucide-react" +import { useAuthStore } from "../store/auth" import clsx from "clsx" +const OWNER_ID = 1 + export default function Navigation() { + const user = useAuthStore((s) => s.user) + const isOwner = user?.id === OWNER_ID + const navItems = [ { to: "/", icon: Home, label: "Главная" }, { to: "/tracker", icon: BarChart3, label: "Трекер" }, - { to: "/finance", icon: Wallet, label: "Финансы" }, + isOwner && { to: "/finance", icon: Wallet, label: "Финансы" }, { to: "/savings", icon: PiggyBank, label: "Накопления" }, { to: "/settings", icon: Settings, label: "Настройки" }, - ] + ].filter(Boolean) return (