diff --git a/package.json b/package.json index 3e7aedf9..d9826b2b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", "@next/eslint-plugin-next": "^15.5.9", - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -73,6 +73,8 @@ "globals": "^16.5.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", + "miragejs": "^0.1.48", + "pretender": "^3.4.7", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^3.4.19", @@ -81,7 +83,7 @@ "typescript-eslint": "^8.53.1", "vite-plugin-node-polyfills": "^0.24.0", "vite-tsconfig-paths": "^6.0.4", - "vitest": "^4.0.18" + "vitest": "^4.1.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae3cb8df..84c18067 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: version: 2.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -59,10 +59,10 @@ importers: version: 12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: specifier: 15.5.18 - version: 15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + version: 4.2.3(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) phone: specifier: ^3.1.70 version: 3.1.70 @@ -107,8 +107,8 @@ importers: specifier: ^15.5.9 version: 15.5.9 '@playwright/test': - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -178,6 +178,12 @@ importers: jest-environment-jsdom: specifier: ^30.2.0 version: 30.2.0 + miragejs: + specifier: ^0.1.48 + version: 0.1.48 + pretender: + specifier: ^3.4.7 + version: 3.4.7 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -198,13 +204,13 @@ importers: version: 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) vite-plugin-node-polyfills: specifier: ^0.24.0 - version: 0.24.0(rollup@4.56.0)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)) + version: 0.24.0(rollup@4.61.0)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)) vite-tsconfig-paths: specifier: ^6.0.4 version: 6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)) vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@20.19.30)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.8.0) + specifier: ^4.1.0 + version: 4.1.0(@types/node@20.19.30)(jsdom@26.1.0)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)) packages: @@ -236,8 +242,8 @@ packages: resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} '@babel/compat-data@7.28.6': @@ -282,6 +288,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -402,8 +412,8 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} '@babel/template@7.28.6': @@ -468,158 +478,158 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -987,6 +997,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@miragejs/pretender-node-polyfill@0.1.2': + resolution: {integrity: sha512-M/BexG/p05C5lFfMunxo/QcgIJnMT2vDVCd00wNqK2ImZONIlEETZwWJu1QtLxtmYlSHlCFl3JNzp0tLe7OJ5g==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1078,8 +1091,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -1424,141 +1437,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.56.0': - resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.56.0': - resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.56.0': - resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.56.0': - resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.56.0': - resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.56.0': - resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.56.0': - resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.56.0': - resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.56.0': - resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.56.0': - resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.56.0': - resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.56.0': - resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.56.0': - resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.56.0': - resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.56.0': - resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.56.0': - resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.56.0': - resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.56.0': - resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.56.0': - resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.56.0': - resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.56.0': - resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.56.0': - resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.56.0': - resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.56.0': - resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.56.0': - resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} cpu: [x64] os: [win32] @@ -1704,6 +1717,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} @@ -2005,34 +2021,34 @@ 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/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} '@webgpu/types@0.1.70': resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==} @@ -2586,8 +2602,8 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -2605,8 +2621,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -2792,6 +2808,9 @@ packages: resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} engines: {node: '>=0.10.0'} + fake-xml-http-request@2.1.2: + resolution: {integrity: sha512-HaFMBi7r+oEC9iJNpc3bvcW7Z7iLmM26hPDmlb0mFwyANSsOQAtJxbdWsXITKOzZUyMYK0zYCv3h5yDj9TsiXg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2824,8 +2843,8 @@ packages: picomatch: optional: true - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -3076,6 +3095,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inflected@2.1.0: + resolution: {integrity: sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3624,6 +3646,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + miragejs@0.1.48: + resolution: {integrity: sha512-MGZAq0Q3OuRYgZKvlB69z4gLN4G3PvgC4A2zhkCXCXrLD5wm2cCnwNB59xOBVA+srZ0zEes6u+VylcPIkB4SqA==} + engines: {node: 6.* || 8.* || >= 10.*} + mixin-deep@1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} @@ -3863,12 +3889,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pify@2.3.0: @@ -3887,13 +3913,13 @@ packages: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -3952,8 +3978,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} postcss@8.5.6: @@ -3977,6 +4003,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretender@3.4.7: + resolution: {integrity: sha512-jkPAvt1BfRi0RKamweJdEcnjkeu7Es8yix3bJ+KgBC5VpG/Ln4JE3hYN6vJym4qprm8Xo5adhWpm3HCoft1dOw==} + prettier-plugin-tailwindcss@0.6.14: resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} engines: {node: '>=14.21.3'} @@ -4234,11 +4263,14 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} - rollup@4.56.0: - resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + route-recognizer@0.3.4: + resolution: {integrity: sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -4379,8 +4411,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -4532,16 +4564,16 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tldts-core@6.1.86: @@ -4755,20 +4787,21 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} 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 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -4924,8 +4957,8 @@ packages: react: optional: true - zustand@5.0.13: - resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -4970,9 +5003,9 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -5038,6 +5071,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.6': @@ -5146,7 +5181,7 @@ snapshots: '@babel/runtime@7.28.6': {} - '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} '@babel/template@7.28.6': dependencies: @@ -5217,82 +5252,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.7': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': @@ -5721,6 +5756,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@miragejs/pretender-node-polyfill@0.1.2': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -5781,9 +5818,9 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.57.0': + '@playwright/test@1.60.0': dependencies: - playwright: 1.57.0 + playwright: 1.60.0 '@radix-ui/primitive@1.1.3': {} @@ -6040,7 +6077,7 @@ snapshots: '@react-three/fiber@9.1.2(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.176.0)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@types/react-reconciler': 0.28.9(@types/react@19.2.9) '@types/webxr': 0.5.24 base64-js: 1.5.1 @@ -6053,7 +6090,7 @@ snapshots: suspend-react: 0.1.3(react@19.2.3) three: 0.176.0 use-sync-external-store: 1.6.0(react@19.2.3) - zustand: 5.0.13(@types/react@19.2.9)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + zustand: 5.0.14(@types/react@19.2.9)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) optionalDependencies: react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: @@ -6077,95 +6114,95 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/plugin-inject@5.0.5(rollup@4.56.0)': + '@rollup/plugin-inject@5.0.5(rollup@4.61.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.56.0) + '@rollup/pluginutils': 5.3.0(rollup@4.61.0) estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: - rollup: 4.56.0 + rollup: 4.61.0 - '@rollup/pluginutils@5.3.0(rollup@4.56.0)': + '@rollup/pluginutils@5.3.0(rollup@4.61.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: - rollup: 4.56.0 + rollup: 4.61.0 - '@rollup/rollup-android-arm-eabi@4.56.0': + '@rollup/rollup-android-arm-eabi@4.61.0': optional: true - '@rollup/rollup-android-arm64@4.56.0': + '@rollup/rollup-android-arm64@4.61.0': optional: true - '@rollup/rollup-darwin-arm64@4.56.0': + '@rollup/rollup-darwin-arm64@4.61.0': optional: true - '@rollup/rollup-darwin-x64@4.56.0': + '@rollup/rollup-darwin-x64@4.61.0': optional: true - '@rollup/rollup-freebsd-arm64@4.56.0': + '@rollup/rollup-freebsd-arm64@4.61.0': optional: true - '@rollup/rollup-freebsd-x64@4.56.0': + '@rollup/rollup-freebsd-x64@4.61.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.56.0': + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.56.0': + '@rollup/rollup-linux-arm-musleabihf@4.61.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.56.0': + '@rollup/rollup-linux-arm64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.56.0': + '@rollup/rollup-linux-arm64-musl@4.61.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.56.0': + '@rollup/rollup-linux-loong64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.56.0': + '@rollup/rollup-linux-loong64-musl@4.61.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.56.0': + '@rollup/rollup-linux-ppc64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.56.0': + '@rollup/rollup-linux-ppc64-musl@4.61.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.56.0': + '@rollup/rollup-linux-riscv64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.56.0': + '@rollup/rollup-linux-riscv64-musl@4.61.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.56.0': + '@rollup/rollup-linux-s390x-gnu@4.61.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.56.0': + '@rollup/rollup-linux-x64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-x64-musl@4.56.0': + '@rollup/rollup-linux-x64-musl@4.61.0': optional: true - '@rollup/rollup-openbsd-x64@4.56.0': + '@rollup/rollup-openbsd-x64@4.61.0': optional: true - '@rollup/rollup-openharmony-arm64@4.56.0': + '@rollup/rollup-openharmony-arm64@4.61.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.56.0': + '@rollup/rollup-win32-arm64-msvc@4.61.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.56.0': + '@rollup/rollup-win32-ia32-msvc@4.61.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.56.0': + '@rollup/rollup-win32-x64-gnu@4.61.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.56.0': + '@rollup/rollup-win32-x64-msvc@4.61.0': optional: true '@rtsao/scc@1.1.0': {} @@ -6213,8 +6250,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -6315,6 +6352,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 20.19.30 @@ -6405,7 +6444,7 @@ snapshots: '@types/stats.js': 0.17.4 '@types/webxr': 0.5.24 '@webgpu/types': 0.1.70 - fflate: 0.8.2 + fflate: 0.8.3 meshoptimizer: 0.18.1 '@types/tough-cookie@4.0.5': {} @@ -6487,7 +6526,7 @@ snapshots: debug: 4.4.3 minimatch: 9.0.5 semver: 7.8.0 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6582,9 +6621,9 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.3 - '@vercel/analytics@1.6.1(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': optionalDependencies: - next: 15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0))': @@ -6599,44 +6638,46 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.0': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0))': + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.0': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.0': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.0 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.0': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@webgpu/types@0.1.70': {} @@ -6680,7 +6721,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 arg@4.1.3: {} @@ -7336,7 +7377,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -7359,34 +7400,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.2: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -7434,7 +7475,7 @@ snapshots: get-tsconfig: 4.13.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 unrs-resolver: 1.11.1 optionalDependencies: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) @@ -7604,7 +7645,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -7649,6 +7690,8 @@ snapshots: assign-symbols: 1.0.0 is-extendable: 1.0.1 + fake-xml-http-request@2.1.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -7679,11 +7722,11 @@ snapshots: dependencies: bser: 2.1.1 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 - fflate@0.8.2: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: dependencies: @@ -7929,6 +7972,8 @@ snapshots: indent-string@4.0.0: {} + inflected@2.1.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -8434,7 +8479,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.3.1 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 jest-validate@30.2.0: dependencies: @@ -8657,7 +8702,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 miller-rabin@4.0.1: dependencies: @@ -8684,6 +8729,13 @@ snapshots: minipass@7.1.2: {} + miragejs@0.1.48: + dependencies: + '@miragejs/pretender-node-polyfill': 0.1.2 + inflected: 2.1.0 + lodash: 4.17.23 + pretender: 3.4.7 + mixin-deep@1.3.2: dependencies: for-in: 1.0.2 @@ -8722,15 +8774,15 @@ snapshots: natural-compare@1.4.0: {} - next-sitemap@4.2.3(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): + next-sitemap@4.2.3(next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@15.5.18(@babel/core@7.28.6)(@playwright/test@1.60.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -8748,7 +8800,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.18 '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 - '@playwright/test': 1.57.0 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -8949,9 +9001,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pify@2.3.0: {} @@ -8965,11 +9017,11 @@ snapshots: dependencies: find-up: 5.0.0 - playwright-core@1.57.0: {} + playwright-core@1.60.0: {} - playwright@1.57.0: + playwright@1.60.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -9015,7 +9067,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.14: + postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -9039,6 +9091,11 @@ snapshots: prelude-ls@1.2.1: {} + pretender@3.4.7: + dependencies: + fake-xml-http-request: 2.1.2 + route-recognizer: 0.3.4 + prettier-plugin-tailwindcss@0.6.14(@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.8.1))(prettier@3.8.1): dependencies: prettier: 3.8.1 @@ -9191,7 +9248,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 redent@3.0.0: dependencies: @@ -9249,37 +9306,39 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 - rollup@4.56.0: + rollup@4.61.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.56.0 - '@rollup/rollup-android-arm64': 4.56.0 - '@rollup/rollup-darwin-arm64': 4.56.0 - '@rollup/rollup-darwin-x64': 4.56.0 - '@rollup/rollup-freebsd-arm64': 4.56.0 - '@rollup/rollup-freebsd-x64': 4.56.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.56.0 - '@rollup/rollup-linux-arm-musleabihf': 4.56.0 - '@rollup/rollup-linux-arm64-gnu': 4.56.0 - '@rollup/rollup-linux-arm64-musl': 4.56.0 - '@rollup/rollup-linux-loong64-gnu': 4.56.0 - '@rollup/rollup-linux-loong64-musl': 4.56.0 - '@rollup/rollup-linux-ppc64-gnu': 4.56.0 - '@rollup/rollup-linux-ppc64-musl': 4.56.0 - '@rollup/rollup-linux-riscv64-gnu': 4.56.0 - '@rollup/rollup-linux-riscv64-musl': 4.56.0 - '@rollup/rollup-linux-s390x-gnu': 4.56.0 - '@rollup/rollup-linux-x64-gnu': 4.56.0 - '@rollup/rollup-linux-x64-musl': 4.56.0 - '@rollup/rollup-openbsd-x64': 4.56.0 - '@rollup/rollup-openharmony-arm64': 4.56.0 - '@rollup/rollup-win32-arm64-msvc': 4.56.0 - '@rollup/rollup-win32-ia32-msvc': 4.56.0 - '@rollup/rollup-win32-x64-gnu': 4.56.0 - '@rollup/rollup-win32-x64-msvc': 4.56.0 + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 fsevents: 2.3.3 + route-recognizer@0.3.4: {} + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -9457,7 +9516,7 @@ snapshots: stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -9585,7 +9644,7 @@ snapshots: lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 supports-color@7.2.0: @@ -9660,14 +9719,14 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.0.2: {} + tinyexec@1.2.4: {} - tinyglobby@0.2.15: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} tldts-core@6.1.86: {} @@ -9865,9 +9924,9 @@ snapshots: v8n@1.5.1: {} - vite-plugin-node-polyfills@0.24.0(rollup@4.56.0)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)): + vite-plugin-node-polyfills@0.24.0(rollup@4.61.0)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)): dependencies: - '@rollup/plugin-inject': 5.0.5(rollup@4.56.0) + '@rollup/plugin-inject': 5.0.5(rollup@4.61.0) node-stdlib-browser: 1.3.1 vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0) transitivePeerDependencies: @@ -9886,55 +9945,45 @@ snapshots: vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0): dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.14 - rollup: 4.56.0 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.0 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.30 fsevents: 2.3.3 jiti: 1.21.7 yaml: 2.8.0 - vitest@4.0.18(@types/node@20.19.30)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.8.0): + vitest@4.1.0(@types/node@20.19.30)(jsdom@26.1.0)(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)) - '@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 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.1.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 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.30 jsdom: 26.1.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vm-browserify@1.1.2: {} @@ -10067,7 +10116,7 @@ snapshots: optionalDependencies: react: 19.2.3 - zustand@5.0.13(@types/react@19.2.9)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): + zustand@5.0.14(@types/react@19.2.9)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: '@types/react': 19.2.9 react: 19.2.3 diff --git a/src/app/QueryClientWrapper.tsx b/src/app/QueryClientWrapper.tsx index bf182cea..0f51c922 100644 --- a/src/app/QueryClientWrapper.tsx +++ b/src/app/QueryClientWrapper.tsx @@ -15,3 +15,7 @@ export default function QueryClientWrapper({ ) } + +export function clearQueryClient() { + queryClient.clear() +} diff --git a/src/app/admin/panels/fundraising/fundraising.helpers.ts b/src/app/admin/panels/fundraising/fundraising.helpers.ts new file mode 100644 index 00000000..f3aa3b55 --- /dev/null +++ b/src/app/admin/panels/fundraising/fundraising.helpers.ts @@ -0,0 +1,336 @@ +import type { ChartGranularityMode } from '@/components/common/charts/timeBuckets' + +export const PRESETS = [ + 'All Time', + 'Year To Date', + 'Month To Date', + 'Last Month', + 'Week To Date', + 'Last 7 Days', + 'Today', + 'Yesterday', +] as const + +export type Preset = (typeof PRESETS)[number] + +export const CHART_GRANULARITY_LABELS: Record = { + auto: 'Auto', + year: 'Years', + quarter: 'Quarters', + month: 'Months', + week: 'Weeks', + day: 'Days', + hour: 'Hours', +} + +export function formatCurrency(value?: number) { + if (value == null || !Number.isFinite(value)) return '—' + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + +export function formatCount(value?: number) { + if (value == null || !Number.isFinite(value)) return '—' + return value.toLocaleString('en-US') +} + +export function formatCurrencyAxis(value: number, step: number): string { + if (!Number.isFinite(value)) return '' + let digits = 0 + if (step < 1) digits = 2 + else if (step < 10 && !Number.isInteger(step)) digits = 1 + + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toLocaleString('en-US', { + maximumFractionDigits: 1, + })}M` + } + + if (value >= 10_000) { + return `$${Math.round(value / 1000).toLocaleString('en-US')}k` + } + + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }) +} + +export function formatCountAxis(value: number): string { + if (!Number.isFinite(value)) return '' + const rounded = Math.round(value) + if (rounded >= 1_000_000) { + return `${(rounded / 1_000_000).toLocaleString('en-US', { + maximumFractionDigits: 1, + })}M` + } + if (rounded >= 10_000) { + return `${Math.round(rounded / 1000).toLocaleString('en-US')}k` + } + return rounded.toLocaleString('en-US') +} + +export function formatDonationCountLabel(value?: number) { + return `${formatCount(value)} ${value === 1 ? 'donation' : 'donations'}` +} + +export function startOfDayISO(d: Date): string { + return new Date( + d.getFullYear(), + d.getMonth(), + d.getDate(), + 0, + 0, + 0 + ).toISOString() +} + +export function endOfDayISO(d: Date): string { + return new Date( + d.getFullYear(), + d.getMonth(), + d.getDate(), + 23, + 59, + 59 + ).toISOString() +} + +export function addDays(date: Date, days: number) { + const d = new Date(date) + d.setDate(d.getDate() + days) + return d +} + +export function getPresetRange(preset: Preset): [string, string] { + const today = new Date() + + switch (preset) { + case 'All Time': + return ['', ''] + case 'Year To Date': + return [ + startOfDayISO(new Date(today.getFullYear(), 0, 1)), + endOfDayISO(today), + ] + case 'Month To Date': + return [ + startOfDayISO( + new Date(today.getFullYear(), today.getMonth(), 1) + ), + endOfDayISO(today), + ] + case 'Last Month': { + const start = new Date(today.getFullYear(), today.getMonth() - 1, 1) + const end = new Date(today.getFullYear(), today.getMonth(), 0) + return [startOfDayISO(start), endOfDayISO(end)] + } + case 'Week To Date': { + const day = today.getDay() + const diff = day === 0 ? 6 : day - 1 + const start = new Date(today) + start.setDate(today.getDate() - diff) + return [startOfDayISO(start), endOfDayISO(today)] + } + case 'Last 7 Days': { + const start = new Date(today) + start.setDate(today.getDate() - 6) + return [startOfDayISO(start), endOfDayISO(today)] + } + case 'Today': + return [startOfDayISO(today), endOfDayISO(today)] + case 'Yesterday': { + const yesterday = new Date(today) + yesterday.setDate(today.getDate() - 1) + return [startOfDayISO(yesterday), endOfDayISO(yesterday)] + } + } +} + +export function getResolvedPresetRange( + preset: Preset, + allTimeFirstIso?: string +): [string, string] { + let [startIso, endIso] = getPresetRange(preset) + + if (preset === 'All Time') { + const today = new Date() + endIso = endOfDayISO(today) + startIso = allTimeFirstIso + ? startOfDayISO(new Date(allTimeFirstIso)) + : startOfDayISO(today) + } + + return [startIso, endIso] +} + +export function inferPresetFromRange( + startIso: string, + endIso: string, + allTimeFirstIso?: string +): Preset | null { + if (!startIso || !endIso) return null + + for (const preset of PRESETS) { + const [presetStart, presetEnd] = getResolvedPresetRange( + preset, + allTimeFirstIso + ) + if (startIso === presetStart && endIso === presetEnd) { + return preset + } + } + + return null +} + +export function isXToDatePreset( + preset: Preset | null +): preset is 'Year To Date' | 'Month To Date' | 'Week To Date' | 'Today' { + return ( + preset === 'Year To Date' || + preset === 'Month To Date' || + preset === 'Week To Date' || + preset === 'Today' + ) +} + +export function getXToDatePeriodName( + preset: Preset | null, + granularityMode: ChartGranularityMode +): string { + if (preset === 'Year To Date') return 'Year' + if (preset === 'Month To Date') return 'Month' + if (preset === 'Week To Date') return 'Week' + if (preset === 'Today' && granularityMode === 'hour') return 'Hour' + return 'Date' +} + +export function getExtendedEndForXToDatePreset( + preset: 'Year To Date' | 'Month To Date' | 'Week To Date' | 'Today', + startIso: string, + endIso: string +): string { + if (!startIso || !endIso) return endIso + + if (preset === 'Today') { + const d = new Date(startIso) + return endOfDayISO(d) + } + + if (preset === 'Year To Date') { + const d = new Date(startIso) + return endOfDayISO(new Date(d.getFullYear(), 11, 31)) + } + + if (preset === 'Month To Date') { + const d = new Date(startIso) + return endOfDayISO(new Date(d.getFullYear(), d.getMonth() + 1, 0)) + } + + const start = new Date(startIso) + const weekEnd = addDays( + new Date(start.getFullYear(), start.getMonth(), start.getDate()), + 6 + ) + return endOfDayISO(weekEnd) +} + +export function formatRangeDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +export function isSameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +type RangeKind = 'preset' | 'all' | 'both' | 'from' | 'until' + +function getRangeKind( + committedPreset: Preset | null, + startDate: string, + endDate: string +): RangeKind { + switch (true) { + case !!committedPreset: + return 'preset' + case !!(startDate && endDate): + return 'both' + case !!startDate: + return 'from' + case !!endDate: + return 'until' + default: + return 'all' + } +} + +export function getSelectedRangeLabel( + committedPreset: Preset | null, + startDate: string, + endDate: string +) { + const kind = getRangeKind(committedPreset, startDate, endDate) + switch (kind) { + case 'preset': + return committedPreset! + case 'both': + return isSameDay(new Date(startDate), new Date(endDate)) + ? formatRangeDate(startDate) + : `${formatRangeDate(startDate)} - ${formatRangeDate(endDate)}` + case 'from': + return `From ${formatRangeDate(startDate)}` + case 'until': + return `Until ${formatRangeDate(endDate)}` + case 'all': + return 'All Time' + default: { + const _exhaustive: never = kind + return _exhaustive + } + } +} + +export function getRaisedKickerLabel( + committedPreset: Preset | null, + startDate: string, + endDate: string +) { + const kind = getRangeKind(committedPreset, startDate, endDate) + switch (kind) { + case 'preset': + return `Total Raised ${committedPreset!}` + case 'both': + return `Total Raised ${formatRangeDate(startDate)} - ${formatRangeDate(endDate)}` + case 'from': + return `Total Raised From ${formatRangeDate(startDate)}` + case 'until': + return `Total Raised Until ${formatRangeDate(endDate)}` + case 'all': + return 'Total Raised Custom Range' + default: { + const _exhaustive: never = kind + return _exhaustive + } + } +} + +export function getPercentage(part: number, total: number): number | null { + if (!Number.isFinite(total) || total <= 0) return null + const pct = Math.round((part / total) * 100) + return Number.isFinite(pct) ? pct : null +} diff --git a/src/app/admin/panels/fundraising/fundraising.service.ts b/src/app/admin/panels/fundraising/fundraising.service.ts new file mode 100644 index 00000000..fd3f86ca --- /dev/null +++ b/src/app/admin/panels/fundraising/fundraising.service.ts @@ -0,0 +1,72 @@ +import { ActBlueDonationPacket, zActBlueDonationPacket } from '@/contracts/data' +import { SortDirection } from '@/contracts/requests' +import { PaginatedResponse, zPaginatedResponse } from '@/contracts/responses' +import { + ActBlueFundraisingStatsResponse, + zActBlueFundraisingStatsResponse, +} from '@/contracts/responses/fundraisingStatsResponse' +import type { QueryParams, ZodSchema } from '@/util/hooks/useFetch' + +interface GetOptions { + query?: QueryParams + signal?: AbortSignal +} + +type OnGet = ( + url: string, + schema: ZodSchema, + options?: GetOptions +) => Promise + +export async function getFundraisingStats( + onGet: OnGet, + startDate?: string, + endDate?: string +) { + const isAllTime = !startDate && !endDate + + return onGet( + '/actblue/fundraising/stats', + zActBlueFundraisingStatsResponse, + isAllTime + ? undefined + : { + query: { + ...(startDate && { startDate }), + ...(endDate && { endDate }), + }, + } + ) +} + +export async function getEarliestContribution(onGet: OnGet) { + return onGet>( + '/actblue/contributions', + zPaginatedResponse(zActBlueDonationPacket), + { + query: { + page: 0, + limit: 1, + sortField: 'paidAt', + sort: SortDirection.ASC, + }, + } + ) +} + +export async function getFundraisingBucketStats( + onGet: OnGet, + bucketStartIso: string, + bucketEndIso: string +) { + return onGet( + '/actblue/fundraising/stats', + zActBlueFundraisingStatsResponse, + { + query: { + startDate: bucketStartIso, + endDate: bucketEndIso, + }, + } + ) +} diff --git a/src/app/admin/panels/fundraising/page.module.css b/src/app/admin/panels/fundraising/page.module.css index d115f80c..0ce1c817 100644 --- a/src/app/admin/panels/fundraising/page.module.css +++ b/src/app/admin/panels/fundraising/page.module.css @@ -1,10 +1,23 @@ .panelContents { width: 100%; min-width: 0; - height: 100%; + height: calc(100dvh - 100px); + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; padding: 0; } +.scrollView { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 0 0 1rem; +} + .dateContainer { display: grid; justify-items: end; @@ -27,161 +40,133 @@ color: rgba(15, 23, 42, 0.58); } -.dateRangeTriggerButton { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.65rem; - min-width: 12rem; - padding: 0.45rem 0.68rem; - border-radius: 0.62rem; - border: 1px solid rgba(15, 23, 42, 0.1); - background: rgba(255, 255, 255, 0.88); - color: rgba(9, 34, 58, 0.9); - font-size: 0.86rem; - font-weight: 650; - text-align: left; - cursor: pointer; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.95) inset; - transition: - border-color 140ms ease, - background-color 140ms ease, - box-shadow 140ms ease, - transform 140ms ease; +.customDateRangeBox { + --presetColMin: 12.5rem; + --presetColMax: 14rem; + --calendarWidth: 15.5rem; + --popBodyGap: 0.85rem; } -.dateRangeTriggerButton > span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.dateRangePopBody { + display: grid; + grid-template-columns: minmax(var(--presetColMin), var(--presetColMax)) var( + --calendarWidth + ); + gap: var(--popBodyGap); + align-items: stretch; } -.dateRangeTriggerChevron { - flex-shrink: 0; - color: rgba(15, 23, 42, 0.58); - stroke-width: 2.6; - transition: transform 140ms ease; +.chartOptionsBox { + min-width: 14rem; + background: rgba(255, 255, 255, 0.96); } -.dateRangeTriggerButton[aria-expanded='true'] .dateRangeTriggerChevron { - transform: rotate(180deg); +.chartOptionsBody { + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; } -.dateRangeOptionList { +.chartOptionRow { display: grid; - gap: 0.34rem; + gap: 0.3rem; + padding: 0 0.15rem; } -.customRangeLabel { +.chartOptionLabel { font-size: 0.72rem; font-weight: 700; - color: rgba(15, 23, 42, 0.62); -} - -.dateRangeOptionButton { - border-radius: 0.54rem; - background: rgba(255, 255, 255, 0.78); - color: rgba(9, 34, 58, 0.9); - text-align: left; - font-size: 0.8rem; - font-weight: 700; - padding: 0.38rem 0.52rem; - cursor: pointer; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.58); } -.dateRangeOptionButton:hover { - background: rgba(15, 23, 42, 0.05); +.chartOptionsControl { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.45rem; } -.dateRangeOptionButtonActive { - color: rgba(2, 132, 199, 1); - background: rgba(14, 165, 233, 0.14); +.resetViewButton { + border-radius: 0.48rem; + background: rgb(241, 241, 242); + color: rgba(15, 23, 42, 0.72); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.01em; + padding: 0.26rem 0.5rem; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } -.dateRangeOptionButtonActive:hover { - color: rgba(2, 132, 199, 1); - background: rgba(84, 158, 214, 0.14); +.resetViewButton:hover:not(:disabled) { + background-color: #d1d5db; + color: rgba(9, 34, 58, 0.9); } -.customDateRangeBox { - width: min(20rem, 85vw); - padding: 0.62rem; - border-radius: 0.75rem; - border: 1px solid rgba(15, 23, 42, 0.14); - background: rgba(255, 255, 255, 0.94); +.dateRangePresetCol { display: grid; - gap: 0.5rem; - position: absolute; - top: calc(100% + 0.35rem); - right: 0; - z-index: 120; - box-shadow: 0 12px 34px rgba(15, 23, 42, 0.16); - overflow-y: auto; + gap: 0.34rem; + align-content: start; + padding-top: 0.1rem; } -.customDateField { +.dateRangePresetButton { display: grid; - gap: 0.24rem; -} - -.customDateField label { - font-size: 0.72rem; + grid-template-columns: 1fr 1.1rem; + align-items: center; + gap: 0.4rem; + border: none; + background: transparent; + color: rgba(9, 34, 58, 0.88); + text-align: left; + font-size: 0.8rem; font-weight: 700; - color: rgba(15, 23, 42, 0.62); + padding: 0.5rem 0.45rem; + border-radius: 0.54rem; + cursor: pointer; } -.customDateField input { - width: 100%; - padding: 0.34rem 0.46rem; - border-radius: 0.5rem; - border: 1px solid rgba(15, 23, 42, 0.16); - background: rgba(255, 255, 255, 0.8); - font-size: 0.84rem; +.dateRangePresetButton:hover { + background: rgba(15, 23, 42, 0.05); } -.customDateActions { - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.42rem; +.dateRangePresetButtonCommitted { + color: rgba(2, 132, 199, 1); + font-weight: 700; } -.setRangeButton { - margin-left: auto; +.dateRangePresetButtonCommitted:hover { + background: rgba(14, 165, 233, 0.08); } -.cancelRangeButton, -.setRangeButton { - border: none; - border-radius: 0.54rem; - padding: 0.34rem 0.56rem; - font-size: 0.74rem; - font-weight: 700; - cursor: pointer; -} - -.cancelRangeButton { - background: rgba(15, 23, 42, 0.08); - color: rgba(15, 23, 42, 0.7); +.dateRangePresetButtonDraft { + background: rgba(14, 165, 233, 0.14); + color: rgba(2, 132, 199, 1); } -.cancelRangeButton:hover { - background: rgba(15, 23, 42, 0.12); +.dateRangePresetButtonDraft:hover { + background: rgba(14, 165, 233, 0.2); } -.setRangeButton { - background: rgba(9, 34, 58, 0.9); - color: #fff; +.dateRangePresetButtonDraft.dateRangePresetButtonCommitted { + background: rgba(14, 165, 233, 0.18); } -.setRangeButton:hover { - background: rgba(9, 34, 58, 0.98); +.dateRangePresetCheck { + display: inline-flex; + align-items: center; + justify-content: center; + color: rgba(2, 132, 199, 1); + width: 1.1rem; } -.setRangeButton:disabled { - background: rgba(9, 34, 58, 0.34); - cursor: not-allowed; +.dateRangePresetCheck svg { + stroke-width: 3.5; } .panelHeader { @@ -391,83 +376,13 @@ gap: 0.75rem; } -.metricCard { - padding: 0.72rem 0.8rem; - border-radius: 0.9rem; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(15, 23, 42, 0.1); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset; -} - -.metricLabel { - font-size: 0.7rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; - color: rgba(15, 23, 42, 0.52); -} - -.metricValue { - margin-top: 0.25rem; - font-size: 1.16rem; - font-weight: 850; - letter-spacing: -0.025em; - color: rgba(9, 34, 58, 0.9); -} - -.metricMeta { - margin-top: 0.18rem; - font-size: 0.73rem; - font-weight: 650; - color: rgba(15, 23, 42, 0.48); -} - -.splitTrack { - margin-top: 0.88rem; - display: grid; - gap: 0.48rem; -} - -.splitRow { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.74rem; - font-weight: 760; - letter-spacing: 0.01em; - color: rgba(15, 23, 42, 0.65); -} - -.trackBar { - width: 100%; - height: 0.42rem; - border-radius: 99rem; - overflow: hidden; - background: rgba(15, 23, 42, 0.1); -} - -.trackFillRecurring, -.trackFillOneTime { - display: block; - height: 100%; - border-radius: inherit; -} - -.trackFillRecurring { - background: linear-gradient(90deg, #9fc9e8, #549ed6); -} - -.trackFillOneTime { - background: linear-gradient(90deg, #ebafa9, #ce3728); -} - @media (max-width: 72rem) { .metricGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 42rem) { +@media (max-width: 53rem) { .panelHeader { flex-direction: column; align-items: flex-start; @@ -495,13 +410,11 @@ } .dateRangeTriggerButton { - min-width: 0; + width: 100%; } - .customDateRangeBox { - width: 100%; - right: auto; - left: 0; + .dateRangePopBody { + grid-template-columns: 1fr; } .metricGrid { diff --git a/src/app/admin/panels/fundraising/page.tsx b/src/app/admin/panels/fundraising/page.tsx index a9aa8897..cfef15d0 100644 --- a/src/app/admin/panels/fundraising/page.tsx +++ b/src/app/admin/panels/fundraising/page.tsx @@ -1,147 +1,35 @@ 'use client' +import { + CHART_GRANULARITY_LABELS, + PRESETS, + formatCount, + formatCountAxis, + formatCurrency, + formatCurrencyAxis, + formatDonationCountLabel, + getResolvedPresetRange, + getXToDatePeriodName, + inferPresetFromRange, +} from './fundraising.helpers' import styles from './page.module.css' +import { useFundraisingDashboardController } from './useFundraisingDashboardController' import { - ActBlueFundraisingStatsResponse, - zActBlueFundraisingStatsResponse, -} from '@/contracts/responses/fundraisingStatsResponse' + DashboardWidget, + DropdownButton, + DropdownOverlay, + DateRangePicker, + ProgressBar, + ToggleGroup, +} from '@/components/common' +import { Chart } from '@/components/common/charts/DualAxisBarLineChart' +import { type ChartGranularityMode } from '@/components/common/charts/timeBuckets' import { useFetch } from '@/util/hooks' -import { keepPreviousData, useQuery } from '@tanstack/react-query' import Link from 'next/link' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { FaDonate } from 'react-icons/fa' import { FaDollarSign } from 'react-icons/fa6' -import { FiChevronDown } from 'react-icons/fi' - -function formatCurrency(value?: number) { - if (value == null || !Number.isFinite(value)) return '—' - return value.toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) -} - -function formatCount(value?: number) { - if (value == null || !Number.isFinite(value)) return '—' - return value.toLocaleString('en-US') -} - -function formatDonationCountLabel(value?: number) { - return `${formatCount(value)} ${value === 1 ? 'donation' : 'donations'}` -} - -function startOfDayISO(d: Date): string { - return new Date( - d.getFullYear(), - d.getMonth(), - d.getDate(), - 0, - 0, - 0 - ).toISOString() -} - -function endOfDayISO(d: Date): string { - return new Date( - d.getFullYear(), - d.getMonth(), - d.getDate(), - 23, - 59, - 59 - ).toISOString() -} - -function getPresetRange(preset: string): [string, string] { - const today = new Date() - - switch (preset) { - case 'All Time': - return ['', ''] - case 'Year To Date': - return [ - startOfDayISO(new Date(today.getFullYear(), 0, 1)), - endOfDayISO(today), - ] - case 'Month To Date': - return [ - startOfDayISO( - new Date(today.getFullYear(), today.getMonth(), 1) - ), - endOfDayISO(today), - ] - case 'Last Month': { - const start = new Date(today.getFullYear(), today.getMonth() - 1, 1) - const end = new Date(today.getFullYear(), today.getMonth(), 0) - return [startOfDayISO(start), endOfDayISO(end)] - } - case 'Week To Date': { - const day = today.getDay() - const diff = day === 0 ? 6 : day - 1 - const start = new Date(today) - start.setDate(today.getDate() - diff) - return [startOfDayISO(start), endOfDayISO(today)] - } - case 'Last 7 Days': { - const start = new Date(today) - start.setDate(today.getDate() - 6) - return [startOfDayISO(start), endOfDayISO(today)] - } - case 'Today': - return [startOfDayISO(today), endOfDayISO(today)] - case 'Yesterday': { - const yesterday = new Date(today) - yesterday.setDate(today.getDate() - 1) - return [startOfDayISO(yesterday), endOfDayISO(yesterday)] - } - default: - return ['', ''] - } -} - -const PRESETS = [ - 'All Time', - 'Year To Date', - 'Month To Date', - 'Last Month', - 'Week To Date', - 'Last 7 Days', - 'Today', - 'Yesterday', -] as const -type Preset = (typeof PRESETS)[number] -type DateRangeOption = Preset | 'Custom Range' - -function isoToDateInput(iso: string): string { - if (!iso) return '' - const d = new Date(iso) - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - return `${y}-${m}-${day}` -} - -function dateInputToStartISO(value: string): string { - if (!value) return '' - const [y, mo, d] = value.split('-').map(Number) - return new Date(y, mo - 1, d, 0, 0, 0).toISOString() -} - -function dateInputToEndISO(value: string): string { - if (!value) return '' - const [y, mo, d] = value.split('-').map(Number) - return new Date(y, mo - 1, d, 23, 59, 59).toISOString() -} - -function formatRangeDate(iso: string): string { - return new Date(iso).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) -} +import { FiCheck } from 'react-icons/fi' interface FundraisingCardProps { title: string @@ -178,91 +66,78 @@ function FundraisingCard({ } export default function Page() { - const [startDate, setStartDate] = useState(() => getPresetRange('Today')[0]) - const [endDate, setEndDate] = useState(() => getPresetRange('Today')[1]) - const [activePreset, setActivePreset] = useState('Today') - const [previousPreset, setPreviousPreset] = useState('Today') - const [isDateRangeOverlayOpen, setIsDateRangeOverlayOpen] = useState(false) const [dateRangeOverlayMaxHeight, setDateRangeOverlayMaxHeight] = useState() const [dateRangeOverlayOffset, setDateRangeOverlayOffset] = useState(0) - const [draftStartDate, setDraftStartDate] = useState(startDate) - const [draftEndDate, setDraftEndDate] = useState(endDate) - const dateRangeControlRef = useRef(null) + const [isNarrowDateRangeLayout, setIsNarrowDateRangeLayout] = + useState(false) const dateRangeTriggerRef = useRef(null) const dateRangeOverlayRef = useRef(null) - function applyPreset(preset: Preset) { - const [start, end] = getPresetRange(preset) - setStartDate(start) - setEndDate(end) - setActivePreset(preset) - setPreviousPreset(preset) - } - - const selectedRangeLabel = useMemo(() => { - if (activePreset) return activePreset - if (!startDate && !endDate) return 'All Time' - if (startDate && endDate) { - return `${formatRangeDate(startDate)} - ${formatRangeDate(endDate)}` - } - if (startDate) return `From ${formatRangeDate(startDate)}` - if (endDate) return `Until ${formatRangeDate(endDate)}` - return 'Custom Range' - }, [activePreset, startDate, endDate]) - - const todayInputValue = useMemo( - () => isoToDateInput(new Date().toISOString()), - [] - ) - const draftStartInputValue = useMemo( - () => isoToDateInput(draftStartDate), - [draftStartDate] - ) - const draftEndInputValue = useMemo( - () => isoToDateInput(draftEndDate), - [draftEndDate] - ) - const canApplyCustomRange = Boolean(draftStartDate && draftEndDate) - - const activeDateOption: DateRangeOption = activePreset ?? 'Custom Range' - - useEffect(() => { - const onDocumentMouseDown = (event: MouseEvent) => { - if (!isDateRangeOverlayOpen) return - - const control = dateRangeControlRef.current - if (!control) return - - if (!control.contains(event.target as Node)) { - setIsDateRangeOverlayOpen(false) - setDraftStartDate(startDate) - setDraftEndDate(endDate) - } - } - - document.addEventListener('mousedown', onDocumentMouseDown) - return () => { - document.removeEventListener('mousedown', onDocumentMouseDown) - } - }, [isDateRangeOverlayOpen, startDate, endDate]) + const { onGet } = useFetch() + const { + startDate, + endDate, + committedPreset, + draftPreset, + draftStartDate, + draftEndDate, + smoothLine, + showAreaFill, + showDonationsLine, + chartBarDisplayMode, + zoomEnabled, + showFullXToDateSpan, + granularityMode, + selectedRangeLabel, + raisedKickerLabel, + recurringPct, + oneTimePct, + validGranularityModes, + chartPoints, + chartViewOverrideActive, + allTimeFirstIso, + isXToDateSpanOptionRelevant, + canApplyCustomRange, + isAwaitingDraftEndDate, + statsQuery, + allTimeStatsQuery, + setStartDate, + setEndDate, + setCommittedPreset, + setDraftPreset, + setDraftStartDate, + setDraftEndDate, + setSmoothLine, + setShowAreaFill, + setShowDonationsLine, + setChartBarDisplayMode, + setZoomEnabled, + setShowFullXToDateSpan, + setGranularityMode, + applyChartViewOverrideRange, + resetChartViewToSelectedRange, + } = useFundraisingDashboardController(onGet) useEffect(() => { - if (!isDateRangeOverlayOpen) { - setDateRangeOverlayMaxHeight(undefined) - setDateRangeOverlayOffset(0) - return - } - const viewportPadding = 12 const constrainedBottomMargin = 16 const triggerGap = 6 + const narrowOverlayQuery = '(max-width: 53rem)' const updateDateRangeOverlayPosition = () => { const trigger = dateRangeTriggerRef.current const overlay = dateRangeOverlayRef.current if (!trigger || !overlay) return + const isNarrowLayout = window.matchMedia(narrowOverlayQuery).matches + setIsNarrowDateRangeLayout(isNarrowLayout) + if (isNarrowLayout) { + setDateRangeOverlayOffset(0) + setDateRangeOverlayMaxHeight(undefined) + return + } + const triggerRect = trigger.getBoundingClientRect() const viewportHeight = window.innerHeight const naturalOverlayHeight = overlay.scrollHeight @@ -279,7 +154,9 @@ export default function Page() { const overflowBelow = Math.max(0, naturalBottom - viewportBottom) const maxUpwardShift = Math.max(0, naturalTop - viewportPadding) - const upwardShift = Math.min(overflowBelow, maxUpwardShift) + const upwardShift = isNarrowLayout + ? 0 + : Math.min(overflowBelow, maxUpwardShift) const shiftedTop = naturalTop - upwardShift const availableHeight = viewportBottom - shiftedTop @@ -308,86 +185,7 @@ export default function Page() { true ) } - }, [isDateRangeOverlayOpen, activeDateOption]) - - const { onGet } = useFetch() - - const isAllTime = !startDate && !endDate - - const statsQuery = useQuery({ - queryKey: [ - '/actblue/fundraising/stats', - startDate || null, - endDate || null, - ], - queryFn: () => - onGet( - '/actblue/fundraising/stats', - zActBlueFundraisingStatsResponse, - isAllTime - ? undefined - : { - query: { - ...(startDate && { startDate }), - ...(endDate && { endDate }), - }, - } - ), - placeholderData: keepPreviousData, - }) - - const allTimeStatsQuery = useQuery({ - queryKey: ['/actblue/fundraising/stats', 'all-time-cards'], - queryFn: () => - onGet( - '/actblue/fundraising/stats', - zActBlueFundraisingStatsResponse - ), - }) - - const recurringPct = useMemo(() => { - if (!statsQuery.data) return null - - const total = statsQuery.data.totalDollarsRaised - if (!Number.isFinite(total) || total <= 0) return null - - const pct = Math.round( - (statsQuery.data.recurringDollarsRaised / total) * 100 - ) - - return Number.isFinite(pct) ? pct : null - }, [statsQuery.data]) - - const oneTimePct = useMemo(() => { - if (!statsQuery.data) return null - - const total = statsQuery.data.totalDollarsRaised - if (!Number.isFinite(total) || total <= 0) return null - - const pct = Math.round( - (statsQuery.data.oneTimeDollarsRaised / total) * 100 - ) - - return Number.isFinite(pct) ? pct : null - }, [statsQuery.data]) - - const raisedKickerLabel = useMemo(() => { - if (activePreset) return `Total Raised ${activePreset}` - - if (startDate && endDate) { - return `Total Raised ${formatRangeDate(startDate)} - ${formatRangeDate(endDate)}` - } - - if (startDate) { - return `Total Raised From ${formatRangeDate(startDate)}` - } - - if (endDate) { - return `Total Raised Until ${formatRangeDate(endDate)}` - } - - return 'Total Raised Custom Range' - }, [activePreset, endDate, startDate]) + }, [draftStartDate, draftEndDate, committedPreset]) return (
@@ -401,406 +199,691 @@ export default function Page() { {/* logic will eventually need to be reworked to show last api fetch and not most recent contribution date */}
Last Updated: N/A
+
+
+

Fundraising

+

+ Manage ActBlue donors and contribution records. +

+
-
-

Fundraising

-

- Manage ActBlue donors and contribution records. -

-
- -
-
-
-
- {raisedKickerLabel} -
-
- {formatCurrency( - statsQuery.data?.totalDollarsRaised - )} +
+
+
+
+ {raisedKickerLabel} +
+
+ {formatCurrency( + statsQuery.data?.totalDollarsRaised + )} +
-
-
-
-
- - - - - {isDateRangeOverlayOpen && ( -
{ + setDraftStartDate(startDate) + setDraftEndDate(endDate) + setDraftPreset(committedPreset) }} - > -
- Select Range -
-
- {PRESETS.map((preset) => ( - - ))} - - -
- - {activeDateOption === - 'Custom Range' && ( - <> -
- - { - const next = - dateInputToStartISO( - ev.target - .value - ) - - if ( - next && - draftEndDate && - new Date( - next - ).getTime() > - new Date( - draftEndDate - ).getTime() - ) { - return - } - - setDraftStartDate( - next - ) - }} - value={isoToDateInput( - draftStartDate - )} - /> -
- -
- - { - const next = - dateInputToEndISO( - ev.target - .value - ) - - if ( - next && - draftStartDate && - new Date( - draftStartDate - ).getTime() > - new Date( - next - ).getTime() - ) { - return + label="Select date range" + onClose={() => { + closeDropdown() + }} + body={ + <> +
-
- -
- - + ) + } + )} +
+ + - Set Range - -
- + } + onRangeChange={( + nextStartDate: string, + nextEndDate: string + ) => { + setDraftStartDate( + nextStartDate + ) + setDraftEndDate( + nextEndDate + ) + setDraftPreset( + inferPresetFromRange( + nextStartDate, + nextEndDate, + allTimeFirstIso + ) + ) + }} + /> + + } + bodyClassName={ + styles.dateRangePopBody + } + footerButtonLabel={ + isAwaitingDraftEndDate + ? 'Select End Date' + : 'Select' + } + footerButtonDisabled={ + !canApplyCustomRange + } + footerButtonOnClick={() => { + if (!canApplyCustomRange) + return + setStartDate(draftStartDate) + setEndDate(draftEndDate) + setCommittedPreset( + draftPreset + ) + closeDropdown() + }} + /> )} -
- )} + /> +
-
-
-
-
Recurring
-
- {formatCurrency( +
+ -
- {recurringPct != null && - statsQuery.data?.recurringContributionCount != null - ? `${recurringPct}% of total · ${formatDonationCountLabel(statsQuery.data.recurringContributionCount)}` - : '—'} -
-
+ stat1={ + recurringPct != null && + statsQuery.data?.recurringContributionCount != + null + ? `${recurringPct}% of total` + : '—' + } + stat2={ + statsQuery.data?.recurringContributionCount != + null + ? formatDonationCountLabel( + statsQuery.data + .recurringContributionCount + ) + : '—' + } + /> -
-
One-Time
-
- {formatCurrency( + -
- {oneTimePct != null && - statsQuery.data?.oneTimeContributionCount != null - ? `${oneTimePct}% of total · ${formatDonationCountLabel(statsQuery.data.oneTimeContributionCount)}` - : '—'} -
-
+ stat1={ + oneTimePct != null && + statsQuery.data?.oneTimeContributionCount != + null + ? `${oneTimePct}% of total` + : '—' + } + stat2={ + statsQuery.data?.oneTimeContributionCount != + null + ? formatDonationCountLabel( + statsQuery.data + .oneTimeContributionCount + ) + : '—' + } + /> -
-
Donors
-
- {formatCount(statsQuery.data?.totalDonorCount)} -
-
- {`${formatCount(statsQuery.data?.recurringDonorCount)} Recurring · ${formatCount(statsQuery.data?.oneTimeDonorCount)} One-Time`} -
-
+ -
-
Contributions
-
- {formatCount( + -
- {`${formatCount(statsQuery.data?.recurringContributionCount)} Recurring · ${formatCount(statsQuery.data?.oneTimeContributionCount)} One-Time`} -
-
-
- -
-
- Recurring Share - - {recurringPct != null ? `${recurringPct}%` : '—'} - -
- -
- One-Time Share - - {oneTimePct != null ? `${oneTimePct}%` : '—'} - -
- + formatCurrency(value), + secondaryBar: (value) => formatCurrency(value), + line: (value) => formatCount(value), + }} + axisFormatters={{ + left: formatCurrencyAxis, + right: formatCountAxis, + }} + ariaLabel="Fundraising volume by period: one-time and recurring dollars with total donations" + chartAriaLabel={`Fundraising volume across ${chartPoints.length} periods`} + tableConfig={{ + caption: 'Fundraising volume per period', + periodHeader: 'Period', + primaryBarHeader: 'One-Time', + secondaryBarHeader: 'Recurring', + lineHeader: 'Donations', + }} + onRangeSelect={ + zoomEnabled + ? ({ + startIso, + endIso, + }: { + startIso: string + endIso: string + }) => { + const start = new Date(startIso) + const end = new Date(endIso) + const oneDayMs = 24 * 60 * 60 * 1000 + const inclusiveRangeMs = + end.getTime() - start.getTime() + 1000 + + if ( + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) + ) { + return + } + + if (inclusiveRangeMs < oneDayMs) { + return + } + + applyChartViewOverrideRange({ + startIso, + endIso, + }) + } + : undefined + } + cornerTopRight={ + chartViewOverrideActive ? ( + + ) : null + } + headerRight={ +
+ ( + { + closeDropdown() + }} + bodyClassName={ + styles.chartOptionsBody + } + body={ + <> +
+ + Time scale + + + ariaLabel="Time scale" + orientation="horizontal" + value={ + granularityMode + } + options={( + [ + 'auto', + 'year', + 'quarter', + 'month', + 'week', + 'day', + 'hour', + ] as ChartGranularityMode[] + ) + .filter( + (mode) => + validGranularityModes.includes( + mode + ) + ) + .map( + (mode) => ({ + value: mode, + label: CHART_GRANULARITY_LABELS[ + mode + ], + }) + )} + onChange={ + setGranularityMode + } + /> +
+ + {isXToDateSpanOptionRelevant && ( +
+ + Show{' '} + {getXToDatePeriodName( + committedPreset, + granularityMode + )}{' '} + To Date + + + ariaLabel="X-to-date span" + orientation="horizontal" + value={ + showFullXToDateSpan + } + options={[ + { + value: true, + label: 'Hide', + }, + { + value: false, + label: 'Show', + }, + ]} + onChange={ + setShowFullXToDateSpan + } + /> +
+ )} + +
+ + Contributions line + + + ariaLabel="Contributions line" + orientation="horizontal" + value={ + showDonationsLine + } + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={ + setShowDonationsLine + } + /> +
+ + {showDonationsLine && ( + <> +
+ + Line style + + + ariaLabel="Line style" + orientation="horizontal" + value={ + smoothLine + } + options={[ + { + value: true, + label: 'Curved', + }, + { + value: false, + label: 'Straight', + }, + ]} + onChange={ + setSmoothLine + } + /> +
+ +
+ + Area fill + + + ariaLabel="Area fill" + orientation="horizontal" + value={ + showAreaFill + } + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={ + setShowAreaFill + } + /> +
+ + )} + +
+ + Drag To Zoom + + + ariaLabel="Chart zoom" + orientation="horizontal" + value={zoomEnabled} + options={[ + { + value: true, + label: 'Enabled', + }, + { + value: false, + label: 'Disabled', + }, + ]} + onChange={ + setZoomEnabled + } + /> +
+ +
+ + Bar layout + + + ariaLabel="Bar layout" + orientation="horizontal" + value={ + chartBarDisplayMode + } + options={[ + { + value: 'stacked', + label: 'Stacked', + }, + { + value: 'grouped', + label: 'Seperate', + }, + ]} + onChange={ + setChartBarDisplayMode + } + /> +
+ + } + /> + )} + /> +
+ } + /> + + + +
+ +
+ + +
-
-
- - -
) diff --git a/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts b/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts new file mode 100644 index 00000000..6cffd614 --- /dev/null +++ b/src/app/admin/panels/fundraising/useFundraisingDashboardController.ts @@ -0,0 +1,398 @@ +import { + getExtendedEndForXToDatePreset, + getPercentage, + getPresetRange, + getRaisedKickerLabel, + getSelectedRangeLabel, + isXToDatePreset, + type Preset, +} from './fundraising.helpers' +import { + getEarliestContribution, + getFundraisingBucketStats, + getFundraisingStats, +} from './fundraising.service' +import type { ChartPoint } from '@/components/common/charts/DualAxisBarLineChart' +import { + buildChartBuckets, + getValidChartGranularityModes, + type ChartGranularityMode, +} from '@/components/common/charts/timeBuckets' +import type { QueryParams, ZodSchema } from '@/util/hooks/useFetch' +import { keepPreviousData, useQueries, useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' + +interface GetOptions { + query?: QueryParams + signal?: AbortSignal +} + +type OnGet = ( + url: string, + schema: ZodSchema, + options?: GetOptions +) => Promise + +type ChartBarDisplayMode = 'grouped' | 'stacked' + +function isSameLocalDay(aIso: string, bIso: string): boolean { + const a = new Date(aIso) + const b = new Date(bIso) + + if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) { + return false + } + + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +function formatFullLabel( + anchorIso: string, + granularity: Exclude +): string { + const anchor = new Date(anchorIso) + + if (granularity === 'year') return String(anchor.getFullYear()) + + if (granularity === 'hour') { + return anchor.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + } + + if (granularity === 'quarter') { + const quarter = Math.floor(anchor.getMonth() / 3) + 1 + return `Q${quarter} ${anchor.getFullYear()}` + } + + if (granularity === 'month') { + return anchor.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }) + } + + if (granularity === 'week') { + return `Week of ${anchor.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })}` + } + + return anchor.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }) +} + +export function useFundraisingDashboardController(onGet: OnGet) { + const [startDate, setStartDate] = useState(() => getPresetRange('Today')[0]) + const [endDate, setEndDate] = useState(() => getPresetRange('Today')[1]) + const [committedPreset, setCommittedPreset] = useState( + 'Today' + ) + const [draftPreset, setDraftPreset] = useState('Today') + + const [draftStartDate, setDraftStartDate] = useState(startDate) + const [draftEndDate, setDraftEndDate] = useState(endDate) + + const [smoothLine, setSmoothLine] = useState(true) + const [showAreaFill, setShowAreaFill] = useState(true) + const [showDonationsLine, setShowDonationsLine] = useState(true) + const [chartBarDisplayMode, setChartBarDisplayMode] = + useState('grouped') + const [zoomEnabled, setZoomEnabled] = useState(true) + const [showFullXToDateSpan, setShowFullXToDateSpan] = useState(false) + const [granularityMode, setGranularityMode] = + useState('auto') + const [chartViewOverrideRange, setChartViewOverrideRange] = useState<{ + startIso: string + endIso: string + } | null>(null) + + const isAllTime = !startDate && !endDate + + const statsQuery = useQuery({ + queryKey: [ + '/actblue/fundraising/stats', + startDate || null, + endDate || null, + ], + queryFn: () => getFundraisingStats(onGet, startDate, endDate), + placeholderData: keepPreviousData, + }) + + const allTimeStatsQuery = useQuery({ + queryKey: ['/actblue/fundraising/stats', 'all-time-cards'], + queryFn: () => getFundraisingStats(onGet), + }) + + const earliestContributionQuery = useQuery({ + queryKey: ['/actblue/contributions', 'earliest'], + queryFn: () => getEarliestContribution(onGet), + staleTime: 5 * 60_000, + }) + + const allTimeFirstIso = useMemo(() => { + const first = earliestContributionQuery.data?.data?.[0]?.paidAt + if (!first) return undefined + const d = first instanceof Date ? first : new Date(first) + return Number.isNaN(d.getTime()) ? undefined : d.toISOString() + }, [earliestContributionQuery.data]) + + const recurringPct = useMemo(() => { + if (!statsQuery.data) return null + return getPercentage( + statsQuery.data.recurringDollarsRaised, + statsQuery.data.totalDollarsRaised + ) + }, [statsQuery.data]) + + const oneTimePct = useMemo(() => { + if (!statsQuery.data) return null + return getPercentage( + statsQuery.data.oneTimeDollarsRaised, + statsQuery.data.totalDollarsRaised + ) + }, [statsQuery.data]) + + const selectedChartStartDate = startDate + const selectedChartBaseEndDate = endDate + + const selectedChartEndDate = useMemo(() => { + if (!showFullXToDateSpan) return selectedChartBaseEndDate + if (!isXToDatePreset(committedPreset)) return selectedChartBaseEndDate + return getExtendedEndForXToDatePreset( + committedPreset, + selectedChartStartDate, + selectedChartBaseEndDate + ) + }, [ + showFullXToDateSpan, + committedPreset, + selectedChartStartDate, + selectedChartBaseEndDate, + ]) + + const chartViewStartDate = chartViewOverrideRange?.startIso ?? startDate + const chartViewBaseEndDate = chartViewOverrideRange?.endIso ?? endDate + + const chartEndDate = useMemo(() => { + if (chartViewOverrideRange) return chartViewBaseEndDate + if (!showFullXToDateSpan) return chartViewBaseEndDate + if (!isXToDatePreset(committedPreset)) return chartViewBaseEndDate + return getExtendedEndForXToDatePreset( + committedPreset, + chartViewStartDate, + chartViewBaseEndDate + ) + }, [ + chartViewOverrideRange, + chartViewBaseEndDate, + showFullXToDateSpan, + committedPreset, + chartViewStartDate, + ]) + + const validGranularityModes = useMemo( + () => getValidChartGranularityModes(chartViewStartDate, chartEndDate), + [chartViewStartDate, chartEndDate] + ) + + const chartBuckets = useMemo( + () => + buildChartBuckets( + chartViewStartDate, + chartEndDate, + allTimeFirstIso, + granularityMode, + !chartViewOverrideRange && committedPreset === 'All Time' + ), + [ + chartViewStartDate, + chartEndDate, + allTimeFirstIso, + granularityMode, + chartViewOverrideRange, + committedPreset, + ] + ) + + useEffect(() => { + setChartViewOverrideRange(null) + }, [startDate, endDate, committedPreset]) + + useEffect(() => { + if (!zoomEnabled) { + setChartViewOverrideRange(null) + } + }, [zoomEnabled]) + + const chartBucketQueries = useQueries({ + queries: chartBuckets.map((bucket) => ({ + queryKey: [ + '/actblue/fundraising/stats', + 'bucket', + bucket.startIso, + bucket.endIso, + ], + queryFn: () => + getFundraisingBucketStats( + onGet, + bucket.startIso, + bucket.endIso + ), + staleTime: 60_000, + placeholderData: keepPreviousData, + })), + }) + + const chartPoints = useMemo(() => { + return chartBuckets.map((bucket, idx) => { + const data = chartBucketQueries[idx]?.data + + return { + key: bucket.key, + anchorIso: bucket.anchorIso, + startIso: bucket.startIso, + endIso: bucket.endIso, + granularity: bucket.granularity, + label: bucket.label, + fullLabel: formatFullLabel( + bucket.anchorIso, + bucket.granularity + ), + primaryBarValue: data?.oneTimeDollarsRaised ?? 0, + secondaryBarValue: data?.recurringDollarsRaised ?? 0, + lineValue: data?.totalContributionCount ?? 0, + } + }) + }, [chartBuckets, chartBucketQueries]) + + useEffect(() => { + if (!validGranularityModes.includes(granularityMode)) { + setGranularityMode('auto') + } + }, [validGranularityModes, granularityMode]) + + const selectedRangeLabel = useMemo( + () => getSelectedRangeLabel(committedPreset, startDate, endDate), + [committedPreset, startDate, endDate] + ) + + const raisedKickerLabel = useMemo( + () => getRaisedKickerLabel(committedPreset, startDate, endDate), + [committedPreset, startDate, endDate] + ) + + const hasValidDraft = Boolean(draftStartDate && draftEndDate) + const isAwaitingDraftEndDate = Boolean(draftStartDate && !draftEndDate) + const isDraftUnchanged = + draftStartDate === startDate && + draftEndDate === endDate && + draftPreset === committedPreset + const canApplyCustomRange = hasValidDraft && !isDraftUnchanged + + const isXToDateSpanOptionRelevant = isXToDatePreset(committedPreset) + + const applyChartViewOverrideRange = (range: { + startIso: string + endIso: string + }) => { + const selectedStart = selectedChartStartDate + const selectedEnd = selectedChartEndDate + + if (range.startIso === selectedStart && range.endIso === selectedEnd) { + setChartViewOverrideRange(null) + return + } + + const oneDayMs = 24 * 60 * 60 * 1000 + const selectedInclusiveMs = + new Date(selectedEnd).getTime() - + new Date(selectedStart).getTime() + + 1000 + const selectedIsOneDayWindow = + Number.isFinite(selectedInclusiveMs) && + selectedInclusiveMs >= oneDayMs && + selectedInclusiveMs < oneDayMs * 2 + + if ( + selectedIsOneDayWindow && + isSameLocalDay(range.startIso, selectedStart) && + isSameLocalDay(range.endIso, selectedEnd) + ) { + setChartViewOverrideRange(null) + return + } + + setChartViewOverrideRange(range) + } + + const chartViewOverrideActive = + chartViewOverrideRange != null && + (chartViewOverrideRange.startIso !== selectedChartStartDate || + chartViewOverrideRange.endIso !== selectedChartEndDate) + + const resetChartViewToSelectedRange = () => { + setChartViewOverrideRange(null) + } + + return { + isAllTime, + startDate, + endDate, + committedPreset, + draftPreset, + draftStartDate, + draftEndDate, + smoothLine, + showAreaFill, + showDonationsLine, + chartBarDisplayMode, + zoomEnabled, + showFullXToDateSpan, + granularityMode, + selectedRangeLabel, + raisedKickerLabel, + recurringPct, + oneTimePct, + validGranularityModes, + chartPoints, + chartViewOverrideActive, + allTimeFirstIso, + isXToDateSpanOptionRelevant, + hasValidDraft, + isAwaitingDraftEndDate, + canApplyCustomRange, + statsQuery, + allTimeStatsQuery, + setStartDate, + setEndDate, + setCommittedPreset, + setDraftPreset, + setDraftStartDate, + setDraftEndDate, + setSmoothLine, + setShowAreaFill, + setShowDonationsLine, + setChartBarDisplayMode, + setZoomEnabled, + setShowFullXToDateSpan, + setGranularityMode, + applyChartViewOverrideRange, + resetChartViewToSelectedRange, + } +} diff --git a/src/app/admin/panels/members/page.tsx b/src/app/admin/panels/members/page.tsx index f6f1e297..2dd8d539 100644 --- a/src/app/admin/panels/members/page.tsx +++ b/src/app/admin/panels/members/page.tsx @@ -488,6 +488,7 @@ export default function Page() { { value: 'first_name', label: 'First Name' }, { value: 'last_name', label: 'Last Name' }, { value: 'created_at_utc', label: 'Created At' }, + { value: 'updated_at_utc', label: 'Recently Edited' }, ]} filters={[ { diff --git a/src/app/endorsements/endorsements.data.ts b/src/app/endorsements/endorsements.data.ts index 8c970c31..4abd1ad8 100644 --- a/src/app/endorsements/endorsements.data.ts +++ b/src/app/endorsements/endorsements.data.ts @@ -31,29 +31,29 @@ export interface CandidateConfig { export const CANDIDATES: CandidateConfig[] = [ { - id: '1', - name: 'Jeromie Whalen', - state: 'Massachusetts', - electionStatus: '', - electionDate: new Date('09/01/26'), - handle: '@jeromiewhalen', - handleHref: 'https://www.whalenforcongressma.com/', - bodyText: - ' is not only a passionate progressive voice, he is also a powerful member of our community! There is nobody we are more excited to endorse this midterm season!', - image: '/images/endorsement_images/2026/Silhouette/Jeromie Whalen.png', - learnMoreHref: 'https://www.whalenforcongressma.com/platform', - initiativeType: 'national', + id: '23', + name: 'Aaron Bailey', + state: 'Michigan', + electionStatus: 'Dropped Out', + electionDate: new Date('08/04/26'), + handle: 'baileepolitics', + handleHref: 'https://x.com/BaileePolitics', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Aaron Bailey.png', + learnMoreHref: '', + initiativeType: 'state', endorsementType: 'PV Pledge', - showPvMember: true, + showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '2', name: 'Abdul El-Sayed', state: 'Michigan', electionStatus: '', - electionDate: new Date('11/03/26'), + electionDate: new Date('08/04/26'), handle: '@abdulelsayed', handleHref: 'https://x.com/AbdulElSayed', bodyText: @@ -66,138 +66,79 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '3', - name: 'Saikat Chakrabarti', - state: 'California', + id: '51', + name: 'Abdun Matin', + state: 'Maryland', electionStatus: '', - electionDate: new Date('06/02/26'), - handle: '@saikatc', - handleHref: 'https://x.com/saikatc', - bodyText: - " founded the Justice Democrats because he belives in the left's ability to come togeather to fight establishment power. In congress, he will continue that energy!", - image: '/images/endorsement_images/2026/Silhouette/Saikat Chakrabarti.png', + electionDate: new Date('06/23/26'), + handle: '', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Abdun Matin.png', learnMoreHref: '', - initiativeType: 'national', - - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - { - id: '4', - name: 'Kat Abughazaleh', - state: 'Illinois', - electionStatus: 'Lost Primary', - electionDate: new Date('03/17/26'), - handle: '@katabughazaleh', - handleHref: 'https://x.com/KatAbughazaleh', - bodyText: - ' is exactly what we are looking for at PV. A young, media savvy firebrand willing to tackle any challenge that gets in her way.', - image: '/images/endorsement_images/2026/Silhouette/Kat Abughazaleh.png', - learnMoreHref: - '/images/endorsement_images/2026/Silhouette/Kat Abughazaleh.png', - initiativeType: 'national', - - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'blue', - }, - { - id: '5', - name: 'Graham Platner', - state: 'Maine', - electionStatus: 'Won Primary', - electionDate: new Date('11/03/26'), - handle: '@grahamformaine', - handleHref: 'https://x.com/grahamformaine', - bodyText: - ' is what happens when normal people get fed up and run for office over sanitized aspiring political wannabes! Our Maine team is so excited to get to work on his behalf!', - image: '/images/endorsement_images/2026/Silhouette/Graham Platner.png', - learnMoreHref: - '/images/endorsement_images/2026/Silhouette/Graham Platner.png', - initiativeType: 'national', - + initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, - { - id: '6', - name: 'Salaam Bhatti', - state: 'Virginia', - electionStatus: '', - electionDate: new Date('08/01/26'), - handle: '@salaambhattiva', - handleHref: 'https://x.com/SalaamBhattiVA', - bodyText: - ' is a candidate whose advocacy for Medicare for All and drive to fight to get money out of politics will bring us one step closer to free healthcare once he wins his district.', - image: '/images/endorsement_images/2026/Silhouette/Salaam Bhatti.png', - learnMoreHref: '', - initiativeType: 'national', - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, { - id: '7', - name: 'Karishma Manzur', - state: 'New Hampshire', + id: '52', + name: 'Abel Chavez', + state: 'California', electionStatus: '', - electionDate: new Date('09/08/26'), - handle: '@karishma4senate', - handleHref: 'https://x.com/Karishma4Senate', + electionDate: new Date('06/02/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Karishma Manzur.png', + image: '/images/endorsement_images/2026/Silhouette/Abel Chavez.png', learnMoreHref: '', - initiativeType: 'national', - - endorsementType: 'PV Pledge', + initiativeType: '', + endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'blue', + avatarBackgroundColor: 'yellow', }, + { - id: '8', - name: 'Oliver Larkin', - state: 'Florida', + id: '24', + name: 'Adam Bojak', + state: 'New York', electionStatus: '', - electionDate: new Date('08/18/26'), - handle: '@oliveralarkin', - handleHref: 'https://x.com/OliverALarkin', - bodyText: - ' is proving Progressive politics is alive and well even in places like Florida! We are proud to endorse him and to do our part to help him win.', - image: '/images/endorsement_images/2026/Silhouette/Oliver Larkin.png', + electionDate: new Date('06/23/26'), + handle: 'bojak4assembly', + handleHref: 'https://x.com/bojak4assembly', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Adam Bojak.png', learnMoreHref: '', - initiativeType: 'national', + initiativeType: '', endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '9', - name: 'Heath Howard', - state: 'New Hampshire', + id: '53', + name: 'Adam Hamawy', + state: 'New Jersey', electionStatus: '', - electionDate: new Date('09/08/26'), - handle: '@heathhowardnh', - handleHref: 'https://x.com/HeathHowardNH', + electionDate: new Date('06/02/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Heath Howard.png', + image: '/images/endorsement_images/2026/Silhouette/Adam Hamawy.png', learnMoreHref: '', initiativeType: 'national', - endorsementType: 'PV Pledge', - showPvMember: true, + showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '10', name: 'Adam Murphy', state: 'Virginia', electionStatus: '', - electionDate: new Date('08/01/26'), + electionDate: new Date('08/04/26'), handle: '@murphy4va', handleHref: 'https://x.com/Murphy4VA', bodyText: @@ -210,6 +151,23 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: true, avatarBackgroundColor: 'yellow', }, + + { + id: '54', + name: 'Ali Aljarrah', + state: 'New Jersey', + electionStatus: '', + electionDate: new Date('06/02/26'), + handle: '', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Ali Aljarrah.png', + learnMoreHref: '', + initiativeType: '', + endorsementType: 'PV Pledge', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + { id: '11', name: 'Analilia Mejia', @@ -228,226 +186,69 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '12', - name: 'Mary Peltola', - state: 'Alaska', + id: '50', + name: 'Audrey Denny', + state: 'California', electionStatus: '', - electionDate: new Date('11/03/26'), - handle: '@marypeltola', - handleHref: 'https://x.com/MaryPeltola', - bodyText: - " is America's first Native Alaskan representative and a fierce advocate for her community. We are proud to endorse her for re-election in Alaska's at-large congressional district!", - image: '/images/endorsement_images/2026/Silhouette/Mary Peltola.png', + electionDate: new Date('06/02/26'), + handle: '', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Audrey Denny.png', learnMoreHref: '', - initiativeType: 'state', - + initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'blue', }, - { - id: '13', - name: 'Erica Watkins', - state: 'Oklahoma', - electionStatus: 'Dropped Out', - electionDate: new Date('11/03/26'), - handle: '@erica4ok', - handleHref: 'https://x.com/erica4ok', - bodyText: - " stands apart not just as someone who will take on the machine, but someone with bold ideas to fight Oklahoma's poverty and exploitation.", - image: '/images/endorsement_images/2026/Silhouette/Erica Watkins.png', - learnMoreHref: '', - initiativeType: 'state', - endorsementType: 'PV Pledge', - showPvMember: true, - avatarBackgroundColor: 'blue', - }, { - id: '14', - name: 'Taylor Rehmet', - state: 'Texas', - electionStatus: 'Elected', - electionDate: new Date('01/27/26'), - handle: '@taylorrehmettx', - handleHref: 'https://x.com/TaylorRehmetTX', + id: '25', + name: 'Beth Andres-Beck', + state: 'Massachusetts', + electionStatus: '', + electionDate: new Date('09/01/26'), + handle: 'beth4ma', + handleHref: 'https://x.com/beth4ma', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Taylor Rehmet.png', + image: '/images/endorsement_images/2026/Silhouette/Beth Andres-Beck.png', learnMoreHref: '', - initiativeType: 'state', + initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, + { - id: '15', - name: 'Luis Villarreal', - state: 'Utah', + id: '26', + name: 'Blake Bracht', + state: 'Arizona', electionStatus: 'Dropped Out', - electionDate: new Date('11/03/26'), - handle: '@luis4utah', - handleHref: 'https://x.com/Luis4Utah', + electionDate: new Date('07/21/26'), + handle: 'brachtforaz5', + handleHref: 'https://x.com/BrachtforAZ5', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Luis Villarreal.png', + image: '/images/endorsement_images/2026/Silhouette/Blake Bracht.png', learnMoreHref: '', - initiativeType: 'state', + initiativeType: '', endorsementType: 'PV Pledge', - showPvMember: true, + showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '16', - name: 'Brad Lander', - state: 'New York', - electionStatus: '', - electionDate: new Date('11/03/26'), - handle: '@bradlander', - handleHref: 'https://x.com/bradlander', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Brad Lander.png', - learnMoreHref: '', - initiativeType: '', - - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - { - id: '17', - name: 'Ro Khanna', - state: 'California', - electionStatus: '', - electionDate: new Date('06/02/26'), - handle: '@rokhanna', - handleHref: 'https://x.com/RoKhanna', - bodyText: - ' is the leader our party needs! His commitment to new media politics has made him an outstanding voice capable of real change.', - image: '/images/endorsement_images/2026/Silhouette/Ro Khanna.png', - learnMoreHref: '', - initiativeType: '', - - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - { - id: '18', - name: 'Cori Bush', - state: 'Missouri', - electionStatus: '', - electionDate: new Date('08/04/26'), - handle: '@coribush', - handleHref: 'https://x.com/CoriBush', - bodyText: - ' is back to finish what she started! Her passion and tenacity have repeatedly shown that she is a powerful progressive voice ready to take on whatever battle is needed.', - image: '/images/endorsement_images/2026/Silhouette/Cori Bush.png', - learnMoreHref: '', - initiativeType: 'state', - - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'blue', - }, - { - id: '19', - name: 'James Talarico', - state: 'Texas', - electionStatus: 'Won Primary', - electionDate: new Date('03/03/26'), - handle: '@jamestalarico', - handleHref: 'https://x.com/jamestalarico', - bodyText: - "'s time has come to turn Texas blue. PV is proud to endorse him for the US Senate Seat in Texas! He is exctly what is needed.", - image: '/images/endorsement_images/2026/Silhouette/James Talarico.png', - learnMoreHref: '', - initiativeType: 'national', - - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - // { - // id: '20', - // name: 'Jon Stewart', - // state: 'President', - // electionStatus: '', - // electionDate: new Date('11/07/28'), - // handle: '@jonstewart', - // handleHref: 'https://x.com/jonstewart', - // bodyText: - // ' isn’t perfect, but his decades long track record of integrity and progressivism make him the decisive pick. We urge him to serve his country by running for the office.', - // image: '/images/endorsement_images/2026/Silhouette/Jon Stewart.png', - // learnMoreHref: '', - // initiativeType: '', - // endorsementType: 'Endorsement', - // showPvMember: false, - // }, - { - id: '21', - name: 'Zeeshan Hafeez', - state: 'Texas', - electionStatus: 'Lost Primary', - electionDate: new Date('03/03/26'), - handle: '@zeeshanfortexas', - handleHref: '', - bodyText: - " is going to bring a fire to Texa's 33rd Congressional District. We are excited to see a bold progressive like him making all the right waves in all the right places!", - image: '/images/endorsement_images/2026/Silhouette/Zeeshan Hafeez.png', - learnMoreHref: '', - initiativeType: 'national', - - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - { - id: '22', - name: 'Michael Black', - state: 'New York', - electionStatus: '', - electionDate: new Date('06/23/26'), - handle: '@MrMikeBlake', - handleHref: 'https://x.com/MrMikeBlake', - bodyText: - ' is an authentic, progressive fighter who has a deep love for his community. The progressive movement won in New Jersey and he will bring that momentum to New York!', - image: '/images/endorsement_images/2026/Silhouette/Michael Blake.png', - learnMoreHref: '', - initiativeType: '', - - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - { - id: '23', - name: 'Aaron Bailey', - state: 'Michigan', - electionStatus: '', + id: '27', + name: 'Bobby Nichols', + state: 'Arizona', + electionStatus: 'Elected', electionDate: new Date('05/19/26'), - handle: 'baileepolitics', - handleHref: 'https://x.com/BaileePolitics', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Aaron Bailey.png', - learnMoreHref: '', - initiativeType: 'state', - - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, - { - id: '24', - name: 'Adam Bojak', - state: 'New Jersey', - electionStatus: '', - electionDate: new Date('06/23/26'), - handle: 'bojak4assembly', - handleHref: 'https://x.com/bojak4assembly', + handle: 'bobby4tempe', + handleHref: 'https://www.instagram.com/bobby4tempe/', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Adam Bojak.png', + image: '/images/endorsement_images/2026/Silhouette/Bobby Nichols.png', learnMoreHref: '', initiativeType: '', @@ -455,63 +256,31 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, - { - id: '25', - name: 'Beth Andres-Beck', - state: 'Massachusetts', - electionStatus: '', - electionDate: new Date('09/01/26'), - handle: 'beth4ma', - handleHref: 'https://x.com/beth4ma', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Beth Andres-Beck.png', - learnMoreHref: '', - initiativeType: '', - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'blue', - }, { - id: '26', - name: 'Blake Bracht', - state: 'Arizona', + id: '16', + name: 'Brad Lander', + state: 'New York', electionStatus: '', - electionDate: new Date('07/29/26'), - handle: 'brachtforaz5', - handleHref: 'https://x.com/BrachtforAZ5', + electionDate: new Date('06/23/26'), + handle: '@bradlander', + handleHref: 'https://x.com/bradlander', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Blake Bracht.png', + image: '/images/endorsement_images/2026/Silhouette/Brad Lander.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'PV Pledge', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, - { - id: '27', - name: 'Bobby Nichols', - state: 'Arizona', - electionStatus: '', - electionDate: new Date('07/29/26'), - handle: 'bobby4tempe', - handleHref: 'https://www.instagram.com/bobby4tempe/', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Bobby Nichols.png', - learnMoreHref: '', - initiativeType: '', - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, { id: '28', name: 'Brent Hennrich', state: 'Washington', electionStatus: '', - electionDate: new Date('09/01/26'), + electionDate: new Date('08/04/26'), handle: 'bhennrich', handleHref: 'https://x.com/bhennrich', bodyText: '', @@ -523,295 +292,261 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '29', - name: 'Ethan Wechtaluk', - state: 'Maryland', - electionStatus: '', + id: '56', + name: 'Charles Booker', + state: 'Kentucky', + electionStatus: 'Won Primary', electionDate: new Date('05/19/26'), - handle: 'ethanformd', - handleHref: 'https://x.com/ethanformd', + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Ethan Wechtaluk.png', + image: '/images/endorsement_images/2026/Silhouette/Charles Booker.png', learnMoreHref: '', - initiativeType: '', - + initiativeType: 'national', endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '30', - name: 'Greg Casar', + id: '57', + name: 'Chelsey Hockett', state: 'Texas', electionStatus: 'Won Primary', - electionDate: new Date('03/03/26'), - handle: 'gregcasar', - handleHref: 'https://x.com/gregcasar', + electionDate: new Date('05/26/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Greg Casar.png', + image: '/images/endorsement_images/2026/Silhouette/Chelsey Hockett.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, + { - id: '32', - name: 'Hunter Gordon', - state: 'Washington', - electionStatus: '', - electionDate: new Date('08/04/26'), - handle: 'hunter4wa', - handleHref: 'https://x.com/hunter4wa', + id: '55', + name: 'Chris Rabb', + state: 'Pennsylvania', + electionStatus: 'Won Primary', + electionDate: new Date('05/19/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Hunter Gordon.png', + image: '/images/endorsement_images/2026/Silhouette/Chris Rabb.png', learnMoreHref: '', - initiativeType: '', - + initiativeType: 'state', endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '33', - name: 'Jakeya Johnson', - state: 'Maryland', + id: '58', + name: 'Christopher Ahuja', + state: 'California', electionStatus: '', - electionDate: new Date('08/04/26'), - handle: 'jakeya_hq', - handleHref: 'https://www.instagram.com/jakeya_hq', + electionDate: new Date('06/02/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Jakeya Johnson.png', + image: '/images/endorsement_images/2026/Silhouette/Christopher Ahuja.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'PV Pledge', showPvMember: false, - avatarBackgroundColor: 'blue', + avatarBackgroundColor: 'yellow', }, + { - id: '34', - name: 'John Padora', - state: 'Colorado', - electionStatus: 'Dropped Out', - electionDate: new Date('06/30/26'), - handle: 'padoraforco', - handleHref: 'https://x.com/PadoraforCO', + id: '59', + name: 'Chuck Park', + state: 'New York', + electionStatus: '', + electionDate: new Date('06/23/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/John Padora.png', + image: '/images/endorsement_images/2026/Silhouette/Chuck Park.png', learnMoreHref: '', initiativeType: '', - - endorsementType: 'PV Pledge', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '35', - name: 'Josie Caballero', - state: 'Maryland', + id: '18', + name: 'Cori Bush', + state: 'Missouri', electionStatus: '', - electionDate: new Date('05/19/26'), - handle: 'josieformd', - handleHref: 'https://x.com/josieformd', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Josie Caballero.png', + electionDate: new Date('08/04/26'), + handle: '@coribush', + handleHref: 'https://x.com/CoriBush', + bodyText: + ' is back to finish what she started! Her passion and tenacity have repeatedly shown that she is a powerful progressive voice ready to take on whatever battle is needed.', + image: '/images/endorsement_images/2026/Silhouette/Cori Bush.png', learnMoreHref: '', - initiativeType: 'national', + initiativeType: 'state', endorsementType: 'Endorsement', - showPvMember: true, + showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '36', - name: 'Katie Bansil', - state: 'New Jersey', + id: '61', + name: 'Darializa Avila Chevalier', + state: 'New York', electionStatus: '', - electionDate: new Date('06/02/26'), - handle: 'katiebansil4nj', - handleHref: 'https://x.com/katiebansil4nj', + electionDate: new Date('06/23/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Katie Bansil.png', + image: '/images/endorsement_images/2026/Silhouette/Darializa Avila Chevalier.png', learnMoreHref: '', initiativeType: 'national', - - endorsementType: 'PV Pledge', - showPvMember: true, + endorsementType: 'Endorsement', + showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '37', - name: 'Matt Conroy', + id: '62', + name: 'Demi Palecek', state: 'Illinois', - electionStatus: 'Lost Primary', + electionStatus: 'Won Primary', electionDate: new Date('03/17/26'), - handle: 'conroyforil', - handleHref: 'https://x.com/ConroyForIL', + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Matt Conroy.png', + image: '/images/endorsement_images/2026/Silhouette/Demi Palecek.png', learnMoreHref: '', initiativeType: '', - - endorsementType: 'PV Pledge', + endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, - { - id: '38', - name: 'Matt Fulmer', - state: 'South Carolina', - electionStatus: '', - electionDate: new Date('05/19/26'), - handle: 'mattforcarolina', - handleHref: 'https://x.com/MattForCarolina', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Matt Fulmer.png', - learnMoreHref: '', - initiativeType: 'state', - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', - }, { - id: '39', - name: 'Maurice Brown', - state: 'New York', - electionStatus: '', + id: '60', + name: 'Dylan Blaha', + state: 'Illinois', + electionStatus: 'Lost Primary', electionDate: new Date('03/17/26'), - handle: 'electmauricebrown', - handleHref: 'https://www.instagram.com/electmauricebrown', + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Maurice Brown.png', + image: '/images/endorsement_images/2026/Silhouette/Dylan Blaha.png', learnMoreHref: '', initiativeType: '', - - endorsementType: 'PV Pledge', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '40', - name: 'Max Diaz', - state: 'South Carolina', + id: '13', + name: 'Erica Watkins', + state: 'Oklahoma', electionStatus: '', - electionDate: new Date('08/04/26'), - handle: 'votemaxdiaz', - handleHref: 'https://x.com/VoteMaxDiaz', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Max Diaz.png', + electionDate: new Date('06/16/26'), + handle: '@erica4ok', + handleHref: 'https://x.com/erica4ok', + bodyText: + " stands apart not just as someone who will take on the machine, but someone with bold ideas to fight Oklahoma's poverty and exploitation.", + image: '/images/endorsement_images/2026/Silhouette/Erica Watkins.png', learnMoreHref: '', initiativeType: 'state', endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', + showPvMember: true, + avatarBackgroundColor: 'blue', }, + { - id: '41', - name: 'Maxwell Alejandro Frost', - state: 'Florida', - electionStatus: '', - electionDate: new Date('08/18/26'), - handle: 'maxwellfrostfl', - handleHref: 'https://x.com/MaxwellFrostFL', + id: '63', + name: 'Erin Petrey', + state: 'Kentucky', + electionStatus: 'Lost Primary', + electionDate: new Date('05/19/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Alejandro Maxwell-Frost.png', + image: '/images/endorsement_images/2026/Silhouette/Erin Petrey.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, + { - id: '42', - name: 'Melissa Bird', - state: 'Oregon', + id: '29', + name: 'Ethan Wechtaluk', + state: 'Maryland', electionStatus: '', - electionDate: new Date('05/19/26'), - handle: 'melissabirdforcongress', - handleHref: 'https://www.instagram.com/melissabirdforcongress/', + electionDate: new Date('06/23/26'), + handle: 'ethanformd', + handleHref: 'https://x.com/ethanformd', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Melissa Bird.png', + image: '/images/endorsement_images/2026/Silhouette/Ethan Wechtaluk.png', learnMoreHref: '', initiativeType: '', endorsementType: 'PV Pledge', showPvMember: false, - avatarBackgroundColor: 'blue', + avatarBackgroundColor: 'yellow', }, - { - id: '44', - name: 'Nida Allam', - state: 'North Carolina', - electionStatus: 'Lost Primary', - electionDate: new Date('05/19/26'), - handle: '', - handleHref: 'https://x.com/NidaAllam', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Nida Allam.png', - learnMoreHref: '', - initiativeType: '', - endorsementType: 'Endorsement', - showPvMember: false, - avatarBackgroundColor: 'blue', - }, { - id: '45', - name: 'Robert Arnold', - state: 'South Dakota', - electionStatus: 'Dropped Out', - electionDate: new Date('06/02/26'), - handle: 'robertarnoldsd', - handleHref: 'https://x.com/RobertArnoldSD', + id: '64', + name: 'Evan Smith', + state: 'Maryland', + electionStatus: '', + electionDate: new Date('06/23/26'), + handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Robert Arnold.png', + image: '/images/endorsement_images/2026/Silhouette/Evan Smith.png', learnMoreHref: '', - initiativeType: 'state', - + initiativeType: '', endorsementType: 'Endorsement', - showPvMember: true, + showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '46', - name: 'Val Thomason', - state: 'Nevada', + id: '5', + name: 'Graham Platner', + state: 'Maine', electionStatus: '', electionDate: new Date('06/09/26'), - handle: 'valfornevada', - handleHref: 'https://x.com/valfornevada', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Val Thomason.png', - learnMoreHref: '', - initiativeType: '', + handle: '@grahamformaine', + handleHref: 'https://x.com/grahamformaine', + bodyText: + ' is what happens when normal people get fed up and run for office over sanitized aspiring political wannabes! Our Maine team is so excited to get to work on his behalf!', + image: '/images/endorsement_images/2026/Silhouette/Graham Platner.png', + learnMoreHref: + '/images/endorsement_images/2026/Silhouette/Graham Platner.png', + initiativeType: 'national', - endorsementType: 'PV Pledge', + endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'blue', + avatarBackgroundColor: 'yellow', }, + { - id: '47', - name: 'William Compton', - state: 'Kentucky', - electionStatus: 'Lost Primary', - electionDate: new Date('05/19/26'), - handle: 'compton4ky2024', - handleHref: 'https://x.com/Compton4KY2024', + id: '30', + name: 'Greg Casar', + state: 'Texas', + electionStatus: 'Won Primary', + electionDate: new Date('03/03/26'), + handle: 'gregcasar', + handleHref: 'https://x.com/gregcasar', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/William Compton.png', + image: '/images/endorsement_images/2026/Silhouette/Greg Casar.png', learnMoreHref: '', - initiativeType: 'national', + initiativeType: '', - endorsementType: 'PV Pledge', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '48', name: 'Hartzell Gray', @@ -830,380 +565,571 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '49', - name: 'Jackson Franklin', - state: 'Indiana', - electionStatus: 'Lost Primary', - electionDate: new Date('05/05/26'), - handle: 'jacksonfrankli', - handleHref: 'https://x.com/jacksonfrankli', - bodyText: - " has a passionate voice committed to fighting for a better world not just in the U.S. but around the world. We are proud to endorse him for Indiana's 5th Congressional District!", - image: '/images/endorsement_images/2026/Silhouette/Jackson Franklin.png', - learnMoreHref: 'https://jacksonfranklinforcongress.com/', + id: '9', + name: 'Heath Howard', + state: 'New Hampshire', + electionStatus: '', + electionDate: new Date('09/08/26'), + handle: '@heathhowardnh', + handleHref: 'https://x.com/HeathHowardNH', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Heath Howard.png', + learnMoreHref: '', initiativeType: 'national', endorsementType: 'PV Pledge', - showPvMember: false, + showPvMember: true, avatarBackgroundColor: 'yellow', }, + { - id: '50', - name: 'Audrey Denny', + id: '65', + name: 'Heidi Hall', state: 'California', electionStatus: '', electionDate: new Date('06/02/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Audrey Denny.png', + image: '/images/endorsement_images/2026/Silhouette/Heidi Hall.png', learnMoreHref: '', initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '51', - name: 'Abdun Matin', - state: 'Maryland', + id: '32', + name: 'Hunter Gordon', + state: 'Washington', electionStatus: '', - handle: '', + electionDate: new Date('08/04/26'), + handle: 'hunter4wa', + handleHref: 'https://x.com/hunter4wa', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Abdun Matin.png', + image: '/images/endorsement_images/2026/Silhouette/Hunter Gordon.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '52', - name: 'Abel Chavez', - state: 'California', + id: '66', + name: 'Imara Crooms', + state: 'Maryland', electionStatus: '', - electionDate: new Date('06/02/26'), + electionDate: new Date('06/23/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Abel Chavez.png', + image: '/images/endorsement_images/2026/Silhouette/Imara Crooms.png', learnMoreHref: '', initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '53', - name: 'Adam Hamawy', - state: 'New York', - electionStatus: '', - handle: '', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Adam Hamawy.png', - learnMoreHref: '', + id: '49', + name: 'Jackson Franklin', + state: 'Indiana', + electionStatus: 'Lost Primary', + electionDate: new Date('05/05/26'), + handle: 'jacksonfrankli', + handleHref: 'https://x.com/jacksonfrankli', + bodyText: + " has a passionate voice committed to fighting for a better world not just in the U.S. but around the world. We are proud to endorse him for Indiana's 5th Congressional District!", + image: '/images/endorsement_images/2026/Silhouette/Jackson Franklin.png', + learnMoreHref: 'https://jacksonfranklinforcongress.com/', initiativeType: 'national', + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '54', - name: 'Ali Aljarrah', - state: 'New Jersey', + id: '33', + name: 'Jakeya Johnson', + state: 'Maryland', electionStatus: '', - handle: '', + electionDate: new Date('06/23/26'), + handle: 'jakeya_hq', + handleHref: 'https://www.instagram.com/jakeya_hq', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Ali Aljarrah.png', + image: '/images/endorsement_images/2026/Silhouette/Jakeya Johnson.png', learnMoreHref: '', initiativeType: '', + endorsementType: 'PV Pledge', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, + { - id: '55', - name: 'Chris Rabb', - state: 'Pennsylvania', + id: '19', + name: 'James Talarico', + state: 'Texas', electionStatus: 'Won Primary', - handle: '', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Chris Rabb.png', + electionDate: new Date('03/03/26'), + handle: '@jamestalarico', + handleHref: 'https://x.com/jamestalarico', + bodyText: + "'s time has come to turn Texas blue. PV is proud to endorse him for the US Senate Seat in Texas! He is exctly what is needed.", + image: '/images/endorsement_images/2026/Silhouette/James Talarico.png', learnMoreHref: '', - initiativeType: 'state', - endorsementType: 'PV Pledge', + initiativeType: 'national', + + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '56', - name: 'Charles Booker', + id: '1', + name: 'Jeromie Whalen', + state: 'Massachusetts', + electionStatus: '', + electionDate: new Date('09/01/26'), + handle: '@jeromiewhalen', + handleHref: 'https://www.whalenforcongressma.com/', + bodyText: + ' is not only a passionate progressive voice, he is also a powerful member of our community! There is nobody we are more excited to endorse this midterm season!', + image: '/images/endorsement_images/2026/Silhouette/Jeromie Whalen.png', + learnMoreHref: 'https://www.whalenforcongressma.com/platform', + initiativeType: 'national', + + endorsementType: 'PV Pledge', + showPvMember: true, + avatarBackgroundColor: 'yellow', + }, + + { + id: '68', + name: 'Jesse Brewer', state: 'Kentucky', - electionStatus: 'Won Primary', + electionStatus: 'Lost Primary', + electionDate: new Date('03/19/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Charles Booker.png', + image: '/images/endorsement_images/2026/Silhouette/Jesse Brewer.png', learnMoreHref: '', - initiativeType: 'national', - endorsementType: 'PV Pledge', + initiativeType: '', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '57', - name: 'Chelsey Hockett', - state: 'Texas', - electionStatus: 'Won Primary', + id: '69', + name: 'Jillian Gilchrest', + state: 'Connecticut', + electionStatus: '', + electionDate: new Date('08/11/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Chelsey Hockett.png', + image: '/images/endorsement_images/2026/Silhouette/Jillian Gilchrest.png', learnMoreHref: '', initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '58', - name: 'Christopher Ahuja', - state: 'California', + id: '67', + name: 'Jo Bennett', + state: 'New York', electionStatus: '', - electionDate: new Date('06/02/26'), + electionDate: new Date('06/23/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Christopher Ahuja.png', + image: '/images/endorsement_images/2026/Silhouette/Jo Bennett.png', + learnMoreHref: '', + initiativeType: '', + endorsementType: 'Endorsement', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + + { + id: '34', + name: 'John Padora', + state: 'Colorado', + electionStatus: 'Dropped Out', + electionDate: new Date('06/30/26'), + handle: 'padoraforco', + handleHref: 'https://x.com/PadoraforCO', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/John Padora.png', + learnMoreHref: '', + initiativeType: '', + + endorsementType: 'PV Pledge', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + + { + id: '35', + name: 'Josie Caballero', + state: 'Maryland', + electionStatus: '', + electionDate: new Date('06/23/26'), + handle: 'josieformd', + handleHref: 'https://x.com/josieformd', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Josie Caballero.png', learnMoreHref: '', - initiativeType: '', - endorsementType: 'PV Pledge', - showPvMember: false, - avatarBackgroundColor: 'yellow', + initiativeType: 'national', + + endorsementType: 'Endorsement', + showPvMember: true, + avatarBackgroundColor: 'blue', }, + { - id: '59', - name: 'Chuck Park', - state: 'New York', + id: '70', + name: 'Julie Gonzales', + state: 'Colorado', electionStatus: '', + electionDate: new Date('06/30/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Chuck Park.png', + image: '/images/endorsement_images/2026/Silhouette/Julie Gonzales.png', learnMoreHref: '', - initiativeType: '', + initiativeType: 'national', endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, + { - id: '60', - name: 'Dylan Blaha', + id: '71', + name: 'Junaid Ahmed', state: 'Illinois', electionStatus: 'Lost Primary', + electionDate: new Date('03/17/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Dylan Blaha.png', + image: '/images/endorsement_images/2026/Silhouette/Junaid Ahmed.png', learnMoreHref: '', initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '61', - name: 'Darializa Avila Chevalier', - state: 'New York', + id: '7', + name: 'Karishma Manzur', + state: 'New Hampshire', electionStatus: '', - handle: '', + electionDate: new Date('09/08/26'), + handle: '@karishma4senate', + handleHref: 'https://x.com/Karishma4Senate', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Darializa Avila Chevalier.png', + image: '/images/endorsement_images/2026/Silhouette/Karishma Manzur.png', learnMoreHref: '', initiativeType: 'national', - endorsementType: 'Endorsement', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '62', - name: 'Demi Palecek', + id: '4', + name: 'Kat Abughazaleh', state: 'Illinois', - electionStatus: 'Won Primary', - handle: '', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Demi Palecek.png', - learnMoreHref: '', - initiativeType: '', + electionStatus: 'Lost Primary', + electionDate: new Date('03/17/26'), + handle: '@katabughazaleh', + handleHref: 'https://x.com/KatAbughazaleh', + bodyText: + ' is exactly what we are looking for at PV. A young, media savvy firebrand willing to tackle any challenge that gets in her way.', + image: '/images/endorsement_images/2026/Silhouette/Kat Abughazaleh.png', + learnMoreHref: + '/images/endorsement_images/2026/Silhouette/Kat Abughazaleh.png', + initiativeType: 'national', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '63', - name: 'Erin Petrey', - state: 'Kentucky', + id: '36', + name: 'Katie Bansil', + state: 'New Jersey', electionStatus: '', - handle: '', + electionDate: new Date('06/02/26'), + handle: 'katiebansil4nj', + handleHref: 'https://x.com/katiebansil4nj', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Erin Petrey.png', + image: '/images/endorsement_images/2026/Silhouette/Katie Bansil.png', learnMoreHref: '', - initiativeType: '', - endorsementType: 'Endorsement', - showPvMember: false, + initiativeType: 'national', + + endorsementType: 'PV Pledge', + showPvMember: true, avatarBackgroundColor: 'blue', }, + { - id: '64', - name: 'Evan Smith', - state: 'Maryland', + id: '72', + name: 'Kyle Gauck', + state: 'Connecticut', electionStatus: '', + electionDate: new Date('08/11/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Evan Smith.png', + image: '/images/endorsement_images/2026/Silhouette/Kyle Gauck.png', learnMoreHref: '', initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '65', - name: 'Heidi Hall', + id: '15', + name: 'Luis Villarreal', + state: 'Utah', + electionStatus: 'Dropped Out', + electionDate: new Date('06/23/26'), + handle: '@luis4utah', + handleHref: 'https://x.com/Luis4Utah', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Luis Villarreal.png', + learnMoreHref: '', + initiativeType: 'state', + + endorsementType: 'PV Pledge', + showPvMember: true, + avatarBackgroundColor: 'yellow', + }, + + { + id: '73', + name: 'Mai Vang', state: 'California', electionStatus: '', electionDate: new Date('06/02/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Heidi Hall.png', + image: '/images/endorsement_images/2026/Silhouette/Mai Vang.png', learnMoreHref: '', initiativeType: '', endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '66', - name: 'Imara Crooms', - state: 'Georgia', + id: '12', + name: 'Mary Peltola', + state: 'Alaska', electionStatus: '', - handle: '', + electionDate: new Date('08/18/26'), + handle: '@marypeltola', + handleHref: 'https://x.com/MaryPeltola', + bodyText: + " is America's first Native Alaskan representative and a fierce advocate for her community. We are proud to endorse her for re-election in Alaska's at-large congressional district!", + image: '/images/endorsement_images/2026/Silhouette/Mary Peltola.png', + learnMoreHref: '', + initiativeType: 'state', + + endorsementType: 'Endorsement', + showPvMember: false, + avatarBackgroundColor: 'blue', + }, + + { + id: '37', + name: 'Matt Conroy', + state: 'Illinois', + electionStatus: 'Lost Primary', + electionDate: new Date('03/17/26'), + handle: 'conroyforil', + handleHref: 'https://x.com/ConroyForIL', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Imara Crooms.png', + image: '/images/endorsement_images/2026/Silhouette/Matt Conroy.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '67', - name: 'Jo Bennett', - state: 'New York', + id: '38', + name: 'Matt Fulmer', + state: 'South Carolina', electionStatus: '', - handle: '', + electionDate: new Date('06/09/26'), + handle: 'mattforcarolina', + handleHref: 'https://x.com/MattForCarolina', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Jo Bennett.png', + image: '/images/endorsement_images/2026/Silhouette/Matt Fulmer.png', learnMoreHref: '', - initiativeType: '', - endorsementType: 'Endorsement', + initiativeType: 'state', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '68', - name: 'Jesse Brewer', - state: 'Kentucky', + id: '74', + name: 'Matt Ortega', + state: 'California', electionStatus: '', + electionDate: new Date('06/02/26'), handle: '', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Jesse Brewer.png', + image: '/images/endorsement_images/2026/Silhouette/Matt Ortega.png', + learnMoreHref: '', + initiativeType: 'state', + endorsementType: 'PV Pledge', + showPvMember: true, + avatarBackgroundColor: 'yellow', + }, + + { + id: '39', + name: 'Maurice Brown', + state: 'New York', + electionStatus: '', + electionDate: new Date('06/23/26'), + handle: 'electmauricebrown', + handleHref: 'https://www.instagram.com/electmauricebrown', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Maurice Brown.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '69', - name: 'Jillian Gilchrest', - state: 'Connecticut', + id: '40', + name: 'Max Diaz', + state: 'South Carolina', electionStatus: '', - handle: '', + electionDate: new Date('06/09/26'), + handle: 'votemaxdiaz', + handleHref: 'https://x.com/VoteMaxDiaz', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Jillian Gilchrest.png', + image: '/images/endorsement_images/2026/Silhouette/Max Diaz.png', learnMoreHref: '', - initiativeType: '', - endorsementType: 'Endorsement', + initiativeType: 'state', + + endorsementType: 'PV Pledge', showPvMember: false, - avatarBackgroundColor: 'blue', + avatarBackgroundColor: 'yellow', }, + { - id: '70', - name: 'Julie Gonzales', - state: 'Colorado', + id: '41', + name: 'Maxwell Alejandro Frost', + state: 'Florida', electionStatus: '', - handle: '', + electionDate: new Date('08/18/26'), + handle: 'maxwellfrostfl', + handleHref: 'https://x.com/MaxwellFrostFL', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Julie Gonzales.png', + image: '/images/endorsement_images/2026/Silhouette/Alejandro Maxwell-Frost.png', learnMoreHref: '', - initiativeType: 'national', + initiativeType: '', + endorsementType: 'Endorsement', showPvMember: false, - avatarBackgroundColor: 'blue', + avatarBackgroundColor: 'yellow', }, + { - id: '71', - name: 'Junaid Ahmed', - state: 'Illinois', + id: '42', + name: 'Melissa Bird', + state: 'Oregon', electionStatus: 'Lost Primary', - handle: '', + electionDate: new Date('05/19/26'), + handle: 'melissabirdforcongress', + handleHref: 'https://www.instagram.com/melissabirdforcongress/', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Junaid Ahmed.png', + image: '/images/endorsement_images/2026/Silhouette/Melissa Bird.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', + + endorsementType: 'PV Pledge', showPvMember: false, - avatarBackgroundColor: 'yellow', + avatarBackgroundColor: 'blue', }, + { - id: '72', - name: 'Kyle Gauck', - state: 'Connecticut', + id: '22', + name: 'Michael Blake', + state: 'New York', electionStatus: '', - handle: '', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Kyle Gauck.png', + electionDate: new Date('06/23/26'), + handle: '@MrMikeBlake', + handleHref: 'https://x.com/MrMikeBlake', + bodyText: + ' is an authentic, progressive fighter who has a deep love for his community. The progressive movement won in New Jersey and he will bring that momentum to New York!', + image: '/images/endorsement_images/2026/Silhouette/Michael Blake.png', learnMoreHref: '', initiativeType: '', - endorsementType: 'Endorsement', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { - id: '73', - name: 'Mai Vang', - state: 'California', - electionStatus: '', - electionDate: new Date('06/02/26'), + id: '44', + name: 'Nida Allam', + state: 'North Carolina', + electionStatus: 'Lost Primary', + electionDate: new Date('05/19/26'), handle: '', + handleHref: 'https://x.com/NidaAllam', bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Mai Vang.png', + image: '/images/endorsement_images/2026/Silhouette/Nida Allam.png', learnMoreHref: '', initiativeType: '', + endorsementType: 'Endorsement', showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '74', - name: 'Matt Ortega', - state: 'California', + id: '8', + name: 'Oliver Larkin', + state: 'Florida', electionStatus: '', - electionDate: new Date('06/02/26'), - handle: '', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Matt Ortega.png', + electionDate: new Date('08/18/26'), + handle: '@oliveralarkin', + handleHref: 'https://x.com/OliverALarkin', + bodyText: + ' is proving Progressive politics is alive and well even in places like Florida! We are proud to endorse him and to do our part to help him win.', + image: '/images/endorsement_images/2026/Silhouette/Oliver Larkin.png', learnMoreHref: '', - initiativeType: 'state', + initiativeType: 'national', + endorsementType: 'PV Pledge', - showPvMember: true, + showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '75', name: 'Oliver Ma', @@ -1219,11 +1145,13 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '76', name: 'Omar Mohamad', state: 'New York', electionStatus: '', + electionDate: new Date('06/23/26'), handle: '', bodyText: '', image: '/images/endorsement_images/2026/Silhouette/Omar Mohamad.png', @@ -1233,11 +1161,13 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '77', name: 'Paige Loud', state: 'Maine', electionStatus: '', + electionDate: new Date('06/09/26'), handle: '', bodyText: '', image: '/images/endorsement_images/2026/Silhouette/Paige Loud.png', @@ -1247,11 +1177,13 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '78', name: 'Raj Goyle', state: 'New York', electionStatus: '', + electionDate: new Date('06/23/26'), handle: '', bodyText: '', image: '/images/endorsement_images/2026/Silhouette/Raj Goyle.png', @@ -1261,6 +1193,44 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + + { + id: '17', + name: 'Ro Khanna', + state: 'California', + electionStatus: '', + electionDate: new Date('06/02/26'), + handle: '@rokhanna', + handleHref: 'https://x.com/RoKhanna', + bodyText: + ' is the leader our party needs! His commitment to new media politics has made him an outstanding voice capable of real change.', + image: '/images/endorsement_images/2026/Silhouette/Ro Khanna.png', + learnMoreHref: '', + initiativeType: '', + + endorsementType: 'Endorsement', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + + { + id: '45', + name: 'Robert Arnold', + state: 'South Dakota', + electionStatus: 'Dropped Out', + electionDate: new Date('06/02/26'), + handle: 'robertarnoldsd', + handleHref: 'https://x.com/RobertArnoldSD', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Robert Arnold.png', + learnMoreHref: '', + initiativeType: 'state', + + endorsementType: 'Endorsement', + showPvMember: true, + avatarBackgroundColor: 'yellow', + }, + { id: '79', name: 'Rose Penelope Yee', @@ -1276,27 +1246,51 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'blue', }, + { - id: '80', - name: 'Tom Steyer', + id: '3', + name: 'Saikat Chakrabarti', state: 'California', electionStatus: '', electionDate: new Date('06/02/26'), - handle: '', - bodyText: '', - image: '/images/endorsement_images/2026/Silhouette/Tom Steyer.png', + handle: '@saikatc', + handleHref: 'https://x.com/saikatc', + bodyText: + " founded the Justice Democrats because he belives in the left's ability to come togeather to fight establishment power. In congress, he will continue that energy!", + image: '/images/endorsement_images/2026/Silhouette/Saikat Chakrabarti.png', learnMoreHref: '', - initiativeType: '', - endorsementType: 'Endorsement', + initiativeType: 'national', + + endorsementType: 'PV Pledge', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + + { + id: '6', + name: 'Salaam Bhatti', + state: 'Virginia', + electionStatus: '', + electionDate: new Date('08/04/26'), + handle: '@salaambhattiva', + handleHref: 'https://x.com/SalaamBhattiVA', + bodyText: + ' is a candidate whose advocacy for Medicare for All and drive to fight to get money out of politics will bring us one step closer to free healthcare once he wins his district.', + image: '/images/endorsement_images/2026/Silhouette/Salaam Bhatti.png', + learnMoreHref: '', + initiativeType: 'national', + + endorsementType: 'PV Pledge', showPvMember: false, avatarBackgroundColor: 'yellow', }, + { id: '81', name: 'Tammy Honeywell', - state: 'California', + state: 'New York', electionStatus: '', - electionDate: new Date('06/02/26'), + electionDate: new Date('06/23/26'), handle: '', bodyText: '', image: '/images/endorsement_images/2026/Silhouette/Tammy Honeywell.png', @@ -1306,6 +1300,25 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'blue', }, + + { + id: '14', + name: 'Taylor Rehmet', + state: 'Texas', + electionStatus: 'Elected', + electionDate: new Date('01/31/26'), + handle: '@taylorrehmettx', + handleHref: 'https://x.com/TaylorRehmetTX', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Taylor Rehmet.png', + learnMoreHref: '', + initiativeType: 'state', + + endorsementType: 'Endorsement', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + { id: '82', name: 'Tessa Lynn Hodge', @@ -1321,11 +1334,65 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'blue', }, + + { + id: '80', + name: 'Tom Steyer', + state: 'California', + electionStatus: '', + electionDate: new Date('06/02/26'), + handle: '', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Tom Steyer.png', + learnMoreHref: '', + initiativeType: '', + endorsementType: 'Endorsement', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + + { + id: '46', + name: 'Val Thomason', + state: 'Nevada', + electionStatus: '', + electionDate: new Date('06/09/26'), + handle: 'valfornevada', + handleHref: 'https://x.com/valfornevada', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/Val Thomason.png', + learnMoreHref: '', + initiativeType: '', + + endorsementType: 'PV Pledge', + showPvMember: false, + avatarBackgroundColor: 'blue', + }, + + { + id: '47', + name: 'William Compton', + state: 'Kentucky', + electionStatus: 'Lost Primary', + electionDate: new Date('05/19/26'), + handle: 'compton4ky2024', + handleHref: 'https://x.com/Compton4KY2024', + bodyText: '', + image: '/images/endorsement_images/2026/Silhouette/William Compton.png', + learnMoreHref: '', + initiativeType: 'national', + + endorsementType: 'PV Pledge', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + { id: '83', name: 'William Lawrence', state: 'Michigan', electionStatus: '', + electionDate: new Date('08/04/26'), handle: '', bodyText: '', image: '/images/endorsement_images/2026/Silhouette/William Lawrence.png', @@ -1335,4 +1402,40 @@ export const CANDIDATES: CandidateConfig[] = [ showPvMember: false, avatarBackgroundColor: 'yellow', }, + + { + id: '21', + name: 'Zeeshan Hafeez', + state: 'Texas', + electionStatus: 'Lost Primary', + electionDate: new Date('03/03/26'), + handle: '@zeeshanfortexas', + handleHref: '', + bodyText: + " is going to bring a fire to Texa's 33rd Congressional District. We are excited to see a bold progressive like him making all the right waves in all the right places!", + image: '/images/endorsement_images/2026/Silhouette/Zeeshan Hafeez.png', + learnMoreHref: '', + initiativeType: 'national', + + endorsementType: 'PV Pledge', + showPvMember: false, + avatarBackgroundColor: 'yellow', + }, + + // { + // id: '20', + // name: 'Jon Stewart', + // state: 'President', + // electionStatus: '', + // electionDate: new Date('11/07/28'), + // handle: '@jonstewart', + // handleHref: 'https://x.com/jonstewart', + // bodyText: + // ' isn’t perfect, but his decades long track record of integrity and progressivism make him the decisive pick. We urge him to serve his country by running for the office.', + // image: '/images/endorsement_images/2026/Silhouette/Jon Stewart.png', + // learnMoreHref: '', + // initiativeType: '', + // endorsementType: 'Endorsement', + // showPvMember: false, + // }, ] diff --git a/src/app/endorsements/endorsements.utils.ts b/src/app/endorsements/endorsements.utils.ts index dee2ec49..044ba61e 100644 --- a/src/app/endorsements/endorsements.utils.ts +++ b/src/app/endorsements/endorsements.utils.ts @@ -17,6 +17,12 @@ export function getCandidateStateLabel(candidate: CandidateConfig): string { return candidate.state || UNSPECIFIED_STATE_LABEL } +function getStartOfToday(): number { + const today = new Date() + today.setHours(0, 0, 0, 0) + return today.getTime() +} + export function compareCandidateNames( a: CandidateConfig, b: CandidateConfig @@ -86,7 +92,7 @@ export function getCandidateSubtitleText( : null const isPastElectionCandidate = !!candidate.electionDate && - candidate.electionDate.getTime() < Date.now() + candidate.electionDate.getTime() < getStartOfToday() if (displayMode === 'flat') { return getFlatSubtitleText(candidate) @@ -116,7 +122,7 @@ export function getSectionLabel( return 'No Election Date' } - if (candidate.electionDate.getTime() < Date.now()) { + if (candidate.electionDate.getTime() < getStartOfToday()) { return PAST_ELECTION_LABEL } diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index 270e6137..efc4f0e1 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -39,6 +39,11 @@ const calendarMap = new Map([ 'Y185NDhjMzI5OWU3OTQ2N2M3MjBkNWQzMTY0YjEzOGU4OGRiM2FjNTFiNmUxMmM0ZTc2ZjAzZjY0NThjMTA2OGYzQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20', '009949', ], // Volunteeer Initiatives was #fc035e + + [ + 'OTAzMjM5NWVjOWU1MmYzYTdjZWI4YWE2NjE3MjRiY2FjNjg5ZjA2OTQxODlhNzk0ZjMzMjE2NjMyNGY0ZmFjMUBncm91cC5jYWxlbmRhci5nb29nbGUuY29t', + '004649', + ], ]) export default function Events() { const calendarUri = new URL('https://calendar.google.com/calendar/embed') diff --git a/src/app/volunteer/CollectInfoStage.tsx b/src/app/volunteer/CollectInfoStage.tsx index b6e3748f..abf765e7 100644 --- a/src/app/volunteer/CollectInfoStage.tsx +++ b/src/app/volunteer/CollectInfoStage.tsx @@ -17,6 +17,7 @@ export interface IOnboardingForm { getAlerts: boolean usCitizen: boolean privacyPolicy: boolean + oneTimePasscode: boolean } export interface CollectInfoStageProps { @@ -90,7 +91,8 @@ export function CollectInfoStage({ dateOfBirthIsValid && zipCodeIsValid && parsedPhone.isValid && - form.privacyPolicy + form.privacyPolicy && + form.oneTimePasscode useInit(() => setFormattedPhoneNumber(initialForm.phoneNumber)) @@ -112,7 +114,7 @@ export function CollectInfoStage({ Join us on Discord and make a difference ✨

-
+
setFormattedPhoneNumber(e.target.value)} /> -

