From 876ae4f333f2c4780df0ce460a8130fc3109928d Mon Sep 17 00:00:00 2001 From: rotak Date: Thu, 12 Mar 2026 14:40:16 +0100 Subject: [PATCH] Roman Talas changes --- .env.example | 1 + .gitignore | 3 + CHANGES.md | 26 + package.json | 10 +- pnpm-lock.yaml | 785 ++++++++++++++++++++++ src/App.test.tsx | 43 ++ src/App.tsx | 19 +- src/components/AdvancedInfo.test.tsx | 23 + src/components/AdvancedInfo.tsx | 35 + src/components/RemoveButton.tsx | 15 + src/components/SearchBar.test.tsx | 157 +++++ src/components/SearchBar.tsx | 104 +++ src/components/SuggestionsList.tsx | 58 ++ src/components/SunInfo.test.tsx | 29 + src/components/SunInfo.tsx | 39 ++ src/components/Temperature.test.tsx | 45 ++ src/components/Temperature.tsx | 33 + src/components/WeatherCard.fixtures.tsx | 22 + src/components/WeatherCard.test.tsx | 68 ++ src/components/WeatherCard.tsx | 216 ++---- src/components/WeatherCardError.test.tsx | 39 ++ src/components/WeatherCardError.tsx | 26 + src/components/WeatherCardHeader.test.tsx | 36 + src/components/WeatherCardHeader.tsx | 26 + src/components/WeatherCardSkeleton.tsx | 59 ++ src/constants.ts | 1 - src/hooks/useCities.test.ts | 88 +++ src/hooks/useCities.ts | 39 ++ src/hooks/useSuggestions.test.ts | 119 ++++ src/hooks/useSuggestions.ts | 46 ++ src/hooks/useWeather.fixtures.ts | 22 + src/hooks/useWeather.test.ts | 83 +++ src/hooks/useWeather.ts | 40 ++ src/types/GeoLocation.ts | 8 + src/types/WeatherData.ts | 35 + src/utils/api.ts | 7 + src/utils/dedupLocations.test.ts | 54 ++ src/utils/dedupLocations.ts | 11 + src/utils/formatTime.test.ts | 25 + src/utils/formatTime.ts | 8 + tsconfig.app.json | 4 +- vite.config.ts | 7 +- vitest.ts | 1 + 43 files changed, 2334 insertions(+), 181 deletions(-) create mode 100644 .env.example create mode 100644 CHANGES.md create mode 100644 src/App.test.tsx create mode 100644 src/components/AdvancedInfo.test.tsx create mode 100644 src/components/AdvancedInfo.tsx create mode 100644 src/components/RemoveButton.tsx create mode 100644 src/components/SearchBar.test.tsx create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/SuggestionsList.tsx create mode 100644 src/components/SunInfo.test.tsx create mode 100644 src/components/SunInfo.tsx create mode 100644 src/components/Temperature.test.tsx create mode 100644 src/components/Temperature.tsx create mode 100644 src/components/WeatherCard.fixtures.tsx create mode 100644 src/components/WeatherCard.test.tsx create mode 100644 src/components/WeatherCardError.test.tsx create mode 100644 src/components/WeatherCardError.tsx create mode 100644 src/components/WeatherCardHeader.test.tsx create mode 100644 src/components/WeatherCardHeader.tsx create mode 100644 src/components/WeatherCardSkeleton.tsx delete mode 100644 src/constants.ts create mode 100644 src/hooks/useCities.test.ts create mode 100644 src/hooks/useCities.ts create mode 100644 src/hooks/useSuggestions.test.ts create mode 100644 src/hooks/useSuggestions.ts create mode 100644 src/hooks/useWeather.fixtures.ts create mode 100644 src/hooks/useWeather.test.ts create mode 100644 src/hooks/useWeather.ts create mode 100644 src/types/GeoLocation.ts create mode 100644 src/types/WeatherData.ts create mode 100644 src/utils/api.ts create mode 100644 src/utils/dedupLocations.test.ts create mode 100644 src/utils/dedupLocations.ts create mode 100644 src/utils/formatTime.test.ts create mode 100644 src/utils/formatTime.ts create mode 100644 vitest.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..843f45e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_OPEN_WEATHER_API_KEY=example_api_key_here \ No newline at end of file diff --git a/.gitignore b/.gitignore index a547bf3..4abe73e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environment variables +.env diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..e0a87fe --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,26 @@ +1. Vytvořil jsem `.env` soubor a ten přidal do `.gitignore`. Přesunul jsem do něj API klíč, protože by neměl být veřejně viditelný. + +2. Vytáhnul jsem fetchovací logiku na počasí do single hooku, kvůli porušení single responsibility principle a lepší testovatelnosti. + +3. Přidal jsem podporu pro zobrazení loading skeletonu a chyb při fetchování dat pro lepší UX. + +4. Zrefaktoroval jsem WeatherCard a rozdělil na menší komponenty pro lepší přehlednost a testovatelnost. Podrobnější informace o počasí jdou nyní přes jednu společnou komponentu - redukce 4 téměř duplicitních bloků. + +5. Upravil jsem adresářovou strukturu pro lepší přehlednost - components, hooks, types, utils. + +6. Přidal jsem Vitest a RTL balíčky a doplnil ke všemu testy. Hooky testovány izolovaně, komponenty s mockovanými daty. U `useCities` testuji i integraci s localStorage, včetně fallbacku při corrupted datech. + +7. V useWeather jsem přidal city prop do hook dependencies. Při změně city prop by se v původním řešení nenačetla nová data. + +8. Do hooků s fetchem jsem přidal AbortController proti race conditions. + +9. Odstranil jsem zapomenutý console.log, který by se neměl v produkčním kódu vyskytovat. + +10. Funkci formatTime jsem rozšířil o podporu časových zón (timezone offset z API response), aby uživatel viděl místní časy. + +11. Jako vlastní feature jsem přidal search bar s autocomplete podporou. + - Volám OpenWeather GEO API, aby si uživatel mohl pohodlně vybrat z nabídky a nemusel psát celý přesný název. + - Výsledky jsou deduplikované, jelikož API občas vrací stejná místa s lehce odlišnými daty. + - Feature podporuje debounce, aby se api nevolalo po každém stisku klávesy. + - Combobox s nabídkou míst podporuje pohyb a výběr klávesnicí. + - Seznam měst je ukládán do local storage, uživatel si může města přes tlačítko odebírat. \ No newline at end of file diff --git a/package.json b/package.json index d42cf7c..1616769 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@tailwindcss/vite": "^4.1.14", @@ -18,6 +19,9 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -26,9 +30,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "jsdom": "^28.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", - "vite": "^7.1.7" + "vite": "^7.1.7", + "vitest": "^4.0.18" }, "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6765e..a8ca7c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,15 @@ importers: '@eslint/js': specifier: ^9.36.0 version: 9.37.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^24.6.0 version: 24.7.0 @@ -51,6 +60,9 @@ importers: globals: specifier: ^16.4.0 version: 16.4.0 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -60,9 +72,28 @@ importers: vite: specifier: ^7.1.7 version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@24.7.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.1) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -134,6 +165,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -146,6 +181,41 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.25.10': resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} engines: {node: '>=18'} @@ -340,6 +410,15 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -501,6 +580,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.1.14': resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} @@ -591,6 +673,38 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -603,6 +717,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -685,6 +805,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -695,16 +844,39 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -712,6 +884,9 @@ packages: resolution: {integrity: sha512-GM9c0cWWR8Ga7//Ves/9KRgTS8nLausCkP3CGiFLrnwA2CDUluXgaQqvrULoR2Ujrd/mz/lkX87F5BHFsNr5sQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -734,6 +909,10 @@ packages: caniuse-lite@1.0.30001749: resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -759,9 +938,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -771,13 +965,26 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.233: resolution: {integrity: sha512-iUdTQSf7EFXsDdQsp8MwJz5SVk4APEFqXU/S47OtQ0YLqacSwPXdZ5vRlMX3neb07Cy2vgioNuRnWUXFwuslkg==} @@ -785,6 +992,13 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.10: resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} engines: {node: '>=18'} @@ -847,10 +1061,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -930,6 +1151,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -946,6 +1179,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -958,6 +1195,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -972,6 +1212,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1069,6 +1318,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1077,9 +1330,19 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1088,6 +1351,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1117,6 +1384,9 @@ packages: node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1133,6 +1403,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1141,6 +1414,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1160,6 +1436,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1172,6 +1452,9 @@ packages: peerDependencies: react: ^19.2.0 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -1180,6 +1463,14 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1196,6 +1487,10 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1216,10 +1511,23 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1228,6 +1536,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.1.14: resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} @@ -1239,14 +1550,40 @@ packages: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1272,6 +1609,10 @@ packages: undici-types@7.14.0: resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -1321,15 +1662,77 @@ packages: yaml: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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 + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1343,6 +1746,28 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -1432,6 +1857,8 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.6': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1455,6 +1882,32 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.25.10': optional: true @@ -1579,6 +2032,8 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -1693,6 +2148,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.4': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.1.14': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1764,6 +2221,42 @@ snapshots: tailwindcss: 4.1.14 vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4 @@ -1785,6 +2278,13 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1906,12 +2406,53 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -1919,16 +2460,32 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} baseline-browser-mapping@2.8.14: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -1954,6 +2511,8 @@ snapshots: caniuse-lite@1.0.30001749: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -1977,16 +2536,45 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.1.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.233: {} enhanced-resolve@5.18.3: @@ -1994,6 +2582,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + esbuild@0.25.10: optionalDependencies: '@esbuild/aix-ppc64': 0.25.10 @@ -2102,8 +2694,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2169,6 +2767,26 @@ snapshots: has-flag@4.0.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2180,6 +2798,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2188,6 +2808,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} jiti@2.6.1: {} @@ -2198,6 +2820,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + 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.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2268,6 +2917,8 @@ snapshots: lodash.merge@4.6.2: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2276,10 +2927,18 @@ snapshots: dependencies: react: 19.2.0 + lz-string@1.5.0: {} + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2287,6 +2946,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2309,6 +2970,8 @@ snapshots: node-releases@2.0.23: {} + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2330,10 +2993,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2348,6 +3017,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -2357,10 +3032,19 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} reusify@1.1.0: {} @@ -2397,6 +3081,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2409,14 +3097,26 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tailwindcss@4.1.14: {} tapable@2.3.0: {} @@ -2429,15 +3129,35 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2461,6 +3181,8 @@ snapshots: undici-types@7.14.0: {} + undici@7.22.0: {} + update-browserslist-db@1.1.3(browserslist@4.26.3): dependencies: browserslist: 4.26.3 @@ -2485,12 +3207,75 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.1 + vitest@4.0.18(@types/node@24.7.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)) + '@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.3.0 + 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: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.7.0 + jsdom: 28.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yallist@5.0.0: {} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..643f378 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import App from "./App"; + +vi.mock("./hooks/useCities"); +vi.mock("./components/WeatherCard", () => ({ + WeatherCard: ({ city }: { city: string }) =>
{city}
, +})); +vi.mock("./components/SearchBar", () => ({ + SearchBar: () => , +})); + +import { useCities } from "./hooks/useCities"; + +const defaultMock = { + cities: ["Prague,CZ", "Barcelona,ES"], + addCity: vi.fn(), + removeCity: vi.fn(), +}; + +beforeEach(() => { + vi.mocked(useCities).mockReturnValue(defaultMock); +}); + +describe("App", () => { + it("renders the search bar", () => { + render(); + expect(screen.getByTestId("search-bar")).toBeVisible(); + }); + + it("renders a WeatherCard for each city", () => { + render(); + const cards = screen.getAllByTestId("weather-card"); + expect(cards).toHaveLength(2); + expect(cards[0]).toHaveTextContent("Prague,CZ"); + expect(cards[1]).toHaveTextContent("Barcelona,ES"); + }); + + it("renders no cards when cities list is empty", () => { + vi.mocked(useCities).mockReturnValue({ ...defaultMock, cities: [] }); + render(); + expect(screen.queryByTestId("weather-card")).not.toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 3c89538..864d9c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,22 @@ -import WeatherCard from "./components/WeatherCard"; +import { WeatherCard } from "./components/WeatherCard"; +import { SearchBar } from "./components/SearchBar"; +import { useCities } from "./hooks/useCities"; function App() { + const { cities, addCity, removeCity } = useCities(); + + const handleSearch = (city: string) => { + if (city) addCity(city); + }; + return (
+ +
- - - - + {cities.map((city) => ( + + ))}
); diff --git a/src/components/AdvancedInfo.test.tsx b/src/components/AdvancedInfo.test.tsx new file mode 100644 index 0000000..0fab86d --- /dev/null +++ b/src/components/AdvancedInfo.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { AdvancedInfo, type AdvancedInfoType } from "./AdvancedInfo"; + +describe("AdvancedInfo", () => { + it("renders the type label", () => { + render(); + expect(screen.getByText("Pressure")).toBeVisible(); + }); + + it("renders the value", () => { + render(); + expect(screen.getByText("1013 hPa")).toBeVisible(); + }); + + it.each(["Pressure", "Humidity", "Wind", "Visibility"])( + "renders %s type without crashing", + (type) => { + render(); + expect(screen.getByText(type)).toBeVisible(); + expect(screen.getByText("test value")).toBeVisible(); + } + ); +}); diff --git a/src/components/AdvancedInfo.tsx b/src/components/AdvancedInfo.tsx new file mode 100644 index 0000000..89ad8a1 --- /dev/null +++ b/src/components/AdvancedInfo.tsx @@ -0,0 +1,35 @@ +import { GaugeIcon, DropletsIcon, WindIcon, EyeIcon } from "lucide-react"; + +export type AdvancedInfoType = "Pressure" | "Humidity" | "Wind" | "Visibility"; + +type AdvancedInfoProps = { + type: AdvancedInfoType; + value: string; +}; + +export const AdvancedInfo = ({ type, value }: AdvancedInfoProps) => { + return ( +
+
+ {getAdvancedInfoIcon(type)} + {type} +
+
+ {value} +
+
+ ); +} + +const getAdvancedInfoIcon = (type: AdvancedInfoType) => { + switch (type) { + case "Pressure": + return ; + case "Humidity": + return ; + case "Wind": + return ; + case "Visibility": + return ; + } +} diff --git a/src/components/RemoveButton.tsx b/src/components/RemoveButton.tsx new file mode 100644 index 0000000..45f8a81 --- /dev/null +++ b/src/components/RemoveButton.tsx @@ -0,0 +1,15 @@ +type RemoveButtonProps = { + city: string; + onRemove: (city: string) => void; +}; + +export const RemoveButton = ({ city, onRemove }: RemoveButtonProps) => ( + +); diff --git a/src/components/SearchBar.test.tsx b/src/components/SearchBar.test.tsx new file mode 100644 index 0000000..6a08dad --- /dev/null +++ b/src/components/SearchBar.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, fireEvent, within, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SearchBar } from "./SearchBar"; +import type { GeoLocation } from "../types/GeoLocation"; + +vi.mock("../hooks/useSuggestions"); +import { useSuggestions } from "../hooks/useSuggestions"; + +const mockSuggestions: GeoLocation[] = [ + { name: "Prague", lat: 50.09, lon: 14.42, country: "CZ", state: "Bohemia" }, + { name: "Prague", lat: 41.66, lon: -72.43, country: "US", state: "Oklahoma" }, +]; + +beforeEach(() => { + vi.mocked(useSuggestions).mockReturnValue({ suggestions: [], loading: false }); +}); + +describe("SearchBar", () => { + it("renders the search input", () => { + render(); + expect(screen.getByRole("combobox")).toBeVisible(); + expect(screen.getByPlaceholderText("Search for a city (e.g. London)")).toBeVisible(); + }); + + it("dropdown is not visible initially", () => { + render(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("shows suggestions when typing", () => { + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + expect(screen.getAllByText("Prague")).toHaveLength(2); + expect(screen.getAllByRole("option")).toHaveLength(2); + }); + + it("shows state and country alongside city name in each suggestion", () => { + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + expect(screen.getByText("Bohemia, CZ")).toBeVisible(); + expect(screen.getByText("Oklahoma, US")).toBeVisible(); + }); + + it("shows 'Searching...' while loading", () => { + vi.mocked(useSuggestions).mockReturnValue({ suggestions: [], loading: true }); + render(); + typeInInput("Pra"); + expect(screen.getByText("Searching...")).toBeVisible(); + }); + + it("shows 'No cities found' when no suggestions and query length > 1", () => { + vi.mocked(useSuggestions).mockReturnValue({ suggestions: [], loading: false }); + render(); + typeInInput("Xyz"); + expect(screen.getByText("No cities found")).toBeVisible(); + }); + + it("does not show 'No cities found' for single-character queries", () => { + vi.mocked(useSuggestions).mockReturnValue({ suggestions: [], loading: false }); + render(); + typeInInput("X"); + expect(screen.queryByText("No cities found")).not.toBeInTheDocument(); + }); + + it("calls onSearch with 'Name,Country' when a suggestion is clicked", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + const onSearch = vi.fn(); + render(); + typeInInput("Pra"); + await user.click(within(screen.getAllByRole("option")[0]).getByRole("button")); + expect(onSearch).toHaveBeenCalledWith("Prague,CZ"); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it("closes the dropdown after selecting a suggestion", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + await user.click(within(screen.getAllByRole("option")[0]).getByRole("button")); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("updates the input value to the formatted location after selection", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + await user.click(within(screen.getAllByRole("option")[0]).getByRole("button")); + expect(screen.getByRole("combobox")).toHaveValue("Prague, Bohemia, CZ"); + }); + + it("closes the dropdown on Escape", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + expect(screen.getAllByRole("option")).toHaveLength(2); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("navigates suggestions with ArrowDown and ArrowUp", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + const options = screen.getAllByRole("option"); + + await user.keyboard("{ArrowDown}"); + expect(options[0]).toHaveAttribute("aria-selected", "true"); + expect(options[1]).toHaveAttribute("aria-selected", "false"); + + await user.keyboard("{ArrowDown}"); + expect(options[0]).toHaveAttribute("aria-selected", "false"); + expect(options[1]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("{ArrowUp}"); + expect(options[0]).toHaveAttribute("aria-selected", "true"); + }); + + it("wraps ArrowDown at the last suggestion back to the first", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + render(); + typeInInput("Pra"); + const options = screen.getAllByRole("option"); + + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + expect(options[0]).toHaveAttribute("aria-selected", "true"); + }); + + it("selects the active suggestion on Enter", async () => { + const user = userEvent.setup(); + vi.mocked(useSuggestions).mockReturnValue({ suggestions: mockSuggestions, loading: false }); + const onSearch = vi.fn(); + render(); + typeInInput("Pra"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + expect(onSearch).toHaveBeenCalledWith("Prague,CZ"); + }); +}); + +const typeInInput = (value: string) => { + const input = screen.getByRole("combobox"); + act(() => { + input.focus(); + fireEvent.change(input, { target: { value } }); + }); + return input; +}; \ No newline at end of file diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..4839664 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,104 @@ +import { useState, useRef } from "react"; +import { useSuggestions } from "../hooks/useSuggestions"; +import { type GeoLocation } from "../types/GeoLocation"; +import { SuggestionsList } from "./SuggestionsList"; + +interface SearchBarProps { + onSearch: (city: string) => void; +} + +export const SearchBar = ({ onSearch }: SearchBarProps) => { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const { suggestions, loading } = useSuggestions(query); + const containerRef = useRef(null); + + const handleSelect = (location: GeoLocation) => { + const label = formatLocation(location); + setQuery(label); + setOpen(false); + setActiveIndex(-1); + onSearch(`${location.name},${location.country}`); + }; + + const handleBlur = (e: React.FocusEvent) => { + if (!containerRef.current?.contains(e.relatedTarget)) { + setOpen(false); + setActiveIndex(-1); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open || !suggestions.length) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : suggestions.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < suggestions.length) { + handleSelect(suggestions[activeIndex]); + } + break; + case "Escape": + setOpen(false); + setActiveIndex(-1); + break; + } + }; + + return ( +
+ { + setQuery(e.target.value); + setOpen(true); + setActiveIndex(-1); + }} + onKeyDown={handleKeyDown} + onFocus={() => suggestions.length > 0 && setOpen(true)} + placeholder="Search for a city (e.g. London)" + role="combobox" + aria-expanded={open} + aria-autocomplete="list" + aria-controls="search-suggestions" + aria-activedescendant={activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined} + className="w-full px-4 py-3 rounded-xl shadow-md text-lg outline-none focus:ring-2 focus:ring-blue-400" + /> + + {open && ( + + )} +
+ ); +}; + +function formatLocation(loc: GeoLocation): string { + const parts = [loc.name]; + if (loc.state) parts.push(loc.state); + parts.push(loc.country); + return parts.join(", "); +} diff --git a/src/components/SuggestionsList.tsx b/src/components/SuggestionsList.tsx new file mode 100644 index 0000000..1d6502a --- /dev/null +++ b/src/components/SuggestionsList.tsx @@ -0,0 +1,58 @@ +import { type GeoLocation } from "../types/GeoLocation"; + +interface SuggestionsListProps { + suggestions: GeoLocation[]; + loading: boolean; + query: string; + activeIndex: number; + onSelect: (location: GeoLocation) => void; +} + +export const SuggestionsList = ({ + suggestions, + loading, + query, + activeIndex, + onSelect, +}: SuggestionsListProps) => { + if (!query.trim()) return null; + + return ( +
    + {loading && ( +
  • Searching...
  • + )} + {!loading && suggestions.length === 0 && query.trim().length > 1 && ( +
  • No cities found
  • + )} + {!loading && + suggestions.map((loc, index) => ( +
  • + +
  • + ))} +
+ ); +}; diff --git a/src/components/SunInfo.test.tsx b/src/components/SunInfo.test.tsx new file mode 100644 index 0000000..0047e87 --- /dev/null +++ b/src/components/SunInfo.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { SunInfo } from "./SunInfo"; +import { formatTime } from "../utils/formatTime"; + +describe("SunInfo", () => { + const sunriseTime = 1620000000; // 2021-05-03 ~00:00 UTC + const sunsetTime = 1620050000; // 2021-05-03 ~13:53 UTC + const timezoneOffset = 7200; // UTC+2 (e.g. Prague) + + it("renders the Sunrise label", () => { + render(); + expect(screen.getByText("Sunrise")).toBeVisible(); + }); + + it("renders the Sunset label", () => { + render(); + expect(screen.getByText("Sunset")).toBeVisible(); + }); + + it("renders the formatted sunrise time", () => { + render(); + expect(screen.getByText(formatTime(sunriseTime, timezoneOffset))).toBeVisible(); + }); + + it("renders the formatted sunset time", () => { + render(); + expect(screen.getByText(formatTime(sunsetTime, timezoneOffset))).toBeVisible(); + }); +}); diff --git a/src/components/SunInfo.tsx b/src/components/SunInfo.tsx new file mode 100644 index 0000000..1682556 --- /dev/null +++ b/src/components/SunInfo.tsx @@ -0,0 +1,39 @@ +import { SunriseIcon, SunsetIcon } from "lucide-react"; +import { formatTime } from "../utils/formatTime"; + +type SunInfoProps = { + sunriseTime: number; + sunsetTime: number; + timezoneOffset: number; +} + +export const SunInfo = ({ sunriseTime, sunsetTime, timezoneOffset }: SunInfoProps) => { + return ( +
+
+
+ +
+
+ Sunrise +
+
+ {formatTime(sunriseTime, timezoneOffset)} +
+
+
+
+ +
+
+ Sunset +
+
+ {formatTime(sunsetTime, timezoneOffset)} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Temperature.test.tsx b/src/components/Temperature.test.tsx new file mode 100644 index 0000000..48cab5b --- /dev/null +++ b/src/components/Temperature.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { Temperature } from "./Temperature"; + +describe("Temperature", () => { + const defaultProps = { + current: 20, + feelsLike: 18, + tempMin: 15, + tempMax: 25, + }; + + it("renders the section label", () => { + render(); + expect(screen.getByText("Temperature")).toBeVisible(); + }); + + it("renders the current temperature", () => { + render(); + expect(screen.getByText("20°C")).toBeVisible(); + }); + + it("renders the feels-like temperature", () => { + render(); + expect(screen.getByText("Feels like 18°C")).toBeVisible(); + }); + + it("renders the min/max temperature range", () => { + render(); + expect(screen.getByText("15°C - 25°C")).toBeVisible(); + }); + + it("renders negative temperatures correctly", () => { + render( + + ); + expect(screen.getByText("-5°C")).toBeVisible(); + expect(screen.getByText("Feels like -10°C")).toBeVisible(); + expect(screen.getByText("-12°C - -3°C")).toBeVisible(); + }); +}); diff --git a/src/components/Temperature.tsx b/src/components/Temperature.tsx new file mode 100644 index 0000000..5053d18 --- /dev/null +++ b/src/components/Temperature.tsx @@ -0,0 +1,33 @@ +import { ThermometerIcon } from "lucide-react"; + +type TemperatureProps = { + current: number; + feelsLike: number; + tempMin: number; + tempMax: number; +}; + +export const Temperature = ({ current, feelsLike, tempMin, tempMax }: TemperatureProps) => { + return (
+
+ + Temperature +
+
+
+
+ {current}°C +
+
Current
+
+
+
+ Feels like {feelsLike}°C +
+
+ {tempMin}°C - {tempMax}°C +
+
+
+
); +} \ No newline at end of file diff --git a/src/components/WeatherCard.fixtures.tsx b/src/components/WeatherCard.fixtures.tsx new file mode 100644 index 0000000..98d7769 --- /dev/null +++ b/src/components/WeatherCard.fixtures.tsx @@ -0,0 +1,22 @@ +import type { WeatherData } from "../types/WeatherData"; + +export const mockWeatherData: WeatherData = { + coord: { lon: 14.42, lat: 50.09 }, + weather: [{ id: 800, main: "Clear", description: "clear sky", icon: "01d" }], + main: { + temp: 20, + feels_like: 18, + temp_min: 15, + temp_max: 25, + pressure: 1013, + humidity: 55, + sea_level: 1013, + grnd_level: 1000, + }, + visibility: 10000, + wind: { speed: 3.5, deg: 180 }, + sys: { country: "CZ", sunrise: 1620000000, sunset: 1620050000 }, + timezone: 7200, + id: 3067696, + name: "Prague", +}; \ No newline at end of file diff --git a/src/components/WeatherCard.test.tsx b/src/components/WeatherCard.test.tsx new file mode 100644 index 0000000..65073d4 --- /dev/null +++ b/src/components/WeatherCard.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { WeatherCard } from "./WeatherCard"; +import { useWeather } from "../hooks/useWeather"; +import { mockWeatherData } from "./WeatherCard.fixtures"; + +vi.mock("../hooks/useWeather"); +vi.mock("./WeatherCardSkeleton", () => ({ + WeatherCardSkeleton: () =>
, +})); + +beforeEach(() => { + vi.mocked(useWeather).mockReturnValue({ data: null, loading: false, error: null }); +}); + +describe("WeatherCard", () => { + it("shows the skeleton while loading", () => { + vi.mocked(useWeather).mockReturnValue({ data: null, loading: true, error: null }); + render(); + expect(screen.getByTestId("weather-skeleton")).toBeVisible(); + expect(screen.queryByLabelText("Remove Prague,CZ")).not.toBeInTheDocument(); + }); + + it("shows the error card when there is an error", () => { + vi.mocked(useWeather).mockReturnValue({ + data: null, + loading: false, + error: "Failed to fetch weather: 404 Not Found", + }); + render(); + expect(screen.getByText("Weather Unavailable")).toBeVisible(); + expect(screen.getByText("Failed to fetch weather: 404 Not Found")).toBeVisible(); + expect(screen.getByLabelText("Remove Prague,CZ")).toBeVisible(); + }); + + it("shows 'No weather data available' when data is null and there is no error", () => { + vi.mocked(useWeather).mockReturnValue({ data: null, loading: false, error: null }); + render(); + expect(screen.getByText("No weather data available.")).toBeVisible(); + }); + + it("renders city name and weather details when data is available", () => { + vi.mocked(useWeather).mockReturnValue({ data: mockWeatherData, loading: false, error: null }); + render(); + expect(screen.getByText("Prague")).toBeVisible(); + expect(screen.getByText("clear sky")).toBeVisible(); + expect(screen.getByText("20°C")).toBeVisible(); + }); + + it("renders all advanced info sections when data is available", () => { + vi.mocked(useWeather).mockReturnValue({ data: mockWeatherData, loading: false, error: null }); + render(); + expect(screen.getByText("1013 hPa")).toBeVisible(); + expect(screen.getByText("55%")).toBeVisible(); + expect(screen.getByText("3.5 m/s, 180°")).toBeVisible(); + expect(screen.getByText("10.0 km")).toBeVisible(); + }); + + it("calls onRemove with the city when the remove button is clicked", async () => { + const user = userEvent.setup(); + vi.mocked(useWeather).mockReturnValue({ data: mockWeatherData, loading: false, error: null }); + const onRemove = vi.fn(); + render(); + await user.click(screen.getByLabelText("Remove Prague,CZ")); + expect(onRemove).toHaveBeenCalledWith("Prague,CZ"); + expect(onRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/WeatherCard.tsx b/src/components/WeatherCard.tsx index 320266f..cfdb212 100644 --- a/src/components/WeatherCard.tsx +++ b/src/components/WeatherCard.tsx @@ -1,188 +1,64 @@ -import { - EyeIcon, - SunriseIcon, - SunsetIcon, - ThermometerIcon, - WindIcon, - DropletsIcon, - GaugeIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { OPEN_WEATHER_API_KEY } from "../constants"; +import { useWeather } from "../hooks/useWeather"; +import { WeatherCardHeader } from "./WeatherCardHeader"; +import { SunInfo } from "./SunInfo"; +import { Temperature } from "./Temperature"; +import { AdvancedInfo } from "./AdvancedInfo"; +import { WeatherCardSkeleton } from "./WeatherCardSkeleton"; +import { WeatherCardError } from "./WeatherCardError"; +import { RemoveButton } from "./RemoveButton"; -type WeatherData = { - coord: { - lon: number; - lat: number; - }; - weather: { - id: number; - main: string; - description: string; - icon: string; - }[]; - main: { - temp: number; - feels_like: number; - temp_min: number; - temp_max: number; - pressure: number; - humidity: number; - sea_level: number; - grnd_level: number; - }; - visibility: number; - wind: { - speed: number; - deg: number; - }; - sys: { - country: string; - sunrise: number; - sunset: number; - }; - id: number; - name: string; +type WeatherCardProps = { + city: string; + onRemove: (city: string) => void; }; -const WeatherCard = ({ city }: { city: string }) => { - const [data, setData] = useState(null); +export const WeatherCard = ({ city, onRemove }: WeatherCardProps) => { + const { data, loading, error } = useWeather(city); - useEffect(() => { - const getData = async () => { - const res = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${OPEN_WEATHER_API_KEY}&units=metric` - ); - const parsedData = await res.json(); - setData(parsedData); - }; - getData(); - }, []); + if (loading) { + return ; + } - const formatTime = (timestamp: number) => { - return new Date(timestamp * 1000).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }; + if (error) { + return ; + } - console.log(data); + if (!data) { + return ; + } - if (!data) return null; + const weatherData = data.weather?.[0]; + + if (!weatherData) { + return ; + } return ( -
-
-
-
-
{data.name}
- - {data.weather[0].description} - -
- -
-
+
+ +
-
-
- - Temperature -
-
-
-
- {data.main.temp}°C -
-
Current
-
-
-
- Feels like {data.main.feels_like}°C -
-
- {data.main.temp_min}°C - {data.main.temp_max}°C -
-
-
-
+
-
-
- - Pressure -
-
- {data.main.pressure} hPa -
-
- -
-
- - Humidity -
-
- {data.main.humidity}% -
-
- -
-
- - Wind -
-
- {data.wind.speed} m/s -
-
{data.wind.deg}°
-
- -
-
- - Visibility -
-
- {(data.visibility / 1_000).toFixed(1)} km -
-
+ + + +
-
-
-
- -
-
- Sunrise -
-
- {formatTime(data.sys.sunrise)} -
-
-
-
- -
-
- Sunset -
-
- {formatTime(data.sys.sunset)} -
-
-
-
-
+
); -}; - -export default WeatherCard; +}; \ No newline at end of file diff --git a/src/components/WeatherCardError.test.tsx b/src/components/WeatherCardError.test.tsx new file mode 100644 index 0000000..15298b1 --- /dev/null +++ b/src/components/WeatherCardError.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { WeatherCardError } from "./WeatherCardError"; + +describe("WeatherCardError", () => { + it("renders the 'Weather Unavailable' heading", () => { + render(); + expect(screen.getByText("Weather Unavailable")).toBeVisible(); + }); + + it("renders the provided error message", () => { + render(); + expect(screen.getByText("City not found")).toBeVisible(); + }); + + it("renders a different error message", () => { + render(); + expect( + screen.getByText("Failed to fetch weather: 404 Not Found") + ).toBeVisible(); + }); + + it("renders a remove button when city and onRemove are provided", () => { + render(); + expect(screen.getByLabelText("Remove Prague,CZ")).toBeVisible(); + }); + + it("calls onRemove with the city when the remove button is clicked", async () => { + const onRemove = vi.fn(); + render(); + await userEvent.click(screen.getByLabelText("Remove Prague,CZ")); + expect(onRemove).toHaveBeenCalledWith("Prague,CZ"); + }); + + it("does not render a remove button when city and onRemove are not provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/WeatherCardError.tsx b/src/components/WeatherCardError.tsx new file mode 100644 index 0000000..f7ba63c --- /dev/null +++ b/src/components/WeatherCardError.tsx @@ -0,0 +1,26 @@ +import { RemoveButton } from "./RemoveButton"; + +type WeatherCardErrorProps = { + error: string; + city?: string; + onRemove?: (city: string) => void; +}; + +export const WeatherCardError = ({ error, city, onRemove }: WeatherCardErrorProps) => { + return ( +
+ {city && onRemove && ( + + )} +
+
+
+
Weather Unavailable
+ {error} +
+
⚠️
+
+
+
+ ); +}; diff --git a/src/components/WeatherCardHeader.test.tsx b/src/components/WeatherCardHeader.test.tsx new file mode 100644 index 0000000..2533da4 --- /dev/null +++ b/src/components/WeatherCardHeader.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { WeatherCardHeader } from "./WeatherCardHeader"; + +describe("WeatherCardHeader", () => { + const defaultProps = { + cityName: "Prague", + weatherDescription: "clear sky", + icon: "01d", + }; + + it("renders the city name", () => { + render(); + expect(screen.getByText("Prague")).toBeVisible(); + }); + + it("renders the weather description", () => { + render(); + expect(screen.getByText("clear sky")).toBeVisible(); + }); + + it("renders the weather icon with the correct src", () => { + render(); + expect(screen.getByRole("img")).toHaveAttribute( + "src", + "https://openweathermap.org/img/wn/01d@4x.png" + ); + }); + + it("uses the provided icon code in the image URL", () => { + render(); + expect(screen.getByRole("img")).toHaveAttribute( + "src", + "https://openweathermap.org/img/wn/10n@4x.png" + ); + }); +}); diff --git a/src/components/WeatherCardHeader.tsx b/src/components/WeatherCardHeader.tsx new file mode 100644 index 0000000..09a0b8e --- /dev/null +++ b/src/components/WeatherCardHeader.tsx @@ -0,0 +1,26 @@ +type WeatherCardHeaderProps = { + cityName: string; + weatherDescription: string; + icon?: string; +} + +export const WeatherCardHeader = ({ cityName, weatherDescription, icon }: WeatherCardHeaderProps) => { + return (
+
+
+
{cityName}
+ + {weatherDescription} + +
+ {icon && ( + {weatherDescription} + )} +
+
); +} \ No newline at end of file diff --git a/src/components/WeatherCardSkeleton.tsx b/src/components/WeatherCardSkeleton.tsx new file mode 100644 index 0000000..d225bbd --- /dev/null +++ b/src/components/WeatherCardSkeleton.tsx @@ -0,0 +1,59 @@ +const Skeleton = ({ className }: { className?: string }) => ( +
+); + +export const WeatherCardSkeleton = () => { + return ( +
+
+
+
+
+ + +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ +
+
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 737af1e..0000000 --- a/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const OPEN_WEATHER_API_KEY = "deb39e87582e3f10df3332a768ac7cc9"; diff --git a/src/hooks/useCities.test.ts b/src/hooks/useCities.test.ts new file mode 100644 index 0000000..ef4e2c2 --- /dev/null +++ b/src/hooks/useCities.test.ts @@ -0,0 +1,88 @@ +import { renderHook, act } from "@testing-library/react"; +import { useCities } from "./useCities"; + +const STORAGE_KEY = "weather-cities"; +const DEFAULT_CITIES = ["Prague,CZ", "Barcelona,ES", "Banff,CA", "Dubai,AE"]; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("useCities", () => { + it("loads default cities when localStorage is empty", () => { + const { result } = renderHook(() => useCities()); + expect(result.current.cities).toEqual(DEFAULT_CITIES); + }); + + it("persists default cities to localStorage on first load", () => { + renderHook(() => useCities()); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(DEFAULT_CITIES); + }); + + it("loads cities from localStorage when present", () => { + const saved = ["Tokyo,JP", "Sydney,AU"]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + + const { result } = renderHook(() => useCities()); + expect(result.current.cities).toEqual(saved); + }); + + it("addCity prepends a new city", () => { + const { result } = renderHook(() => useCities()); + + act(() => result.current.addCity("Tokyo,JP")); + + expect(result.current.cities[0]).toBe("Tokyo,JP"); + expect(result.current.cities).toHaveLength(DEFAULT_CITIES.length + 1); + }); + + it("addCity does not add a duplicate city", () => { + const { result } = renderHook(() => useCities()); + + act(() => result.current.addCity("Prague,CZ")); + + expect(result.current.cities).toHaveLength(DEFAULT_CITIES.length); + }); + + it("addCity does not add a case-insensitive duplicate", () => { + const { result } = renderHook(() => useCities()); + + act(() => result.current.addCity("prague,cz")); + + expect(result.current.cities).toHaveLength(DEFAULT_CITIES.length); + }); + + it("addCity persists to localStorage", () => { + const { result } = renderHook(() => useCities()); + + act(() => result.current.addCity("Tokyo,JP")); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toContain("Tokyo,JP"); + }); + + it("removeCity removes the specified city", () => { + const { result } = renderHook(() => useCities()); + + act(() => result.current.removeCity("Prague,CZ")); + + expect(result.current.cities).not.toContain("Prague,CZ"); + expect(result.current.cities).toHaveLength(DEFAULT_CITIES.length - 1); + }); + + it("removeCity persists to localStorage", () => { + const { result } = renderHook(() => useCities()); + + act(() => result.current.removeCity("Prague,CZ")); + + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).not.toContain("Prague,CZ"); + }); + + it("falls back to default cities when localStorage contains invalid JSON", () => { + localStorage.setItem(STORAGE_KEY, "not-valid-json{{{"); + + const { result } = renderHook(() => useCities()); + + expect(result.current.cities).toEqual(DEFAULT_CITIES); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(DEFAULT_CITIES); + }); +}); diff --git a/src/hooks/useCities.ts b/src/hooks/useCities.ts new file mode 100644 index 0000000..1ca199c --- /dev/null +++ b/src/hooks/useCities.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; + +const STORAGE_KEY = "weather-cities"; +const DEFAULT_CITIES = ["Prague,CZ", "Barcelona,ES", "Banff,CA", "Dubai,AE"]; + +function loadCities(): string[] { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_CITIES)); + return DEFAULT_CITIES; +} + +export const useCities = () => { + const [cities, setCities] = useState(loadCities); + + const save = (updatedCities: string[]) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedCities)); + setCities(updatedCities); + }; + + const addCity = (city: string) => { + const normalized = city.toLowerCase(); + if (!cities.some((c) => c.toLowerCase() === normalized)) { + save([city, ...cities]); + } + }; + + const removeCity = (city: string) => { + save(cities.filter((c) => c !== city)); + }; + + return { cities, addCity, removeCity }; +}; diff --git a/src/hooks/useSuggestions.test.ts b/src/hooks/useSuggestions.test.ts new file mode 100644 index 0000000..ed156ea --- /dev/null +++ b/src/hooks/useSuggestions.test.ts @@ -0,0 +1,119 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { useSuggestions } from "./useSuggestions"; +import type { GeoLocation } from "../types/GeoLocation"; + +const mockLocations: GeoLocation[] = [ + { name: "Prague", lat: 50.09, lon: 14.42, country: "CZ", state: "Bohemia" }, + { name: "Prague", lat: 41.66, lon: -72.43, country: "US", state: "Oklahoma" }, +]; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +describe("useSuggestions", () => { + it("starts with empty suggestions and loading=false", () => { + const { result } = renderHook(() => useSuggestions("")); + expect(result.current.suggestions).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it("clears suggestions and stays not loading when query is empty", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockLocations, + } as Response); + + const { result, rerender } = renderHook(({ q }) => useSuggestions(q, 0), { + initialProps: { q: "Prague" }, + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.suggestions).toHaveLength(2); + + rerender({ q: "" }); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([]); + expect(result.current.loading).toBe(false); + }); + }); + + it("sets loading=true while debounce is pending", () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockLocations, + } as Response); + + const { result } = renderHook(() => useSuggestions("Pra", 500)); + expect(result.current.loading).toBe(true); + }); + + it("fetches and returns suggestions after debounce", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockLocations, + } as Response); + + const { result } = renderHook(() => useSuggestions("Prague", 0)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.suggestions).toEqual(mockLocations); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("deduplicates locations with the same name, country, and state", async () => { + const duplicated: GeoLocation[] = [ + ...mockLocations, + { name: "Prague", lat: 50.1, lon: 14.5, country: "CZ", state: "Bohemia" }, + ]; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => duplicated, + } as Response); + + const { result } = renderHook(() => useSuggestions("Prague", 0)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.suggestions).toHaveLength(2); + }); + + it("returns empty suggestions on fetch error", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + + const { result } = renderHook(() => useSuggestions("Prague", 0)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.suggestions).toEqual([]); + }); + + it("returns empty suggestions when response is not ok", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + } as Response); + + const { result } = renderHook(() => useSuggestions("Prague", 0)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.suggestions).toEqual([]); + }); + + it("trims whitespace from the query before fetching", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockLocations, + } as Response); + + renderHook(() => useSuggestions(" Prague ", 0)); + + await waitFor(() => expect(fetch).toHaveBeenCalledOnce()); + + const url = vi.mocked(fetch).mock.calls[0][0] as string; + expect(url).toContain(encodeURIComponent("Prague")); + }); +}); diff --git a/src/hooks/useSuggestions.ts b/src/hooks/useSuggestions.ts new file mode 100644 index 0000000..3e1b4a9 --- /dev/null +++ b/src/hooks/useSuggestions.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useRef } from "react"; +import { type GeoLocation } from "../types/GeoLocation"; +import { OPEN_WEATHER_API_KEY, OPEN_WEATHER_BASE_URL } from "../utils/api"; +import { dedupLocations } from "../utils/dedupLocations"; + +export const useSuggestions = (query: string, debounceMs = 300) => { + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + const debounceRef = useRef>(null); + + useEffect(() => { + const controller = new AbortController(); + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (!query.trim()) { + setSuggestions([]); + setLoading(false); + return; + } + + setLoading(true); + + debounceRef.current = setTimeout(async () => { + try { + const res = await fetch( + `${OPEN_WEATHER_BASE_URL}/geo/1.0/direct?q=${encodeURIComponent(query.trim())}&limit=5&appid=${OPEN_WEATHER_API_KEY}`, + { signal: controller.signal } + ); + if (!res.ok) throw new Error("Geocoding request failed"); + const data: GeoLocation[] = await res.json(); + setSuggestions(dedupLocations(data)); + } catch { + if (!controller.signal.aborted) setSuggestions([]); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, debounceMs); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + controller.abort(); + }; + }, [query, debounceMs]); + + return { suggestions, loading }; +}; \ No newline at end of file diff --git a/src/hooks/useWeather.fixtures.ts b/src/hooks/useWeather.fixtures.ts new file mode 100644 index 0000000..5ee54a0 --- /dev/null +++ b/src/hooks/useWeather.fixtures.ts @@ -0,0 +1,22 @@ +import type { WeatherData } from "../types/WeatherData"; + +export const mockWeatherData: WeatherData = { + coord: { lon: 14.42, lat: 50.09 }, + weather: [{ id: 800, main: "Clear", description: "clear sky", icon: "01d" }], + main: { + temp: 20, + feels_like: 19, + temp_min: 18, + temp_max: 22, + pressure: 1013, + humidity: 50, + sea_level: 1013, + grnd_level: 1000, + }, + visibility: 10000, + wind: { speed: 3.5, deg: 180 }, + sys: { country: "CZ", sunrise: 1620000000, sunset: 1620050000 }, + timezone: 7200, + id: 3067696, + name: "Prague", +}; \ No newline at end of file diff --git a/src/hooks/useWeather.test.ts b/src/hooks/useWeather.test.ts new file mode 100644 index 0000000..06aea3e --- /dev/null +++ b/src/hooks/useWeather.test.ts @@ -0,0 +1,83 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { useWeather } from "./useWeather"; +import { mockWeatherData } from "./useWeather.fixtures"; + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +describe("useWeather", () => { + it("starts with loading=true and no data", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockWeatherData, + } as Response); + + const { result } = renderHook(() => useWeather("Prague,CZ")); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("returns data on successful fetch", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockWeatherData, + } as Response); + + const { result } = renderHook(() => useWeather("Prague,CZ")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toEqual(mockWeatherData); + expect(result.current.error).toBeNull(); + }); + + it("sets error when response is not ok", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + } as Response); + + const { result } = renderHook(() => useWeather("Unknown")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBe("Failed to fetch weather: 404 Not Found"); + }); + + it("sets error when fetch throws", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + + const { result } = renderHook(() => useWeather("Prague,CZ")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBe("Network error"); + }); + + it("refetches when city changes", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockWeatherData, + } as Response); + + const { result, rerender } = renderHook(({ city }) => useWeather(city), { + initialProps: { city: "Prague,CZ" }, + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetch).toHaveBeenCalledTimes(1); + + rerender({ city: "Barcelona,ES" }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/hooks/useWeather.ts b/src/hooks/useWeather.ts new file mode 100644 index 0000000..bff6371 --- /dev/null +++ b/src/hooks/useWeather.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from "react"; +import { type WeatherData } from "../types/WeatherData"; +import { OPEN_WEATHER_API_KEY, OPEN_WEATHER_BASE_URL } from "../utils/api"; + +export const useWeather = (city: string) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + + const getData = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `${OPEN_WEATHER_BASE_URL}/data/2.5/weather?q=${encodeURIComponent(city.trim())}&appid=${OPEN_WEATHER_API_KEY}&units=metric`, + { signal: controller.signal } + ); + if (!res.ok) { + throw new Error(`Failed to fetch weather: ${res.status} ${res.statusText}`); + } + const parsedData = await res.json(); + setData(parsedData); + } catch (err) { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }; + + getData(); + + return () => controller.abort(); + }, [city]); + + return { data, loading, error }; +}; diff --git a/src/types/GeoLocation.ts b/src/types/GeoLocation.ts new file mode 100644 index 0000000..ab28f01 --- /dev/null +++ b/src/types/GeoLocation.ts @@ -0,0 +1,8 @@ +export interface GeoLocation { + name: string; + local_names?: Record; + lat: number; + lon: number; + country: string; + state?: string; +} diff --git a/src/types/WeatherData.ts b/src/types/WeatherData.ts new file mode 100644 index 0000000..097a67c --- /dev/null +++ b/src/types/WeatherData.ts @@ -0,0 +1,35 @@ +export type WeatherData = { + coord: { + lon: number; + lat: number; + }; + weather: { + id: number; + main: string; + description: string; + icon: string; + }[]; + main: { + temp: number; + feels_like: number; + temp_min: number; + temp_max: number; + pressure: number; + humidity: number; + sea_level: number; + grnd_level: number; + }; + visibility: number; + wind: { + speed: number; + deg: number; + }; + sys: { + country: string; + sunrise: number; + sunset: number; + }; + timezone: number; + id: number; + name: string; +}; \ No newline at end of file diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..4c70b15 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,7 @@ +export const OPEN_WEATHER_API_KEY = import.meta.env.VITE_OPEN_WEATHER_API_KEY; + +if (!OPEN_WEATHER_API_KEY) { + throw new Error("Missing VITE_OPEN_WEATHER_API_KEY environment variable."); +} + +export const OPEN_WEATHER_BASE_URL = "https://api.openweathermap.org"; diff --git a/src/utils/dedupLocations.test.ts b/src/utils/dedupLocations.test.ts new file mode 100644 index 0000000..f59f0c4 --- /dev/null +++ b/src/utils/dedupLocations.test.ts @@ -0,0 +1,54 @@ +import { dedupLocations } from "./dedupLocations"; +import type { GeoLocation } from "../types/GeoLocation"; + +const loc = ( + name: string, + country: string, + state?: string +): GeoLocation => ({ name, country, state, lat: 0, lon: 0 }); + +describe("dedupLocations", () => { + it("returns empty array for empty input", () => { + expect(dedupLocations([])).toEqual([]); + }); + + it("returns locations unchanged when there are no duplicates", () => { + const locations = [loc("Prague", "CZ"), loc("Barcelona", "ES")]; + expect(dedupLocations(locations)).toEqual(locations); + }); + + it("removes duplicate locations with same name, country, and state", () => { + const locations = [ + loc("Springfield", "US", "IL"), + loc("Springfield", "US", "IL"), + ]; + expect(dedupLocations(locations)).toEqual([loc("Springfield", "US", "IL")]); + }); + + it("keeps locations with the same name but different countries", () => { + const locations = [loc("London", "GB"), loc("London", "CA")]; + expect(dedupLocations(locations)).toEqual(locations); + }); + + it("keeps locations with the same name and country but different states", () => { + const locations = [ + loc("Springfield", "US", "IL"), + loc("Springfield", "US", "MO"), + ]; + expect(dedupLocations(locations)).toEqual(locations); + }); + + it("treats undefined and missing state the same way", () => { + const locations = [ + loc("Dubai", "AE"), + loc("Dubai", "AE", undefined), + ]; + expect(dedupLocations(locations)).toHaveLength(1); + }); + + it("preserves the first occurrence when deduplicating", () => { + const first = { ...loc("Prague", "CZ"), lat: 50.08 }; + const second = { ...loc("Prague", "CZ"), lat: 99 }; + expect(dedupLocations([first, second])).toEqual([first]); + }); +}); diff --git a/src/utils/dedupLocations.ts b/src/utils/dedupLocations.ts new file mode 100644 index 0000000..829277b --- /dev/null +++ b/src/utils/dedupLocations.ts @@ -0,0 +1,11 @@ +import type { GeoLocation } from "../types/GeoLocation"; + +export function dedupLocations(locations: GeoLocation[]): GeoLocation[] { + const seen = new Set(); + return locations.filter((loc) => { + const key = `${loc.name}|${loc.country}|${loc.state ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} \ No newline at end of file diff --git a/src/utils/formatTime.test.ts b/src/utils/formatTime.test.ts new file mode 100644 index 0000000..33996d5 --- /dev/null +++ b/src/utils/formatTime.test.ts @@ -0,0 +1,25 @@ +import { formatTime } from "./formatTime"; + +describe("formatTime", () => { + it("returns a string containing hours and minutes", () => { + const result = formatTime(1620000000, 0); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it("returns different times for different timestamps", () => { + const t1 = formatTime(1620000000, 0); + const t2 = formatTime(1620003600, 0); // 1 hour later + expect(t1).not.toBe(t2); + }); + + it("returns the same time for the same timestamp", () => { + expect(formatTime(1620000000, 0)).toBe(formatTime(1620000000, 0)); + }); + + it("adjusts time based on timezone offset", () => { + const utcTime = formatTime(1620000000, 0); + const offsetTime = formatTime(1620000000, 3600); // UTC+1 + expect(utcTime).not.toBe(offsetTime); + }); +}); diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts new file mode 100644 index 0000000..b657450 --- /dev/null +++ b/src/utils/formatTime.ts @@ -0,0 +1,8 @@ +export const formatTime = (timestamp: number, timezoneOffset: number) => { + const localMs = (timestamp + timezoneOffset) * 1000; + return new Date(localMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + }); +}; \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..f84d8bc 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,7 +5,7 @@ "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], + "types": ["vite/client", "vitest/globals"], "skipLibCheck": true, /* Bundler mode */ @@ -24,5 +24,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "vitest.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 4ff4f8f..994863f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,13 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.ts"], + }, }); diff --git a/vitest.ts b/vitest.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/vitest.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom";