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:
5
.env.example
Normal file
5
.env.example
Normal 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
7
.gitignore
vendored
@@ -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
53
CLAUDE.md
Normal 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
|
||||||
|
```
|
||||||
11
biome.json
11
biome.json
@@ -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
125
bun.lock
@@ -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=="],
|
||||||
|
|||||||
15
deploy.sh
15
deploy.sh
@@ -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
10
drizzle.config.ts
Normal 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 ?? "",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
13007
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
@@ -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) {
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
@@ -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 () => {
|
||||||
@@ -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) {
|
||||||
@@ -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")
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
@@ -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()
|
||||||
@@ -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) {
|
||||||
@@ -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" }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
214
src/client/features/politician/components/politician-detail.tsx
Normal file
214
src/client/features/politician/components/politician-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/client/features/politician/index.ts
Normal file
1
src/client/features/politician/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PoliticianDetail } from "./components/politician-detail"
|
||||||
35
src/client/features/politician/lib/api.ts
Normal file
35
src/client/features/politician/lib/api.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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} />
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
@@ -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()`,
|
||||||
@@ -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))
|
||||||
@@ -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`,
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
{
|
{
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -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",
|
||||||
@@ -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
23
src/server/app.ts
Normal 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
|
||||||
1
src/server/features/politicians/index.ts
Normal file
1
src/server/features/politicians/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { politicianRouter } from "./router"
|
||||||
22
src/server/features/politicians/router.ts
Normal file
22
src/server/features/politicians/router.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
122
src/server/features/politicians/service.ts
Normal file
122
src/server/features/politicians/service.ts
Normal 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
Reference in New Issue
Block a user