+

US numbers only. Message and data rates may apply. Must - be SMS reachable. + be SMS reachable. For assistance, reply HELP to the + number from which you received the message, or contact + us at the email below. To stop receiving messages, reply + STOP at any time. Carriers are not liable for delayed or + undelivered messages

@@ -207,7 +215,7 @@ export function CollectInfoStage({ referrerPolicy="no-referrer" className="text-steel-blue underline" > - Privacy Policy + Terms and Conditions & Privacy Policy } @@ -217,6 +225,26 @@ export function CollectInfoStage({ }} required /> + + *I agree to + provide Progressive Victory my mobile number to + obtain a one-time text message for account + verification. + + } + tooltip="You are agreeing to recieve a one-time message via SMS" + onChange={() => { + setForm({ + ...form, + oneTimePasscode: !form.oneTimePasscode, + }) + }} + required + />
* = required field diff --git a/src/app/volunteer/VolunteerPage.tsx b/src/app/volunteer/VolunteerPage.tsx index d0b36ff2..408d6248 100644 --- a/src/app/volunteer/VolunteerPage.tsx +++ b/src/app/volunteer/VolunteerPage.tsx @@ -222,6 +222,7 @@ export default function VolunteerPage() { user.data?.acceptedAlerts ?? false, usCitizen: false, privacyPolicy: false, + oneTimePasscode: false, }} isPending={collectInfoMutation.isPending} onSubmit={handleCollectInfoSuccess} diff --git a/src/components/common/charts/DualAxisBarLineChart.module.css b/src/components/common/charts/DualAxisBarLineChart.module.css new file mode 100644 index 00000000..a202ae25 --- /dev/null +++ b/src/components/common/charts/DualAxisBarLineChart.module.css @@ -0,0 +1,154 @@ +.chart { + margin-top: 0.9rem; + padding: 0.82rem 0.86rem 0.9rem; + border-radius: 0.95rem; + background: rgba(255, 255, 255, 0.76); + border: 1px solid rgba(15, 23, 42, 0.1); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset; +} + +.headerRow { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.6rem; +} + +.title { + margin: 0; + font-size: 0.84rem; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.66); +} + +.hint { + font-size: 0.7rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.46); +} + +.headerRight { + display: inline-flex; + align-items: center; + gap: 0.55rem; + position: relative; +} + +.legend { + margin-top: 0.55rem; + display: flex; + flex-wrap: wrap; + gap: 0.65rem 1rem; +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 0.34rem; + font-size: 0.7rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.6); +} + +.swatch { + width: 0.74rem; + height: 0.3rem; + border-radius: 0.2rem; +} + +.lineSwatch { + height: 2px; +} + +.canvasWrap { + position: relative; + margin-top: 0.72rem; + height: 14rem; + width: 100%; +} + +.cornerTopRight { + position: absolute; + top: 1rem; + right: 3.5rem; + z-index: 7; + pointer-events: auto; +} + +.canvas { + display: block; + width: 100%; + height: 100%; + cursor: crosshair; + border-radius: 0.5rem; +} + +.tooltip { + position: absolute; + pointer-events: none; + transform: translate(-100%, 0); + background: rgba(15, 23, 42, 0.92); + color: #fff; + padding: 0.45rem 0.6rem; + border-radius: 0.45rem; + font-size: 0.7rem; + line-height: 1.25; + white-space: nowrap; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.24); + z-index: 6; + opacity: 0; + transition: opacity 90ms ease; +} + +.tooltipRight { + transform: translate(0, 0); +} + +.tooltipVisible { + opacity: 1; +} + +.tooltipLabel { + font-weight: 800; + letter-spacing: 0.02em; + margin-bottom: 0.18rem; +} + +.tooltipRow { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; +} + +.tooltipDot { + width: 0.5rem; + height: 0.5rem; + border-radius: 99rem; + display: inline-block; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 42rem) { + .headerRow { + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + } + .canvasWrap { + height: 12rem; + } +} diff --git a/src/components/common/charts/DualAxisBarLineChart.tsx b/src/components/common/charts/DualAxisBarLineChart.tsx new file mode 100644 index 00000000..02d45abe --- /dev/null +++ b/src/components/common/charts/DualAxisBarLineChart.tsx @@ -0,0 +1,1052 @@ +'use client' + +import styles from './DualAxisBarLineChart.module.css' +import { formatHour, pickLabel } from './chartLabel.helpers' +import { + getColumnCenter, + niceScale, + roundRect, + type PlotGeometry, +} from './chartMath.helpers' +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' + +export type ChartGranularity = + | 'hour' + | 'day' + | 'week' + | 'month' + | 'quarter' + | 'year' + +export interface ChartPoint { + key: string + anchorIso?: string + startIso?: string + endIso?: string + granularity?: ChartGranularity + label?: string + fullLabel: string + primaryBarValue: number + secondaryBarValue: number + lineValue: number +} + +interface DualAxisBarLineChartSeriesLabels { + primaryBar: string + secondaryBar: string + line: string +} + +interface DualAxisBarLineChartValueFormatters { + primaryBar?: (value: number) => string + secondaryBar?: (value: number) => string + line?: (value: number) => string +} + +interface DualAxisBarLineChartAxisFormatters { + left?: (value: number, step: number) => string + right?: (value: number, step: number) => string +} + +interface DualAxisBarLineChartColors { + primaryBar: string + secondaryBar: string + line: string + lineFill: string + linePointFill: string + linePointStroke: string +} + +interface DualAxisBarLineTableConfig { + caption: string + periodHeader: string + primaryBarHeader: string + secondaryBarHeader: string + lineHeader: string +} + +type ChartBarDisplayMode = 'grouped' | 'stacked' + +interface ChartProps { + title?: string + hint?: string + points: ChartPoint[] + smoothLine?: boolean + showAreaFill?: boolean + showLine?: boolean + barDisplayMode?: ChartBarDisplayMode + headerRight?: ReactNode + seriesLabels?: Partial + valueFormatters?: DualAxisBarLineChartValueFormatters + axisFormatters?: DualAxisBarLineChartAxisFormatters + colors?: Partial + ariaLabel?: string + chartAriaLabel?: string + tableConfig?: Partial + onRangeSelect?: (range: { startIso: string; endIso: string }) => void + cornerTopRight?: ReactNode +} + +const DEFAULT_COLORS: DualAxisBarLineChartColors = { + primaryBar: '#6d95d1', + secondaryBar: '#7fb800', + line: '#6b44c5', + lineFill: 'rgba(107, 68, 197, 0.10)', + linePointFill: '#8b6ad9', + linePointStroke: '#5a35bc', +} + +const COLOR_GRID = 'rgba(15, 23, 42, 0.10)' +const COLOR_AXIS_TEXT = 'rgba(15, 23, 42, 0.48)' +const COLOR_X_LABEL = 'rgba(15, 23, 42, 0.6)' +const COLOR_YEAR_DIVIDER = 'rgba(15, 23, 42, 0.32)' +const COLOR_YEAR_LABEL_BG = 'rgba(15, 23, 42, 0.06)' +const COLOR_YEAR_LABEL = 'rgba(15, 23, 42, 0.55)' +const COLOR_HOVER_BAND = 'rgba(107, 68, 197, 0.08)' +const COLOR_DRAG_BAND = 'rgba(14, 165, 233, 0.16)' +const COLOR_DRAG_BAND_EDGE = 'rgba(2, 132, 199, 0.6)' + +const PADDING_LEFT = 56 +const PADDING_RIGHT = 52 +const PADDING_TOP = 12 +const PADDING_BOTTOM = 26 +const BAR_GAP = 2 +const BAR_MAX_WIDTH = 18 +const BAR_MIN_WIDTH = 2 +const GROUP_GAP_RATIO = 0.28 + +function formatCount(value: number): string { + if (!Number.isFinite(value)) return '—' + return value.toLocaleString('en-US') +} + +function formatCountAxis(value: number): string { + if (!Number.isFinite(value)) return '' + const rounded = Math.round(value) + if (rounded >= 1_000_000) { + return `${(rounded / 1_000_000).toLocaleString('en-US', { + maximumFractionDigits: 1, + })}M` + } + if (rounded >= 10_000) { + return `${Math.round(rounded / 1000).toLocaleString('en-US')}k` + } + return rounded.toLocaleString('en-US') +} + +export function Chart({ + title = 'Chart', + hint, + points, + smoothLine = true, + showAreaFill = true, + showLine = true, + barDisplayMode = 'grouped', + headerRight, + seriesLabels, + valueFormatters, + axisFormatters, + colors, + ariaLabel, + chartAriaLabel, + tableConfig, + onRangeSelect, + cornerTopRight, +}: ChartProps) { + const labels: DualAxisBarLineChartSeriesLabels = { + primaryBar: seriesLabels?.primaryBar ?? 'Primary Series', + secondaryBar: seriesLabels?.secondaryBar ?? 'Secondary Series', + line: seriesLabels?.line ?? 'Line Series', + } + + const palette = useMemo( + () => ({ + ...DEFAULT_COLORS, + ...colors, + }), + [colors] + ) + + const tooltipFormatPrimary = valueFormatters?.primaryBar ?? formatCount + const tooltipFormatSecondary = valueFormatters?.secondaryBar ?? formatCount + const tooltipFormatLine = valueFormatters?.line ?? formatCount + const leftAxisFormatter = axisFormatters?.left ?? formatCountAxis + const rightAxisFormatter = axisFormatters?.right ?? formatCountAxis + + const table: DualAxisBarLineTableConfig = { + caption: tableConfig?.caption ?? `${title} by period`, + periodHeader: tableConfig?.periodHeader ?? 'Period', + primaryBarHeader: tableConfig?.primaryBarHeader ?? labels.primaryBar, + secondaryBarHeader: + tableConfig?.secondaryBarHeader ?? labels.secondaryBar, + lineHeader: tableConfig?.lineHeader ?? labels.line, + } + + const canvasRef = useRef(null) + const wrapRef = useRef(null) + const tooltipRef = useRef(null) + const [size, setSize] = useState({ width: 0, height: 0 }) + const [hoverIdx, setHoverIdx] = useState(null) + const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>( + null + ) + const [tooltipPos, setTooltipPos] = useState<{ + x: number + y: number + } | null>(null) + const [tooltipSide, setTooltipSide] = useState<'left' | 'right'>('left') + const [dragStartIdx, setDragStartIdx] = useState(null) + const [dragCurrentIdx, setDragCurrentIdx] = useState(null) + const [dragPointerId, setDragPointerId] = useState(null) + + useEffect(() => { + const node = wrapRef.current + if (!node) return + + const update = () => { + const rect = node.getBoundingClientRect() + setSize((prev) => { + if ( + Math.abs(prev.width - rect.width) < 0.5 && + Math.abs(prev.height - rect.height) < 0.5 + ) { + return prev + } + return { width: rect.width, height: rect.height } + }) + } + + update() + + let observer: ResizeObserver | null = null + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(update) + observer.observe(node) + } + window.addEventListener('resize', update) + return () => { + observer?.disconnect() + window.removeEventListener('resize', update) + } + }, []) + + useLayoutEffect(() => { + if (hoverIdx == null || !hoverPos) { + setTooltipPos(null) + return + } + + const wrapNode = wrapRef.current + const tooltipNode = tooltipRef.current + if (!wrapNode || !tooltipNode) return + + const padding = 8 + const wrapRect = wrapNode.getBoundingClientRect() + const tooltipRect = tooltipNode.getBoundingClientRect() + + const leftSpace = hoverPos.x - padding + const rightSpace = wrapRect.width - hoverPos.x - padding + const shouldFlipRight = + leftSpace < tooltipRect.width + 12 && rightSpace > leftSpace + + const side: 'left' | 'right' = shouldFlipRight ? 'right' : 'left' + const x = + side === 'right' + ? Math.min( + hoverPos.x + 12, + wrapRect.width - tooltipRect.width - padding + ) + : Math.max(hoverPos.x - 12, tooltipRect.width + padding) + const y = Math.min( + Math.max(hoverPos.y, padding), + wrapRect.height - tooltipRect.height - padding + ) + + setTooltipPos((prev) => { + if ( + prev && + Math.abs(prev.x - x) < 0.5 && + Math.abs(prev.y - y) < 0.5 + ) { + setTooltipSide((prevSide) => + prevSide === side ? prevSide : side + ) + return prev + } + setTooltipSide(side) + return { x, y } + }) + }, [hoverIdx, hoverPos, size]) + + const barScale = useMemo(() => { + const max = Math.max( + ...points.map((p) => + barDisplayMode === 'stacked' + ? p.primaryBarValue + p.secondaryBarValue + : Math.max(p.primaryBarValue, p.secondaryBarValue) + ), + 0 + ) + return niceScale(Math.max(max, 5), 6, false) + }, [points, barDisplayMode]) + + const lineScale = useMemo(() => { + const max = Math.max(...points.map((p) => p.lineValue), 0) + return niceScale(Math.max(max, 5), 6, true) + }, [points]) + + const geometry = useMemo(() => { + const width = size.width + const height = size.height + const plotLeft = PADDING_LEFT + const plotRight = width - PADDING_RIGHT + const plotTop = PADDING_TOP + const plotBottom = height - PADDING_BOTTOM + const plotWidth = Math.max(0, plotRight - plotLeft) + const plotHeight = Math.max(0, plotBottom - plotTop) + const columnWidth = plotWidth / Math.max(points.length, 1) + return { + width, + height, + plotLeft, + plotRight, + plotTop, + plotBottom, + plotWidth, + plotHeight, + columnWidth, + } + }, [size, points.length]) + + const yearDividers = useMemo(() => { + const out: { idx: number; year: number }[] = [] + for (let i = 1; i < points.length; i += 1) { + const prev = points[i - 1].anchorIso + const curr = points[i].anchorIso + if (!prev || !curr) continue + const prevY = new Date(prev).getFullYear() + const currY = new Date(curr).getFullYear() + if (prevY !== currY) out.push({ idx: i, year: currY }) + } + return out + }, [points]) + + const spansYears = useMemo(() => { + if (points.length < 2) return false + const firstIso = points[0].anchorIso + const lastIso = points[points.length - 1].anchorIso + if (!firstIso || !lastIso) return false + return ( + new Date(firstIso).getFullYear() !== new Date(lastIso).getFullYear() + ) + }, [points]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const { width, height } = size + if (width <= 0 || height <= 0) return + + const dpr = Math.min(window.devicePixelRatio || 1, 3) + canvas.width = Math.round(width * dpr) + canvas.height = Math.round(height * dpr) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + const g = geometry + if (g.plotWidth <= 0 || g.plotHeight <= 0) return + + const fontFamily = + getComputedStyle(canvas).fontFamily || + 'system-ui, -apple-system, sans-serif' + + ctx.lineWidth = 1 + ctx.strokeStyle = COLOR_GRID + ctx.fillStyle = COLOR_AXIS_TEXT + ctx.font = `700 10px ${fontFamily}` + ctx.textBaseline = 'middle' + + const barTicks = barScale.ticks + for (const tick of barTicks) { + const y = + Math.round( + g.plotBottom - (tick / barScale.max) * g.plotHeight + ) + 0.5 + ctx.beginPath() + ctx.moveTo(g.plotLeft, y) + ctx.lineTo(g.plotRight, y) + ctx.stroke() + + ctx.textAlign = 'right' + ctx.fillText( + leftAxisFormatter(tick, barScale.step), + g.plotLeft - 8, + y + ) + } + + if (showLine) { + for (const tick of barTicks) { + const ratio = tick / barScale.max + const y = Math.round(g.plotBottom - ratio * g.plotHeight) + 0.5 + const lineValue = ratio * lineScale.max + ctx.textAlign = 'left' + ctx.fillText( + rightAxisFormatter(lineValue, lineScale.step), + g.plotRight + 8, + y + ) + } + } + + ctx.save() + ctx.setLineDash([3, 3]) + ctx.strokeStyle = COLOR_YEAR_DIVIDER + ctx.lineWidth = 1 + yearDividers.forEach((d) => { + const x = + Math.round(g.plotLeft + (d.idx / points.length) * g.plotWidth) + + 0.5 + ctx.beginPath() + ctx.moveTo(x, g.plotTop) + ctx.lineTo(x, g.plotBottom) + ctx.stroke() + }) + ctx.restore() + + ctx.font = `700 9px ${fontFamily}` + yearDividers.forEach((d) => { + const x = g.plotLeft + (d.idx / points.length) * g.plotWidth + const label = String(d.year) + const padX = 4 + const padY = 2 + const metrics = ctx.measureText(label) + const w = metrics.width + padX * 2 + const h = 12 + const bx = Math.round(x + 4) + const by = g.plotTop + 4 + ctx.fillStyle = COLOR_YEAR_LABEL_BG + roundRect(ctx, bx, by, w, h, 3) + ctx.fill() + ctx.fillStyle = COLOR_YEAR_LABEL + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(label, bx + padX, by + h / 2 + padY * 0) + }) + + if (hoverIdx != null && hoverIdx >= 0 && hoverIdx < points.length) { + const cx = getColumnCenter(g, hoverIdx, points.length) + const bandW = g.columnWidth + ctx.fillStyle = COLOR_HOVER_BAND + ctx.fillRect(cx - bandW / 2, g.plotTop, bandW, g.plotHeight) + } + + if ( + dragStartIdx != null && + dragCurrentIdx != null && + dragStartIdx >= 0 && + dragCurrentIdx >= 0 && + dragStartIdx < points.length && + dragCurrentIdx < points.length + ) { + const rangeStart = Math.min(dragStartIdx, dragCurrentIdx) + const rangeEnd = Math.max(dragStartIdx, dragCurrentIdx) + const startCenter = getColumnCenter(g, rangeStart, points.length) + const endCenter = getColumnCenter(g, rangeEnd, points.length) + const left = startCenter - g.columnWidth / 2 + const right = endCenter + g.columnWidth / 2 + + ctx.fillStyle = COLOR_DRAG_BAND + ctx.fillRect(left, g.plotTop, right - left, g.plotHeight) + + ctx.strokeStyle = COLOR_DRAG_BAND_EDGE + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(Math.round(left) + 0.5, g.plotTop) + ctx.lineTo(Math.round(left) + 0.5, g.plotBottom) + ctx.moveTo(Math.round(right) + 0.5, g.plotTop) + ctx.lineTo(Math.round(right) + 0.5, g.plotBottom) + ctx.stroke() + } + + const groupAvailable = Math.max( + 0, + g.columnWidth * (1 - GROUP_GAP_RATIO) + ) + const barWidth = Math.max( + BAR_MIN_WIDTH, + Math.min(BAR_MAX_WIDTH, (groupAvailable - BAR_GAP) / 2) + ) + const stackedBarWidth = Math.max( + BAR_MIN_WIDTH, + Math.min(BAR_MAX_WIDTH * 2 + BAR_GAP, groupAvailable) + ) + + for (let i = 0; i < points.length; i += 1) { + const p = points[i] + const cx = getColumnCenter(g, i, points.length) + const primaryRatio = + barScale.max > 0 ? p.primaryBarValue / barScale.max : 0 + const secondaryRatio = + barScale.max > 0 ? p.secondaryBarValue / barScale.max : 0 + const primaryHeight = Math.max( + p.primaryBarValue > 0 ? 2 : 0, + primaryRatio * g.plotHeight + ) + const secondaryHeight = Math.max( + p.secondaryBarValue > 0 ? 2 : 0, + secondaryRatio * g.plotHeight + ) + + const baseY = g.plotBottom + + if (barDisplayMode === 'stacked') { + const stackedX = cx - stackedBarWidth / 2 + const stackedHeight = secondaryHeight + primaryHeight + + if (stackedHeight > 0) { + const stackedY = baseY - stackedHeight + const stackedRadius = Math.min(4, stackedBarWidth / 2) + + ctx.save() + roundRect( + ctx, + stackedX, + stackedY, + stackedBarWidth, + stackedHeight, + stackedRadius, + true + ) + ctx.clip() + + if (secondaryHeight > 0) { + ctx.fillStyle = palette.secondaryBar + ctx.fillRect( + stackedX, + baseY - secondaryHeight, + stackedBarWidth, + secondaryHeight + ) + } + + if (primaryHeight > 0) { + ctx.fillStyle = palette.primaryBar + ctx.fillRect( + stackedX, + baseY - stackedHeight, + stackedBarWidth, + primaryHeight + ) + } + + ctx.restore() + } + } else { + const primaryX = cx - barWidth - BAR_GAP / 2 + const secondaryX = cx + BAR_GAP / 2 + + ctx.fillStyle = palette.primaryBar + roundRect( + ctx, + primaryX, + baseY - primaryHeight, + barWidth, + primaryHeight, + Math.min(3, barWidth / 2), + true + ) + ctx.fill() + + ctx.fillStyle = palette.secondaryBar + roundRect( + ctx, + secondaryX, + baseY - secondaryHeight, + barWidth, + secondaryHeight, + Math.min(3, barWidth / 2), + true + ) + ctx.fill() + } + } + + if (showLine && points.length > 0 && lineScale.max > 0) { + const linePts = points.map((p, i) => { + const ratio = p.lineValue / lineScale.max + return { + x: getColumnCenter(g, i, points.length), + y: g.plotBottom - ratio * g.plotHeight, + } + }) + + if (showAreaFill) { + ctx.beginPath() + ctx.moveTo(linePts[0].x, g.plotBottom) + ctx.lineTo(linePts[0].x, linePts[0].y) + for (let i = 1; i < linePts.length; i += 1) { + const prev = linePts[i - 1] + const curr = linePts[i] + if (smoothLine) { + const mx = (prev.x + curr.x) / 2 + ctx.bezierCurveTo( + mx, + prev.y, + mx, + curr.y, + curr.x, + curr.y + ) + } else { + ctx.lineTo(curr.x, curr.y) + } + } + ctx.lineTo(linePts[linePts.length - 1].x, g.plotBottom) + ctx.closePath() + ctx.fillStyle = palette.lineFill + ctx.fill() + } + + ctx.beginPath() + ctx.moveTo(linePts[0].x, linePts[0].y) + for (let i = 1; i < linePts.length; i += 1) { + const prev = linePts[i - 1] + const curr = linePts[i] + if (smoothLine) { + const mx = (prev.x + curr.x) / 2 + ctx.bezierCurveTo(mx, prev.y, mx, curr.y, curr.x, curr.y) + } else { + ctx.lineTo(curr.x, curr.y) + } + } + ctx.strokeStyle = palette.line + ctx.lineWidth = 2.4 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.stroke() + + for (let i = 0; i < linePts.length; i += 1) { + const pt = linePts[i] + const r = i === hoverIdx ? 4.5 : 3.2 + ctx.beginPath() + ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2) + ctx.fillStyle = palette.linePointFill + ctx.fill() + ctx.lineWidth = 1 + ctx.strokeStyle = palette.linePointStroke + ctx.stroke() + } + } + + ctx.font = `760 10px ${fontFamily}` + ctx.fillStyle = COLOR_X_LABEL + ctx.textBaseline = 'top' + const stride = Math.max(1, Math.ceil(32 / Math.max(g.columnWidth, 1))) + const labelCandidates: { + idx: number + text: string + x: number + align: CanvasTextAlign + left: number + right: number + }[] = [] + + for (let i = 0; i < points.length; i += 1) { + const p = points[i] + const cx = getColumnCenter(g, i, points.length) + const isEnd = i === 0 || i === points.length - 1 + let label: string + if (p.anchorIso && p.granularity) { + const date = new Date(p.anchorIso) + label = pickLabel( + p.granularity, + date, + spansYears, + g.columnWidth, + i, + stride, + points.length + ) + if (!label && isEnd) { + if (p.granularity === 'year') + label = date.toLocaleDateString('en-US', { + year: 'numeric', + }) + else if (p.granularity === 'month') + label = date.toLocaleDateString('en-US', { + month: 'short', + }) + else if (p.granularity === 'quarter') + label = `Q${Math.floor(date.getMonth() / 3) + 1}` + else if (p.granularity === 'hour') label = formatHour(date) + else + label = date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }) + } + } else { + label = isEnd || i % stride === 0 ? (p.label ?? '') : '' + } + if (!label) continue + + let align: CanvasTextAlign = 'center' + if (i === 0) align = 'left' + else if (i === points.length - 1) align = 'right' + + let lx = cx + if (i === 0) lx = Math.max(cx - g.columnWidth / 2, g.plotLeft) + else if (i === points.length - 1) + lx = Math.min(cx + g.columnWidth / 2, g.plotRight) + + const width = ctx.measureText(label).width + let left = lx - width / 2 + let right = lx + width / 2 + if (align === 'left') { + left = lx + right = lx + width + } else if (align === 'right') { + left = lx - width + right = lx + } + + labelCandidates.push({ + idx: i, + text: label, + x: lx, + align, + left, + right, + }) + } + + const placedRanges: { left: number; right: number }[] = [] + const placementPadding = 6 + + const placeLabel = ( + candidate: (typeof labelCandidates)[number], + force = false + ) => { + const candidateLeft = candidate.left - placementPadding + const candidateRight = candidate.right + placementPadding + const overlaps = placedRanges.some( + (range) => + candidateLeft <= range.right && candidateRight >= range.left + ) + + if (overlaps && !force) return + + ctx.textAlign = candidate.align + ctx.fillText(candidate.text, candidate.x, g.plotBottom + 6) + placedRanges.push({ left: candidateLeft, right: candidateRight }) + } + + const firstIdx = 0 + const lastIdx = points.length - 1 + const firstCandidate = labelCandidates.find((c) => c.idx === firstIdx) + const lastCandidate = labelCandidates.find((c) => c.idx === lastIdx) + + if (firstCandidate) placeLabel(firstCandidate, true) + if (lastCandidate && lastCandidate !== firstCandidate) + placeLabel(lastCandidate, true) + + for (const candidate of labelCandidates) { + if (candidate.idx === firstIdx || candidate.idx === lastIdx) + continue + placeLabel(candidate) + } + }, [ + size, + geometry, + points, + barScale, + lineScale, + yearDividers, + spansYears, + hoverIdx, + dragStartIdx, + dragCurrentIdx, + smoothLine, + showAreaFill, + showLine, + leftAxisFormatter, + rightAxisFormatter, + palette, + barDisplayMode, + ]) + + function getIndexAtX(x: number) { + const g = geometry + const ratio = (x - g.plotLeft) / g.plotWidth + return Math.min( + points.length - 1, + Math.max(0, Math.floor(ratio * points.length)) + ) + } + + function onPointerMove(e: React.PointerEvent) { + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + const g = geometry + + if (dragPointerId != null && e.pointerId === dragPointerId) { + if ( + x >= g.plotLeft && + x <= g.plotRight && + y >= g.plotTop && + y <= g.plotBottom && + points.length > 0 + ) { + setDragCurrentIdx(getIndexAtX(x)) + } + return + } + + if ( + x < g.plotLeft || + x > g.plotRight || + y < g.plotTop || + y > g.plotBottom || + points.length === 0 + ) { + if (hoverIdx !== null) setHoverIdx(null) + return + } + const idx = getIndexAtX(x) + setHoverIdx(idx) + const cx = getColumnCenter(g, idx, points.length) + setHoverPos({ x: cx, y }) + } + + function onPointerDown(e: React.PointerEvent) { + if (!onRangeSelect) return + + const canvas = canvasRef.current + if (!canvas || points.length === 0) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + const g = geometry + + if ( + x < g.plotLeft || + x > g.plotRight || + y < g.plotTop || + y > g.plotBottom + ) { + return + } + + const idx = getIndexAtX(x) + setDragStartIdx(idx) + setDragCurrentIdx(idx) + setDragPointerId(e.pointerId) + setHoverIdx(null) + canvas.setPointerCapture(e.pointerId) + } + + function onPointerUp(e: React.PointerEvent) { + const canvas = canvasRef.current + if (!canvas) return + + if (dragPointerId == null || e.pointerId !== dragPointerId) return + + if (dragStartIdx != null && dragCurrentIdx != null && onRangeSelect) { + const low = Math.min(dragStartIdx, dragCurrentIdx) + const high = Math.max(dragStartIdx, dragCurrentIdx) + const first = points[low] + const last = points[high] + const startIso = first?.startIso ?? first?.anchorIso + const endIso = last?.endIso ?? last?.anchorIso + + if (startIso && endIso) { + onRangeSelect({ startIso, endIso }) + } + } + + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId) + } + setDragPointerId(null) + setDragStartIdx(null) + setDragCurrentIdx(null) + } + + function onPointerCancel(e: React.PointerEvent) { + const canvas = canvasRef.current + if (!canvas) return + if (dragPointerId == null || e.pointerId !== dragPointerId) return + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId) + } + setDragPointerId(null) + setDragStartIdx(null) + setDragCurrentIdx(null) + } + + function onPointerLeave() { + if (dragPointerId != null) return + setHoverIdx(null) + } + + const hoverPoint = hoverIdx != null ? points[hoverIdx] : null + + return ( +
+
+

{title}

+
+ {headerRight ?? + (hint ? ( + {hint} + ) : null)} +
+
+ +
+ + + + + {showLine && ( + + + )} +
+ +
+ {cornerTopRight ? ( +
+ {cornerTopRight} +
+ ) : null} + + + + {hoverPoint && hoverPos && ( +
+
+ {hoverPoint.fullLabel} +
+
+
+
+
+ {showLine && ( +
+
+ )} +
+ )} +
+ + + + + + + + + + + + + {points.map((p) => ( + + + + + + + ))} + +
{table.caption}
{table.periodHeader}{table.primaryBarHeader}{table.secondaryBarHeader}{table.lineHeader}
{p.fullLabel}{tooltipFormatPrimary(p.primaryBarValue)} + {tooltipFormatSecondary(p.secondaryBarValue)} + {tooltipFormatLine(p.lineValue)}
+
+ ) +} diff --git a/src/components/common/charts/chartLabel.helpers.ts b/src/components/common/charts/chartLabel.helpers.ts new file mode 100644 index 00000000..c7e1e04b --- /dev/null +++ b/src/components/common/charts/chartLabel.helpers.ts @@ -0,0 +1,157 @@ +import type { ChartGranularity } from './DualAxisBarLineChart' + +export function formatHour(date: Date, includeMinute = false): string { + const hours = date.getHours() + const suffix = hours >= 12 ? 'pm' : 'am' + const display = hours % 12 === 0 ? 12 : hours % 12 + + if (includeMinute) { + const minutes = date.getMinutes().toString().padStart(2, '0') + return `${display}:${minutes}${suffix}` + } + + return `${display}${suffix}` +} + +export function pickLabel( + granularity: ChartGranularity, + date: Date, + spansYears: boolean, + perColumnWidth: number, + idx: number, + stride: number, + totalCount: number +): string { + const widthsByGranularity: Record< + ChartGranularity, + [number, number, number] + > = { + year: [60, 40, 24], + quarter: [74, 48, 30], + month: [78, 44, 26], + week: [90, 56, 38], + day: [98, 62, 38], + hour: [62, 36, 24], + } + + const widths = widthsByGranularity[granularity] + + let level: 0 | 1 | 2 | 3 + if (perColumnWidth >= widths[0]) level = 0 + else if (perColumnWidth >= widths[1]) level = 1 + else if (perColumnWidth >= widths[2]) level = 2 + else level = 3 + + if (granularity === 'year') { + if (level <= 1) + return date.toLocaleDateString('en-US', { year: 'numeric' }) + if (level === 2) + return date + .toLocaleDateString('en-US', { year: '2-digit' }) + .replace(/^/, "'") + return idx % stride === 0 + ? date + .toLocaleDateString('en-US', { year: '2-digit' }) + .replace(/^/, "'") + : '' + } + + if (granularity === 'month') { + const isFirst = idx === 0 + const isLast = idx === totalCount - 1 + const isQuarterStart = date.getMonth() % 3 === 0 + + if (level === 0) + return date.toLocaleDateString('en-US', { + month: 'long', + ...(spansYears ? { year: 'numeric' } : {}), + }) + + if (level === 1) + return date.toLocaleDateString('en-US', { + month: 'short', + ...(spansYears ? { year: '2-digit' } : {}), + }) + + if (level === 2) { + return date.toLocaleDateString('en-US', { month: 'short' }) + } + + if (isFirst || isLast || isQuarterStart) { + return date.toLocaleDateString('en-US', { month: 'short' }) + } + + return '' + } + + if (granularity === 'quarter') { + const quarter = Math.floor(date.getMonth() / 3) + 1 + const yearFull = date.getFullYear() + const yearShort = String(yearFull).slice(-2) + + if (level === 0) return `Q${quarter} ${yearFull}` + if (level === 1) return `Q${quarter} '${yearShort}` + if (level === 2) return `Q${quarter}` + return idx % stride === 0 ? `Q${quarter}` : '' + } + + if (granularity === 'week') { + if (level === 0) + return date.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + }) + + if (level === 1) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + + if (level === 2) + return date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }) + + return idx % stride === 0 + ? date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }) + : '' + } + + if (granularity === 'day') { + if (level === 0) + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }) + + if (level === 1) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + + if (level === 2) + return date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }) + + return idx % stride === 0 + ? date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }) + : '' + } + + if (level === 0) return formatHour(date, true) + if (level === 1) return formatHour(date) + if (level === 2) return idx % 2 === 0 ? formatHour(date) : '' + return idx % stride === 0 ? formatHour(date) : '' +} diff --git a/src/components/common/charts/chartMath.helpers.ts b/src/components/common/charts/chartMath.helpers.ts new file mode 100644 index 00000000..2fb359f4 --- /dev/null +++ b/src/components/common/charts/chartMath.helpers.ts @@ -0,0 +1,92 @@ +export interface NiceScale { + max: number + step: number + ticks: number[] +} + +export interface PlotGeometry { + width: number + height: number + plotLeft: number + plotRight: number + plotTop: number + plotBottom: number + plotWidth: number + plotHeight: number + columnWidth: number +} + +export function niceScale( + maxValue: number, + tickCount = 6, + forceInteger = false +): NiceScale { + if (!Number.isFinite(maxValue) || maxValue <= 0) { + return { + max: 1, + step: forceInteger ? 1 : 0.25, + ticks: [0, 0.25, 0.5, 0.75, 1], + } + } + + const safe = Math.max(2, tickCount) + const rawStep = maxValue / (safe - 1) + const magnitude = 10 ** Math.floor(Math.log10(rawStep)) + const normalized = rawStep / magnitude + + let nice = 10 + if (normalized <= 1) nice = 1 + else if (normalized <= 2) nice = 2 + else if (normalized <= 2.5) nice = 2.5 + else if (normalized <= 5) nice = 5 + + let step = nice * magnitude + if (forceInteger) step = Math.max(1, Math.ceil(step)) + + const max = Math.ceil(maxValue / step) * step + const ticks: number[] = [] + for (let value = 0; value <= max + 1e-9; value += step) { + ticks.push(value) + } + + return { max: max > 0 ? max : 1, step, ticks } +} + +export function getColumnCenter( + geometry: PlotGeometry, + idx: number, + count: number +): number { + return ( + geometry.plotLeft + + ((idx + 0.5) / Math.max(count, 1)) * geometry.plotWidth + ) +} + +export function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, + topOnly = false +) { + const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2)) + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + w - radius, y) + ctx.quadraticCurveTo(x + w, y, x + w, y + radius) + if (topOnly) { + ctx.lineTo(x + w, y + h) + ctx.lineTo(x, y + h) + } else { + ctx.lineTo(x + w, y + h - radius) + ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h) + ctx.lineTo(x + radius, y + h) + ctx.quadraticCurveTo(x, y + h, x, y + h - radius) + } + ctx.lineTo(x, y + radius) + ctx.quadraticCurveTo(x, y, x + radius, y) + ctx.closePath() +} diff --git a/src/components/common/charts/index.ts b/src/components/common/charts/index.ts new file mode 100644 index 00000000..76dfc924 --- /dev/null +++ b/src/components/common/charts/index.ts @@ -0,0 +1,2 @@ +export * from './DualAxisBarLineChart' +export * from './timeBuckets' diff --git a/src/components/common/charts/timeBuckets.ts b/src/components/common/charts/timeBuckets.ts new file mode 100644 index 00000000..cc590d7f --- /dev/null +++ b/src/components/common/charts/timeBuckets.ts @@ -0,0 +1,386 @@ +import type { ChartGranularity } from './DualAxisBarLineChart' + +export type ChartGranularityMode = 'auto' | ChartGranularity + +export interface ChartBucket { + key: string + label: string + anchorIso: string + startIso: string + endIso: string + granularity: ChartGranularity +} + +function clampToNoon(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12) +} + +function startOfDayDate(d: Date) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0) +} + +function endOfDayDate(d: Date) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59) +} + +function startOfHourDate(d: Date) { + return new Date( + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getHours(), + 0, + 0 + ) +} + +function endOfHourDate(d: Date) { + return new Date( + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getHours(), + 59, + 59 + ) +} + +function clampMin(d: Date, lower?: Date) { + if (lower && d.getTime() < lower.getTime()) return new Date(lower) + return d +} + +function clampMax(d: Date, upper?: Date) { + if (upper && d.getTime() > upper.getTime()) return new Date(upper) + return d +} + +function addDays(date: Date, days: number) { + const d = new Date(date) + d.setDate(d.getDate() + days) + return d +} + +export function daysBetweenInclusive(start: Date, end: Date) { + const msPerDay = 1000 * 60 * 60 * 24 + const startUtc = Date.UTC( + start.getFullYear(), + start.getMonth(), + start.getDate() + ) + const endUtc = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()) + return Math.max(1, Math.floor((endUtc - startUtc) / msPerDay) + 1) +} + +function buildHourBuckets(start: Date, end: Date): ChartBucket[] { + const rangeStart = startOfDayDate(start) + const rangeEnd = endOfDayDate(end) + const buckets: ChartBucket[] = [] + + const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate()) + let currentDay = new Date( + start.getFullYear(), + start.getMonth(), + start.getDate() + ) + + while (currentDay.getTime() <= endDay.getTime()) { + for (let hour = 0; hour < 24; hour += 1) { + const hourDate = new Date( + currentDay.getFullYear(), + currentDay.getMonth(), + currentDay.getDate(), + hour, + 0, + 0 + ) + const bStart = clampMin(startOfHourDate(hourDate), rangeStart) + const bEnd = clampMax(endOfHourDate(hourDate), rangeEnd) + + buckets.push({ + key: `h-${currentDay.getFullYear()}-${currentDay.getMonth() + 1}-${currentDay.getDate()}-${hour}`, + label: hourDate.toLocaleDateString('en-US', { + hour: 'numeric', + }), + anchorIso: bStart.toISOString(), + startIso: bStart.toISOString(), + endIso: bEnd.toISOString(), + granularity: 'hour', + }) + } + + currentDay = new Date( + currentDay.getFullYear(), + currentDay.getMonth(), + currentDay.getDate() + 1 + ) + } + + return buckets +} + +function buildDayBuckets( + start: Date, + end: Date, + maxBuckets = 365 +): ChartBucket[] { + const rangeStart = startOfDayDate(start) + const rangeEnd = endOfDayDate(end) + const totalDays = daysBetweenInclusive(start, end) + const count = Math.min(totalDays, maxBuckets) + const effectiveStart = + count < totalDays ? addDays(end, -(count - 1)) : start + const buckets: ChartBucket[] = [] + for (let idx = 0; idx < count; idx += 1) { + const d = addDays(effectiveStart, idx) + const bStart = clampMin(startOfDayDate(d), rangeStart) + const bEnd = clampMax(endOfDayDate(d), rangeEnd) + buckets.push({ + key: `d-${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`, + label: d.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }), + anchorIso: bStart.toISOString(), + startIso: bStart.toISOString(), + endIso: bEnd.toISOString(), + granularity: 'day', + }) + } + return buckets +} + +function buildWeekBuckets( + start: Date, + end: Date, + maxBuckets = 104 +): ChartBucket[] { + const rangeStart = startOfDayDate(start) + const rangeEnd = endOfDayDate(end) + const totalDays = daysBetweenInclusive(start, end) + const weekCount = Math.ceil(totalDays / 7) + const count = Math.min(weekCount, maxBuckets) + const effectiveStart = + count < weekCount ? addDays(end, -(count * 7 - 1)) : start + const buckets: ChartBucket[] = [] + for (let idx = 0; idx < count; idx += 1) { + const d = addDays(effectiveStart, idx * 7) + const lastDay = addDays(effectiveStart, (idx + 1) * 7 - 1) + const bStart = clampMin(startOfDayDate(d), rangeStart) + const bEnd = clampMax(endOfDayDate(lastDay), rangeEnd) + buckets.push({ + key: `w-${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`, + label: d.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + }), + anchorIso: bStart.toISOString(), + startIso: bStart.toISOString(), + endIso: bEnd.toISOString(), + granularity: 'week', + }) + } + return buckets +} + +function buildMonthBuckets( + start: Date, + end: Date, + maxBuckets = 60 +): ChartBucket[] { + const rangeStart = startOfDayDate(start) + const rangeEnd = endOfDayDate(end) + const startMonth = new Date(start.getFullYear(), start.getMonth(), 1) + const endMonth = new Date(end.getFullYear(), end.getMonth(), 1) + const monthCount = + (endMonth.getFullYear() - startMonth.getFullYear()) * 12 + + (endMonth.getMonth() - startMonth.getMonth()) + + 1 + const count = Math.min(monthCount, maxBuckets) + const offsetStart = monthCount - count + const buckets: ChartBucket[] = [] + for (let idx = 0; idx < count; idx += 1) { + const d = new Date( + startMonth.getFullYear(), + startMonth.getMonth() + offsetStart + idx, + 1 + ) + const bucketEndDay = new Date(d.getFullYear(), d.getMonth() + 1, 0) + const bStart = clampMin(startOfDayDate(d), rangeStart) + const bEnd = clampMax(endOfDayDate(bucketEndDay), rangeEnd) + buckets.push({ + key: `m-${d.getFullYear()}-${d.getMonth() + 1}`, + label: d.toLocaleDateString('en-US', { month: 'short' }), + anchorIso: bStart.toISOString(), + startIso: bStart.toISOString(), + endIso: bEnd.toISOString(), + granularity: 'month', + }) + } + return buckets +} + +function buildQuarterBuckets( + start: Date, + end: Date, + maxBuckets = 40 +): ChartBucket[] { + const rangeStart = startOfDayDate(start) + const rangeEnd = endOfDayDate(end) + + const startQuarterMonth = Math.floor(start.getMonth() / 3) * 3 + const startQuarter = new Date(start.getFullYear(), startQuarterMonth, 1) + + const endQuarterMonth = Math.floor(end.getMonth() / 3) * 3 + const endQuarter = new Date(end.getFullYear(), endQuarterMonth, 1) + + const quarterCount = + Math.floor( + ((endQuarter.getFullYear() - startQuarter.getFullYear()) * 12 + + (endQuarter.getMonth() - startQuarter.getMonth())) / + 3 + ) + 1 + + const count = Math.min(quarterCount, maxBuckets) + const offsetStart = quarterCount - count + const buckets: ChartBucket[] = [] + + for (let idx = 0; idx < count; idx += 1) { + const d = new Date( + startQuarter.getFullYear(), + startQuarter.getMonth() + (offsetStart + idx) * 3, + 1 + ) + const quarterEndDay = new Date(d.getFullYear(), d.getMonth() + 3, 0) + const bStart = clampMin(startOfDayDate(d), rangeStart) + const bEnd = clampMax(endOfDayDate(quarterEndDay), rangeEnd) + const q = Math.floor(d.getMonth() / 3) + 1 + + buckets.push({ + key: `q-${d.getFullYear()}-Q${q}`, + label: `Q${q}`, + anchorIso: bStart.toISOString(), + startIso: bStart.toISOString(), + endIso: bEnd.toISOString(), + granularity: 'quarter', + }) + } + + return buckets +} + +function buildYearBuckets(start: Date, end: Date): ChartBucket[] { + const rangeStart = startOfDayDate(start) + const rangeEnd = endOfDayDate(end) + const startYear = start.getFullYear() + const endYear = end.getFullYear() + const buckets: ChartBucket[] = [] + for (let yr = startYear; yr <= endYear; yr += 1) { + const d = new Date(yr, 0, 1) + const last = new Date(yr, 11, 31) + const bStart = clampMin(startOfDayDate(d), rangeStart) + const bEnd = clampMax(endOfDayDate(last), rangeEnd) + buckets.push({ + key: `y-${yr}`, + label: String(yr), + anchorIso: bStart.toISOString(), + startIso: bStart.toISOString(), + endIso: bEnd.toISOString(), + granularity: 'year', + }) + } + return buckets +} + +export function getValidChartGranularityModes( + startIso: string, + endIso: string +): ChartGranularityMode[] { + const start = clampToNoon(new Date(startIso)) + const end = clampToNoon(new Date(endIso)) + + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return ['auto', 'day'] + } + + const totalDays = daysBetweenInclusive(start, end) + const modes: ChartGranularityMode[] = ['auto', 'day'] + if (totalDays <= 7) modes.push('hour') + if (totalDays >= 7) modes.push('week') + if (totalDays >= 30) modes.push('month') + if (totalDays >= 90) modes.push('quarter') + if (totalDays >= 365) modes.push('year') + + return modes +} + +export function buildChartBuckets( + startIso: string, + endIso: string, + allTimeFirstIso?: string, + mode: ChartGranularityMode = 'auto', + isAllTimeSelection = false +): ChartBucket[] { + const today = clampToNoon(new Date()) + + let start: Date + let end: Date + + if (!startIso || !endIso) { + const firstDate = allTimeFirstIso + ? clampToNoon(new Date(allTimeFirstIso)) + : null + const useFirst = + firstDate && + !Number.isNaN(firstDate.getTime()) && + firstDate.getTime() <= today.getTime() + start = useFirst + ? new Date(firstDate.getFullYear(), firstDate.getMonth(), 1) + : new Date(today.getFullYear(), today.getMonth() - 11, 1) + end = today + } else { + const parsedStart = clampToNoon(new Date(startIso)) + const parsedEnd = clampToNoon(new Date(endIso)) + if ( + Number.isNaN(parsedStart.getTime()) || + Number.isNaN(parsedEnd.getTime()) + ) { + return buildChartBuckets( + '', + '', + allTimeFirstIso, + mode, + isAllTimeSelection + ) + } + start = parsedStart <= parsedEnd ? parsedStart : parsedEnd + end = parsedStart <= parsedEnd ? parsedEnd : parsedStart + } + + const totalDays = daysBetweenInclusive(start, end) + + if (mode === 'hour') return buildHourBuckets(start, end) + if (totalDays === 1 && mode === 'auto') { + return buildHourBuckets(start, end) + } + + if (mode === 'day') return buildDayBuckets(start, end) + if (mode === 'week') return buildWeekBuckets(start, end) + if (mode === 'month') return buildMonthBuckets(start, end) + if (mode === 'quarter') return buildQuarterBuckets(start, end) + if (mode === 'year') return buildYearBuckets(start, end) + + if (isAllTimeSelection) { + if (totalDays <= 90) + return buildDayBuckets(start, end, Number.MAX_SAFE_INTEGER) + if (totalDays <= 366 * 5) + return buildMonthBuckets(start, end, Number.MAX_SAFE_INTEGER) + return buildYearBuckets(start, end) + } + + if (totalDays <= 90) + return buildDayBuckets(start, end, Number.MAX_SAFE_INTEGER) + if (totalDays <= 366 * 5) + return buildMonthBuckets(start, end, Number.MAX_SAFE_INTEGER) + return buildYearBuckets(start, end) +} diff --git a/src/components/common/dropdown/DropdownButton.module.css b/src/components/common/dropdown/DropdownButton.module.css new file mode 100644 index 00000000..f055358c --- /dev/null +++ b/src/components/common/dropdown/DropdownButton.module.css @@ -0,0 +1,93 @@ +.button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + min-width: 12rem; + padding: 0.45rem 0.68rem; + border-radius: 0.62rem; + border: 1px solid rgba(15, 23, 42, 0.1); + background: rgba(255, 255, 255, 0.88); + color: rgba(9, 34, 58, 0.9); + font-size: 0.86rem; + font-weight: 650; + text-align: left; + cursor: pointer; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.95) inset; + transition: + border-color 140ms ease, + background-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.button > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.button :global(svg) { + flex-shrink: 0; + color: rgba(15, 23, 42, 0.58); + stroke-width: 2.6; + transition: transform 140ms ease; +} + +.button[aria-expanded='true'] :global(svg) { + transform: rotate(180deg); +} + +.buttonMinimal { + display: inline-flex; + align-items: center; + gap: 0.28rem; + padding: 0; + border: none; + background: transparent; + color: rgba(15, 23, 42, 0.46); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0; + text-transform: none; + cursor: pointer; + transition: color 0.15s ease; +} + +.buttonMinimal:hover { + color: rgba(15, 23, 42, 0.8); +} + +.buttonMinimal:focus-visible { + outline: 2px solid rgba(14, 165, 233, 0.55); + outline-offset: 2px; + border-radius: 0.25rem; +} + +.buttonShort { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.shortIcon { + width: 1rem; + height: 1rem; +} + +.chevron { + width: 0.9rem; + height: 0.9rem; +} + +.chevronMinimal { + width: 0.75rem; + height: 0.75rem; +} + +@media (max-width: 53rem) { + .button { + min-width: 0; + } +} diff --git a/src/components/common/dropdown/DropdownButton.tsx b/src/components/common/dropdown/DropdownButton.tsx new file mode 100644 index 00000000..c96304ea --- /dev/null +++ b/src/components/common/dropdown/DropdownButton.tsx @@ -0,0 +1,149 @@ +'use client' + +import styles from './DropdownButton.module.css' +import { EllipsisVerticalIcon } from '@heroicons/react/24/outline' +import { + forwardRef, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from 'react' +import { FiChevronDown } from 'react-icons/fi' + +export type DropdownButtonVariant = 'long' | 'short' | 'minimal' + +interface DropdownVariantConfig { + ariaHasPopup: 'menu' | 'dialog' + buttonClassName: string + chevronClassName?: string + chevronSize?: number + showLabel: boolean + showEllipsisIcon: boolean +} + +function getDropdownVariantConfig( + variant: DropdownButtonVariant +): DropdownVariantConfig { + if (variant === 'short') { + return { + ariaHasPopup: 'menu', + buttonClassName: styles.buttonShort, + showLabel: false, + showEllipsisIcon: true, + } + } + + if (variant === 'minimal') { + return { + ariaHasPopup: 'dialog', + buttonClassName: styles.buttonMinimal, + chevronClassName: styles.chevronMinimal, + chevronSize: 12, + showLabel: true, + showEllipsisIcon: false, + } + } + + return { + ariaHasPopup: 'dialog', + buttonClassName: styles.button, + chevronClassName: styles.chevron, + chevronSize: 14, + showLabel: true, + showEllipsisIcon: false, + } +} + +export interface DropdownButtonProps extends React.ButtonHTMLAttributes { + label?: string + menu?: + | React.ReactNode + | ((controls: { closeDropdown: () => void }) => React.ReactNode) + buttonVariant?: DropdownButtonVariant +} + +export const DropdownButton = forwardRef< + HTMLButtonElement, + DropdownButtonProps +>(function DropdownButton( + { label, menu, buttonVariant = 'long', className, onClick, ...props }, + ref +) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + const closeDropdown = () => { + setIsOpen(false) + } + + useEffect(() => { + if (!isOpen) return + + const onDocumentMouseDown = (event: MouseEvent) => { + const container = containerRef.current + if (!container) return + + if (!container.contains(event.target as Node)) { + closeDropdown() + } + } + + const onDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeDropdown() + } + } + + document.addEventListener('mousedown', onDocumentMouseDown) + document.addEventListener('keydown', onDocumentKeyDown) + + return () => { + document.removeEventListener('mousedown', onDocumentMouseDown) + document.removeEventListener('keydown', onDocumentKeyDown) + } + }, [isOpen]) + + const variant = getDropdownVariantConfig(buttonVariant) + + const renderedMenu = + typeof menu === 'function' ? menu({ closeDropdown }) : menu + + const handleButtonClick = (event: ReactMouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + + setIsOpen((current) => !current) + } + + return ( +
+ + {isOpen && renderedMenu} +
+ ) +}) diff --git a/src/components/common/dropdown/DropdownOverlay.module.css b/src/components/common/dropdown/DropdownOverlay.module.css new file mode 100644 index 00000000..381336b2 --- /dev/null +++ b/src/components/common/dropdown/DropdownOverlay.module.css @@ -0,0 +1,90 @@ +.shell { + box-sizing: border-box; + width: max-content; + max-width: 92dvw; + padding: 0.62rem; + border-radius: 0.75rem; + border: 1px solid rgba(15, 23, 42, 0.14); + background: rgba(255, 255, 255, 0.94); + display: grid; + gap: 0.5rem; + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + z-index: 120; + box-shadow: 0 12px 34px rgba(15, 23, 42, 0.16); + overflow-y: auto; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.15rem 0.25rem 0.5rem; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); +} + +.title { + font-size: 0.85rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.85); + letter-spacing: 0.01em; +} + +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border: none; + border-radius: 0.375rem; + background: transparent; + color: rgba(15, 23, 42, 0.6); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.closeButton:hover { + background: rgba(15, 23, 42, 0.08); + color: rgba(15, 23, 42, 0.9); +} + +.body { + display: grid; + gap: 0.55rem; + align-items: stretch; +} + +.footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.5rem; + margin-top: 0.2rem; + padding-top: 0.55rem; + border-top: 1px solid rgba(15, 23, 42, 0.08); +} + +.footerButton { + border: none; + border-radius: 0.5rem; + padding: 0.45rem 0.9rem; + font-size: 0.82rem; + font-weight: 700; + cursor: pointer; + background: rgba(2, 132, 199, 1); + color: #fff; +} + +.footerButton:hover { + background: rgba(3, 105, 161, 1); +} + +.footerButton:disabled { + background: rgba(2, 132, 199, 0.34); + cursor: not-allowed; +} diff --git a/src/components/common/dropdown/DropdownOverlay.tsx b/src/components/common/dropdown/DropdownOverlay.tsx new file mode 100644 index 00000000..825632f3 --- /dev/null +++ b/src/components/common/dropdown/DropdownOverlay.tsx @@ -0,0 +1,354 @@ +'use client' + +import styles from './DropdownOverlay.module.css' +import { areOverlayStylesEqual } from '@/util/' +import { + forwardRef, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type RefObject, +} from 'react' +import { FiX } from 'react-icons/fi' + +type DropdownOverlayNarrowLayoutMode = 'container' | 'trigger' | 'flow' + +const DROPDOWN_OVERLAY_LAYOUT_CONFIG = { + viewportPadding: 12, + narrowBreakpointQuery: '(max-width: 53rem)', + parentGrowthThreshold: 24, + triggerBoundaryInset: 8, +} as const + +const INITIAL_OVERLAY_RESPONSIVE_STYLE: CSSProperties = { + maxWidth: `calc(100dvw - ${DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding * 2}px)`, +} + +interface RectLike { + left: number + right: number +} + +interface ComputeResponsiveOverlayStyleInput { + narrowLayoutMode: DropdownOverlayNarrowLayoutMode + isNarrowLayout: boolean + viewportWidth: number + viewportMaxWidth: number + anchorRect: RectLike + anchorWidth: number + boundaryRect: RectLike + overlayScrollWidth: number +} + +function computeResponsiveOverlayStyle({ + narrowLayoutMode, + isNarrowLayout, + viewportWidth, + viewportMaxWidth, + anchorRect, + anchorWidth, + boundaryRect, + overlayScrollWidth, +}: ComputeResponsiveOverlayStyleInput): CSSProperties { + if (isNarrowLayout) { + if (narrowLayoutMode === 'flow') { + return { + position: 'static', + top: 'auto', + left: 'auto', + right: 'auto', + width: '100%', + minWidth: '100%', + maxWidth: '100%', + marginTop: '0', + } + } + + const boundaryInset = + narrowLayoutMode === 'container' + ? 0 + : DROPDOWN_OVERLAY_LAYOUT_CONFIG.triggerBoundaryInset + const boundaryStart = Math.max( + boundaryRect.left + boundaryInset, + DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding + ) + const usableEnd = Math.min( + boundaryRect.right - boundaryInset, + viewportWidth - DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding + ) + const usableStart = + narrowLayoutMode === 'trigger' + ? Math.max(anchorRect.left, boundaryStart) + : boundaryStart + const availableWidth = Math.max(0, usableEnd - usableStart) + const width = Math.min( + Math.max(anchorWidth, availableWidth), + viewportMaxWidth + ) + const leftOffset = usableStart - anchorRect.left + + return { + left: `${Math.round(leftOffset)}px`, + right: 'auto', + width: `${Math.floor(width)}px`, + minWidth: `${Math.floor(width)}px`, + maxWidth: `${Math.floor(width)}px`, + } + } + + const fitWidth = Math.min(overlayScrollWidth, viewportMaxWidth) + const rightAlignedStart = anchorRect.right - fitWidth + const leftAlignedEnd = anchorRect.left + fitWidth + const shouldAlignLeft = + rightAlignedStart < DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding && + leftAlignedEnd <= + viewportWidth - DROPDOWN_OVERLAY_LAYOUT_CONFIG.viewportPadding + + return { + position: undefined, + top: undefined, + marginTop: undefined, + left: shouldAlignLeft ? '0px' : 'auto', + right: shouldAlignLeft ? 'auto' : '0px', + width: undefined, + minWidth: undefined, + maxWidth: `${Math.floor(viewportMaxWidth)}px`, + } +} + +interface UseDropdownOverlayResponsiveStyleInput { + overlayRef: RefObject + narrowLayoutMode: DropdownOverlayNarrowLayoutMode +} + +// This function figures out the container dimensions and sets the dropdown width and horizontal position to make sure it fits within the container and aligns it with dropdownbutton. It also updates width dynamically on window resize. +function useDropdownOverlayResponsiveStyle({ + overlayRef, + narrowLayoutMode, +}: UseDropdownOverlayResponsiveStyleInput): CSSProperties { + const [responsiveStyle, setResponsiveStyle] = useState( + INITIAL_OVERLAY_RESPONSIVE_STYLE + ) + + useLayoutEffect(() => { + const overlay = overlayRef.current + if (!overlay) return + + const { + viewportPadding, + narrowBreakpointQuery, + parentGrowthThreshold, + } = DROPDOWN_OVERLAY_LAYOUT_CONFIG + + const narrowLayoutMedia = window.matchMedia(narrowBreakpointQuery) + let frame = 0 + + const findResponsiveBoundary = (anchor: HTMLElement): HTMLElement => { + const anchorWidth = anchor.clientWidth + let node: HTMLElement | null = anchor + + while (node) { + const parentElement = node.parentElement + if (!(parentElement instanceof HTMLElement)) { + break + } + + const parent: HTMLElement = parentElement + if (parent.clientWidth >= anchorWidth + parentGrowthThreshold) { + return parent + } + node = parent + } + + return anchor + } + + const applyLayout = () => { + const currentOverlay = overlayRef.current + if (!currentOverlay) return + + const anchor = + currentOverlay.offsetParent instanceof HTMLElement + ? currentOverlay.offsetParent + : null + + const viewportWidth = window.innerWidth + const viewportMaxWidth = Math.max( + 0, + viewportWidth - viewportPadding * 2 + ) + + const nextStyle: CSSProperties = !anchor + ? { maxWidth: `${Math.floor(viewportMaxWidth)}px` } + : computeResponsiveOverlayStyle({ + narrowLayoutMode, + isNarrowLayout: narrowLayoutMedia.matches, + viewportWidth, + viewportMaxWidth, + anchorRect: anchor.getBoundingClientRect(), + anchorWidth: anchor.clientWidth, + boundaryRect: + findResponsiveBoundary( + anchor + ).getBoundingClientRect(), + overlayScrollWidth: currentOverlay.scrollWidth, + }) + + setResponsiveStyle((currentStyle) => + areOverlayStylesEqual(currentStyle, nextStyle) + ? currentStyle + : nextStyle + ) + } + + const scheduleLayout = () => { + cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(applyLayout) + } + + applyLayout() + + const anchor = + overlay.offsetParent instanceof HTMLElement + ? overlay.offsetParent + : null + const boundary = anchor ? findResponsiveBoundary(anchor) : null + + const resizeObserver = new ResizeObserver(scheduleLayout) + resizeObserver.observe(overlay) + if (anchor) resizeObserver.observe(anchor) + if (boundary && boundary !== anchor) resizeObserver.observe(boundary) + + window.addEventListener('resize', scheduleLayout) + window.addEventListener('scroll', scheduleLayout, true) + narrowLayoutMedia.addEventListener('change', scheduleLayout) + + return () => { + cancelAnimationFrame(frame) + resizeObserver.disconnect() + window.removeEventListener('resize', scheduleLayout) + window.removeEventListener('scroll', scheduleLayout, true) + narrowLayoutMedia.removeEventListener('change', scheduleLayout) + } + }, [overlayRef, narrowLayoutMode]) + + return responsiveStyle +} + +export interface DropdownOverlayProps extends React.HTMLAttributes { + header?: React.ReactNode + label?: React.ReactNode + onClose?: () => void + body?: React.ReactNode + footer?: React.ReactNode + footerButtonLabel?: React.ReactNode + footerButtonOnClick?: () => void + footerButtonDisabled?: boolean + headerClassName?: string + closeButtonClassName?: string + bodyClassName?: string + footerClassName?: string + footerButtonClassName?: string + narrowLayoutMode?: DropdownOverlayNarrowLayoutMode +} + +export const DropdownOverlay = forwardRef( + function DropdownOverlay( + { + header, + label, + onClose, + body, + footer, + footerButtonLabel, + footerButtonOnClick, + footerButtonDisabled, + headerClassName, + closeButtonClassName, + bodyClassName, + footerClassName, + footerButtonClassName, + narrowLayoutMode = 'container', + className, + style, + children, + ...props + }, + ref + ) { + const localRef = useRef(null) + const responsiveStyle = useDropdownOverlayResponsiveStyle({ + overlayRef: localRef, + narrowLayoutMode, + }) + + const shellClassName = [styles.shell, className] + .filter(Boolean) + .join(' ') + const headerClasses = [styles.header, headerClassName] + .filter(Boolean) + .join(' ') + const bodyClasses = [styles.body, bodyClassName] + .filter(Boolean) + .join(' ') + const footerClasses = [styles.footer, footerClassName] + .filter(Boolean) + .join(' ') + const footerButtonClasses = [styles.footerButton, footerButtonClassName] + .filter(Boolean) + .join(' ') + const closeClasses = [styles.closeButton, closeButtonClassName] + .filter(Boolean) + .join(' ') + + return ( +
{ + localRef.current = node + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }} + className={shellClassName} + style={{ ...responsiveStyle, ...style }} + {...props} + > + {header ? ( +
{header}
+ ) : label ? ( +
+ {label} + +
+ ) : null} + {body ?
{body}
: null} + {footer || footerButtonLabel ? ( +
+ {footer} + {footerButtonLabel ? ( + + ) : null} +
+ ) : null} + {children} +
+ ) + } +) diff --git a/src/components/common/dropdown/index.ts b/src/components/common/dropdown/index.ts new file mode 100644 index 00000000..4806e4d3 --- /dev/null +++ b/src/components/common/dropdown/index.ts @@ -0,0 +1,2 @@ +export * from './DropdownButton' +export * from './DropdownOverlay' diff --git a/src/components/common/forms/FormField.tsx b/src/components/common/forms/FormField.tsx index 63587c1b..848c5bdd 100644 --- a/src/components/common/forms/FormField.tsx +++ b/src/components/common/forms/FormField.tsx @@ -114,7 +114,7 @@ export interface FieldConfiguration { /** * Dynamic properties populated automatically by the parent Form. * - * Basically, the fields need to know what's goind on with the Form. We + * Basically, the fields need to know what's going on with the Form. We * could've had the user pass in FormState through every single field and * group, but that would've been awful and cluttered up the interface. * Instead, we populate it dynamically within Form so that the user doesn't diff --git a/src/components/common/index.ts b/src/components/common/index.ts index a1cc77e2..4f924ed8 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -8,6 +8,12 @@ export * from './CollapsibleSection' export * from './DiscordAvatar' export * from './ImageWithFallback' export * from './Logo' +export * from './mini_calendar' +export * from './dropdown' +export * from './widget/ProgressBar' +export * from './widget/DashboardWidget' export * from './MultiSelect' export * from './Popup' +export * from './toggle/Toggle' export * from './ToolTip' +export * from './charts' diff --git a/src/components/common/mini_calendar/DateRangePicker.module.css b/src/components/common/mini_calendar/DateRangePicker.module.css new file mode 100644 index 00000000..61aa25ea --- /dev/null +++ b/src/components/common/mini_calendar/DateRangePicker.module.css @@ -0,0 +1,56 @@ +.column { + display: grid; + grid-template-rows: auto 1fr; + gap: 0.34rem; + min-width: 0; + padding-top: 0.1rem; +} + +.inputsRow { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.6rem; + width: 100%; + justify-self: start; +} + +.input { + width: 100%; + padding: 0.4rem 0.45rem; + border-radius: 0.45rem; + border: 1px solid rgba(15, 23, 42, 0.18); + background: #fff; + font-size: 0.78rem; + color: rgba(9, 34, 58, 0.92); + font-family: inherit; + text-align: center; +} + +.input:focus-visible { + outline: 2px solid rgba(14, 165, 233, 0.45); + outline-offset: 1px; +} + +.input::-webkit-calendar-picker-indicator { + display: none; + -webkit-appearance: none; + appearance: none; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.input::-webkit-inner-spin-button, +.input::-webkit-clear-button { + display: none; + -webkit-appearance: none; + appearance: none; +} + +.separator { + font-size: 0.78rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.55); +} diff --git a/src/components/common/mini_calendar/DateRangePicker.tsx b/src/components/common/mini_calendar/DateRangePicker.tsx new file mode 100644 index 00000000..707c4b4b --- /dev/null +++ b/src/components/common/mini_calendar/DateRangePicker.tsx @@ -0,0 +1,130 @@ +'use client' + +import styles from './DateRangePicker.module.css' +import { MiniCalendar } from './MiniCalendar' +import { + dateInputToEndISO, + dateInputToStartISO, + endOfDayISO, + isoToDateInput, + startOfDayISO, +} from './dateRange.helpers' +import { useDateRangeSelectionController } from './useDateRangeSelectionController' +import { useEffect, useMemo, useState } from 'react' + +interface DateRangePickerProps { + startDate: string + endDate: string + onRangeChange: (startDate: string, endDate: string) => void + maxDate?: Date + stretch?: boolean +} + +export function DateRangePicker({ + startDate, + endDate, + onRangeChange, + maxDate = new Date(), + stretch = true, +}: DateRangePickerProps) { + const [calendarMonth, setCalendarMonth] = useState(() => { + const anchor = startDate ? new Date(startDate) : new Date() + return new Date(anchor.getFullYear(), anchor.getMonth(), 1) + }) + + useEffect(() => { + if (!startDate) return + const anchor = new Date(startDate) + if (Number.isNaN(anchor.getTime())) return + setCalendarMonth(new Date(anchor.getFullYear(), anchor.getMonth(), 1)) + }, [startDate]) + + const todayInputValue = useMemo( + () => isoToDateInput(new Date().toISOString()), + [] + ) + const startInputValue = useMemo( + () => isoToDateInput(startDate), + [startDate] + ) + const endInputValue = useMemo(() => isoToDateInput(endDate), [endDate]) + const { startDateObj, endDateObj, onDayClick, onSetEndDateToToday } = + useDateRangeSelectionController({ + startDate, + endDate, + onRangeChange, + }) + + return ( +
+
+ { + const next = dateInputToStartISO(event.target.value) + if (next === null) return + if ( + next && + endDate && + new Date(next).getTime() > + new Date(endDate).getTime() + ) { + return + } + onRangeChange(next, endDate) + }} + value={startInputValue} + aria-label="Start date" + /> + to + { + const next = dateInputToEndISO(event.target.value) + if (next === null) return + if ( + next && + startDate && + new Date(startDate).getTime() > + new Date(next).getTime() + ) { + const nextStartIso = startOfDayISO(new Date(next)) + const nextEndIso = endOfDayISO(new Date(startDate)) + onRangeChange(nextStartIso, nextEndIso) + return + } + onRangeChange(startDate, next) + }} + value={endInputValue} + aria-label="End date" + /> +
+ + +
+ ) +} diff --git a/src/components/common/date_selector/DateSelector.module.css b/src/components/common/mini_calendar/DateSelector.module.css similarity index 100% rename from src/components/common/date_selector/DateSelector.module.css rename to src/components/common/mini_calendar/DateSelector.module.css diff --git a/src/components/common/date_selector/DateSelector.tsx b/src/components/common/mini_calendar/DateSelector.tsx similarity index 98% rename from src/components/common/date_selector/DateSelector.tsx rename to src/components/common/mini_calendar/DateSelector.tsx index cf265586..d437ee21 100644 --- a/src/components/common/date_selector/DateSelector.tsx +++ b/src/components/common/mini_calendar/DateSelector.tsx @@ -30,7 +30,7 @@ function dateInputToEndISO(value: string): string { return new Date(y, mo - 1, d, 23, 59, 59).toISOString() } -export default function DateSelector({ +export function DateSelector({ startDate, endDate, onStartDateChange, diff --git a/src/components/common/mini_calendar/MiniCalendar.module.css b/src/components/common/mini_calendar/MiniCalendar.module.css new file mode 100644 index 00000000..89adb8cb --- /dev/null +++ b/src/components/common/mini_calendar/MiniCalendar.module.css @@ -0,0 +1,414 @@ +.miniCalendar { + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 0.55rem; + padding: 0.3rem 0.4rem 0.4rem; + background: #fff; + width: 100%; + justify-self: start; + box-sizing: border-box; +} + +.miniCalendarStretch { + align-self: stretch; + min-height: var(--calendarMinHeight, 14.5rem); + height: 100%; + display: flex; + flex-direction: column; +} + +.miniCalendarHeader { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + background: rgba(15, 23, 42, 0.05); + margin: -0.3rem -0.4rem 0.25rem; + padding: 0.3rem 0.45rem; + border-radius: 0.55rem 0.55rem 0 0; +} + +.miniCalendarHeaderRight { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.miniCalendarMonthLabel { + text-align: center; + font-size: 0.82rem; + font-weight: 800; + color: rgba(9, 34, 58, 0.92); +} + +.miniCalendarNavButton, +.miniCalendarSetTodayBtn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.38rem; + border: none; + background: transparent; + color: rgba(15, 23, 42, 0.6); + cursor: pointer; +} + +.miniCalendarNavButton { + width: 1.4rem; + height: 1.4rem; +} + +.miniCalendarSetTodayBtn { + width: 1.4rem; + height: 1.4rem; +} + +.miniCalendarNavButton:hover, +.miniCalendarSetTodayBtn:hover { + background: rgba(15, 23, 42, 0.08); + color: rgba(9, 34, 58, 0.92); +} + +.miniCalendarNavButton:focus-visible, +.miniCalendarSetTodayBtn:focus-visible { + outline: 2px solid rgba(14, 165, 233, 0.5); + outline-offset: 1px; +} + +.miniCalendarWeekRow, +.miniCalendarGrid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.08rem; +} + +.miniCalendarWeekRow { + flex: 0 0 auto; +} + +.miniCalendarGrid { + flex: 1 1 auto; + grid-auto-rows: 1fr; +} + +.miniCalendarWeekday { + text-align: center; + font-size: 0.7rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.55); + padding: 0.15rem 0; +} + +.miniCalendarDay { + border: none; + box-sizing: border-box; + position: relative; + background: transparent; + aspect-ratio: auto; + min-height: 0; + height: 100%; + border-radius: 0.34rem; + font-size: 0.72rem; + font-weight: 600; + color: rgba(9, 34, 58, 0.9); + cursor: pointer; + padding: 0; + line-height: 1; + font-family: inherit; +} + +.miniCalendarDay:hover:not(:disabled) { + background: rgba(15, 23, 42, 0.08); +} + +.miniCalendarDay:focus-visible { + outline: 2px solid rgba(14, 165, 233, 0.5); + outline-offset: 1px; +} + +.miniCalendarDayMuted { + color: rgba(15, 23, 42, 0.32); +} + +.miniCalendarDayDisabled { + color: rgba(15, 23, 42, 0.22); + cursor: not-allowed; +} + +.miniCalendarDayToday { + box-shadow: inset 0 0 0 1px rgba(2, 132, 199, 0.45); +} + +.miniCalendarDayToday.miniCalendarDayHoverEdgeLeft, +.miniCalendarDayToday.miniCalendarDayHoverEdgeRight { + box-shadow: none; +} + +.miniCalendarDayInRange { + background: rgba(14, 165, 233, 0.16); + color: rgba(2, 132, 199, 1); + border-radius: 0; +} + +.miniCalendarDayRangeBordered { + --calendarRangeGap: 0.08rem; + --calendarRangeHalfGap: calc(var(--calendarRangeGap) / 2); + --calendarRangeLineColor: rgba(41, 134, 204, 1); + --calendarRangeTop: 0; + --calendarRangeRight: 0; + --calendarRangeBottom: 0; + --calendarRangeLeft: 0; + background-image: + linear-gradient( + rgb(41 134 204 / var(--calendarRangeTop)), + rgb(41 134 204 / var(--calendarRangeTop)) + ), + linear-gradient( + rgb(41 134 204 / var(--calendarRangeRight)), + rgb(41 134 204 / var(--calendarRangeRight)) + ), + linear-gradient( + rgb(41 134 204 / var(--calendarRangeBottom)), + rgb(41 134 204 / var(--calendarRangeBottom)) + ), + linear-gradient( + rgb(41 134 204 / var(--calendarRangeLeft)), + rgb(41 134 204 / var(--calendarRangeLeft)) + ); + background-repeat: no-repeat; + background-size: + calc(100% + var(--calendarRangeGap)) 1px, + 1px calc(100% + var(--calendarRangeGap)), + calc(100% + var(--calendarRangeGap)) 1px, + 1px calc(100% + var(--calendarRangeGap)); + background-position: + calc(-1 * var(--calendarRangeHalfGap)) + calc(-1 * var(--calendarRangeHalfGap)), + calc(100% + var(--calendarRangeHalfGap)) + calc(-1 * var(--calendarRangeHalfGap)), + calc(-1 * var(--calendarRangeHalfGap)) + calc(100% + var(--calendarRangeHalfGap)), + calc(-1 * var(--calendarRangeHalfGap)) + calc(-1 * var(--calendarRangeHalfGap)); +} + +.miniCalendarDayRangeBorderTop { + --calendarRangeTop: 1; +} + +.miniCalendarDayRangeBorderRight { + --calendarRangeRight: 1; +} + +.miniCalendarDayRangeBorderBottom { + --calendarRangeBottom: 1; +} + +.miniCalendarDayRangeBorderLeft { + --calendarRangeLeft: 1; +} + +.miniCalendarDayRangeBorderTop.miniCalendarDayRangeBorderLeft { + border-top-left-radius: 0.34rem; +} + +.miniCalendarDayRangeBorderTop.miniCalendarDayRangeBorderRight { + border-top-right-radius: 0.34rem; +} + +.miniCalendarDayRangeBorderBottom.miniCalendarDayRangeBorderLeft { + border-bottom-left-radius: 0.34rem; +} + +.miniCalendarDayRangeBorderBottom.miniCalendarDayRangeBorderRight { + border-bottom-right-radius: 0.34rem; +} + +.miniCalendarDayInRange::after { + display: none; +} + +.miniCalendarDayRangeStart::before, +.miniCalendarDayRangeEnd::before { + display: none; +} + +.miniCalendarDayRangeStart::after, +.miniCalendarDayRangeEnd::after { + display: none; +} + +.miniCalendarDayHoverRange { + background: + linear-gradient(rgba(14, 165, 233, 0.16), rgba(14, 165, 233, 0.16)), + repeating-linear-gradient( + 45deg, + rgba(41, 134, 204, 0.16) 0, + rgba(41, 134, 204, 0.16) 5px, + rgba(41, 134, 204, 0.06) 5px, + rgba(41, 134, 204, 0.06) 10px + ); + color: rgba(3, 105, 161, 0.96); + border-radius: 0; +} + +.miniCalendarDayHoverRange:hover:not(:disabled) { + background: + linear-gradient(rgba(14, 165, 233, 0.16), rgba(14, 165, 233, 0.16)), + repeating-linear-gradient( + 45deg, + rgba(41, 134, 204, 0.16) 0, + rgba(41, 134, 204, 0.16) 5px, + rgba(41, 134, 204, 0.06) 5px, + rgba(41, 134, 204, 0.06) 10px + ); +} + +.miniCalendarDayHoverEdgeLeft, +.miniCalendarDayHoverEdgeRight { + background: rgba(15, 23, 42, 0.08); + color: rgba(3, 105, 161, 1); +} + +.miniCalendarDayHoverEdgeLeft { + border-radius: 0.34rem 0 0 0.34rem; +} + +.miniCalendarDayHoverEdgeRight { + border-radius: 0 0.34rem 0.34rem 0; +} + +.miniCalendarDayHoverStartLeft, +.miniCalendarDayHoverStartRight { + background: + linear-gradient(rgba(14, 165, 233, 0.24), rgba(14, 165, 233, 0.24)), + repeating-linear-gradient( + 45deg, + rgba(41, 134, 204, 0.2) 0, + rgba(41, 134, 204, 0.2) 5px, + rgba(41, 134, 204, 0.08) 5px, + rgba(41, 134, 204, 0.08) 10px + ); +} + +.miniCalendarDayHoverStartLeft { + border-radius: 0.34rem 0 0 0.34rem; +} + +.miniCalendarDayHoverStartRight { + border-radius: 0 0.34rem 0.34rem 0; +} + +.miniCalendarDayHoverStartLeft:hover:not(:disabled), +.miniCalendarDayHoverStartRight:hover:not(:disabled) { + background: + linear-gradient(rgba(14, 165, 233, 0.24), rgba(14, 165, 233, 0.24)), + repeating-linear-gradient( + 45deg, + rgba(41, 134, 204, 0.2) 0, + rgba(41, 134, 204, 0.2) 5px, + rgba(41, 134, 204, 0.08) 5px, + rgba(41, 134, 204, 0.08) 10px + ); +} + +.miniCalendarDayHoverRange::before, +.miniCalendarDayHoverEdgeLeft::before, +.miniCalendarDayHoverEdgeRight::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + border-top: 1px solid rgba(41, 134, 204, 1); + border-bottom: 1px solid rgba(41, 134, 204, 1); + z-index: 2; +} + +.miniCalendarDayHoverRange::before { + left: calc(-0.08rem / 2); + right: calc(-0.08rem / 2); +} + +.miniCalendarDayHoverStartLeft::before { + left: 0; + right: 0; + content: ''; + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + border-top: 1px solid rgba(41, 134, 204, 1); + border-bottom: 1px solid rgba(41, 134, 204, 1); + border-left: 1px solid rgba(41, 134, 204, 1); + border-radius: 0.34rem 0 0 0.34rem; + z-index: 2; +} + +.miniCalendarDayHoverStartRight::before { + left: 0; + right: 0; + content: ''; + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + border-top: 1px solid rgba(41, 134, 204, 1); + border-bottom: 1px solid rgba(41, 134, 204, 1); + border-right: 1px solid rgba(41, 134, 204, 1); + border-radius: 0 0.34rem 0.34rem 0; + z-index: 2; +} + +.miniCalendarDayHoverStartLeft::after, +.miniCalendarDayHoverStartRight::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + border-top: 1px solid rgba(41, 134, 204, 1); + border-bottom: 1px solid rgba(41, 134, 204, 1); + z-index: 2; +} + +.miniCalendarDayHoverStartLeft::after { + left: 100%; + right: calc(-0.08rem / 2); +} + +.miniCalendarDayHoverStartRight::after { + left: calc(-0.08rem / 2); + right: 100%; +} + +.miniCalendarDayHoverEdgeLeft::before { + left: 0; + right: calc(-0.08rem / 2); + border-left: 1px solid rgba(41, 134, 204, 1); + border-radius: 0.34rem 0 0 0.34rem; +} + +.miniCalendarDayHoverEdgeRight::before { + left: calc(-0.08rem / 2); + right: 0; + border-right: 1px solid rgba(41, 134, 204, 1); + border-radius: 0 0.34rem 0.34rem 0; +} + +.miniCalendarDaySelected { + background: rgba(2, 132, 199, 1); + color: #fff; + border-color: transparent; +} + +.miniCalendarDaySelected:hover:not(:disabled) { + background: rgba(2, 132, 199, 1); + border-color: transparent; +} + +.miniCalendarDayRangeStart { + border-radius: 0.34rem 0 0 0.34rem; +} + +.miniCalendarDayRangeEnd { + border-radius: 0 0.34rem 0.34rem 0; +} diff --git a/src/components/common/mini_calendar/MiniCalendar.tsx b/src/components/common/mini_calendar/MiniCalendar.tsx new file mode 100644 index 00000000..031e61e9 --- /dev/null +++ b/src/components/common/mini_calendar/MiniCalendar.tsx @@ -0,0 +1,221 @@ +'use client' + +import styles from './MiniCalendar.module.css' +import { + createCalendarCells, + getDayStartTs, + getMiniCalendarDayState, + normalizeMaxDateTs, + normalizeMinDateTs, +} from './miniCalendar.helpers' +import { useState } from 'react' +import { FiCalendar, FiChevronLeft, FiChevronRight } from 'react-icons/fi' + +const WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] + +function isSameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +export interface MiniCalendarProps { + month: Date + onMonthChange: (next: Date) => void + startDate: Date | null + endDate: Date | null + maxDate?: Date + minDate?: Date + onDayClick: (day: Date) => void + mode?: 'single' | 'range' + stretch?: boolean + onSetEndDateToToday?: () => void +} + +export function MiniCalendar({ + month, + onMonthChange, + startDate, + endDate, + maxDate, + minDate, + onDayClick, + mode = 'range', + stretch = false, + onSetEndDateToToday, +}: MiniCalendarProps) { + const [hoveredDayTs, setHoveredDayTs] = useState(null) + const year = month.getFullYear() + const monthIdx = month.getMonth() + const firstOfMonth = new Date(year, monthIdx, 1) + const cells = createCalendarCells(month) + + const startTs = startDate + ? new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + ).getTime() + : null + const endTs = endDate + ? new Date( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate() + ).getTime() + : null + + const isRangeMode = mode === 'range' + const hasPreviewSelection = isRangeMode && startTs != null && endTs == null + const previewRangeStartTs = + hasPreviewSelection && hoveredDayTs != null + ? Math.min(startTs, hoveredDayTs) + : null + const previewRangeEndTs = + hasPreviewSelection && hoveredDayTs != null + ? Math.max(startTs, hoveredDayTs) + : null + + const maxTs = normalizeMaxDateTs(maxDate) + const minTs = normalizeMinDateTs(minDate) + + const monthLabel = firstOfMonth.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }) + + const rootClass = [ + styles.miniCalendar, + stretch ? styles.miniCalendarStretch : '', + ] + .filter(Boolean) + .join(' ') + + return ( +
+
+ +
+ {monthLabel} +
+
+ {startDate && !endDate && onSetEndDateToToday && ( + + )} + +
+
+ +
+ {WEEKDAY_LABELS.map((d) => ( + + ))} +
+ +
setHoveredDayTs(null)} + > + {cells.map((day, idx) => { + const state = getMiniCalendarDayState({ + day, + currentMonthIdx: monthIdx, + startTs, + endTs, + hoveredDayTs, + isRangeMode, + previewRangeStartTs, + previewRangeEndTs, + maxTs, + minTs, + isToday: (value) => isSameDay(value, new Date()), + }) + + const classNames = [styles.miniCalendarDay] + if (!state.inMonth) + classNames.push(styles.miniCalendarDayMuted) + if (state.disabled) + classNames.push(styles.miniCalendarDayDisabled) + if (state.inPreviewRange) + classNames.push(styles.miniCalendarDayHoverRange) + if (state.hasConfirmedRange && state.isStart) + classNames.push(styles.miniCalendarDayRangeStart) + if (state.hasConfirmedRange && state.isEnd) + classNames.push(styles.miniCalendarDayRangeEnd) + if (state.isPreviewStartEdge) + classNames.push( + state.isHoverAfterStart + ? styles.miniCalendarDayHoverStartLeft + : styles.miniCalendarDayHoverStartRight + ) + if (state.isPreviewEdge) + classNames.push( + state.isHoverAfterStart + ? styles.miniCalendarDayHoverEdgeRight + : styles.miniCalendarDayHoverEdgeLeft + ) + if (state.isStart || state.isEnd) + classNames.push(styles.miniCalendarDaySelected) + if (state.inRange) + classNames.push(styles.miniCalendarDayInRange) + if (state.isToday && !state.isStart && !state.isEnd) + classNames.push(styles.miniCalendarDayToday) + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/components/common/mini_calendar/dateRange.helpers.ts b/src/components/common/mini_calendar/dateRange.helpers.ts new file mode 100644 index 00000000..5f4a0a63 --- /dev/null +++ b/src/components/common/mini_calendar/dateRange.helpers.ts @@ -0,0 +1,56 @@ +export function isSameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +export function isoToDateInput(iso: string): string { + if (!iso) return '' + const d = new Date(iso) + const y = String(d.getFullYear()).padStart(4, '0') + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export function dateInputToStartISO(value: string): string | null { + if (!value) return '' + const [y, mo, d] = value.split('-').map(Number) + if (!y || !mo || !d) return null + const date = new Date(2000, mo - 1, d, 0, 0, 0) + date.setFullYear(y, mo - 1, d) + return date.toISOString() +} + +export function dateInputToEndISO(value: string): string | null { + if (!value) return '' + const [y, mo, d] = value.split('-').map(Number) + if (!y || !mo || !d) return null + const date = new Date(2000, mo - 1, d, 23, 59, 59) + date.setFullYear(y, mo - 1, d) + return date.toISOString() +} + +export function startOfDayISO(date: Date): string { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0 + ).toISOString() +} + +export function endOfDayISO(date: Date): string { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59 + ).toISOString() +} diff --git a/src/components/common/mini_calendar/index.ts b/src/components/common/mini_calendar/index.ts new file mode 100644 index 00000000..44fddc13 --- /dev/null +++ b/src/components/common/mini_calendar/index.ts @@ -0,0 +1,3 @@ +export * from './MiniCalendar' +export * from './DateRangePicker' +export * from './DateSelector' diff --git a/src/components/common/mini_calendar/miniCalendar.helpers.ts b/src/components/common/mini_calendar/miniCalendar.helpers.ts new file mode 100644 index 00000000..bf8a4763 --- /dev/null +++ b/src/components/common/mini_calendar/miniCalendar.helpers.ts @@ -0,0 +1,143 @@ +export interface MiniCalendarDayState { + dayStart: number + inMonth: boolean + disabled: boolean + isStart: boolean + isEnd: boolean + hasConfirmedRange: boolean + inRange: boolean + inPreviewRange: boolean + isHoverAfterStart: boolean + isPreviewEdge: boolean + isPreviewStartEdge: boolean + isToday: boolean +} + +export function createCalendarCells(month: Date): Date[] { + const year = month.getFullYear() + const monthIdx = month.getMonth() + const firstOfMonth = new Date(year, monthIdx, 1) + const startWeekday = firstOfMonth.getDay() + const gridStart = new Date(year, monthIdx, 1 - startWeekday) + + const cells: Date[] = [] + for (let i = 0; i < 42; i += 1) { + cells.push( + new Date( + gridStart.getFullYear(), + gridStart.getMonth(), + gridStart.getDate() + i + ) + ) + } + + return cells +} + +export function getDayStartTs(day: Date) { + return new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime() +} + +export function normalizeMinDateTs(minDate?: Date) { + if (!minDate) return null + return new Date( + minDate.getFullYear(), + minDate.getMonth(), + minDate.getDate(), + 0, + 0, + 0 + ).getTime() +} + +export function normalizeMaxDateTs(maxDate?: Date) { + if (!maxDate) return null + return new Date( + maxDate.getFullYear(), + maxDate.getMonth(), + maxDate.getDate(), + 23, + 59, + 59 + ).getTime() +} + +export function getMiniCalendarDayState(params: { + day: Date + currentMonthIdx: number + startTs: number | null + endTs: number | null + hoveredDayTs: number | null + isRangeMode: boolean + previewRangeStartTs: number | null + previewRangeEndTs: number | null + maxTs: number | null + minTs: number | null + isToday: (day: Date) => boolean +}): MiniCalendarDayState { + const { + day, + currentMonthIdx, + startTs, + endTs, + hoveredDayTs, + isRangeMode, + previewRangeStartTs, + previewRangeEndTs, + maxTs, + minTs, + isToday, + } = params + + const dayStart = getDayStartTs(day) + const inMonth = day.getMonth() === currentMonthIdx + const disabled = + (maxTs != null && dayStart > maxTs) || + (minTs != null && dayStart < minTs) + const isStart = startTs != null && dayStart === startTs + const isEnd = endTs != null && dayStart === endTs + const hasConfirmedRange = + isRangeMode && startTs != null && endTs != null && startTs !== endTs + const inRange = + isRangeMode && + startTs != null && + endTs != null && + dayStart > startTs && + dayStart < endTs + const inPreviewRange = + previewRangeStartTs != null && + previewRangeEndTs != null && + dayStart > previewRangeStartTs && + dayStart < previewRangeEndTs + const isHoverAfterStart = + hoveredDayTs != null && startTs != null && hoveredDayTs > startTs + const isPreviewEdge = + isRangeMode && + startTs != null && + endTs == null && + hoveredDayTs != null && + dayStart === hoveredDayTs && + dayStart !== startTs + const isPreviewStartEdge = + isRangeMode && + startTs != null && + endTs == null && + hoveredDayTs != null && + isStart && + hoveredDayTs !== startTs + + return { + dayStart, + inMonth, + disabled, + isStart, + isEnd, + hasConfirmedRange, + inRange, + inPreviewRange, + isHoverAfterStart, + isPreviewEdge, + isPreviewStartEdge, + isToday: isToday(day), + } +} diff --git a/src/components/common/mini_calendar/useDateRangeSelectionController.ts b/src/components/common/mini_calendar/useDateRangeSelectionController.ts new file mode 100644 index 00000000..633d9802 --- /dev/null +++ b/src/components/common/mini_calendar/useDateRangeSelectionController.ts @@ -0,0 +1,66 @@ +import { endOfDayISO, isSameDay, startOfDayISO } from './dateRange.helpers' +import { useMemo } from 'react' + +interface UseDateRangeSelectionControllerParams { + startDate: string + endDate: string + onRangeChange: (startDate: string, endDate: string) => void +} + +export function useDateRangeSelectionController({ + startDate, + endDate, + onRangeChange, +}: UseDateRangeSelectionControllerParams) { + const startDateObj = useMemo( + () => (startDate ? new Date(startDate) : null), + [startDate] + ) + + const hasStart = Boolean(startDate) + const hasEnd = Boolean(endDate) + + const onDayClick = (day: Date) => { + const dayStartIso = startOfDayISO(day) + const dayEndIso = endOfDayISO(day) + + if (!hasStart || (hasStart && hasEnd)) { + if (!hasStart && isSameDay(day, new Date())) { + onRangeChange(dayStartIso, dayEndIso) + return + } + + onRangeChange(dayStartIso, '') + return + } + + const startTs = startDateObj?.getTime() ?? 0 + if (new Date(dayStartIso).getTime() < startTs) { + const priorStartDate = + startDateObj ?? (startDate ? new Date(startDate) : null) + if (!priorStartDate) { + onRangeChange(dayStartIso, '') + return + } + + onRangeChange(dayStartIso, endOfDayISO(priorStartDate)) + return + } + + onRangeChange(startDate, dayEndIso) + } + + const onSetEndDateToToday = () => { + if (!startDate) return + onRangeChange(startDate, endOfDayISO(new Date())) + } + + return { + startDateObj, + endDateObj: endDate ? new Date(endDate) : null, + hasStart, + hasEnd, + onDayClick, + onSetEndDateToToday, + } +} diff --git a/src/components/common/toggle/Toggle.module.css b/src/components/common/toggle/Toggle.module.css new file mode 100644 index 00000000..3c87a718 --- /dev/null +++ b/src/components/common/toggle/Toggle.module.css @@ -0,0 +1,65 @@ +.group { + display: flex; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 0.55rem; + overflow: hidden; + background: rgba(15, 23, 42, 0.04); +} + +.groupHorizontal { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; +} + +.groupVertical { + flex-direction: column; +} + +.button { + appearance: none; + min-width: 0; + border: none; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.4rem 0.85rem; + font-size: 0.82rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.7); + cursor: pointer; + text-align: center; + white-space: nowrap; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.groupHorizontal .button { + width: 100%; +} + +.button:hover { + background: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.9); +} + +.groupHorizontal .button + .button { + border-left: 1px solid rgba(15, 23, 42, 0.1); +} + +.groupVertical .button + .button { + border-top: 1px solid rgba(15, 23, 42, 0.1); +} + +.buttonActive, +.buttonActive:hover { + background: rgba(14, 165, 233, 0.16); + color: rgba(2, 132, 199, 1); +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/src/components/common/toggle/Toggle.tsx b/src/components/common/toggle/Toggle.tsx new file mode 100644 index 00000000..6a75a37e --- /dev/null +++ b/src/components/common/toggle/Toggle.tsx @@ -0,0 +1,81 @@ +'use client' + +import styles from './Toggle.module.css' + +type ToggleValue = string | number | boolean +type ToggleOrientation = 'horizontal' | 'vertical' + +export interface ToggleGroupOption { + value: Value + label: React.ReactNode + disabled?: boolean +} + +export interface ToggleGroupProps< + Value extends ToggleValue = string, +> extends Omit, 'onChange'> { + value: Value + options: ToggleGroupOption[] + onChange: (next: Value) => void + ariaLabel: string + orientation?: ToggleOrientation + buttonClassName?: string + activeButtonClassName?: string +} + +export function ToggleGroup({ + value, + options, + onChange, + ariaLabel, + orientation = 'horizontal', + className, + buttonClassName, + activeButtonClassName, + ...props +}: ToggleGroupProps) { + const groupClassName = [ + styles.group, + orientation === 'vertical' + ? styles.groupVertical + : styles.groupHorizontal, + className, + ] + .filter(Boolean) + .join(' ') + + return ( +
+ {options.map((option) => { + const isActive = option.value === value + const buttonClassNames = [ + styles.button, + buttonClassName, + isActive ? styles.buttonActive : '', + isActive ? activeButtonClassName : '', + ] + .filter(Boolean) + .join(' ') + + return ( + + ) + })} +
+ ) +} diff --git a/src/components/common/widget/DashboardWidget.module.css b/src/components/common/widget/DashboardWidget.module.css new file mode 100644 index 00000000..3caa0f0d --- /dev/null +++ b/src/components/common/widget/DashboardWidget.module.css @@ -0,0 +1,37 @@ +.card { + padding: 0.72rem 0.8rem; + border-radius: 0.9rem; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(15, 23, 42, 0.1); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset; +} + +.title { + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.52); +} + +.value { + margin-top: 0.25rem; + font-size: 1.16rem; + font-weight: 850; + letter-spacing: -0.025em; + color: rgba(9, 34, 58, 0.9); +} + +.stats { + margin-top: 0.18rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + font-size: 0.73rem; + font-weight: 650; + color: rgba(15, 23, 42, 0.48); +} + +.separator { + color: rgba(15, 23, 42, 0.34); +} diff --git a/src/components/common/widget/DashboardWidget.tsx b/src/components/common/widget/DashboardWidget.tsx new file mode 100644 index 00000000..d6a5c6cb --- /dev/null +++ b/src/components/common/widget/DashboardWidget.tsx @@ -0,0 +1,41 @@ +'use client' + +import styles from './DashboardWidget.module.css' +import { forwardRef } from 'react' + +export interface DashboardWidgetProps extends React.HTMLAttributes { + title: string + value: React.ReactNode + stat1?: React.ReactNode + stat2?: React.ReactNode +} + +export const DashboardWidget = forwardRef( + function DashboardWidget( + { title, value, stat1, stat2, className, ...props }, + ref + ) { + const hasStat1 = stat1 != null + const hasStat2 = stat2 != null + + return ( +
+
{title}
+
{value}
+ {(hasStat1 || hasStat2) && ( +
+ {hasStat1 && {stat1}} + {hasStat1 && hasStat2 && ( + · + )} + {hasStat2 && {stat2}} +
+ )} +
+ ) + } +) diff --git a/src/components/common/widget/ProgressBar.module.css b/src/components/common/widget/ProgressBar.module.css new file mode 100644 index 00000000..90dcf2d6 --- /dev/null +++ b/src/components/common/widget/ProgressBar.module.css @@ -0,0 +1,29 @@ +.container { + margin-top: 0.88rem; + display: grid; + gap: 0.48rem; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.74rem; + font-weight: 760; + letter-spacing: 0.01em; + color: rgba(15, 23, 42, 0.65); +} + +.bar { + width: 100%; + height: 0.42rem; + border-radius: 99rem; + overflow: hidden; + background: rgba(15, 23, 42, 0.1); +} + +.fill { + display: block; + height: 100%; + border-radius: inherit; +} diff --git a/src/components/common/widget/ProgressBar.tsx b/src/components/common/widget/ProgressBar.tsx new file mode 100644 index 00000000..802db931 --- /dev/null +++ b/src/components/common/widget/ProgressBar.tsx @@ -0,0 +1,55 @@ +'use client' + +import styles from './ProgressBar.module.css' + +function defaultValueFormatter(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '—' + return `${value}%` +} + +export interface ProgressBarProps extends React.HTMLAttributes { + label: string + value: number | null | undefined + fill: string + valueText?: string + barBackground?: string + valueFormatter?: (value: number | null | undefined) => string +} + +export function ProgressBar({ + label, + value, + fill, + valueText, + barBackground, + valueFormatter = defaultValueFormatter, + className, + ...props +}: ProgressBarProps) { + return ( +
+
+ {label} + {valueText ?? valueFormatter(value)} +
+ +
+ ) +} diff --git a/src/test/mirage.tsx b/src/test/mirage.tsx new file mode 100644 index 00000000..8a4ae9af --- /dev/null +++ b/src/test/mirage.tsx @@ -0,0 +1,59 @@ +import { User } from '@/contracts/data' +import { MockDataService, mockDataService } from '@/test/mockDataService' +import { createServer, Model, Registry, Request, Server } from 'miragejs' +import 'miragejs' +import { FactoryDefinition } from 'miragejs/-types' +import Schema from 'miragejs/orm/schema' + +export const MIRAGE_API_BASE_URL = 'http://somewhere.over.the.rainbow' + +const UserModel = Model.extend>({}) +export type AppRegistry = Registry< + { user: typeof UserModel }, + Record +> +export type AppSchema = Schema + +export function registerDefaultRoutes(server: Server) { + server.get('/api/settings', () => ({ + apiBaseUrl: MIRAGE_API_BASE_URL, + })) + + server.get(`${MIRAGE_API_BASE_URL}/auth`, () => + mockDataService.getTokenClaimsForUser() + ) + + server.post(`${MIRAGE_API_BASE_URL}/auth/refresh`, () => true) + + server.get(`${MIRAGE_API_BASE_URL}/users/current`, () => { + // Use mock data service to get a user + const user = mockDataService.getUserById( + MockDataService.VERIFIED_USER_ID + ) + return user + }) + + server.get( + `${MIRAGE_API_BASE_URL}/discordUsers/:discordUserId/isInServer`, + (schema: AppSchema, request: Request) => { + // Access the dynamic parameter from the request + const discordUserId = request.params.discordUserId + console.log('Mocking isInServer for:', discordUserId) + + return { isInServer: true } + } + ) +} + +export function startMirage({ environment = 'test' } = {}) { + return createServer({ + environment, + trackRequests: true, + routes() { + registerDefaultRoutes(this) + }, + models: { + user: UserModel, + }, + }) +} diff --git a/src/test/mockData.ts b/src/test/mockData.ts new file mode 100644 index 00000000..bca8fc69 --- /dev/null +++ b/src/test/mockData.ts @@ -0,0 +1,111 @@ +import { OnboardingStage, ShirtSize, User } from '@/contracts/data' + +export interface MockUser extends User { + // Add any test-specific fields here + testNotes?: string +} + +export interface MockData { + users: MockUser[] + // Add other entities as needed + // discordUsers: MockDiscordUser[] + // settings: MockSettings[] +} + +// Factory functions for generating mock data +export class MockDataFactory { + static createUser( + baseUser: MockUser, + overrides: Partial = {} + ): MockUser { + return { + ...baseUser, + ...overrides, + } + } +} + +export function createMockStaticData(): MockData { + return { + users: [ + { + id: 1, + email: 'john.doe@example.com', + phone: '+1234567890', + preferredName: 'Johnny', + firstName: 'John', + lastName: 'Doe', + birthdate: new Date('1990-01-01T00:00:00.000Z'), + address: { + addressLine1: '123 Main St', + addressLine2: null, + city: 'Anytown', + county: 'Any County', + state: 'CA', + zip: '12345', + }, + acceptedAlerts: true, + verified: true, + onboardingStage: OnboardingStage.VERIFIED, + lastSmsCode: null, + lastSmsCodeSendTimeUtc: null, + status: 1, + createdAtUtc: new Date('2024-01-01T00:00:00.000Z'), + joinedAtUtc: new Date('2024-01-15T00:00:00.000Z'), + completedIntakeUtc: new Date('2024-01-10T00:00:00.000Z'), + membershipCardStatus: 2, + membershipMerchStatus: 1, + shirtSize: ShirtSize.Medium, + duesPayingMember: true, + membershipFulfillmentStatus: 2, + nameConfirmed: true, + addressConfirmed: true, + location: null, + aliases: ['JD', 'Johnny D'], + roles: [], + discordUsers: [], + donors: [], + testNotes: 'Verified user with membership card', + }, + { + id: 2, + email: 'jane.smith@example.com', + phone: '+1987654321', + preferredName: null, + firstName: 'Jane', + lastName: 'Smith', + birthdate: new Date('1985-05-15T00:00:00.000Z'), + address: { + addressLine1: '456 Oak Ave', + addressLine2: 'Apt 2B', + city: 'Somewhere', + county: 'Some County', + state: 'NY', + zip: '67890', + }, + acceptedAlerts: false, + verified: false, + onboardingStage: OnboardingStage.NOT_STARTED, + lastSmsCode: null, + lastSmsCodeSendTimeUtc: null, + status: 1, + createdAtUtc: new Date('2024-02-01T00:00:00.000Z'), + joinedAtUtc: null, + completedIntakeUtc: null, + membershipCardStatus: 0, + membershipMerchStatus: 0, + shirtSize: null, + duesPayingMember: false, + membershipFulfillmentStatus: null, + nameConfirmed: false, + addressConfirmed: false, + location: null, + aliases: [], + roles: [], + discordUsers: [], + donors: [], + testNotes: 'New user, not started onboarding', + }, + ], + } +} diff --git a/src/test/mockDataService.ts b/src/test/mockDataService.ts new file mode 100644 index 00000000..8d6eaacd --- /dev/null +++ b/src/test/mockDataService.ts @@ -0,0 +1,101 @@ +import { + MockUser, + MockData, + MockDataFactory, + createMockStaticData, +} from '@/test/mockData' + +/** + * Service for managing mock data across tests + * Provides both static fixtures and dynamic factory-generated data + */ +export class MockDataService { + private static instance: MockDataService + private mockData: MockData + + public static DEFAULT_USER_ID = 1 + public static VERIFIED_USER_ID = 1 + public static NEW_USER_ID = 2 + + private constructor() { + // Load static fixtures + this.mockData = this.loadFixtures() + } + + static getInstance(): MockDataService { + return (MockDataService.instance ??= new MockDataService()) + } + + // Get all users + getUsers(): MockUser[] { + return this.mockData.users + } + + // Get user by ID + getUserById(id: number): MockUser { + const user = this.mockData.users.find((user) => user.id === id) + if (!user) { + throw new Error(`User with ID ${id} not found`) + } + return user + } + + // Create a new user dynamically + createUser(overrides: Partial = {}, id = 1): MockUser { + return MockDataFactory.createUser(this.getUserById(id), overrides) + } + + // Add a user to the mock database + addUser(user: MockUser): void { + if (this.mockData.users.some((u) => u.id === user.id)) { + throw new Error(`User with ID ${user.id} already exists`) + } + this.mockData.users.push(user) + } + + getTokenClaimsForUser( + userId: number = MockDataService.DEFAULT_USER_ID, + permissions: { id: number; name: string }[] = [] + ): { + userId: number + discordUserId: string + permissions: { id: number; name: string }[] + } { + return { + userId, + discordUserId: 'test-discord-user-id', + permissions, + } + } + + createPermission( + name: string, + id = MockDataService.DEFAULT_USER_ID + ): { id: number; name: string } { + return { id, name } + } + + createRole( + name: string, + permissions: { id: number; name: string }[], + id = MockDataService.DEFAULT_USER_ID + ): { + id: number + name: string + permissions: { id: number; name: string }[] + } { + return { id, name, permissions } + } + + loadFixtures(): MockData { + return createMockStaticData() + } + + // Reset to original fixture data + reset(): void { + this.mockData = this.loadFixtures() + } +} + +// Export singleton instance +export const mockDataService = MockDataService.getInstance() diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts new file mode 100644 index 00000000..e4593fe1 --- /dev/null +++ b/src/test/testUtils.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest' + +/** + * Mock matchMedia for testing responsive behavior + * @param queries - Object mapping media queries to their match results + * @example + * mockMatchMedia({ '(min-width: 1280px)': true }) // Desktop + * mockMatchMedia({ '(min-width: 1280px)': false }) // Mobile + */ +export function mockMatchMedia(queries: Record) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: queries[query] ?? false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) +} + +/** + * Reset matchMedia to the default test polyfill (always returns false) + */ +export function resetMatchMedia() { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) +} diff --git a/src/test/useAuthMock.ts b/src/test/useAuthMock.ts new file mode 100644 index 00000000..57462c38 --- /dev/null +++ b/src/test/useAuthMock.ts @@ -0,0 +1,19 @@ +import { MIRAGE_API_BASE_URL } from '@/test/mirage' +import { MockDataService } from '@/test/mockDataService' +import type * as authModule from '@/util/hooks/useAuth' +import { vi } from 'vitest' + +export const getSuccessfulAuthReturn = ( + userId = MockDataService.DEFAULT_USER_ID +): ReturnType => ({ + apiBaseUrl: MIRAGE_API_BASE_URL, + isSessionLoading: false, + session: { + userId, + discordUserId: 'test-discord-user', + permissions: [], + }, + onLogin: vi.fn(), + onRefresh: vi.fn().mockResolvedValue(true), + onLogout: vi.fn(), +}) diff --git a/src/test/useFetchMock.ts b/src/test/useFetchMock.ts new file mode 100644 index 00000000..0e6aad2d --- /dev/null +++ b/src/test/useFetchMock.ts @@ -0,0 +1,11 @@ +import { vi } from 'vitest' + +export const getFetchMocks = () => ({ + ready: true, + onFetch: vi.fn(), + onGet: vi.fn(), + onPut: vi.fn(), + onPost: vi.fn(), + onPatch: vi.fn(), + onDelete: vi.fn(), +}) diff --git a/src/util/helpers.ts b/src/util/helpers.ts index b00b6926..b4006ad1 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,18 +1,18 @@ -export function pascalToNormal(str: string): string { - const pattern = /[a-z](?=[A-Z])/ - const pascalBounds: number[] = [] +import type { CSSProperties } from 'react' - for (let i = 0; i < str.length - 1; i++) { - const sample = str.substring(i, i + 2) - if (pattern.test(sample)) pascalBounds.push(i) - } +export function areOverlayStylesEqual( + a: CSSProperties, + b: CSSProperties +): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set< + keyof CSSProperties + > - const slicedStr: string[] = [] - slicedStr.push(str.slice(0, pascalBounds[0] + 1)) - for (let i = 0; i < pascalBounds.length - 1; i++) { - slicedStr.push(str.slice(pascalBounds[i] + 1, pascalBounds[i + 1] + 1)) + for (const key of keys) { + if (a[key] !== b[key]) { + return false + } } - slicedStr.push(str.slice(pascalBounds[pascalBounds.length - 1] + 1)) - return slicedStr.join(' ') + return true } diff --git a/src/util/hooks/useAuth.spec.tsx b/src/util/hooks/useAuth.spec.tsx new file mode 100644 index 00000000..a52e6147 --- /dev/null +++ b/src/util/hooks/useAuth.spec.tsx @@ -0,0 +1,233 @@ +import { useAuth } from './useAuth' +import { clearQueryClient } from '@/app/QueryClientWrapper' +import QueryClientWrapper from '@/app/QueryClientWrapper' +import { startMirage, MIRAGE_API_BASE_URL } from '@/test/mirage' +import { mockDataService } from '@/test/mockDataService' +import { renderHook } from '@testing-library/react' +import 'miragejs' +import { Response } from 'miragejs' +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest' + +let server: ReturnType + +beforeEach(() => { + server = startMirage() + vi.useFakeTimers() +}) + +afterEach(() => { + server.shutdown() + vi.useRealTimers() + clearQueryClient() +}) + +describe('useAuth', () => { + describe('Settings Query', () => { + it('should fetch apiBaseUrl from settings', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + await vi.advanceTimersByTimeAsync(100) + + expect(result.current.apiBaseUrl).toBe(MIRAGE_API_BASE_URL) + }) + }) + + describe('Session Query', () => { + it('should fetch the current session when apiBaseUrl is available', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(result.current.session).not.toBeNull() + + expect(result.current.session).toEqual( + mockDataService.getTokenClaimsForUser() + ) + }) + + it('should set isSessionLoading to true while loading', async () => { + server.timing = 50 + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + expect(result.current.isSessionLoading).toBe(true) + + await vi.advanceTimersByTimeAsync(200) + + expect(result.current.session).not.toBeNull() + }) + + it('should set session to null when user is not authenticated (404)', async () => { + server.get(`${MIRAGE_API_BASE_URL}/auth`, () => new Response(404)) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + + await vi.advanceTimersByTimeAsync(100) + expect(result.current.isSessionLoading).toBe(false) + + expect(result.current.session).toBeNull() + }) + + it('should trigger logout on non-404 auth errors', async () => { + server.get(`${MIRAGE_API_BASE_URL}/auth`, () => new Response(500)) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + + await vi.advanceTimersByTimeAsync(100) + + // After error, session should be null due to logout + expect(result.current.session).toBeNull() + }) + }) + + describe('Login Function', () => { + let mockAssign = vi.fn() + beforeEach(() => { + // Create a mock with the method you want to track + mockAssign = vi.fn() + + // Use stubGlobal to replace the read-only window.location + vi.stubGlobal('location', { + ...window.location, + assign: mockAssign, + }) + }) + afterEach(() => { + mockAssign.mockRestore() + vi.unstubAllGlobals() + }) + it('should throw error when login fetch fails', async () => { + // Mock a failed login endpoint + server.get(`${MIRAGE_API_BASE_URL}/auth/discord/login`, () => { + return new Response(500, { + errors: 'Internal Server Error', + }) + }) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + await vi.advanceTimersByTimeAsync(100) + + const loginPromise = expect( + result.current.onLogin('redirect-url') + ).rejects.toThrow('Failed to get login url from the API') + await vi.advanceTimersByTimeAsync(100) + await loginPromise + + expect(mockAssign).not.toHaveBeenCalled() + }) + + it('should handle login with custom redirect', async () => { + // Mock the login endpoint to return a redirect URL + server.get(`${MIRAGE_API_BASE_URL}/auth/discord/login`, () => ({ + redirectUri: 'login-redirect-url', + })) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + await vi.advanceTimersByTimeAsync(100) + + const loginPromise = result.current.onLogin('login-redirect-url') + await vi.advanceTimersByTimeAsync(1000) + await loginPromise + + expect(mockAssign).toHaveBeenCalledWith('login-redirect-url') + }) + }) + + describe('Logout Function', () => { + let mockAssign = vi.fn() + beforeEach(() => { + // Create a mock with the method you want to track + mockAssign = vi.fn() + + // Use stubGlobal to replace the read-only window.location + vi.stubGlobal('location', { + ...window.location, + assign: mockAssign, + }) + }) + afterEach(() => { + mockAssign.mockRestore() + vi.unstubAllGlobals() + }) + it('should make POST request to logout endpoint', async () => { + let logoutCalled = false + server.post(`${MIRAGE_API_BASE_URL}/auth/logout`, () => { + logoutCalled = true + return {} + }) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + await vi.advanceTimersByTimeAsync(100) + + // Actually invoke the mutation + const logoutPromise = result.current.onLogout() + await vi.advanceTimersByTimeAsync(100) + await logoutPromise + + // Verify the POST was made + expect(logoutCalled).toBe(true) + }) + }) + + describe('Refresh Function', () => { + it('should call refresh endpoint with credentials', async () => { + let requestMade = false + server.post(`${MIRAGE_API_BASE_URL}/auth/refresh`, () => { + requestMade = true + return true + }) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + await vi.advanceTimersByTimeAsync(100) + expect(result.current.session).not.toBeNull() + + // Invoke refresh and verify the endpoint was called + const refreshPromise = result.current.onRefresh() + await vi.advanceTimersByTimeAsync(100) + const refreshResult = await refreshPromise + + expect(requestMade).toBe(true) + expect(refreshResult.data).toBe(true) + }) + + it('should trigger logout when refresh fails', async () => { + server.post( + `${MIRAGE_API_BASE_URL}/auth/refresh`, + () => new Response(401) + ) + + // Spy on the logout endpoint to verify it's called + const logoutSpy = vi.fn(() => ({})) + server.post(`${MIRAGE_API_BASE_URL}/auth/logout`, logoutSpy) + + const { result } = renderHook(() => useAuth(), { + wrapper: QueryClientWrapper, + }) + await vi.advanceTimersByTimeAsync(100) + expect(result.current.session).not.toBeNull() + + const refreshPromise = result.current.onRefresh() + await vi.advanceTimersByTimeAsync(100) + const refreshResult = await refreshPromise + // Query succeeds but returns false data + expect(refreshResult.data).toBe(false) + // Verify logout endpoint was called + expect(logoutSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/src/util/hooks/useAuth.ts b/src/util/hooks/useAuth.ts index 9fdd5183..ffdeb162 100644 --- a/src/util/hooks/useAuth.ts +++ b/src/util/hooks/useAuth.ts @@ -46,7 +46,7 @@ export function useAuth() { if (!res.ok) throw new Error('Failed to get login url from the API') const login = (await res.json()) as DiscordLoginResponse - window.location.href = login.redirectUri + window.location.assign(login.redirectUri) }, }) @@ -60,7 +60,7 @@ export function useAuth() { }) }, async onSuccess(_data, { redirect }) { - window.location.href = redirect ?? '/' + window.location.assign(redirect ?? '/') await queryClient.invalidateQueries({ queryKey: ['/auth'] }) }, }) diff --git a/src/util/hooks/useClickAway.spec.tsx b/src/util/hooks/useClickAway.spec.tsx new file mode 100644 index 00000000..6836a579 --- /dev/null +++ b/src/util/hooks/useClickAway.spec.tsx @@ -0,0 +1,41 @@ +import { useClickAway } from '@/util/hooks/useClickAway' +import { act, renderHook } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +describe('useClickAway', () => { + it('should call callback when clicking outside the referenced element', () => { + const callback = vi.fn() + const { result } = renderHook(() => + useClickAway(callback) + ) + + const div = document.createElement('div') + document.body.appendChild(div) + result.current.current = div + + act(() => { + const event = new MouseEvent('mousedown', { bubbles: true }) + document.body.dispatchEvent(event) + }) + + expect(callback).toHaveBeenCalled() + }) + + it('should not call callback when clicking inside the referenced element', () => { + const callback = vi.fn() + const { result } = renderHook(() => + useClickAway(callback) + ) + + const div = document.createElement('div') + document.body.appendChild(div) + result.current.current = div + + act(() => { + const event = new MouseEvent('mousedown', { bubbles: true }) + div.dispatchEvent(event) + }) + + expect(callback).not.toHaveBeenCalled() + }) +}) diff --git a/src/util/hooks/useCurrentUser.spec.tsx b/src/util/hooks/useCurrentUser.spec.tsx new file mode 100644 index 00000000..a24ffa40 --- /dev/null +++ b/src/util/hooks/useCurrentUser.spec.tsx @@ -0,0 +1,68 @@ +import { clearQueryClient } from '@/app/QueryClientWrapper' +import QueryClientWrapper from '@/app/QueryClientWrapper' +import { startMirage, registerDefaultRoutes } from '@/test/mirage' +import { MockDataService, mockDataService } from '@/test/mockDataService' +import { useCurrentUser, hasPermission } from '@/util/hooks/useCurrentUser' +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' + +let server: ReturnType + +beforeAll(() => { + server = startMirage() +}) + +afterAll(() => { + server.shutdown() +}) + +afterEach(() => { + registerDefaultRoutes(server) + clearQueryClient() +}) + +describe('useCurrentUser', () => { + it('should fetch the current user when ready', async () => { + const { result } = renderHook(() => useCurrentUser(), { + wrapper: QueryClientWrapper, + }) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + }) + + expect(result.current.data?.id).toBe(MockDataService.DEFAULT_USER_ID) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('should return false for missing permissions', () => { + expect( + hasPermission( + mockDataService.createUser({ + roles: [ + mockDataService.createRole('Member', [ + mockDataService.createPermission('READ', 1), + ]), + ], + }), + 'WRITE' + ) + ).toBe(false) + }) + + it('should return true when permission exists', () => { + expect( + hasPermission( + mockDataService.createUser({ + roles: [ + mockDataService.createRole('Member', [ + mockDataService.createPermission('WRITE', 1), + ]), + ], + }), + 'WRITE' + ) + ).toBe(true) + }) +}) diff --git a/src/util/hooks/useFetch.spec.tsx b/src/util/hooks/useFetch.spec.tsx new file mode 100644 index 00000000..545fab80 --- /dev/null +++ b/src/util/hooks/useFetch.spec.tsx @@ -0,0 +1,118 @@ +import { clearQueryClient } from '@/app/QueryClientWrapper' +import QueryClientWrapper from '@/app/QueryClientWrapper' +import { + startMirage, + MIRAGE_API_BASE_URL, + registerDefaultRoutes, +} from '@/test/mirage' +import { getSuccessfulAuthReturn } from '@/test/useAuthMock' +import * as authModule from '@/util/hooks/useAuth' +import { useFetch } from '@/util/hooks/useFetch' +import { renderHook } from '@testing-library/react' +import { Response } from 'miragejs' +import { + vi, + describe, + beforeAll, + afterAll, + afterEach, + beforeEach, + it, + expect, +} from 'vitest' +import z from 'zod' + +vi.mock('@/util/hooks/useAuth', () => ({ + useAuth: vi.fn(), +})) + +const useAuthMock = vi.mocked(authModule.useAuth) + +let authReturnValue: ReturnType + +let server: ReturnType + +beforeAll(() => { + server = startMirage() +}) + +afterAll(() => { + server.shutdown() +}) + +afterEach(() => { + registerDefaultRoutes(server) + clearQueryClient() + useAuthMock.mockReset() +}) + +describe('useFetch', () => { + beforeEach(() => { + authReturnValue = getSuccessfulAuthReturn() + useAuthMock.mockReturnValue(authReturnValue) + }) + + it('should perform a GET request with query params', async () => { + server.get(`${MIRAGE_API_BASE_URL}/items`, () => { + return { id: 1, name: 'Item 1' } + }) + + const { result } = renderHook(() => useFetch(), { + wrapper: QueryClientWrapper, + }) + + const data = await result.current.onGet( + '/items', + z.object({ id: z.number(), name: z.string() }) + ) + + expect(data).toEqual({ id: 1, name: 'Item 1' }) + }) + + it('should retry if the first response is 401 and refresh succeeds', async () => { + let callCount = 0 + + server.get(`${MIRAGE_API_BASE_URL}/items`, () => { + callCount += 1 + if (callCount === 1) + return new Response( + 401, + {}, + { message: 'Unauthorized', error: 'AUTH' } + ) + return { id: 2, name: 'Retry Item' } + }) + + const { result } = renderHook(() => useFetch(), { + wrapper: QueryClientWrapper, + }) + + const response = await result.current.onGet( + '/items', + z.object({ id: z.number(), name: z.string() }) + ) + + expect(authReturnValue.onRefresh).toHaveBeenCalled() + expect(response).toEqual({ id: 2, name: 'Retry Item' }) + }) + + it('should throw a FetchError when the response is not ok', async () => { + server.post( + `${MIRAGE_API_BASE_URL}/items`, + () => + new Response( + 400, + {}, + { message: 'Bad request', error: 'INVALID' } + ) + ) + + const { result } = renderHook(() => useFetch(), { + wrapper: QueryClientWrapper, + }) + + await expect( + result.current.onPost('/items', { test: true }, null) + ).rejects.toThrow() + }) +}) diff --git a/src/util/hooks/useHydration.spec.tsx b/src/util/hooks/useHydration.spec.tsx new file mode 100644 index 00000000..18c0a82b --- /dev/null +++ b/src/util/hooks/useHydration.spec.tsx @@ -0,0 +1,21 @@ +import { useHydration } from '@/util/hooks/useHydration' +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +// Electro: Unclear what the hook is meant for. +// Possible it's for server-side rendering. useEffects don't fire on the server, so it prevents react hydration errors. +// If true, then this hook can't be easily unit tested. +describe('useHydration', () => { + it('should return true after hydration', async () => { + vi.useFakeTimers() + const { result } = renderHook(() => useHydration()) + + // This hook theoretically should initialize as false, but it's always true. + // expect(result.current).toBe(false) + + vi.useRealTimers() + await waitFor(() => { + expect(result.current).toBe(true) + }) + }) +}) diff --git a/src/util/hooks/useInView.spec.tsx b/src/util/hooks/useInView.spec.tsx new file mode 100644 index 00000000..57d0a27f --- /dev/null +++ b/src/util/hooks/useInView.spec.tsx @@ -0,0 +1,50 @@ +import useInView from '@/util/hooks/useInView' +import { act, renderHook } from '@testing-library/react' +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest' + +describe('useInView', () => { + const observeMock = vi.fn() + let intersectionCallback: IntersectionObserverCallback | undefined + + beforeAll(() => { + class IntersectionObserverMock { + constructor(cb: IntersectionObserverCallback) { + intersectionCallback = cb + } + observe = observeMock + unobserve = vi.fn() + disconnect = vi.fn() + } + vi.stubGlobal('IntersectionObserver', IntersectionObserverMock) + }) + + afterEach(() => { + observeMock.mockClear() + }) + + it('should update inView when the observer reports intersection', () => { + const { result } = renderHook(() => useInView()) + + expect(result.current.inView).toBe(false) + + act(() => { + intersectionCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ) + }) + + expect(result.current.inView).toBe(true) + }) + + it('should call observe on the provided element', () => { + const { result } = renderHook(() => useInView()) + const element = document.createElement('div') + + act(() => { + result.current.observe(element) + }) + + expect(observeMock).toHaveBeenCalledWith(element) + }) +}) diff --git a/src/util/hooks/useInit.spec.tsx b/src/util/hooks/useInit.spec.tsx new file mode 100644 index 00000000..c5ff9150 --- /dev/null +++ b/src/util/hooks/useInit.spec.tsx @@ -0,0 +1,14 @@ +import { useInit } from '@/util/hooks/useInit' +import { renderHook } from '@testing-library/react' +import { vi } from 'vitest' +import { describe, it, expect } from 'vitest' + +describe('useInit', () => { + it('should call the callback once when initialized', () => { + const callback = vi.fn() + + renderHook(() => useInit(callback)) + + expect(callback).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/util/hooks/usePaginatedSearch.spec.tsx b/src/util/hooks/usePaginatedSearch.spec.tsx new file mode 100644 index 00000000..d0139ea3 --- /dev/null +++ b/src/util/hooks/usePaginatedSearch.spec.tsx @@ -0,0 +1,149 @@ +import { clearQueryClient } from '@/app/QueryClientWrapper' +import QueryClientWrapper from '@/app/QueryClientWrapper' +import { getFetchMocks } from '@/test/useFetchMock' +import * as fetchModule from '@/util/hooks/useFetch' +import { ZodSchema } from '@/util/hooks/useFetch' +import { usePaginatedSearch } from '@/util/hooks/usePaginatedSearch' +import { renderHook, waitFor, act } from '@testing-library/react' +import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest' +import z from 'zod' + +vi.mock('@/util/hooks/useFetch', () => ({ + useFetch: vi.fn(), +})) +const useFetchMock = vi.mocked(fetchModule.useFetch) + +const ORIGINAL_LIMIT = 2 +const SEARCH_ITEMS = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + { id: 4, name: 'Item 4' }, + { id: 5, name: 'Item 5' }, +] + +const createPageResponse = (page: number, limit: number) => { + const offset = page * ORIGINAL_LIMIT + const pageData = SEARCH_ITEMS.slice(offset, offset + limit) + + return { + page, + limit, + count: SEARCH_ITEMS.length, + data: pageData, + } +} +const onGetMock = vi.fn() +useFetchMock.mockReturnValue({ + ...getFetchMocks(), + onGet: onGetMock, +}) + +beforeEach(() => { + onGetMock.mockImplementation( + ( + url: string, + schema: ZodSchema, + options: { query?: Record } + ) => { + const page = Number(options?.query?.page ?? 0) + const limit = Number(options?.query?.limit ?? ORIGINAL_LIMIT) + return Promise.resolve(createPageResponse(page, limit)) + } + ) +}) + +afterEach(() => { + clearQueryClient() + onGetMock.mockClear() +}) + +describe('usePaginatedSearch', () => { + it('should fetch the first page of results', async () => { + const { result } = renderHook( + () => + usePaginatedSearch( + '/users/search', + z.object({ id: z.number(), name: z.string() }), + { search: { limit: 2, page: 0 } } + ), + { + wrapper: QueryClientWrapper, + } + ) + + await waitFor(() => { + expect(result.current.query.data).toBeDefined() + }) + + expect(result.current.query.data?.data).toHaveLength(2) + expect(result.current.search.limit).toBe(2) + expect(onGetMock).toHaveBeenCalledWith( + '/users/search', + expect.anything(), + expect.objectContaining({ query: { limit: 2, page: 0 } }) + ) + }) + + it('should fetch all pages when all option is enabled', async () => { + const { result } = renderHook( + () => + usePaginatedSearch( + '/users/search', + z.object({ id: z.number(), name: z.string() }), + { search: { limit: 2, page: 0 }, all: true } + ), + { + wrapper: QueryClientWrapper, + } + ) + + await waitFor(() => { + expect(result.current.query.data).toBeDefined() + }) + + expect(result.current.query.data?.count).toBe(SEARCH_ITEMS.length) + expect(onGetMock).toHaveBeenCalledTimes(3) + expect(onGetMock).toHaveBeenCalledWith( + '/users/search', + expect.anything(), + expect.objectContaining({ query: { limit: 2, page: 0 } }) + ) + expect(onGetMock).toHaveBeenCalledWith( + '/users/search', + expect.anything(), + expect.objectContaining({ query: { limit: 2, page: 1 } }) + ) + expect(onGetMock).toHaveBeenCalledWith( + '/users/search', + expect.anything(), + expect.objectContaining({ query: { limit: 1, page: 2 } }) + ) + }) + + it('should update the search object when onSearch is called', async () => { + const { result } = renderHook( + () => + usePaginatedSearch( + '/users/search', + z.object({ id: z.number(), name: z.string() }), + { search: { limit: 2, page: 0 } } + ), + { + wrapper: QueryClientWrapper, + } + ) + + await waitFor(() => { + expect(result.current.query.data).toBeDefined() + }) + + act(() => { + result.current.onSearch({ limit: 1, page: 0 }) + }) + + await waitFor(() => { + expect(result.current.search.limit).toBe(1) + }) + }) +}) diff --git a/src/util/hooks/usePaginatedSearch.ts b/src/util/hooks/usePaginatedSearch.ts index c68a4816..c76fb4ec 100644 --- a/src/util/hooks/usePaginatedSearch.ts +++ b/src/util/hooks/usePaginatedSearch.ts @@ -1,6 +1,6 @@ -import { useFetch } from './useFetch' import { SearchRequest } from '@/contracts/requests' import { PaginatedResponse, zPaginatedResponse } from '@/contracts/responses' +import { useFetch } from '@/util/hooks/useFetch' import { keepPreviousData, skipToken, diff --git a/src/util/theme.spec.ts b/src/util/theme.spec.ts new file mode 100644 index 00000000..ede54d80 --- /dev/null +++ b/src/util/theme.spec.ts @@ -0,0 +1,21 @@ +import { getBrandColor, ShadeIndex } from '@/util/theme' +import { describe, it, expect } from 'vitest' + +describe('theme colors', () => { + it('should return default color when no shade is provided', () => { + expect(getBrandColor('brandLightBlue')).toBe('#2986CC') + expect(getBrandColor('mapBlue')).toBe('#09223A') + }) + + it('should return the requested color shade', () => { + expect(getBrandColor('brandLightBlue', 300)).toBe('#549ED6') + expect(getBrandColor('brandRed', 100)).toBe('#EBAFA9') + }) + + it('should fall back to default when an unknown shade is requested', () => { + expect(getBrandColor('brandYellow', 'DEFAULT')).toBe('#FDB515') + expect(getBrandColor('mapBlue', 'unknown' as ShadeIndex)).toBe( + '#09223A' + ) + }) +}) diff --git a/src/util/types.spec.ts b/src/util/types.spec.ts new file mode 100644 index 00000000..c9e701e5 --- /dev/null +++ b/src/util/types.spec.ts @@ -0,0 +1,23 @@ +import { zIntParam, zBoolQuery, zIntArrayQuery } from '@/util/types' +import { describe, it, expect } from 'vitest' + +describe('util types', () => { + it('should parse integer route params', () => { + expect(zIntParam.parse('123')).toBe(123) + }) + + it('should transform boolean query values correctly', () => { + expect(zBoolQuery.parse('true')).toBe(true) + expect(zBoolQuery.parse('false')).toBe(false) + expect(zBoolQuery.parse(undefined)).toBe(false) + }) + + it('should parse comma separated integer arrays', () => { + expect(zIntArrayQuery.parse('1,2,3')).toEqual([1, 2, 3]) + }) + + it('should return a parse error when invalid arrays are provided', () => { + const result = zIntArrayQuery.safeParse('1,foo,3') + expect(result.success).toBe(false) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index eb21af86..ad58c862 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "noImplicitAny": false, "jsx": "preserve", "incremental": true, + "sourceMap": true, "plugins": [ { "name": "next" diff --git a/vitest.setup.ts b/vitest.setup.ts index a9d0dd31..4a442ce2 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,5 @@ +import { resetMatchMedia } from '@/test/testUtils' import '@testing-library/jest-dom/vitest' + +// Initialize matchMedia polyfill for tests +resetMatchMedia()