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 && (
+

+ )}
+
+
);
+}
\ 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";