normalize project structure: src/client + src/server + src/shared

- restructure from src/ + server/ to src/client/ + src/server/ + src/shared/
- switch backend runtime from Node (tsx) to Bun
- merge server/package.json into root, remove @hono/node-server + tsx
- convert server @/ imports to relative paths
- standardize biome config (lineWidth 80, quoteStyle double)
- add CLAUDE.md, .env.example at root
- update vite.config, tsconfig, deploy.sh for new structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 22:55:52 +01:00
parent 08029ee68c
commit 053707d96a
119 changed files with 15320 additions and 254 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
DATABASE_URL=postgres://user:password@localhost:5432/abgeordnetenwatch
PORT=3000
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:your-email@example.com

7
.gitignore vendored
View File

@@ -2,6 +2,9 @@ node_modules/
dist/ dist/
.env .env
*.DS_Store *.DS_Store
src/routeTree.gen.ts src/client/routeTree.gen.ts
server/drizzle/ drizzle/
*.log *.log
.mise.local.toml
.env.local
.env.*.local

53
CLAUDE.md Normal file
View File

@@ -0,0 +1,53 @@
# agw — Abgeordnetenwatch PWA
Political transparency app tracking Bundestag/Landtag votes, politicians, and push notifications.
## Stack
- **Frontend:** React 19, Vite, Tailwind CSS 4, TanStack Router (file-based), Zustand, PGlite, Radix UI
- **Backend:** Hono (Bun), Drizzle ORM, PostgreSQL, pg-boss (job scheduler), web-push
- **Linting:** Biome (tabs, 80 chars, double quotes)
## Project Structure
```
src/
├── client/ ← React PWA (features/, routes/, shared/)
├── server/ ← Hono API (features/, shared/)
└── shared/ ← isomorphic code (types, schemas)
```
## Local Development
```bash
bun install
bun run dev # frontend (Vite)
bun run dev:server # backend (Bun --watch)
```
## Deployment
```bash
./deploy.sh
```
Deploys to Uberspace (`serve.uber.space`):
- Frontend → `/var/www/virtual/serve/html/agw/`
- Backend → `~/services/agw-backend/` (systemd: `agw-backend.service`, port 3002)
- Route: `/agw/api/*` → port 3002 (prefix removed)
## Environment Variables
See `.env.example`:
- `DATABASE_URL` — PostgreSQL connection string
- `PORT` — server port (default 3002)
- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` — web push credentials
## Database
PostgreSQL via Drizzle ORM. Migrations in `drizzle/`.
```bash
bun run db:generate # generate migration
bun run db:migrate # apply migrations
```

View File

@@ -6,7 +6,7 @@
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "tab", "indentStyle": "tab",
"lineWidth": 120 "lineWidth": 80
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {
@@ -25,6 +25,13 @@
} }
}, },
"files": { "files": {
"ignore": ["dist/", "node_modules/", "src/routeTree.gen.ts", "src/shared/components/ui/", ".claude/"] "ignore": [
"dist/",
"node_modules/",
"src/client/routeTree.gen.ts",
"src/client/shared/components/ui/",
".claude/",
"drizzle/"
]
} }
} }

125
bun.lock
View File

@@ -9,11 +9,16 @@
"@tanstack/react-router": "^1.120.3", "@tanstack/react-router": "^1.120.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.0",
"hono": "^4.7.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"pg-boss": "^10.1.0",
"postgres": "^3.4.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"zod": "^3.24.3", "zod": "^3.24.3",
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
@@ -26,7 +31,9 @@
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@types/web-push": "^3.6.0",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.5.1",
"drizzle-kit": "^0.31.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"lint-staged": "^16.1.0", "lint-staged": "^16.1.0",
"shadcn": "^3.8.5", "shadcn": "^3.8.5",
@@ -276,10 +283,16 @@
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -662,6 +675,8 @@
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@@ -708,6 +723,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
@@ -734,6 +751,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
@@ -742,6 +761,8 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -812,6 +833,8 @@
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
@@ -868,8 +891,14 @@
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -908,6 +937,8 @@
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1038,6 +1069,8 @@
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
@@ -1180,6 +1213,10 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@@ -1230,6 +1267,8 @@
"lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -1256,6 +1295,8 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1336,6 +1377,24 @@
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"pg": ["pg@8.19.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ=="],
"pg-boss": ["pg-boss@10.4.2", "", { "dependencies": { "cron-parser": "^4.9.0", "pg": "^8.16.3", "serialize-error": "^8.1.0" } }, "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.12.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg=="],
"pg-protocol": ["pg-protocol@1.12.0", "", {}, "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -1348,6 +1407,16 @@
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
@@ -1456,6 +1525,8 @@
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="],
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
@@ -1506,6 +1577,8 @@
"sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -1664,6 +1737,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
@@ -1732,6 +1807,8 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -1758,6 +1835,8 @@
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@rollup/plugin-babel/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], "@rollup/plugin-babel/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="],
@@ -1836,6 +1915,8 @@
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"serialize-error/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -1880,6 +1961,50 @@
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],

View File

@@ -3,7 +3,7 @@
# Usage: ./deploy.sh [uberspace-user@host] (default: serve) # Usage: ./deploy.sh [uberspace-user@host] (default: serve)
set -euo pipefail set -euo pipefail
# activate mise so tool versions (bun, node, etc.) from .mise.toml are on $PATH # activate mise so tool versions (bun, etc.) from .mise.toml are on $PATH
eval "$(mise activate bash --shims)" eval "$(mise activate bash --shims)"
REMOTE="${1:-serve}" REMOTE="${1:-serve}"
@@ -38,18 +38,21 @@ rsync -avz --delete -e "ssh -S $SSH_SOCK" \
echo "==> Deploying backend to $REMOTE:$REMOTE_SERVICE_DIR" echo "==> Deploying backend to $REMOTE:$REMOTE_SERVICE_DIR"
r "mkdir -p $REMOTE_SERVICE_DIR" r "mkdir -p $REMOTE_SERVICE_DIR"
rsync -avz --delete -e "ssh -S $SSH_SOCK" \ rsync -avz --delete -e "ssh -S $SSH_SOCK" \
--exclude='node_modules/' \ --exclude='.git/' \
--exclude='.env' \ --exclude='.env' \
"$LOCAL_DIR/server/" "$REMOTE:$REMOTE_SERVICE_DIR/" --exclude='dist/' \
--exclude='node_modules/' \
--exclude='.DS_Store' \
"$LOCAL_DIR/" "$REMOTE:$REMOTE_SERVICE_DIR/"
echo "==> Installing backend dependencies" echo "==> Installing backend dependencies"
r "cd $REMOTE_SERVICE_DIR && npm install" r "cd $REMOTE_SERVICE_DIR && bun install"
echo "==> Checking .env exists" echo "==> Checking .env exists"
r "test -f $REMOTE_SERVICE_DIR/.env || { echo 'ERROR: $REMOTE_SERVICE_DIR/.env not found — create it first'; exit 1; }" r "test -f $REMOTE_SERVICE_DIR/.env || { echo 'ERROR: $REMOTE_SERVICE_DIR/.env not found — create it first'; exit 1; }"
echo "==> Running database migrations" echo "==> Running database migrations"
r "cd $REMOTE_SERVICE_DIR && set -a && source .env && set +a && npx drizzle-kit migrate" r "cd $REMOTE_SERVICE_DIR && set -a && source .env && set +a && bunx drizzle-kit migrate"
# --- systemd user service (Uberspace 8) --- # --- systemd user service (Uberspace 8) ---
@@ -63,7 +66,7 @@ After=network.target postgresql.service
Type=simple Type=simple
WorkingDirectory=$REMOTE_SERVICE_DIR WorkingDirectory=$REMOTE_SERVICE_DIR
EnvironmentFile=$REMOTE_SERVICE_DIR/.env EnvironmentFile=$REMOTE_SERVICE_DIR/.env
ExecStart=$REMOTE_SERVICE_DIR/node_modules/.bin/tsx src/index.ts ExecStart=/usr/bin/bun run src/server/index.ts
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "postgresql",
schema: "./src/server/shared/db/schema/*.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
})

View File

@@ -9,6 +9,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/client/main.tsx"></script>
</body> </body>
</html> </html>

13007
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,16 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:server": "bun --watch src/server/index.ts",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "bun run src/server/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --fix .", "lint:fix": "biome check --fix .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"prepare": "simple-git-hooks" "prepare": "simple-git-hooks"
}, },
"dependencies": { "dependencies": {
@@ -17,11 +21,16 @@
"@tanstack/react-router": "^1.120.3", "@tanstack/react-router": "^1.120.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.0",
"hono": "^4.7.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"pg-boss": "^10.1.0",
"postgres": "^3.4.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"zod": "^3.24.3", "zod": "^3.24.3",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
@@ -34,7 +43,9 @@
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@types/web-push": "^3.6.0",
"@vitejs/plugin-react": "^4.5.1", "@vitejs/plugin-react": "^4.5.1",
"drizzle-kit": "^0.31.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"lint-staged": "^16.1.0", "lint-staged": "^16.1.0",
"shadcn": "^3.8.5", "shadcn": "^3.8.5",

View File

@@ -96,7 +96,8 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
} }

View File

@@ -2,7 +2,10 @@ import { RepresentativeList } from "@/shared/components/representative-list"
import { useDb } from "@/shared/db/provider" import { useDb } from "@/shared/db/provider"
import type { MandateWithPolitician } from "@/shared/lib/aw-api" import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { fetchAndCacheBundestagMandates, loadCachedResult } from "../../location/lib/geo" import {
fetchAndCacheBundestagMandates,
loadCachedResult,
} from "../../location/lib/geo"
import { useBundestagUI } from "../store" import { useBundestagUI } from "../store"
export function BundestagConfigure() { export function BundestagConfigure() {
@@ -40,7 +43,9 @@ export function BundestagConfigure() {
onSearchChange={setPoliticianSearch} onSearchChange={setPoliticianSearch}
/> />
) : ( ) : (
<p className="px-4 py-6 text-sm text-muted-foreground text-center">Keine Abgeordneten verfügbar.</p> <p className="px-4 py-6 text-sm text-muted-foreground text-center">
Keine Abgeordneten verfügbar.
</p>
)} )}
</div> </div>
) )

View File

@@ -13,14 +13,17 @@ function formatCacheAge(timestamp: number): string {
} }
export function BundestagFeed() { export function BundestagFeed() {
const { items, loading, refreshing, error, lastUpdated, refresh } = useBundestagFeed() const { items, loading, refreshing, error, lastUpdated, refresh } =
useBundestagFeed()
const hasItems = items.length > 0 const hasItems = items.length > 0
return ( return (
<div className="pb-4"> <div className="pb-4">
{lastUpdated && ( {lastUpdated && (
<div className="flex items-center justify-between px-4 py-2 border-b border-border"> <div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-xs text-muted-foreground">Aktualisiert {formatCacheAge(lastUpdated)}</span> <span className="text-xs text-muted-foreground">
Aktualisiert {formatCacheAge(lastUpdated)}
</span>
<button <button
type="button" type="button"
onClick={() => refresh({ silent: true })} onClick={() => refresh({ silent: true })}
@@ -48,7 +51,10 @@ export function BundestagFeed() {
)} )}
{loading && !hasItems && ( {loading && !hasItems && (
<output className="flex items-center justify-center h-48" aria-label="Feed wird geladen"> <output
className="flex items-center justify-center h-48"
aria-label="Feed wird geladen"
>
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</output> </output>
)} )}
@@ -68,7 +74,10 @@ export function BundestagFeed() {
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen. Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.
</p> </p>
<Link to="/app/bundestag/configure" className="text-primary text-sm underline mt-4 inline-block"> <Link
to="/app/bundestag/configure"
className="text-primary text-sm underline mt-4 inline-block"
>
Bundestag konfigurieren Bundestag konfigurieren
</Link> </Link>
</div> </div>

View File

@@ -1,5 +1,9 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed" import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import { loadFeedCache, mergeFeedItems, saveFeedCache } from "@/features/feed/lib/feed-cache" import {
loadFeedCache,
mergeFeedItems,
saveFeedCache,
} from "@/features/feed/lib/feed-cache"
import { useDb } from "@/shared/db/provider" import { useDb } from "@/shared/db/provider"
import { useFollows } from "@/shared/hooks/use-follows" import { useFollows } from "@/shared/hooks/use-follows"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
@@ -18,8 +22,15 @@ export function useBundestagFeed() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<number | null>(null) const [lastUpdated, setLastUpdated] = useState<number | null>(null)
const topicIDs = useMemo(() => follows.filter((f) => f.type === "topic").map((f) => f.entity_id), [follows]) const topicIDs = useMemo(
const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows]) () => follows.filter((f) => f.type === "topic").map((f) => f.entity_id),
[follows],
)
const politicianIDs = useMemo(
() =>
follows.filter((f) => f.type === "politician").map((f) => f.entity_id),
[follows],
)
const refreshingRef = useRef(false) const refreshingRef = useRef(false)
const lastUpdatedRef = useRef<number | null>(null) const lastUpdatedRef = useRef<number | null>(null)
@@ -105,7 +116,10 @@ export function useBundestagFeed() {
if (document.hidden) { if (document.hidden) {
stopInterval() stopInterval()
} else { } else {
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) { if (
!lastUpdatedRef.current ||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
) {
refresh({ silent: true }) refresh({ silent: true })
} }
startInterval() startInterval()

View File

@@ -20,16 +20,24 @@ export async function assembleBundestagFeed(
]) ])
const topicMap = new Map(topics.map((t) => [t.id, t.label])) const topicMap = new Map(topics.map((t) => [t.id, t.label]))
const topicLabelSet = new Set(topics.filter((t) => followedTopicIDs.includes(t.id)).map((t) => t.label.toLowerCase())) const topicLabelSet = new Set(
topics
.filter((t) => followedTopicIDs.includes(t.id))
.map((t) => t.label.toLowerCase()),
)
// --- Past: AW API polls filtered by followed topics + politicians --- // --- Past: AW API polls filtered by followed topics + politicians ---
const topicSet = new Set(followedTopicIDs) const topicSet = new Set(followedTopicIDs)
const filteredByTopics = topicSet.size > 0 ? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id))) : [] const filteredByTopics =
topicSet.size > 0
? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id)))
: []
const politicianPolls = await fetchPollsForPoliticians(followedPoliticianIDs) const politicianPolls = await fetchPollsForPoliticians(followedPoliticianIDs)
const combined = new Map<number, Poll>() const combined = new Map<number, Poll>()
for (const p of [...filteredByTopics, ...politicianPolls]) combined.set(p.id, p) for (const p of [...filteredByTopics, ...politicianPolls])
combined.set(p.id, p)
const pollItems: FeedItem[] = Array.from(combined.values()).map((poll) => ({ const pollItems: FeedItem[] = Array.from(combined.values()).map((poll) => ({
id: `poll-${poll.id}`, id: `poll-${poll.id}`,
@@ -49,7 +57,9 @@ export async function assembleBundestagFeed(
const vorgangItems: FeedItem[] = vorgaenge const vorgangItems: FeedItem[] = vorgaenge
.filter((v) => { .filter((v) => {
if (topicLabelSet.size === 0) return false if (topicLabelSet.size === 0) return false
return v.sachgebiet?.some((s) => topicLabelSet.has(s.toLowerCase())) ?? false return (
v.sachgebiet?.some((s) => topicLabelSet.has(s.toLowerCase())) ?? false
)
}) })
.map((v) => ({ .map((v) => ({
id: `vorgang-${v.id}`, id: `vorgang-${v.id}`,
@@ -78,13 +88,21 @@ function classifyPoll(poll: Poll): "upcoming" | "past" {
return poll.field_poll_date > today ? "upcoming" : "past" return poll.field_poll_date > today ? "upcoming" : "past"
} }
async function fetchPollsForPoliticians(politicianIDs: number[]): Promise<Poll[]> { async function fetchPollsForPoliticians(
politicianIDs: number[],
): Promise<Poll[]> {
if (politicianIDs.length === 0) return [] if (politicianIDs.length === 0) return []
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid))) const mandateResults = await Promise.all(
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id)) politicianIDs.map((pid) => fetchCandidacyMandates(pid)),
)
const mandateIDs = mandateResults.flatMap((mandates) =>
mandates.slice(0, 3).map((m) => m.id),
)
const voteResults = await Promise.all(mandateIDs.map((mid) => fetchVotes(mid))) const voteResults = await Promise.all(
mandateIDs.map((mid) => fetchVotes(mid)),
)
const pollIDSet = new Set<number>() const pollIDSet = new Set<number>()
for (const votes of voteResults) { for (const votes of voteResults) {
for (const v of votes) { for (const v of votes) {

View File

@@ -5,7 +5,11 @@ function formatDate(iso: string | null): string {
if (!iso) return "" if (!iso) return ""
const d = new Date(iso) const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }) return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
} }
export function FeedItemCard({ item }: { item: FeedItemType }) { export function FeedItemCard({ item }: { item: FeedItemType }) {
@@ -25,7 +29,10 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
<h2 className="text-[15px] font-medium leading-snug">{item.title}</h2> <h2 className="text-[15px] font-medium leading-snug">{item.title}</h2>
)} )}
{item.date && ( {item.date && (
<time dateTime={item.date} className="text-xs text-muted-foreground whitespace-nowrap shrink-0"> <time
dateTime={item.date}
className="text-xs text-muted-foreground whitespace-nowrap shrink-0"
>
{formatDate(item.date)} {formatDate(item.date)}
</time> </time>
)} )}
@@ -34,7 +41,12 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
<div className="flex flex-wrap gap-1 mt-2" aria-label="Themen"> <div className="flex flex-wrap gap-1 mt-2" aria-label="Themen">
{item.topics.map((topic) => {item.topics.map((topic) =>
topic.url ? ( topic.url ? (
<a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer"> <a
key={topic.label}
href={topic.url}
target="_blank"
rel="noopener noreferrer"
>
<Badge variant="secondary" className="cursor-pointer"> <Badge variant="secondary" className="cursor-pointer">
{topic.label} {topic.label}
</Badge> </Badge>

View File

@@ -16,8 +16,15 @@ export function useFeed() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<number | null>(null) const [lastUpdated, setLastUpdated] = useState<number | null>(null)
const topicIDs = useMemo(() => follows.filter((f) => f.type === "topic").map((f) => f.entity_id), [follows]) const topicIDs = useMemo(
const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows]) () => follows.filter((f) => f.type === "topic").map((f) => f.entity_id),
[follows],
)
const politicianIDs = useMemo(
() =>
follows.filter((f) => f.type === "politician").map((f) => f.entity_id),
[follows],
)
const refreshingRef = useRef(false) const refreshingRef = useRef(false)
const lastUpdatedRef = useRef<number | null>(null) const lastUpdatedRef = useRef<number | null>(null)
@@ -103,7 +110,10 @@ export function useFeed() {
if (document.hidden) { if (document.hidden) {
stopInterval() stopInterval()
} else { } else {
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) { if (
!lastUpdatedRef.current ||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
) {
refresh({ silent: true }) refresh({ silent: true })
} }
startInterval() startInterval()

View File

@@ -19,7 +19,12 @@ function okResponse(data: unknown) {
} }
const TOPICS = [ const TOPICS = [
{ id: 1, label: "Umwelt", abgeordnetenwatch_url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }, {
id: 1,
label: "Umwelt",
abgeordnetenwatch_url:
"https://www.abgeordnetenwatch.de/themen-dip21/umwelt",
},
{ id: 2, label: "Bildung" }, { id: 2, label: "Bildung" },
{ id: 3, label: "Wirtschaft" }, { id: 3, label: "Wirtschaft" },
] ]
@@ -28,26 +33,46 @@ const POLLS = [
{ {
id: 100, id: 100,
label: "Klimaschutzgesetz", label: "Klimaschutzgesetz",
abgeordnetenwatch_url: "https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz", abgeordnetenwatch_url:
"https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz",
field_poll_date: "2024-06-15", field_poll_date: "2024-06-15",
field_topics: [ field_topics: [
{ id: 1, label: "Umwelt", abgeordnetenwatch_url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }, {
id: 1,
label: "Umwelt",
abgeordnetenwatch_url:
"https://www.abgeordnetenwatch.de/themen-dip21/umwelt",
},
], ],
}, },
{ id: 101, label: "Schulreform", field_poll_date: "2024-06-10", field_topics: [{ id: 2 }] }, {
{ id: 102, label: "Steuerreform", field_poll_date: "2024-06-05", field_topics: [{ id: 3 }] }, id: 101,
label: "Schulreform",
field_poll_date: "2024-06-10",
field_topics: [{ id: 2 }],
},
{
id: 102,
label: "Steuerreform",
field_poll_date: "2024-06-05",
field_topics: [{ id: 3 }],
},
] ]
describe("assembleFeed", () => { describe("assembleFeed", () => {
it("returns empty feed when no follows", async () => { it("returns empty feed when no follows", async () => {
mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) mockFetch
.mockResolvedValueOnce(okResponse(TOPICS))
.mockResolvedValueOnce(okResponse(POLLS))
const feed = await assembleFeed([], []) const feed = await assembleFeed([], [])
expect(feed).toEqual([]) expect(feed).toEqual([])
}) })
it("filters polls by followed topic IDs", async () => { it("filters polls by followed topic IDs", async () => {
mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) mockFetch
.mockResolvedValueOnce(okResponse(TOPICS))
.mockResolvedValueOnce(okResponse(POLLS))
const feed = await assembleFeed([1, 2], []) const feed = await assembleFeed([1, 2], [])
expect(feed).toHaveLength(2) expect(feed).toHaveLength(2)
@@ -56,7 +81,9 @@ describe("assembleFeed", () => {
}) })
it("sorts by date descending", async () => { it("sorts by date descending", async () => {
mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) mockFetch
.mockResolvedValueOnce(okResponse(TOPICS))
.mockResolvedValueOnce(okResponse(POLLS))
const feed = await assembleFeed([1, 2, 3], []) const feed = await assembleFeed([1, 2, 3], [])
expect(feed[0].date).toBe("2024-06-15") expect(feed[0].date).toBe("2024-06-15")
@@ -65,11 +92,20 @@ describe("assembleFeed", () => {
}) })
it("includes topic labels and URLs", async () => { it("includes topic labels and URLs", async () => {
mockFetch.mockResolvedValueOnce(okResponse(TOPICS)).mockResolvedValueOnce(okResponse(POLLS)) mockFetch
.mockResolvedValueOnce(okResponse(TOPICS))
.mockResolvedValueOnce(okResponse(POLLS))
const feed = await assembleFeed([1], []) const feed = await assembleFeed([1], [])
expect(feed[0].topics).toEqual([{ label: "Umwelt", url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }]) expect(feed[0].topics).toEqual([
expect(feed[0].url).toBe("https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz") {
label: "Umwelt",
url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt",
},
])
expect(feed[0].url).toBe(
"https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz",
)
}) })
it("fetches polls for followed politicians", async () => { it("fetches polls for followed politicians", async () => {

View File

@@ -32,17 +32,24 @@ function classifyPoll(poll: Poll): "upcoming" | "past" {
return poll.field_poll_date > today ? "upcoming" : "past" return poll.field_poll_date > today ? "upcoming" : "past"
} }
export async function assembleFeed(followedTopicIDs: number[], followedPoliticianIDs: number[]): Promise<FeedItem[]> { export async function assembleFeed(
followedTopicIDs: number[],
followedPoliticianIDs: number[],
): Promise<FeedItem[]> {
const [topics, polls] = await Promise.all([fetchTopics(), fetchPolls(150)]) const [topics, polls] = await Promise.all([fetchTopics(), fetchPolls(150)])
const topicMap = new Map(topics.map((t) => [t.id, t.label])) const topicMap = new Map(topics.map((t) => [t.id, t.label]))
const topicSet = new Set(followedTopicIDs) const topicSet = new Set(followedTopicIDs)
const filteredByTopics = topicSet.size > 0 ? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id))) : [] const filteredByTopics =
topicSet.size > 0
? polls.filter((p) => p.field_topics.some((t) => topicSet.has(t.id)))
: []
const politicianPolls = await fetchPollsForPoliticians(followedPoliticianIDs) const politicianPolls = await fetchPollsForPoliticians(followedPoliticianIDs)
const combined = new Map<number, Poll>() const combined = new Map<number, Poll>()
for (const p of [...filteredByTopics, ...politicianPolls]) combined.set(p.id, p) for (const p of [...filteredByTopics, ...politicianPolls])
combined.set(p.id, p)
const items: FeedItem[] = Array.from(combined.values()).map((poll) => ({ const items: FeedItem[] = Array.from(combined.values()).map((poll) => ({
id: `poll-${poll.id}`, id: `poll-${poll.id}`,
@@ -66,13 +73,21 @@ export async function assembleFeed(followedTopicIDs: number[], followedPoliticia
}) })
} }
async function fetchPollsForPoliticians(politicianIDs: number[]): Promise<Poll[]> { async function fetchPollsForPoliticians(
politicianIDs: number[],
): Promise<Poll[]> {
if (politicianIDs.length === 0) return [] if (politicianIDs.length === 0) return []
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid))) const mandateResults = await Promise.all(
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id)) politicianIDs.map((pid) => fetchCandidacyMandates(pid)),
)
const mandateIDs = mandateResults.flatMap((mandates) =>
mandates.slice(0, 3).map((m) => m.id),
)
const voteResults = await Promise.all(mandateIDs.map((mid) => fetchVotes(mid))) const voteResults = await Promise.all(
mandateIDs.map((mid) => fetchVotes(mid)),
)
const pollIDSet = new Set<number>() const pollIDSet = new Set<number>()
for (const votes of voteResults) { for (const votes of voteResults) {
for (const v of votes) { for (const v of votes) {

View File

@@ -2,10 +2,28 @@ import { createTestDb } from "@/shared/db/client"
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
import { beforeEach, describe, expect, it } from "vitest" import { beforeEach, describe, expect, it } from "vitest"
import type { FeedItem } from "./assemble-feed" import type { FeedItem } from "./assemble-feed"
import { clearFeedCache, loadFeedCache, mergeFeedItems, saveFeedCache } from "./feed-cache" import {
clearFeedCache,
loadFeedCache,
mergeFeedItems,
saveFeedCache,
} from "./feed-cache"
function makeItem(id: string, date: string | null = "2025-01-15", title = `Poll ${id}`): FeedItem { function makeItem(
return { id, kind: "poll", status: "past", title, url: null, date, topics: [], source: "Bundestag" } id: string,
date: string | null = "2025-01-15",
title = `Poll ${id}`,
): FeedItem {
return {
id,
kind: "poll",
status: "past",
title,
url: null,
date,
topics: [],
source: "Bundestag",
}
} }
let db: PGlite let db: PGlite
@@ -37,7 +55,10 @@ describe("feed cache persistence", () => {
describe("mergeFeedItems", () => { describe("mergeFeedItems", () => {
it("keeps old items and adds new ones", () => { it("keeps old items and adds new ones", () => {
const cached = [makeItem("poll-1", "2025-01-10"), makeItem("poll-2", "2025-01-11")] const cached = [
makeItem("poll-1", "2025-01-10"),
makeItem("poll-2", "2025-01-11"),
]
const fresh = [makeItem("poll-3", "2025-01-12")] const fresh = [makeItem("poll-3", "2025-01-12")]
const merged = mergeFeedItems(cached, fresh) const merged = mergeFeedItems(cached, fresh)
expect(merged).toHaveLength(3) expect(merged).toHaveLength(3)
@@ -55,13 +76,23 @@ describe("mergeFeedItems", () => {
it("sorts by date descending", () => { it("sorts by date descending", () => {
const cached = [makeItem("poll-1", "2025-01-01")] const cached = [makeItem("poll-1", "2025-01-01")]
const fresh = [makeItem("poll-2", "2025-01-15"), makeItem("poll-3", "2025-01-10")] const fresh = [
makeItem("poll-2", "2025-01-15"),
makeItem("poll-3", "2025-01-10"),
]
const merged = mergeFeedItems(cached, fresh) const merged = mergeFeedItems(cached, fresh)
expect(merged.map((i) => i.date)).toEqual(["2025-01-15", "2025-01-10", "2025-01-01"]) expect(merged.map((i) => i.date)).toEqual([
"2025-01-15",
"2025-01-10",
"2025-01-01",
])
}) })
it("sorts null dates after dated items", () => { it("sorts null dates after dated items", () => {
const items = mergeFeedItems([makeItem("poll-1", null, "Zebra")], [makeItem("poll-2", "2025-01-01")]) const items = mergeFeedItems(
[makeItem("poll-1", null, "Zebra")],
[makeItem("poll-2", "2025-01-01")],
)
expect(items[0].id).toBe("poll-2") expect(items[0].id).toBe("poll-2")
expect(items[1].id).toBe("poll-1") expect(items[1].id).toBe("poll-1")
}) })

View File

@@ -1,4 +1,8 @@
import { clearCachedFeed, loadCachedFeed, saveCachedFeed } from "@/shared/db/feed-cache-db" import {
clearCachedFeed,
loadCachedFeed,
saveCachedFeed,
} from "@/shared/db/feed-cache-db"
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
import type { FeedItem } from "./assemble-feed" import type { FeedItem } from "./assemble-feed"
@@ -7,19 +11,32 @@ export interface FeedCacheData {
updatedAt: number updatedAt: number
} }
export async function loadFeedCache(db: PGlite, cacheKey?: string): Promise<FeedCacheData | null> { export async function loadFeedCache(
db: PGlite,
cacheKey?: string,
): Promise<FeedCacheData | null> {
return loadCachedFeed(db, cacheKey) return loadCachedFeed(db, cacheKey)
} }
export async function saveFeedCache(db: PGlite, items: FeedItem[], cacheKey?: string): Promise<void> { export async function saveFeedCache(
db: PGlite,
items: FeedItem[],
cacheKey?: string,
): Promise<void> {
await saveCachedFeed(db, items, cacheKey) await saveCachedFeed(db, items, cacheKey)
} }
export async function clearFeedCache(db: PGlite, cacheKey?: string): Promise<void> { export async function clearFeedCache(
db: PGlite,
cacheKey?: string,
): Promise<void> {
await clearCachedFeed(db, cacheKey) await clearCachedFeed(db, cacheKey)
} }
export function mergeFeedItems(cached: FeedItem[], fresh: FeedItem[]): FeedItem[] { export function mergeFeedItems(
cached: FeedItem[],
fresh: FeedItem[],
): FeedItem[] {
const map = new Map<string, FeedItem>() const map = new Map<string, FeedItem>()
for (const item of cached) map.set(item.id, item) for (const item of cached) map.set(item.id, item)
for (const item of fresh) map.set(item.id, item) for (const item of fresh) map.set(item.id, item)

View File

@@ -2,7 +2,9 @@ export function HomePage() {
return ( return (
<div className="text-center mt-12 px-4"> <div className="text-center mt-12 px-4">
<p className="text-lg font-medium">Willkommen</p> <p className="text-lg font-medium">Willkommen</p>
<p className="text-sm text-muted-foreground mt-2">Verfolge Abstimmungen im Bundestag und deinem Landtag.</p> <p className="text-sm text-muted-foreground mt-2">
Verfolge Abstimmungen im Bundestag und deinem Landtag.
</p>
</div> </div>
) )
} }

View File

@@ -29,7 +29,8 @@ export function LandtagConfigure() {
return ( return (
<div className="text-center mt-12 px-4"> <div className="text-center mt-12 px-4">
<p className="text-muted-foreground text-sm mb-4"> <p className="text-muted-foreground text-sm mb-4">
Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den Einstellungen. Noch keine Abgeordneten geladen. Erkenne zuerst deinen Standort in den
Einstellungen.
</p> </p>
<Link to="/app/settings" className="text-primary text-sm underline"> <Link to="/app/settings" className="text-primary text-sm underline">
Zu den Einstellungen Zu den Einstellungen

View File

@@ -13,7 +13,15 @@ function formatCacheAge(timestamp: number): string {
} }
export function LandtagFeed() { export function LandtagFeed() {
const { items, loading, refreshing, error, lastUpdated, legislatureId, refresh } = useLandtagFeed() const {
items,
loading,
refreshing,
error,
lastUpdated,
legislatureId,
refresh,
} = useLandtagFeed()
const hasItems = items.length > 0 const hasItems = items.length > 0
if (!legislatureId && !loading) { if (!legislatureId && !loading) {
@@ -23,7 +31,10 @@ export function LandtagFeed() {
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Erkenne zuerst deinen Standort, um Landtag-Abstimmungen zu sehen. Erkenne zuerst deinen Standort, um Landtag-Abstimmungen zu sehen.
</p> </p>
<Link to="/app/settings" className="text-primary text-sm underline mt-4 inline-block"> <Link
to="/app/settings"
className="text-primary text-sm underline mt-4 inline-block"
>
Zu den Einstellungen Zu den Einstellungen
</Link> </Link>
</div> </div>
@@ -34,7 +45,9 @@ export function LandtagFeed() {
<div className="pb-4"> <div className="pb-4">
{lastUpdated && ( {lastUpdated && (
<div className="flex items-center justify-between px-4 py-2 border-b border-border"> <div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-xs text-muted-foreground">Aktualisiert {formatCacheAge(lastUpdated)}</span> <span className="text-xs text-muted-foreground">
Aktualisiert {formatCacheAge(lastUpdated)}
</span>
<button <button
type="button" type="button"
onClick={() => refresh({ silent: true })} onClick={() => refresh({ silent: true })}
@@ -62,7 +75,10 @@ export function LandtagFeed() {
)} )}
{loading && !hasItems && ( {loading && !hasItems && (
<output className="flex items-center justify-center h-48" aria-label="Feed wird geladen"> <output
className="flex items-center justify-center h-48"
aria-label="Feed wird geladen"
>
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</output> </output>
)} )}
@@ -82,7 +98,10 @@ export function LandtagFeed() {
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Für deinen Landtag liegen noch keine namentlichen Abstimmungen vor. Für deinen Landtag liegen noch keine namentlichen Abstimmungen vor.
</p> </p>
<Link to="/app/landtag/configure" className="text-primary text-sm underline mt-4 inline-block"> <Link
to="/app/landtag/configure"
className="text-primary text-sm underline mt-4 inline-block"
>
Landtag konfigurieren Landtag konfigurieren
</Link> </Link>
</div> </div>

View File

@@ -1,5 +1,9 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed" import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import { loadFeedCache, mergeFeedItems, saveFeedCache } from "@/features/feed/lib/feed-cache" import {
loadFeedCache,
mergeFeedItems,
saveFeedCache,
} from "@/features/feed/lib/feed-cache"
import { loadCachedResult } from "@/features/location/lib/geo" import { loadCachedResult } from "@/features/location/lib/geo"
import { useDb } from "@/shared/db/provider" import { useDb } from "@/shared/db/provider"
import { useFollows } from "@/shared/hooks/use-follows" import { useFollows } from "@/shared/hooks/use-follows"
@@ -20,7 +24,11 @@ export function useLandtagFeed() {
const [lastUpdated, setLastUpdated] = useState<number | null>(null) const [lastUpdated, setLastUpdated] = useState<number | null>(null)
const [legislatureId, setLegislatureId] = useState<number | null>(null) const [legislatureId, setLegislatureId] = useState<number | null>(null)
const politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows]) const politicianIDs = useMemo(
() =>
follows.filter((f) => f.type === "politician").map((f) => f.entity_id),
[follows],
)
const refreshingRef = useRef(false) const refreshingRef = useRef(false)
const lastUpdatedRef = useRef<number | null>(null) const lastUpdatedRef = useRef<number | null>(null)
@@ -118,7 +126,10 @@ export function useLandtagFeed() {
if (document.hidden) { if (document.hidden) {
stopInterval() stopInterval()
} else { } else {
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) { if (
!lastUpdatedRef.current ||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
) {
refresh({ silent: true }) refresh({ silent: true })
} }
startInterval() startInterval()
@@ -134,5 +145,13 @@ export function useLandtagFeed() {
} }
}, [refresh]) }, [refresh])
return { items, loading, refreshing, error, lastUpdated, legislatureId, refresh } return {
items,
loading,
refreshing,
error,
lastUpdated,
legislatureId,
refresh,
}
} }

View File

@@ -1,14 +1,24 @@
import type { FeedItem } from "@/features/feed/lib/assemble-feed" import type { FeedItem } from "@/features/feed/lib/assemble-feed"
import { type Poll, fetchCandidacyMandates, fetchPolls, fetchPollsByIds, fetchVotes } from "@/shared/lib/aw-api" import {
type Poll,
fetchCandidacyMandates,
fetchPolls,
fetchPollsByIds,
fetchVotes,
} from "@/shared/lib/aw-api"
export async function assembleLandtagFeed(legislatureId: number, followedPoliticianIDs: number[]): Promise<FeedItem[]> { export async function assembleLandtagFeed(
legislatureId: number,
followedPoliticianIDs: number[],
): Promise<FeedItem[]> {
const [legislaturePolls, politicianPolls] = await Promise.all([ const [legislaturePolls, politicianPolls] = await Promise.all([
fetchPolls(100, legislatureId), fetchPolls(100, legislatureId),
fetchPollsForPoliticians(followedPoliticianIDs), fetchPollsForPoliticians(followedPoliticianIDs),
]) ])
const combined = new Map<number, Poll>() const combined = new Map<number, Poll>()
for (const p of [...legislaturePolls, ...politicianPolls]) combined.set(p.id, p) for (const p of [...legislaturePolls, ...politicianPolls])
combined.set(p.id, p)
const items: FeedItem[] = Array.from(combined.values()).map((poll) => ({ const items: FeedItem[] = Array.from(combined.values()).map((poll) => ({
id: `lt-poll-${poll.id}`, id: `lt-poll-${poll.id}`,
@@ -39,13 +49,21 @@ function classifyPoll(poll: Poll): "upcoming" | "past" {
return poll.field_poll_date > today ? "upcoming" : "past" return poll.field_poll_date > today ? "upcoming" : "past"
} }
async function fetchPollsForPoliticians(politicianIDs: number[]): Promise<Poll[]> { async function fetchPollsForPoliticians(
politicianIDs: number[],
): Promise<Poll[]> {
if (politicianIDs.length === 0) return [] if (politicianIDs.length === 0) return []
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid))) const mandateResults = await Promise.all(
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id)) politicianIDs.map((pid) => fetchCandidacyMandates(pid)),
)
const mandateIDs = mandateResults.flatMap((mandates) =>
mandates.slice(0, 3).map((m) => m.id),
)
const voteResults = await Promise.all(mandateIDs.map((mid) => fetchVotes(mid))) const voteResults = await Promise.all(
mandateIDs.map((mid) => fetchVotes(mid)),
)
const pollIDSet = new Set<number>() const pollIDSet = new Set<number>()
for (const votes of voteResults) { for (const votes of voteResults) {
for (const v of votes) { for (const v of votes) {

View File

@@ -1,7 +1,12 @@
import { createTestDb } from "@/shared/db/client" import { createTestDb } from "@/shared/db/client"
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { BUNDESLAND_TO_PARLIAMENT, clearGeoCache, detectFromCoords, loadCachedResult } from "./geo" import {
BUNDESLAND_TO_PARLIAMENT,
clearGeoCache,
detectFromCoords,
loadCachedResult,
} from "./geo"
const mockFetch = vi.fn() const mockFetch = vi.fn()
let db: PGlite let db: PGlite
@@ -40,15 +45,26 @@ const MANDATE_RESPONSE = [
id: 500, id: 500,
politician: { id: 1, label: "Max Mustermann" }, politician: { id: 1, label: "Max Mustermann" },
party: { id: 10, label: "CSU" }, party: { id: 10, label: "CSU" },
electoral_data: { constituency: { label: "217 - München-Ost (BT 2025)" }, mandate_won: "constituency" }, electoral_data: {
constituency: { label: "217 - München-Ost (BT 2025)" },
mandate_won: "constituency",
},
}, },
] ]
describe("detectFromCoords", () => { describe("detectFromCoords", () => {
it("returns bundesland, landtag info, and mandates for valid coordinates", async () => { it("returns bundesland, landtag info, and mandates for valid coordinates", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: MANDATE_RESPONSE }), {
status: 200,
}),
)
const result = await detectFromCoords(db, 48.1351, 11.582) const result = await detectFromCoords(db, 48.1351, 11.582)
expect(result.bundesland).toBe("Bayern") expect(result.bundesland).toBe("Bayern")
@@ -60,12 +76,24 @@ describe("detectFromCoords", () => {
it("returns cached result on second call for same Bundesland", async () => { it("returns cached result on second call for same Bundesland", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: MANDATE_RESPONSE }), {
status: 200,
}),
)
await detectFromCoords(db, 48.1351, 11.582) await detectFromCoords(db, 48.1351, 11.582)
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
const result = await detectFromCoords(db, 48.2, 11.6) const result = await detectFromCoords(db, 48.2, 11.6)
expect(result.mandates).toHaveLength(1) expect(result.mandates).toHaveLength(1)
@@ -74,14 +102,30 @@ describe("detectFromCoords", () => {
it("skips cache when skipCache=true", async () => { it("skips cache when skipCache=true", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: MANDATE_RESPONSE }), {
status: 200,
}),
)
await detectFromCoords(db, 48.1351, 11.582) await detectFromCoords(db, 48.1351, 11.582)
mockFetch mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: MANDATE_RESPONSE }), {
status: 200,
}),
)
await detectFromCoords(db, 48.2, 11.6, true) await detectFromCoords(db, 48.2, 11.6, true)
// 4 fetches: Nominatim + mandates + Nominatim + mandates (cache skipped) // 4 fetches: Nominatim + mandates + Nominatim + mandates (cache skipped)
@@ -89,7 +133,11 @@ describe("detectFromCoords", () => {
}) })
it("returns null for unknown state", async () => { it("returns null for unknown state", async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Unknown" } }), { status: 200 })) mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ address: { state: "Unknown" } }), {
status: 200,
}),
)
const result = await detectFromCoords(db, 0, 0) const result = await detectFromCoords(db, 0, 0)
expect(result.bundesland).toBe("Unknown") expect(result.bundesland).toBe("Unknown")
@@ -98,7 +146,9 @@ describe("detectFromCoords", () => {
}) })
it("returns null bundesland when address has no state", async () => { it("returns null bundesland when address has no state", async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: {} }), { status: 200 })) mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ address: {} }), { status: 200 }),
)
const result = await detectFromCoords(db, 0, 0) const result = await detectFromCoords(db, 0, 0)
expect(result.bundesland).toBeNull() expect(result.bundesland).toBeNull()
@@ -107,7 +157,9 @@ describe("detectFromCoords", () => {
it("throws on nominatim error", async () => { it("throws on nominatim error", async () => {
mockFetch.mockResolvedValueOnce(new Response("error", { status: 500 })) mockFetch.mockResolvedValueOnce(new Response("error", { status: 500 }))
await expect(detectFromCoords(db, 0, 0)).rejects.toThrow("Nominatim error 500") await expect(detectFromCoords(db, 0, 0)).rejects.toThrow(
"Nominatim error 500",
)
}) })
}) })
@@ -118,8 +170,16 @@ describe("loadCachedResult", () => {
it("returns cached result after a successful detect", async () => { it("returns cached result after a successful detect", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: MANDATE_RESPONSE }), {
status: 200,
}),
)
await detectFromCoords(db, 48.1351, 11.582) await detectFromCoords(db, 48.1351, 11.582)
@@ -133,8 +193,16 @@ describe("loadCachedResult", () => {
describe("clearGeoCache", () => { describe("clearGeoCache", () => {
it("removes cached results", async () => { it("removes cached results", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 })) .mockResolvedValueOnce(
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 })) new Response(JSON.stringify({ address: { state: "Bayern" } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: MANDATE_RESPONSE }), {
status: 200,
}),
)
await detectFromCoords(db, 48.1351, 11.582) await detectFromCoords(db, 48.1351, 11.582)
expect(await loadCachedResult(db)).not.toBeNull() expect(await loadCachedResult(db)).not.toBeNull()

View File

@@ -4,26 +4,53 @@ import {
loadMostRecentGeoCache, loadMostRecentGeoCache,
saveGeoCache, saveGeoCache,
} from "@/shared/db/geo-cache-db" } from "@/shared/db/geo-cache-db"
import { type MandateWithPolitician, fetchMandatesByParliamentPeriod } from "@/shared/lib/aw-api" import {
type MandateWithPolitician,
fetchMandatesByParliamentPeriod,
} from "@/shared/lib/aw-api"
import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants" import { BUNDESTAG_LEGISLATURE_ID } from "@/shared/lib/constants"
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
const BUNDESLAND_TO_PARLIAMENT: Record<string, { label: string; parliamentPeriodId: number }> = { const BUNDESLAND_TO_PARLIAMENT: Record<
"Baden-Württemberg": { label: "Landtag Baden-Württemberg", parliamentPeriodId: 163 }, string,
{ label: string; parliamentPeriodId: number }
> = {
"Baden-Württemberg": {
label: "Landtag Baden-Württemberg",
parliamentPeriodId: 163,
},
Bayern: { label: "Bayerischer Landtag", parliamentPeriodId: 149 }, Bayern: { label: "Bayerischer Landtag", parliamentPeriodId: 149 },
Berlin: { label: "Abgeordnetenhaus Berlin", parliamentPeriodId: 133 }, Berlin: { label: "Abgeordnetenhaus Berlin", parliamentPeriodId: 133 },
Brandenburg: { label: "Landtag Brandenburg", parliamentPeriodId: 158 }, Brandenburg: { label: "Landtag Brandenburg", parliamentPeriodId: 158 },
Bremen: { label: "Bremische Bürgerschaft", parliamentPeriodId: 146 }, Bremen: { label: "Bremische Bürgerschaft", parliamentPeriodId: 146 },
Hamburg: { label: "Hamburgische Bürgerschaft", parliamentPeriodId: 162 }, Hamburg: { label: "Hamburgische Bürgerschaft", parliamentPeriodId: 162 },
Hessen: { label: "Hessischer Landtag", parliamentPeriodId: 150 }, Hessen: { label: "Hessischer Landtag", parliamentPeriodId: 150 },
"Mecklenburg-Vorpommern": { label: "Landtag Mecklenburg-Vorpommern", parliamentPeriodId: 134 }, "Mecklenburg-Vorpommern": {
Niedersachsen: { label: "Niedersächsischer Landtag", parliamentPeriodId: 143 }, label: "Landtag Mecklenburg-Vorpommern",
"Nordrhein-Westfalen": { label: "Landtag Nordrhein-Westfalen", parliamentPeriodId: 139 }, parliamentPeriodId: 134,
"Rheinland-Pfalz": { label: "Landtag Rheinland-Pfalz", parliamentPeriodId: 164 }, },
Niedersachsen: {
label: "Niedersächsischer Landtag",
parliamentPeriodId: 143,
},
"Nordrhein-Westfalen": {
label: "Landtag Nordrhein-Westfalen",
parliamentPeriodId: 139,
},
"Rheinland-Pfalz": {
label: "Landtag Rheinland-Pfalz",
parliamentPeriodId: 164,
},
Saarland: { label: "Landtag des Saarlandes", parliamentPeriodId: 137 }, Saarland: { label: "Landtag des Saarlandes", parliamentPeriodId: 137 },
Sachsen: { label: "Sächsischer Landtag", parliamentPeriodId: 157 }, Sachsen: { label: "Sächsischer Landtag", parliamentPeriodId: 157 },
"Sachsen-Anhalt": { label: "Landtag Sachsen-Anhalt", parliamentPeriodId: 131 }, "Sachsen-Anhalt": {
"Schleswig-Holstein": { label: "Schleswig-Holsteinischer Landtag", parliamentPeriodId: 138 }, label: "Landtag Sachsen-Anhalt",
parliamentPeriodId: 131,
},
"Schleswig-Holstein": {
label: "Schleswig-Holsteinischer Landtag",
parliamentPeriodId: 138,
},
Thüringen: { label: "Thüringer Landtag", parliamentPeriodId: 156 }, Thüringen: { label: "Thüringer Landtag", parliamentPeriodId: 156 },
} }
@@ -66,14 +93,18 @@ export async function clearGeoCache(db: PGlite): Promise<void> {
const BUNDESTAG_CACHE_KEY = "Bundestag" const BUNDESTAG_CACHE_KEY = "Bundestag"
/** Load cached Bundestag mandates from geo_cache. */ /** Load cached Bundestag mandates from geo_cache. */
export async function loadBundestagMandates(db: PGlite): Promise<MandateWithPolitician[] | null> { export async function loadBundestagMandates(
db: PGlite,
): Promise<MandateWithPolitician[] | null> {
const cached = await loadGeoCache(db, BUNDESTAG_CACHE_KEY) const cached = await loadGeoCache(db, BUNDESTAG_CACHE_KEY)
if (!cached) return null if (!cached) return null
return (cached as unknown as { mandates: MandateWithPolitician[] }).mandates return (cached as unknown as { mandates: MandateWithPolitician[] }).mandates
} }
/** Fetch Bundestag mandates from API and cache them. */ /** Fetch Bundestag mandates from API and cache them. */
export async function fetchAndCacheBundestagMandates(db: PGlite): Promise<MandateWithPolitician[]> { export async function fetchAndCacheBundestagMandates(
db: PGlite,
): Promise<MandateWithPolitician[]> {
const cached = await loadBundestagMandates(db) const cached = await loadBundestagMandates(db)
if (cached) return cached if (cached) return cached
@@ -85,13 +116,20 @@ export async function fetchAndCacheBundestagMandates(db: PGlite): Promise<Mandat
} }
if (mandates.length > 0) { if (mandates.length > 0) {
await saveGeoCache(db, BUNDESTAG_CACHE_KEY, { mandates } as unknown as Record<string, unknown>) await saveGeoCache(db, BUNDESTAG_CACHE_KEY, {
mandates,
} as unknown as Record<string, unknown>)
} }
return mandates return mandates
} }
export async function detectFromCoords(db: PGlite, lat: number, lon: number, skipCache = false): Promise<GeoResult> { export async function detectFromCoords(
db: PGlite,
lat: number,
lon: number,
skipCache = false,
): Promise<GeoResult> {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json` const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`
const res = await fetch(url, { const res = await fetch(url, {
headers: { "User-Agent": "AbgeordnetenwatchPWA/1.0" }, headers: { "User-Agent": "AbgeordnetenwatchPWA/1.0" },
@@ -100,7 +138,12 @@ export async function detectFromCoords(db: PGlite, lat: number, lon: number, ski
const data = (await res.json()) as NominatimResponse const data = (await res.json()) as NominatimResponse
const state = data.address?.state ?? null const state = data.address?.state ?? null
const userCity = data.address?.city ?? data.address?.town ?? data.address?.village ?? data.address?.county ?? null const userCity =
data.address?.city ??
data.address?.town ??
data.address?.village ??
data.address?.county ??
null
const entry = state ? (BUNDESLAND_TO_PARLIAMENT[state] ?? null) : null const entry = state ? (BUNDESLAND_TO_PARLIAMENT[state] ?? null) : null
if (!state || !entry) { if (!state || !entry) {

View File

@@ -31,5 +31,8 @@ function normalize(label: string): string {
export function getPartyMeta(partyLabel: string): PartyMeta { export function getPartyMeta(partyLabel: string): PartyMeta {
const clean = normalize(partyLabel) const clean = normalize(partyLabel)
return PARTY_META[clean] ?? PARTY_META[partyLabel] ?? { short: clean.slice(0, 5), color: "#6b7280" } return (
PARTY_META[clean] ??
PARTY_META[partyLabel] ?? { short: clean.slice(0, 5), color: "#6b7280" }
)
} }

View File

@@ -0,0 +1,214 @@
import { getPartyMeta } from "@/features/location/lib/parties"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import { useFollows } from "@/shared/hooks/use-follows"
import { useEffect, useState } from "react"
import {
type PoliticianProfile,
type PoliticianVote,
fetchPoliticianProfile,
} from "../lib/api"
const VOTE_COLORS: Record<string, { bg: string; text: string; label: string }> =
{
yes: {
bg: "bg-green-100 dark:bg-green-900/40",
text: "text-green-800 dark:text-green-300",
label: "Ja",
},
no: {
bg: "bg-red-100 dark:bg-red-900/40",
text: "text-red-800 dark:text-red-300",
label: "Nein",
},
abstain: {
bg: "bg-gray-100 dark:bg-gray-800/40",
text: "text-gray-600 dark:text-gray-400",
label: "Enthaltung",
},
no_show: {
bg: "bg-gray-50 dark:bg-gray-900/20",
text: "text-gray-400 dark:text-gray-500",
label: "Nicht abgestimmt",
},
}
function voteMeta(vote: string) {
return VOTE_COLORS[vote] ?? VOTE_COLORS.no_show
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return ""
try {
return new Date(dateStr).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
} catch {
return dateStr
}
}
function mandateWonLabel(won: string | null): string | null {
if (!won) return null
if (won === "constituency") return "Direktmandat"
if (won === "list") return "Landesliste"
if (won === "moved_up") return "Nachgerückt"
return won
}
function VoteEntry({ vote }: { vote: PoliticianVote }) {
const meta = voteMeta(vote.vote)
return (
<div className="px-4 py-3 space-y-1.5">
<div className="flex items-start gap-2">
<span
className={`shrink-0 mt-0.5 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${meta.bg} ${meta.text}`}
>
{meta.label}
</span>
<div className="min-w-0 flex-1">
{vote.pollUrl ? (
<a
href={vote.pollUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-foreground hover:text-primary transition-colors line-clamp-2"
>
{vote.pollLabel}
</a>
) : (
<p className="text-sm font-medium text-foreground line-clamp-2">
{vote.pollLabel}
</p>
)}
{vote.pollDate && (
<p className="text-xs text-muted-foreground mt-0.5">
{formatDate(vote.pollDate)}
</p>
)}
</div>
</div>
{vote.topics.length > 0 && (
<div className="flex flex-wrap gap-1 pl-[calc(theme(spacing.2)+theme(spacing.2))]">
{vote.topics.map((topic) => (
<Badge key={topic} variant="secondary" className="text-[10px]">
{topic}
</Badge>
))}
</div>
)}
</div>
)
}
export function PoliticianDetail({ politicianId }: { politicianId: number }) {
const [profile, setProfile] = useState<PoliticianProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const { isFollowing, follow, unfollow } = useFollows()
useEffect(() => {
setLoading(true)
setError(null)
fetchPoliticianProfile(politicianId)
.then(setProfile)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false))
}, [politicianId])
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !profile) {
return (
<div className="px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">
{error ?? "Profil nicht gefunden"}
</p>
</div>
)
}
const partyMeta = profile.party ? getPartyMeta(profile.party) : null
const followed = isFollowing("politician", profile.id)
const wonLabel = mandateWonLabel(profile.mandateWon)
return (
<div className="pb-4">
{/* Header */}
<Card className="mx-4 mt-4">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-lg font-semibold">{profile.label}</h2>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
{partyMeta && (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
style={{ backgroundColor: partyMeta.color }}
>
{profile.party}
</span>
)}
{profile.fraction && profile.fraction !== profile.party && (
<Badge variant="outline" className="text-xs">
{profile.fraction}
</Badge>
)}
</div>
</div>
<Button
size="sm"
variant={followed ? "default" : "outline"}
onClick={() =>
followed
? unfollow("politician", profile.id)
: follow("politician", profile.id, profile.label)
}
aria-pressed={followed}
className="shrink-0"
>
{followed ? "Folgst du" : "Folgen"}
</Button>
</div>
{(profile.constituency || wonLabel) && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{profile.constituency && <span>{profile.constituency}</span>}
{wonLabel && <span>{wonLabel}</span>}
</div>
)}
</CardContent>
</Card>
{/* Votes */}
<div className="px-4 mt-4 mb-2">
<h3 className="text-sm font-semibold text-muted-foreground">
Abstimmungen ({profile.votes.length})
</h3>
</div>
{profile.votes.length > 0 ? (
<Card className="mx-4 py-0 gap-0 overflow-hidden">
<CardContent className="p-0">
<div className="divide-y divide-border">
{profile.votes.map((vote) => (
<VoteEntry key={vote.pollId} vote={vote} />
))}
</div>
</CardContent>
</Card>
) : (
<p className="px-4 text-sm text-muted-foreground">
Keine Abstimmungen gefunden
</p>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export { PoliticianDetail } from "./components/politician-detail"

View File

@@ -0,0 +1,35 @@
import { BACKEND_URL } from "@/shared/lib/constants"
import { z } from "zod"
const politicianVoteSchema = z.object({
vote: z.string(),
pollId: z.number(),
pollLabel: z.string(),
pollDate: z.string().nullable(),
pollUrl: z.string().nullable(),
topics: z.array(z.string()),
})
const politicianProfileSchema = z.object({
id: z.number(),
label: z.string(),
party: z.string().nullable(),
fraction: z.string().nullable(),
constituency: z.string().nullable(),
mandateWon: z.string().nullable(),
votes: z.array(politicianVoteSchema),
})
export type PoliticianProfile = z.infer<typeof politicianProfileSchema>
export type PoliticianVote = z.infer<typeof politicianVoteSchema>
export async function fetchPoliticianProfile(
id: number,
): Promise<PoliticianProfile> {
const res = await fetch(`${BACKEND_URL}/politicians/${id}`)
if (!res.ok) {
throw new Error(`Failed to fetch politician profile: ${res.status}`)
}
const json = await res.json()
return politicianProfileSchema.parse(json)
}

View File

@@ -21,7 +21,11 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
strokeWidth={2} strokeWidth={2}
aria-hidden="true" aria-hidden="true"
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
Zurück Zurück
</button> </button>
@@ -29,7 +33,8 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
<h2 className="text-lg font-semibold mb-4">iPhone-Einrichtung</h2> <h2 className="text-lg font-semibold mb-4">iPhone-Einrichtung</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Push-Benachrichtigungen funktionieren auf dem iPhone nur, wenn die App zum Homescreen hinzugefügt wurde. Push-Benachrichtigungen funktionieren auf dem iPhone nur, wenn die App
zum Homescreen hinzugefügt wurde.
</p> </p>
<ol className="space-y-6"> <ol className="space-y-6">
@@ -68,7 +73,8 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
<div> <div>
<p className="text-sm font-medium">Zum Home-Bildschirm</p> <p className="text-sm font-medium">Zum Home-Bildschirm</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Scrolle im Menü nach unten und wähle <span className="font-medium">Zum Home-Bildschirm</span>. Scrolle im Menü nach unten und wähle{" "}
<span className="font-medium">Zum Home-Bildschirm</span>.
</p> </p>
</div> </div>
</li> </li>
@@ -80,8 +86,8 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
<div> <div>
<p className="text-sm font-medium">App hinzufügen</p> <p className="text-sm font-medium">App hinzufügen</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Bestätige mit <span className="font-medium">Hinzufügen</span>. Die App erscheint als Icon auf deinem Bestätige mit <span className="font-medium">Hinzufügen</span>. Die
Homescreen. App erscheint als Icon auf deinem Homescreen.
</p> </p>
</div> </div>
</li> </li>
@@ -91,9 +97,12 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
4 4
</span> </span>
<div> <div>
<p className="text-sm font-medium">App öffnen & Benachrichtigungen aktivieren</p> <p className="text-sm font-medium">
App öffnen & Benachrichtigungen aktivieren
</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Öffne die App vom Homescreen aus und aktiviere die Benachrichtigungen in den Einstellungen. Öffne die App vom Homescreen aus und aktiviere die
Benachrichtigungen in den Einstellungen.
</p> </p>
</div> </div>
</li> </li>

View File

@@ -7,16 +7,26 @@ import { useFollows } from "@/shared/hooks/use-follows"
import { usePush } from "@/shared/hooks/use-push" import { usePush } from "@/shared/hooks/use-push"
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update" import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
import { fetchTopics } from "@/shared/lib/aw-api" import { fetchTopics } from "@/shared/lib/aw-api"
import { APP_VERSION, BACKEND_URL, VAPID_PUBLIC_KEY } from "@/shared/lib/constants" import {
APP_VERSION,
BACKEND_URL,
VAPID_PUBLIC_KEY,
} from "@/shared/lib/constants"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { type GeoResult, clearGeoCache, detectFromCoords, loadCachedResult } from "../../location/lib/geo" import {
type GeoResult,
clearGeoCache,
detectFromCoords,
loadCachedResult,
} from "../../location/lib/geo"
import { NotificationGuide } from "./notification-guide" import { NotificationGuide } from "./notification-guide"
function isStandalone(): boolean { function isStandalone(): boolean {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
return ( return (
window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator && (navigator as { standalone?: boolean }).standalone === true) ("standalone" in navigator &&
(navigator as { standalone?: boolean }).standalone === true)
) )
} }
@@ -35,8 +45,12 @@ export function SettingsPage() {
const [devPush, setDevPush] = useState<string | null>(null) const [devPush, setDevPush] = useState<string | null>(null)
const [devTopics, setDevTopics] = useState<string | null>(null) const [devTopics, setDevTopics] = useState<string | null>(null)
const [devPoliticians, setDevPoliticians] = useState<string | null>(null) const [devPoliticians, setDevPoliticians] = useState<string | null>(null)
const [devUnfollowTopics, setDevUnfollowTopics] = useState<string | null>(null) const [devUnfollowTopics, setDevUnfollowTopics] = useState<string | null>(
const [devUnfollowPoliticians, setDevUnfollowPoliticians] = useState<string | null>(null) null,
)
const [devUnfollowPoliticians, setDevUnfollowPoliticians] = useState<
string | null
>(null)
const [devReload, setDevReload] = useState<string | null>(null) const [devReload, setDevReload] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
@@ -56,7 +70,12 @@ export function SettingsPage() {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
async (pos) => { async (pos) => {
try { try {
const r = await detectFromCoords(db, pos.coords.latitude, pos.coords.longitude, skipCache) const r = await detectFromCoords(
db,
pos.coords.latitude,
pos.coords.longitude,
skipCache,
)
setResult(r) setResult(r)
} catch (e) { } catch (e) {
setErrorMsg(String(e)) setErrorMsg(String(e))
@@ -98,14 +117,17 @@ export function SettingsPage() {
<div className="px-4 py-4 space-y-6 pb-4"> <div className="px-4 py-4 space-y-6 pb-4">
{/* --- Permissions: Push + Location --- */} {/* --- Permissions: Push + Location --- */}
<section> <section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Berechtigungen</h2> <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Berechtigungen
</h2>
<Card className="py-0 gap-0"> <Card className="py-0 gap-0">
<CardContent className="p-0 divide-y divide-border"> <CardContent className="p-0 divide-y divide-border">
{VAPID_PUBLIC_KEY && {VAPID_PUBLIC_KEY &&
(push.permission === "denied" ? ( (push.permission === "denied" ? (
<div className="px-4 py-3"> <div className="px-4 py-3">
<span className="text-destructive text-sm"> <span className="text-destructive text-sm">
Push blockiert bitte in den Systemeinstellungen aktivieren. Push blockiert bitte in den Systemeinstellungen
aktivieren.
</span> </span>
</div> </div>
) : ( ) : (
@@ -166,7 +188,11 @@ export function SettingsPage() {
strokeWidth={2} strokeWidth={2}
aria-hidden="true" aria-hidden="true"
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg> </svg>
</button> </button>
)} )}
@@ -176,19 +202,28 @@ export function SettingsPage() {
{/* --- Info --- */} {/* --- Info --- */}
<section> <section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Info</h2> <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Info
</h2>
<Card className="py-0 gap-0"> <Card className="py-0 gap-0">
<CardContent className="p-0 divide-y divide-border"> <CardContent className="p-0 divide-y divide-border">
<div className="flex justify-between px-4 py-3"> <div className="flex justify-between px-4 py-3">
<span className="text-sm">Version</span> <span className="text-sm">Version</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{APP_VERSION}</span> <span className="text-sm text-muted-foreground">
{APP_VERSION}
</span>
{needRefresh ? ( {needRefresh ? (
<Button size="sm" onClick={applyUpdate}> <Button size="sm" onClick={applyUpdate}>
Aktualisieren Aktualisieren
</Button> </Button>
) : ( ) : (
<Button size="sm" variant="outline" onClick={handleCheckUpdate} disabled={checking}> <Button
size="sm"
variant="outline"
onClick={handleCheckUpdate}
disabled={checking}
>
{checking ? "Prüfe…" : "Prüfen"} {checking ? "Prüfe…" : "Prüfen"}
</Button> </Button>
)} )}
@@ -207,7 +242,9 @@ export function SettingsPage() {
</div> </div>
<div className="flex justify-between px-4 py-3"> <div className="flex justify-between px-4 py-3">
<span className="text-sm">Geräte-ID</span> <span className="text-sm">Geräte-ID</span>
<span className="font-mono text-xs max-w-[50%] truncate">{deviceId}</span> <span className="font-mono text-xs max-w-[50%] truncate">
{deviceId}
</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -215,14 +252,18 @@ export function SettingsPage() {
{/* --- Developer --- */} {/* --- Developer --- */}
<section> <section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Entwickler</h2> <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Entwickler
</h2>
<Card className="py-0 gap-0"> <Card className="py-0 gap-0">
<CardContent className="p-0 divide-y divide-border"> <CardContent className="p-0 divide-y divide-border">
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Backend Health</span> <span className="text-sm">Backend Health</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devHealth && ( {devHealth && (
<span className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}> <span
className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}
>
{devHealth} {devHealth}
</span> </span>
)} )}
@@ -247,7 +288,9 @@ export function SettingsPage() {
<span className="text-sm">Test-Push</span> <span className="text-sm">Test-Push</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devPush && ( {devPush && (
<span className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}> <span
className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}
>
{devPush} {devPush}
</span> </span>
)} )}
@@ -275,7 +318,9 @@ export function SettingsPage() {
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Alle Themen folgen</span> <span className="text-sm">Alle Themen folgen</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devTopics && <span className="text-xs text-green-600">{devTopics}</span>} {devTopics && (
<span className="text-xs text-green-600">{devTopics}</span>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -297,7 +342,11 @@ export function SettingsPage() {
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Allen Themen entfolgen</span> <span className="text-sm">Allen Themen entfolgen</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devUnfollowTopics && <span className="text-xs text-green-600">{devUnfollowTopics}</span>} {devUnfollowTopics && (
<span className="text-xs text-green-600">
{devUnfollowTopics}
</span>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -314,7 +363,11 @@ export function SettingsPage() {
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Alle Abgeordnete folgen</span> <span className="text-sm">Alle Abgeordnete folgen</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devPoliticians && <span className="text-xs text-green-600">{devPoliticians}</span>} {devPoliticians && (
<span className="text-xs text-green-600">
{devPoliticians}
</span>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -338,7 +391,11 @@ export function SettingsPage() {
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Allen Abgeordneten entfolgen</span> <span className="text-sm">Allen Abgeordneten entfolgen</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devUnfollowPoliticians && <span className="text-xs text-green-600">{devUnfollowPoliticians}</span>} {devUnfollowPoliticians && (
<span className="text-xs text-green-600">
{devUnfollowPoliticians}
</span>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -355,7 +412,9 @@ export function SettingsPage() {
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-sm">Abgeordnete neu laden</span> <span className="text-sm">Abgeordnete neu laden</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{devReload && <span className="text-xs text-green-600">{devReload}</span>} {devReload && (
<span className="text-xs text-green-600">{devReload}</span>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"

View File

@@ -8,7 +8,9 @@ function PoliticianPage() {
if (!Number.isFinite(id) || id <= 0) { if (!Number.isFinite(id) || id <= 0) {
return ( return (
<div className="px-4 py-12 text-center"> <div className="px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">Ungültige Abgeordneten-ID</p> <p className="text-sm text-muted-foreground">
Ungültige Abgeordneten-ID
</p>
</div> </div>
) )
} }

View File

@@ -1,7 +1,13 @@
import { useBundestagUI } from "@/features/bundestag/store" import { useBundestagUI } from "@/features/bundestag/store"
import { useLandtagUI } from "@/features/landtag/store" import { useLandtagUI } from "@/features/landtag/store"
import { DbProvider } from "@/shared/db/provider" import { DbProvider } from "@/shared/db/provider"
import { Link, Outlet, createFileRoute, useMatches, useNavigate } from "@tanstack/react-router" import {
Link,
Outlet,
createFileRoute,
useMatches,
useNavigate,
} from "@tanstack/react-router"
import { Suspense } from "react" import { Suspense } from "react"
interface TabDef { interface TabDef {
@@ -73,10 +79,16 @@ function AppLayout() {
const toggleShowAll = isBundestag ? toggleBundestag : toggleLandtag const toggleShowAll = isBundestag ? toggleBundestag : toggleLandtag
// Determine parent path for back navigation from configure routes // Determine parent path for back navigation from configure routes
const parentPath = isConfigureRoute ? currentPath.replace(/\/configure$/, "") : null const parentPath = isConfigureRoute
? currentPath.replace(/\/configure$/, "")
: null
// Determine configure link target for typed navigation // Determine configure link target for typed navigation
const configureTarget = isBundestag ? "/app/bundestag/configure" : isLandtag ? "/app/landtag/configure" : null const configureTarget = isBundestag
? "/app/bundestag/configure"
: isLandtag
? "/app/landtag/configure"
: null
return ( return (
<div className="flex flex-col h-dvh max-w-lg mx-auto"> <div className="flex flex-col h-dvh max-w-lg mx-auto">
@@ -84,7 +96,11 @@ function AppLayout() {
{(isConfigureRoute && parentPath) || isPoliticianRoute ? ( {(isConfigureRoute && parentPath) || isPoliticianRoute ? (
<button <button
type="button" type="button"
onClick={() => (isPoliticianRoute ? window.history.back() : navigate({ to: parentPath ?? "" }))} onClick={() =>
isPoliticianRoute
? window.history.back()
: navigate({ to: parentPath ?? "" })
}
className="flex items-center gap-1 text-primary" className="flex items-center gap-1 text-primary"
aria-label="Zurück" aria-label="Zurück"
> >
@@ -97,7 +113,11 @@ function AppLayout() {
strokeWidth={2} strokeWidth={2}
aria-hidden="true" aria-hidden="true"
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
<span className="text-sm">Zurück</span> <span className="text-sm">Zurück</span>
</button> </button>
@@ -105,7 +125,11 @@ function AppLayout() {
<h1 <h1
className={`text-base font-semibold text-card-foreground ${isConfigureRoute || isPoliticianRoute ? "ml-2" : ""}`} className={`text-base font-semibold text-card-foreground ${isConfigureRoute || isPoliticianRoute ? "ml-2" : ""}`}
> >
{isPoliticianRoute ? "Abgeordnete/r" : isConfigureRoute ? "Abgeordnete" : currentTab.label} {isPoliticianRoute
? "Abgeordnete/r"
: isConfigureRoute
? "Abgeordnete"
: currentTab.label}
</h1> </h1>
{isConfigureRoute && ( {isConfigureRoute && (
<button <button
@@ -129,7 +153,9 @@ function AppLayout() {
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
/> />
<circle cx="12" cy="9" r="2.5" /> <circle cx="12" cy="9" r="2.5" />
{showAll && <line x1="3" y1="21" x2="21" y2="3" strokeWidth={2} />} {showAll && (
<line x1="3" y1="21" x2="21" y2="3" strokeWidth={2} />
)}
</svg> </svg>
</button> </button>
)} )}
@@ -172,7 +198,11 @@ function AppLayout() {
</DbProvider> </DbProvider>
</Suspense> </Suspense>
<nav className="flex bg-card border-t border-border safe-area-bottom" role="tablist" aria-label="Hauptnavigation"> <nav
className="flex bg-card border-t border-border safe-area-bottom"
role="tablist"
aria-label="Hauptnavigation"
>
{TABS.map((tab) => { {TABS.map((tab) => {
const active = currentPath.startsWith(tab.to) const active = currentPath.startsWith(tab.to)
return ( return (
@@ -186,14 +216,19 @@ function AppLayout() {
if (active) return if (active) return
e.preventDefault() e.preventDefault()
const go = () => navigate({ to: tab.to }) const go = () => navigate({ to: tab.to })
if (document.startViewTransition && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) { if (
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
document.startViewTransition(go) document.startViewTransition(go)
} else { } else {
go() go()
} }
}} }}
className={`flex flex-col items-center justify-center flex-1 py-2 gap-0.5 transition-colors no-underline ${ className={`flex flex-col items-center justify-center flex-1 py-2 gap-0.5 transition-colors no-underline ${
active ? "text-primary" : "text-muted-foreground hover:text-foreground" active
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`} }`}
> >
<TabIcon d={tab.icon} /> <TabIcon d={tab.icon} />

View File

@@ -21,13 +21,19 @@ function partyLabel(m: MandateWithPolitician): string {
} }
/** Check if a mandate's constituency label contains the user's city name. */ /** Check if a mandate's constituency label contains the user's city name. */
export function isLocalConstituency(m: MandateWithPolitician, city: string): boolean { export function isLocalConstituency(
m: MandateWithPolitician,
city: string,
): boolean {
const label = m.electoral_data?.constituency?.label const label = m.electoral_data?.constituency?.label
if (!label) return false if (!label) return false
return label.toLowerCase().includes(city.toLowerCase()) return label.toLowerCase().includes(city.toLowerCase())
} }
export function groupByParty(mandates: MandateWithPolitician[], userCity?: string | null): PartyGroup[] { export function groupByParty(
mandates: MandateWithPolitician[],
userCity?: string | null,
): PartyGroup[] {
const map = new Map<string, MandateWithPolitician[]>() const map = new Map<string, MandateWithPolitician[]>()
for (const m of mandates) { for (const m of mandates) {
const key = partyLabel(m) const key = partyLabel(m)
@@ -118,7 +124,9 @@ export function RepresentativeList({
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({}) const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const filtered = searchQuery const filtered = searchQuery
? mandates.filter((m) => m.politician.label.toLowerCase().includes(searchQuery.toLowerCase())) ? mandates.filter((m) =>
m.politician.label.toLowerCase().includes(searchQuery.toLowerCase()),
)
: mandates : mandates
const groups = groupByParty(filtered, userCity) const groups = groupByParty(filtered, userCity)
@@ -145,7 +153,12 @@ export function RepresentativeList({
type="search" type="search"
/> />
{userCity && ( {userCity && (
<Button size="sm" variant="outline" className="w-full" onClick={handleFollowNearby}> <Button
size="sm"
variant="outline"
className="w-full"
onClick={handleFollowNearby}
>
Abgeordneten in Deiner Nähe folgen Abgeordneten in Deiner Nähe folgen
</Button> </Button>
)} )}
@@ -155,8 +168,14 @@ export function RepresentativeList({
{groups.map((group) => { {groups.map((group) => {
const meta = getPartyMeta(group.partyLabel) const meta = getPartyMeta(group.partyLabel)
const isCollapsed = collapsed[group.partyLabel] ?? false const isCollapsed = collapsed[group.partyLabel] ?? false
const nearbyMembers = userCity ? group.members.filter((m) => isLocalConstituency(m, userCity)) : [] const nearbyMembers = userCity
const visibleMembers = isCollapsed ? [] : effectiveShowAll ? group.members : nearbyMembers ? group.members.filter((m) => isLocalConstituency(m, userCity))
: []
const visibleMembers = isCollapsed
? []
: effectiveShowAll
? group.members
: nearbyMembers
return ( return (
<Card key={group.partyLabel} className="py-0 gap-0 overflow-hidden"> <Card key={group.partyLabel} className="py-0 gap-0 overflow-hidden">
@@ -174,29 +193,47 @@ export function RepresentativeList({
> >
{meta.short.slice(0, 3)} {meta.short.slice(0, 3)}
</span> </span>
<span className="text-sm font-semibold">{group.partyLabel}</span> <span className="text-sm font-semibold">
<span className="text-xs text-muted-foreground ml-auto mr-2">{group.members.length}</span> {group.partyLabel}
</span>
<span className="text-xs text-muted-foreground ml-auto mr-2">
{group.members.length}
</span>
{isCollapsed ? <ChevronRight /> : <ChevronDown />} {isCollapsed ? <ChevronRight /> : <ChevronDown />}
</button> </button>
{visibleMembers.length > 0 && ( {visibleMembers.length > 0 && (
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{visibleMembers.map((m) => { {visibleMembers.map((m) => {
const followed = isFollowing("politician", m.politician.id) const followed = isFollowing(
"politician",
m.politician.id,
)
const fn = mandateFunction(m) const fn = mandateFunction(m)
const local = userCity ? isLocalConstituency(m, userCity) : false const local = userCity
? isLocalConstituency(m, userCity)
: false
return ( return (
<div key={m.id} className="flex items-center px-4 py-2.5 gap-2"> <div
key={m.id}
className="flex items-center px-4 py-2.5 gap-2"
>
<Link <Link
to="/app/politician/$politicianId" to="/app/politician/$politicianId"
params={{ politicianId: String(m.politician.id) }} params={{ politicianId: String(m.politician.id) }}
className="flex-1 min-w-0 no-underline" className="flex-1 min-w-0 no-underline"
> >
<p className="text-sm font-medium text-foreground">{m.politician.label}</p> <p className="text-sm font-medium text-foreground">
{m.politician.label}
</p>
{fn && ( {fn && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{fn} {fn}
{local && <span className="ml-1.5 text-primary font-medium"> in Deiner Nähe</span>} {local && (
<span className="ml-1.5 text-primary font-medium">
in Deiner Nähe
</span>
)}
</p> </p>
)} )}
</Link> </Link>
@@ -206,10 +243,18 @@ export function RepresentativeList({
onClick={() => onClick={() =>
followed followed
? unfollow("politician", m.politician.id) ? unfollow("politician", m.politician.id)
: follow("politician", m.politician.id, m.politician.label) : follow(
"politician",
m.politician.id,
m.politician.label,
)
} }
aria-pressed={followed} aria-pressed={followed}
aria-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`} aria-label={
followed
? `${m.politician.label} entfolgen`
: `${m.politician.label} folgen`
}
className="shrink-0" className="shrink-0"
> >
{followed ? "Folgst du" : "Folgen"} {followed ? "Folgst du" : "Folgen"}
@@ -233,7 +278,9 @@ export function RepresentativeList({
})} })}
{groups.length === 0 && searchQuery && ( {groups.length === 0 && searchQuery && (
<p className="text-center text-sm text-muted-foreground py-6">Keine Abgeordneten für {searchQuery}"</p> <p className="text-center text-sm text-muted-foreground py-6">
Keine Abgeordneten für {searchQuery}"
</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -8,11 +8,16 @@ interface TopicToggleListProps {
onSearchChange: (query: string) => void onSearchChange: (query: string) => void
} }
export function TopicToggleList({ searchQuery, onSearchChange }: TopicToggleListProps) { export function TopicToggleList({
searchQuery,
onSearchChange,
}: TopicToggleListProps) {
const { topics, loading, error } = useTopics() const { topics, loading, error } = useTopics()
const { isFollowing, follow, unfollow } = useFollows() const { isFollowing, follow, unfollow } = useFollows()
const filtered = topics.filter((t) => t.label.toLowerCase().includes(searchQuery.toLowerCase())) const filtered = topics.filter((t) =>
t.label.toLowerCase().includes(searchQuery.toLowerCase()),
)
return ( return (
<div className="pb-4"> <div className="pb-4">
@@ -41,14 +46,25 @@ export function TopicToggleList({ searchQuery, onSearchChange }: TopicToggleList
{filtered.map((topic) => { {filtered.map((topic) => {
const followed = isFollowing("topic", topic.id) const followed = isFollowing("topic", topic.id)
return ( return (
<div key={topic.id} className="flex items-center justify-between px-4 py-3"> <div
key={topic.id}
className="flex items-center justify-between px-4 py-3"
>
<span className="text-sm">{topic.label}</span> <span className="text-sm">{topic.label}</span>
<Button <Button
size="sm" size="sm"
variant={followed ? "default" : "outline"} variant={followed ? "default" : "outline"}
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))} onClick={() =>
followed
? unfollow("topic", topic.id)
: follow("topic", topic.id, topic.label)
}
aria-pressed={followed} aria-pressed={followed}
aria-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`} aria-label={
followed
? `${topic.label} entfolgen`
: `${topic.label} folgen`
}
> >
{followed ? "Folgst du" : "Folgen"} {followed ? "Folgst du" : "Folgen"}
</Button> </Button>

View File

@@ -16,6 +16,8 @@ export async function getOrCreateDeviceId(db: PGlite): Promise<string> {
if (res.rows.length > 0) return res.rows[0].id if (res.rows.length > 0) return res.rows[0].id
const id = generateUUID() const id = generateUUID()
await db.query("INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", [id]) await db.query("INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", [
id,
])
return id return id
} }

View File

@@ -12,7 +12,10 @@ export async function loadCachedFeed(
db: PGlite, db: PGlite,
cacheKey = DEFAULT_CACHE_KEY, cacheKey = DEFAULT_CACHE_KEY,
): Promise<{ items: FeedItem[]; updatedAt: number } | null> { ): Promise<{ items: FeedItem[]; updatedAt: number } | null> {
const res = await db.query<CacheRow>("SELECT data, updated_at FROM feed_cache WHERE id = $1", [cacheKey]) const res = await db.query<CacheRow>(
"SELECT data, updated_at FROM feed_cache WHERE id = $1",
[cacheKey],
)
if (res.rows.length === 0) return null if (res.rows.length === 0) return null
const row = res.rows[0] const row = res.rows[0]
return { return {
@@ -21,7 +24,11 @@ export async function loadCachedFeed(
} }
} }
export async function saveCachedFeed(db: PGlite, items: FeedItem[], cacheKey = DEFAULT_CACHE_KEY): Promise<void> { export async function saveCachedFeed(
db: PGlite,
items: FeedItem[],
cacheKey = DEFAULT_CACHE_KEY,
): Promise<void> {
await db.query( await db.query(
`INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, now()) `INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, now())
ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`, ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`,
@@ -29,6 +36,9 @@ export async function saveCachedFeed(db: PGlite, items: FeedItem[], cacheKey = D
) )
} }
export async function clearCachedFeed(db: PGlite, cacheKey = DEFAULT_CACHE_KEY): Promise<void> { export async function clearCachedFeed(
db: PGlite,
cacheKey = DEFAULT_CACHE_KEY,
): Promise<void> {
await db.query("DELETE FROM feed_cache WHERE id = $1", [cacheKey]) await db.query("DELETE FROM feed_cache WHERE id = $1", [cacheKey])
} }

View File

@@ -7,7 +7,9 @@ export interface Follow {
} }
export async function getFollows(db: PGlite): Promise<Follow[]> { export async function getFollows(db: PGlite): Promise<Follow[]> {
const res = await db.query<Follow>("SELECT type, entity_id, label FROM follows ORDER BY created_at") const res = await db.query<Follow>(
"SELECT type, entity_id, label FROM follows ORDER BY created_at",
)
return res.rows return res.rows
} }
@@ -17,21 +19,30 @@ export async function addFollow(
entityId: number, entityId: number,
label: string, label: string,
): Promise<void> { ): Promise<void> {
await db.query("INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [ await db.query(
type, "INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
entityId, [type, entityId, label],
label, )
])
} }
export async function removeFollow(db: PGlite, type: "topic" | "politician", entityId: number): Promise<void> { export async function removeFollow(
await db.query("DELETE FROM follows WHERE type = $1 AND entity_id = $2", [type, entityId]) db: PGlite,
type: "topic" | "politician",
entityId: number,
): Promise<void> {
await db.query("DELETE FROM follows WHERE type = $1 AND entity_id = $2", [
type,
entityId,
])
} }
export async function removeAllFollows(db: PGlite): Promise<void> { export async function removeAllFollows(db: PGlite): Promise<void> {
await db.query("DELETE FROM follows") await db.query("DELETE FROM follows")
} }
export async function removeFollowsByType(db: PGlite, type: "topic" | "politician"): Promise<void> { export async function removeFollowsByType(
db: PGlite,
type: "topic" | "politician",
): Promise<void> {
await db.query("DELETE FROM follows WHERE type = $1", [type]) await db.query("DELETE FROM follows WHERE type = $1", [type])
} }

View File

@@ -8,8 +8,14 @@ export interface GeoResultRow {
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
export async function loadGeoCache(db: PGlite, bundesland: string): Promise<Record<string, unknown> | null> { export async function loadGeoCache(
const res = await db.query<GeoResultRow>("SELECT data, cached_at FROM geo_cache WHERE bundesland = $1", [bundesland]) db: PGlite,
bundesland: string,
): Promise<Record<string, unknown> | null> {
const res = await db.query<GeoResultRow>(
"SELECT data, cached_at FROM geo_cache WHERE bundesland = $1",
[bundesland],
)
if (res.rows.length === 0) return null if (res.rows.length === 0) return null
const row = res.rows[0] const row = res.rows[0]
if (Date.now() - new Date(row.cached_at).getTime() > CACHE_TTL_MS) return null if (Date.now() - new Date(row.cached_at).getTime() > CACHE_TTL_MS) return null
@@ -19,7 +25,9 @@ export async function loadGeoCache(db: PGlite, bundesland: string): Promise<Reco
export async function loadMostRecentGeoCache( export async function loadMostRecentGeoCache(
db: PGlite, db: PGlite,
): Promise<{ data: Record<string, unknown>; cachedAt: number } | null> { ): Promise<{ data: Record<string, unknown>; cachedAt: number } | null> {
const res = await db.query<GeoResultRow>("SELECT data, cached_at FROM geo_cache ORDER BY cached_at DESC LIMIT 1") const res = await db.query<GeoResultRow>(
"SELECT data, cached_at FROM geo_cache ORDER BY cached_at DESC LIMIT 1",
)
if (res.rows.length === 0) return null if (res.rows.length === 0) return null
const row = res.rows[0] const row = res.rows[0]
const cachedAt = new Date(row.cached_at).getTime() const cachedAt = new Date(row.cached_at).getTime()
@@ -27,7 +35,11 @@ export async function loadMostRecentGeoCache(
return { data: row.data, cachedAt } return { data: row.data, cachedAt }
} }
export async function saveGeoCache(db: PGlite, bundesland: string, data: Record<string, unknown>): Promise<void> { export async function saveGeoCache(
db: PGlite,
bundesland: string,
data: Record<string, unknown>,
): Promise<void> {
await db.query( await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, now()) `INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, now())
ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = now()`, ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = now()`,

View File

@@ -6,7 +6,10 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
// device ID // device ID
const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId) const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId)
if (deviceId) { if (deviceId) {
await db.query("INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", [deviceId]) await db.query(
"INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING",
[deviceId],
)
localStorage.removeItem(STORAGE_KEYS.deviceId) localStorage.removeItem(STORAGE_KEYS.deviceId)
} }
@@ -14,13 +17,16 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
const followsRaw = localStorage.getItem(STORAGE_KEYS.follows) const followsRaw = localStorage.getItem(STORAGE_KEYS.follows)
if (followsRaw) { if (followsRaw) {
try { try {
const follows = JSON.parse(followsRaw) as Array<{ type: string; entity_id: number; label: string }> const follows = JSON.parse(followsRaw) as Array<{
type: string
entity_id: number
label: string
}>
for (const f of follows) { for (const f of follows) {
await db.query("INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [ await db.query(
f.type, "INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
f.entity_id, [f.type, f.entity_id, f.label],
f.label, )
])
} }
} catch { } catch {
// corrupt data — skip // corrupt data — skip
@@ -32,7 +38,10 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache) const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache)
if (feedRaw) { if (feedRaw) {
try { try {
const parsed = JSON.parse(feedRaw) as { items: unknown[]; updatedAt: number } const parsed = JSON.parse(feedRaw) as {
items: unknown[]
updatedAt: number
}
if (Array.isArray(parsed.items)) { if (Array.isArray(parsed.items)) {
await db.query( await db.query(
`INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0)) `INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0))
@@ -50,7 +59,10 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache) const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache)
if (geoRaw) { if (geoRaw) {
try { try {
const cache = JSON.parse(geoRaw) as Record<string, { timestamp: number; result: unknown }> const cache = JSON.parse(geoRaw) as Record<
string,
{ timestamp: number; result: unknown }
>
for (const [bundesland, entry] of Object.entries(cache)) { for (const [bundesland, entry] of Object.entries(cache)) {
await db.query( await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0)) `INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))

View File

@@ -1,11 +1,21 @@
import type { PGlite } from "@electric-sql/pglite" import type { PGlite } from "@electric-sql/pglite"
export async function getPushState(db: PGlite, key: string): Promise<string | null> { export async function getPushState(
const res = await db.query<{ value: string }>("SELECT value FROM push_state WHERE key = $1", [key]) db: PGlite,
key: string,
): Promise<string | null> {
const res = await db.query<{ value: string }>(
"SELECT value FROM push_state WHERE key = $1",
[key],
)
return res.rows.length > 0 ? res.rows[0].value : null return res.rows.length > 0 ? res.rows[0].value : null
} }
export async function setPushState(db: PGlite, key: string, value: string): Promise<void> { export async function setPushState(
db: PGlite,
key: string,
value: string,
): Promise<void> {
await db.query( await db.query(
`INSERT INTO push_state (key, value) VALUES ($1, $2) `INSERT INTO push_state (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`, ON CONFLICT (key) DO UPDATE SET value = $2`,

View File

@@ -71,5 +71,13 @@ export function useFollows() {
emitChange() emitChange()
}, [db]) }, [db])
return { follows, isFollowing, follow, unfollow, unfollowAll, unfollowAllTopics, unfollowAllPoliticians } return {
follows,
isFollowing,
follow,
unfollow,
unfollowAll,
unfollowAllTopics,
unfollowAllPoliticians,
}
} }

View File

@@ -1,6 +1,15 @@
import { useDb } from "@/shared/db/provider" import { useDb } from "@/shared/db/provider"
import { getPushState, removePushState, setPushState } from "@/shared/db/push-state-db" import {
import { isPushSubscribed, subscribeToPush, syncFollowsToBackend, unsubscribeFromPush } from "@/shared/lib/push-client" getPushState,
removePushState,
setPushState,
} from "@/shared/db/push-state-db"
import {
isPushSubscribed,
subscribeToPush,
syncFollowsToBackend,
unsubscribeFromPush,
} from "@/shared/lib/push-client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { useDeviceId } from "./use-device-id" import { useDeviceId } from "./use-device-id"
import { type Follow, useFollows } from "./use-follows" import { type Follow, useFollows } from "./use-follows"
@@ -59,7 +68,11 @@ export function usePush() {
return { permission, subscribed, loading, subscribe, unsubscribe } return { permission, subscribed, loading, subscribe, unsubscribe }
} }
export async function triggerPushSync(deviceId: string, follows: Follow[], db: import("@electric-sql/pglite").PGlite) { export async function triggerPushSync(
deviceId: string,
follows: Follow[],
db: import("@electric-sql/pglite").PGlite,
) {
const enabled = await getPushState(db, "enabled") const enabled = await getPushState(db, "enabled")
if (enabled !== "true") return if (enabled !== "true") return
syncFollowsToBackend(deviceId, follows) syncFollowsToBackend(deviceId, follows)

View File

@@ -51,14 +51,20 @@ describe("fetchTopics", () => {
}) })
it("validates data with Zod", async () => { it("validates data with Zod", async () => {
mockFetch.mockResolvedValueOnce(okResponse([{ id: "not-a-number", label: "Bad" }])) mockFetch.mockResolvedValueOnce(
okResponse([{ id: "not-a-number", label: "Bad" }]),
)
await expect(fetchTopics()).rejects.toThrow() await expect(fetchTopics()).rejects.toThrow()
}) })
}) })
describe("searchPoliticians", () => { describe("searchPoliticians", () => {
it("passes query parameter", async () => { it("passes query parameter", async () => {
mockFetch.mockResolvedValueOnce(okResponse([{ id: 10, label: "Angela Merkel", party: { id: 1, label: "CDU" } }])) mockFetch.mockResolvedValueOnce(
okResponse([
{ id: 10, label: "Angela Merkel", party: { id: 1, label: "CDU" } },
]),
)
const results = await searchPoliticians("Merkel") const results = await searchPoliticians("Merkel")
expect(results).toHaveLength(1) expect(results).toHaveLength(1)
@@ -71,7 +77,14 @@ describe("searchPoliticians", () => {
describe("fetchPolls", () => { describe("fetchPolls", () => {
it("returns sorted polls", async () => { it("returns sorted polls", async () => {
mockFetch.mockResolvedValueOnce( mockFetch.mockResolvedValueOnce(
okResponse([{ id: 1, label: "Poll A", field_poll_date: "2024-01-15", field_topics: [{ id: 5 }] }]), okResponse([
{
id: 1,
label: "Poll A",
field_poll_date: "2024-01-15",
field_topics: [{ id: 5 }],
},
]),
) )
const polls = await fetchPolls(50) const polls = await fetchPolls(50)
@@ -92,8 +105,16 @@ describe("fetchPollsByIds", () => {
it("fetches each poll individually", async () => { it("fetches each poll individually", async () => {
mockFetch mockFetch
.mockResolvedValueOnce(okResponse([{ id: 1, label: "Poll A", field_poll_date: null, field_topics: [] }])) .mockResolvedValueOnce(
.mockResolvedValueOnce(okResponse([{ id: 2, label: "Poll B", field_poll_date: null, field_topics: [] }])) okResponse([
{ id: 1, label: "Poll A", field_poll_date: null, field_topics: [] },
]),
)
.mockResolvedValueOnce(
okResponse([
{ id: 2, label: "Poll B", field_poll_date: null, field_topics: [] },
]),
)
const polls = await fetchPollsByIds([1, 2]) const polls = await fetchPollsByIds([1, 2])
expect(polls).toHaveLength(2) expect(polls).toHaveLength(2)

View File

@@ -96,7 +96,11 @@ export type Vote = z.infer<typeof voteSchema>
// --- Fetch helper --- // --- Fetch helper ---
async function request<T>(path: string, params: Record<string, string>, schema: z.ZodType<T>): Promise<T[]> { async function request<T>(
path: string,
params: Record<string, string>,
schema: z.ZodType<T>,
): Promise<T[]> {
const url = new URL(`${AW_API_BASE}/${path}`) const url = new URL(`${AW_API_BASE}/${path}`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
@@ -128,10 +132,17 @@ export function fetchTopics(): Promise<Topic[]> {
} }
export function searchPoliticians(query: string): Promise<Politician[]> { export function searchPoliticians(query: string): Promise<Politician[]> {
return request("politicians", { "label[cn]": query, range_end: "50" }, politicianSchema) return request(
"politicians",
{ "label[cn]": query, range_end: "50" },
politicianSchema,
)
} }
export function fetchPolls(rangeEnd = 100, legislatureId?: number): Promise<Poll[]> { export function fetchPolls(
rangeEnd = 100,
legislatureId?: number,
): Promise<Poll[]> {
const params: Record<string, string> = { const params: Record<string, string> = {
range_end: String(rangeEnd), range_end: String(rangeEnd),
sort_by: "field_poll_date", sort_by: "field_poll_date",
@@ -144,11 +155,17 @@ export function fetchPolls(rangeEnd = 100, legislatureId?: number): Promise<Poll
export async function fetchPollsByIds(ids: number[]): Promise<Poll[]> { export async function fetchPollsByIds(ids: number[]): Promise<Poll[]> {
if (ids.length === 0) return [] if (ids.length === 0) return []
// AW API does not support id[in] on /polls — fetch individually and dedupe // AW API does not support id[in] on /polls — fetch individually and dedupe
const results = await Promise.all(ids.map((id) => request("polls", { id: String(id), range_end: "1" }, pollSchema))) const results = await Promise.all(
ids.map((id) =>
request("polls", { id: String(id), range_end: "1" }, pollSchema),
),
)
return results.flat() return results.flat()
} }
export function fetchCandidacyMandates(politicianID: number): Promise<CandidacyMandate[]> { export function fetchCandidacyMandates(
politicianID: number,
): Promise<CandidacyMandate[]> {
return request( return request(
"candidacies-mandates", "candidacies-mandates",
{ {
@@ -160,10 +177,16 @@ export function fetchCandidacyMandates(politicianID: number): Promise<CandidacyM
} }
export function fetchVotes(mandateID: number): Promise<Vote[]> { export function fetchVotes(mandateID: number): Promise<Vote[]> {
return request("votes", { mandate: String(mandateID), range_end: "200" }, voteSchema) return request(
"votes",
{ mandate: String(mandateID), range_end: "200" },
voteSchema,
)
} }
export function fetchMandatesByParliamentPeriod(periodID: number): Promise<MandateWithPolitician[]> { export function fetchMandatesByParliamentPeriod(
periodID: number,
): Promise<MandateWithPolitician[]> {
return request( return request(
"candidacies-mandates", "candidacies-mandates",
{ {

View File

@@ -8,7 +8,8 @@ export const DIP_API_KEY = "GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp"
export const BUNDESTAG_LEGISLATURE_ID = 161 export const BUNDESTAG_LEGISLATURE_ID = 161
export const BUNDESTAG_WAHLPERIODE = 21 export const BUNDESTAG_WAHLPERIODE = 21
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "https://serve.uber.space/agw/api" export const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL ?? "https://serve.uber.space/agw/api"
export const VAPID_PUBLIC_KEY = export const VAPID_PUBLIC_KEY =
import.meta.env.VITE_VAPID_PUBLIC_KEY ?? import.meta.env.VITE_VAPID_PUBLIC_KEY ??
"BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ" "BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ"

View File

@@ -20,7 +20,11 @@ export type Vorgang = z.infer<typeof vorgangSchema>
// --- Fetch helper --- // --- Fetch helper ---
async function dipRequest<T>(path: string, params: Record<string, string>, schema: z.ZodType<T>): Promise<T[]> { async function dipRequest<T>(
path: string,
params: Record<string, string>,
schema: z.ZodType<T>,
): Promise<T[]> {
const url = new URL(`${DIP_API_BASE}/${path}`) const url = new URL(`${DIP_API_BASE}/${path}`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)

View File

@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
// mock constants // mock constants
vi.mock("./constants", () => ({ vi.mock("./constants", () => ({
BACKEND_URL: "https://test.example.com/api", BACKEND_URL: "https://test.example.com/api",
VAPID_PUBLIC_KEY: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REqnSw", VAPID_PUBLIC_KEY:
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REqnSw",
})) }))
describe("push-client", () => { describe("push-client", () => {
@@ -17,9 +18,10 @@ describe("push-client", () => {
vi.stubGlobal("fetch", mockFetch) vi.stubGlobal("fetch", mockFetch)
const { syncFollowsToBackend } = await import("./push-client") const { syncFollowsToBackend } = await import("./push-client")
const result = await syncFollowsToBackend("550e8400-e29b-41d4-a716-446655440000", [ const result = await syncFollowsToBackend(
{ type: "topic", entity_id: 1, label: "Test Topic" }, "550e8400-e29b-41d4-a716-446655440000",
]) [{ type: "topic", entity_id: 1, label: "Test Topic" }],
)
expect(result).toBe(true) expect(result).toBe(true)
expect(mockFetch).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
@@ -37,10 +39,16 @@ describe("push-client", () => {
}) })
it("returns false on network error", async () => { it("returns false on network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error"))) vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Network error")),
)
const { syncFollowsToBackend } = await import("./push-client") const { syncFollowsToBackend } = await import("./push-client")
const result = await syncFollowsToBackend("550e8400-e29b-41d4-a716-446655440000", []) const result = await syncFollowsToBackend(
"550e8400-e29b-41d4-a716-446655440000",
[],
)
expect(result).toBe(false) expect(result).toBe(false)
}) })

View File

@@ -74,7 +74,10 @@ export async function unsubscribeFromPush(deviceId: string): Promise<boolean> {
return res.ok return res.ok
} }
export async function syncFollowsToBackend(deviceId: string, follows: Follow[]): Promise<boolean> { export async function syncFollowsToBackend(
deviceId: string,
follows: Follow[],
): Promise<boolean> {
try { try {
const res = await fetch(`${BACKEND_URL}/push/sync`, { const res = await fetch(`${BACKEND_URL}/push/sync`, {
method: "POST", method: "POST",

View File

@@ -78,15 +78,17 @@ self.addEventListener("notificationclick", (event) => {
const url = (event.notification.data as { url?: string })?.url ?? "/agw/" const url = (event.notification.data as { url?: string })?.url ?? "/agw/"
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => { self.clients
// focus existing window if possible .matchAll({ type: "window", includeUncontrolled: true })
for (const client of windowClients) { .then((windowClients) => {
if (client.url.includes("/agw/") && "focus" in client) { // focus existing window if possible
return client.focus() for (const client of windowClients) {
if (client.url.includes("/agw/") && "focus" in client) {
return client.focus()
}
} }
} // otherwise open new window
// otherwise open new window return self.clients.openWindow(url)
return self.clients.openWindow(url) }),
}),
) )
}) })

23
src/server/app.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { logger } from "hono/logger"
import { politicianRouter } from "./features/politicians"
import { pushRouter } from "./features/push"
const app = new Hono()
app.use("*", logger())
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
)
app.get("/health", (c) => c.json({ status: "ok" }))
app.route("/politicians", politicianRouter)
app.route("/push", pushRouter)
export default app

View File

@@ -0,0 +1 @@
export { politicianRouter } from "./router"

View File

@@ -0,0 +1,22 @@
import { Hono } from "hono"
import { getPoliticianProfile } from "./service"
export const politicianRouter = new Hono()
politicianRouter.get("/:id", async (c) => {
const id = Number(c.req.param("id"))
if (!Number.isFinite(id) || id <= 0) {
return c.json({ error: "invalid politician id" }, 400)
}
try {
const profile = await getPoliticianProfile(id)
if (!profile) {
return c.json({ error: "no mandates found for politician" }, 404)
}
return c.json(profile)
} catch (e) {
console.error(`Failed to fetch politician ${id}:`, e)
return c.json({ error: "failed to fetch politician profile" }, 500)
}
})

View File

@@ -0,0 +1,122 @@
import { eq } from "drizzle-orm"
import { db } from "../../shared/db/client"
import { politicianProfiles } from "../../shared/db/schema/politicians"
import {
type Poll,
fetchMandatesForPolitician,
fetchPollById,
fetchVotesByMandate,
} from "../../shared/lib/aw-api"
interface PoliticianVote {
vote: string
pollId: number
pollLabel: string
pollDate: string | null
pollUrl: string | null
topics: string[]
}
interface PoliticianProfile {
id: number
label: string
party: string | null
fraction: string | null
constituency: string | null
mandateWon: string | null
votes: PoliticianVote[]
}
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
export async function getPoliticianProfile(
politicianId: number,
): Promise<PoliticianProfile | null> {
// check cache
const cached = await db.query.politicianProfiles.findFirst({
where: eq(politicianProfiles.politicianId, politicianId),
})
if (cached && Date.now() - cached.cachedAt.getTime() < CACHE_TTL_MS) {
return cached.data as PoliticianProfile
}
// fetch mandates, pick latest
const mandates = await fetchMandatesForPolitician(politicianId)
if (mandates.length === 0) return null
const mandate = mandates.reduce((a, b) => (a.id > b.id ? a : b))
// extract header
const label = mandate.politician.label
const party = mandate.party?.label ?? null
const currentFraction = mandate.fraction_membership?.find(
(f) => !f.valid_until,
)
const fraction =
currentFraction?.fraction.label.replace(/\s*\([^)]+\)\s*$/, "") ?? null
const constituency = mandate.electoral_data?.constituency?.label ?? null
const mandateWon = mandate.electoral_data?.mandate_won ?? null
// fetch votes
const rawVotes = await fetchVotesByMandate(mandate.id)
// collect unique poll IDs and fetch polls in parallel
const pollIds = [
...new Set(
rawVotes.map((v) => v.poll?.id).filter((id): id is number => id != null),
),
]
const polls = await Promise.all(pollIds.map(fetchPollById))
const pollMap = new Map<number, Poll>()
for (const p of polls) {
if (p) pollMap.set(p.id, p)
}
// assemble votes
const votes: PoliticianVote[] = rawVotes
.flatMap((v) => {
const pollId = v.poll?.id
if (pollId == null) return []
const poll = pollMap.get(pollId)
return [
{
vote: v.vote,
pollId,
pollLabel: poll?.label ?? v.poll?.label ?? "",
pollDate: poll?.field_poll_date ?? null,
pollUrl: poll?.abgeordnetenwatch_url ?? null,
topics:
poll?.field_topics
.map((t) => t.label)
.filter((l): l is string => l != null) ?? [],
},
]
})
.sort((a, b) => {
if (a.pollDate && b.pollDate) return b.pollDate.localeCompare(a.pollDate)
if (a.pollDate) return -1
if (b.pollDate) return 1
return 0
})
const profile: PoliticianProfile = {
id: politicianId,
label,
party,
fraction,
constituency,
mandateWon,
votes,
}
// upsert cache
await db
.insert(politicianProfiles)
.values({ politicianId, data: profile, cachedAt: new Date() })
.onConflictDoUpdate({
target: politicianProfiles.politicianId,
set: { data: profile, cachedAt: new Date() },
})
return profile
}

Some files were not shown because too many files have changed in this diff Show More