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/
|
||||
.env
|
||||
*.DS_Store
|
||||
src/routeTree.gen.ts
|
||||
server/drizzle/
|
||||
src/client/routeTree.gen.ts
|
||||
drizzle/
|
||||
*.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": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 120
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
@@ -25,6 +25,13 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"hono": "^4.7.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"pg-boss": "^10.1.0",
|
||||
"postgres": "^3.4.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.11",
|
||||
},
|
||||
@@ -26,7 +31,9 @@
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/web-push": "^3.6.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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_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=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
@@ -1180,6 +1213,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -1336,6 +1377,24 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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/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)
|
||||
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)"
|
||||
|
||||
REMOTE="${1:-serve}"
|
||||
@@ -38,18 +38,21 @@ rsync -avz --delete -e "ssh -S $SSH_SOCK" \
|
||||
echo "==> Deploying backend to $REMOTE:$REMOTE_SERVICE_DIR"
|
||||
r "mkdir -p $REMOTE_SERVICE_DIR"
|
||||
rsync -avz --delete -e "ssh -S $SSH_SOCK" \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='.git/' \
|
||||
--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"
|
||||
r "cd $REMOTE_SERVICE_DIR && npm install"
|
||||
r "cd $REMOTE_SERVICE_DIR && bun install"
|
||||
|
||||
echo "==> Checking .env exists"
|
||||
r "test -f $REMOTE_SERVICE_DIR/.env || { echo 'ERROR: $REMOTE_SERVICE_DIR/.env not found — create it first'; exit 1; }"
|
||||
|
||||
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) ---
|
||||
|
||||
@@ -63,7 +66,7 @@ After=network.target postgresql.service
|
||||
Type=simple
|
||||
WorkingDirectory=$REMOTE_SERVICE_DIR
|
||||
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
|
||||
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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</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",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "bun --watch src/server/index.ts",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "bun run src/server/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --fix .",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"prepare": "simple-git-hooks"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,11 +21,16 @@
|
||||
"@tanstack/react-router": "^1.120.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"hono": "^4.7.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"pg-boss": "^10.1.0",
|
||||
"postgres": "^3.4.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -34,7 +43,9 @@
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/web-push": "^3.6.0",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.0",
|
||||
"shadcn": "^3.8.5",
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
}
|
||||
body {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { RepresentativeList } from "@/shared/components/representative-list"
|
||||
import { useDb } from "@/shared/db/provider"
|
||||
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
|
||||
import { useEffect, useState } from "react"
|
||||
import { fetchAndCacheBundestagMandates, loadCachedResult } from "../../location/lib/geo"
|
||||
import {
|
||||
fetchAndCacheBundestagMandates,
|
||||
loadCachedResult,
|
||||
} from "../../location/lib/geo"
|
||||
import { useBundestagUI } from "../store"
|
||||
|
||||
export function BundestagConfigure() {
|
||||
@@ -40,7 +43,9 @@ export function BundestagConfigure() {
|
||||
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>
|
||||
)
|
||||
@@ -13,14 +13,17 @@ function formatCacheAge(timestamp: number): string {
|
||||
}
|
||||
|
||||
export function BundestagFeed() {
|
||||
const { items, loading, refreshing, error, lastUpdated, refresh } = useBundestagFeed()
|
||||
const { items, loading, refreshing, error, lastUpdated, refresh } =
|
||||
useBundestagFeed()
|
||||
const hasItems = items.length > 0
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{lastUpdated && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => refresh({ silent: true })}
|
||||
@@ -48,7 +51,10 @@ export function BundestagFeed() {
|
||||
)}
|
||||
|
||||
{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" />
|
||||
</output>
|
||||
)}
|
||||
@@ -68,7 +74,10 @@ export function BundestagFeed() {
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Folge Themen oder Abgeordneten, um Abstimmungen hier zu sehen.
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
@@ -18,8 +22,15 @@ export function useBundestagFeed() {
|
||||
const [error, setError] = useState<string | 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 politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows])
|
||||
const topicIDs = useMemo(
|
||||
() => 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 lastUpdatedRef = useRef<number | null>(null)
|
||||
@@ -105,7 +116,10 @@ export function useBundestagFeed() {
|
||||
if (document.hidden) {
|
||||
stopInterval()
|
||||
} else {
|
||||
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) {
|
||||
if (
|
||||
!lastUpdatedRef.current ||
|
||||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
|
||||
) {
|
||||
refresh({ silent: true })
|
||||
}
|
||||
startInterval()
|
||||
@@ -20,16 +20,24 @@ export async function assembleBundestagFeed(
|
||||
])
|
||||
|
||||
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 ---
|
||||
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 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) => ({
|
||||
id: `poll-${poll.id}`,
|
||||
@@ -49,7 +57,9 @@ export async function assembleBundestagFeed(
|
||||
const vorgangItems: FeedItem[] = vorgaenge
|
||||
.filter((v) => {
|
||||
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) => ({
|
||||
id: `vorgang-${v.id}`,
|
||||
@@ -78,13 +88,21 @@ function classifyPoll(poll: Poll): "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 []
|
||||
|
||||
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid)))
|
||||
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id))
|
||||
const mandateResults = await Promise.all(
|
||||
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>()
|
||||
for (const votes of voteResults) {
|
||||
for (const v of votes) {
|
||||
@@ -5,7 +5,11 @@ function formatDate(iso: string | null): string {
|
||||
if (!iso) return ""
|
||||
const d = new Date(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 }) {
|
||||
@@ -25,7 +29,10 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
<h2 className="text-[15px] font-medium leading-snug">{item.title}</h2>
|
||||
)}
|
||||
{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)}
|
||||
</time>
|
||||
)}
|
||||
@@ -34,7 +41,12 @@ export function FeedItemCard({ item }: { item: FeedItemType }) {
|
||||
<div className="flex flex-wrap gap-1 mt-2" aria-label="Themen">
|
||||
{item.topics.map((topic) =>
|
||||
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">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
@@ -16,8 +16,15 @@ export function useFeed() {
|
||||
const [error, setError] = useState<string | 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 politicianIDs = useMemo(() => follows.filter((f) => f.type === "politician").map((f) => f.entity_id), [follows])
|
||||
const topicIDs = useMemo(
|
||||
() => 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 lastUpdatedRef = useRef<number | null>(null)
|
||||
@@ -103,7 +110,10 @@ export function useFeed() {
|
||||
if (document.hidden) {
|
||||
stopInterval()
|
||||
} else {
|
||||
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) {
|
||||
if (
|
||||
!lastUpdatedRef.current ||
|
||||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
|
||||
) {
|
||||
refresh({ silent: true })
|
||||
}
|
||||
startInterval()
|
||||
@@ -19,7 +19,12 @@ function okResponse(data: unknown) {
|
||||
}
|
||||
|
||||
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: 3, label: "Wirtschaft" },
|
||||
]
|
||||
@@ -28,26 +33,46 @@ const POLLS = [
|
||||
{
|
||||
id: 100,
|
||||
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_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", () => {
|
||||
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([], [])
|
||||
expect(feed).toEqual([])
|
||||
})
|
||||
|
||||
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], [])
|
||||
expect(feed).toHaveLength(2)
|
||||
@@ -56,7 +81,9 @@ describe("assembleFeed", () => {
|
||||
})
|
||||
|
||||
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], [])
|
||||
expect(feed[0].date).toBe("2024-06-15")
|
||||
@@ -65,11 +92,20 @@ describe("assembleFeed", () => {
|
||||
})
|
||||
|
||||
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], [])
|
||||
expect(feed[0].topics).toEqual([{ label: "Umwelt", url: "https://www.abgeordnetenwatch.de/themen-dip21/umwelt" }])
|
||||
expect(feed[0].url).toBe("https://www.abgeordnetenwatch.de/bundestag/21/abstimmungen/klimaschutzgesetz")
|
||||
expect(feed[0].topics).toEqual([
|
||||
{
|
||||
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 () => {
|
||||
@@ -32,17 +32,24 @@ function classifyPoll(poll: Poll): "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 topicMap = new Map(topics.map((t) => [t.id, t.label]))
|
||||
|
||||
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 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) => ({
|
||||
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 []
|
||||
|
||||
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid)))
|
||||
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id))
|
||||
const mandateResults = await Promise.all(
|
||||
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>()
|
||||
for (const votes of voteResults) {
|
||||
for (const v of votes) {
|
||||
@@ -2,10 +2,28 @@ import { createTestDb } from "@/shared/db/client"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
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 {
|
||||
return { id, kind: "poll", status: "past", title, url: null, date, topics: [], source: "Bundestag" }
|
||||
function makeItem(
|
||||
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
|
||||
@@ -37,7 +55,10 @@ describe("feed cache persistence", () => {
|
||||
|
||||
describe("mergeFeedItems", () => {
|
||||
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 merged = mergeFeedItems(cached, fresh)
|
||||
expect(merged).toHaveLength(3)
|
||||
@@ -55,13 +76,23 @@ describe("mergeFeedItems", () => {
|
||||
|
||||
it("sorts by date descending", () => {
|
||||
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)
|
||||
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", () => {
|
||||
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[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 { FeedItem } from "./assemble-feed"
|
||||
|
||||
@@ -7,19 +11,32 @@ export interface FeedCacheData {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export async function clearFeedCache(db: PGlite, cacheKey?: string): Promise<void> {
|
||||
export async function clearFeedCache(
|
||||
db: PGlite,
|
||||
cacheKey?: string,
|
||||
): Promise<void> {
|
||||
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>()
|
||||
for (const item of cached) map.set(item.id, item)
|
||||
for (const item of fresh) map.set(item.id, item)
|
||||
@@ -2,7 +2,9 @@ export function HomePage() {
|
||||
return (
|
||||
<div className="text-center mt-12 px-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export function LandtagConfigure() {
|
||||
return (
|
||||
<div className="text-center mt-12 px-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>
|
||||
<Link to="/app/settings" className="text-primary text-sm underline">
|
||||
Zu den Einstellungen
|
||||
@@ -13,7 +13,15 @@ function formatCacheAge(timestamp: number): string {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (!legislatureId && !loading) {
|
||||
@@ -23,7 +31,10 @@ export function LandtagFeed() {
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Erkenne zuerst deinen Standort, um Landtag-Abstimmungen zu sehen.
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
@@ -34,7 +45,9 @@ export function LandtagFeed() {
|
||||
<div className="pb-4">
|
||||
{lastUpdated && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => refresh({ silent: true })}
|
||||
@@ -62,7 +75,10 @@ export function LandtagFeed() {
|
||||
)}
|
||||
|
||||
{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" />
|
||||
</output>
|
||||
)}
|
||||
@@ -82,7 +98,10 @@ export function LandtagFeed() {
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Für deinen Landtag liegen noch keine namentlichen Abstimmungen vor.
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1,5 +1,9 @@
|
||||
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 { useDb } from "@/shared/db/provider"
|
||||
import { useFollows } from "@/shared/hooks/use-follows"
|
||||
@@ -20,7 +24,11 @@ export function useLandtagFeed() {
|
||||
const [lastUpdated, setLastUpdated] = 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 lastUpdatedRef = useRef<number | null>(null)
|
||||
@@ -118,7 +126,10 @@ export function useLandtagFeed() {
|
||||
if (document.hidden) {
|
||||
stopInterval()
|
||||
} else {
|
||||
if (!lastUpdatedRef.current || Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS) {
|
||||
if (
|
||||
!lastUpdatedRef.current ||
|
||||
Date.now() - lastUpdatedRef.current >= REFRESH_INTERVAL_MS
|
||||
) {
|
||||
refresh({ silent: true })
|
||||
}
|
||||
startInterval()
|
||||
@@ -134,5 +145,13 @@ export function useLandtagFeed() {
|
||||
}
|
||||
}, [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 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([
|
||||
fetchPolls(100, legislatureId),
|
||||
fetchPollsForPoliticians(followedPoliticianIDs),
|
||||
])
|
||||
|
||||
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) => ({
|
||||
id: `lt-poll-${poll.id}`,
|
||||
@@ -39,13 +49,21 @@ function classifyPoll(poll: Poll): "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 []
|
||||
|
||||
const mandateResults = await Promise.all(politicianIDs.map((pid) => fetchCandidacyMandates(pid)))
|
||||
const mandateIDs = mandateResults.flatMap((mandates) => mandates.slice(0, 3).map((m) => m.id))
|
||||
const mandateResults = await Promise.all(
|
||||
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>()
|
||||
for (const votes of voteResults) {
|
||||
for (const v of votes) {
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createTestDb } from "@/shared/db/client"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
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()
|
||||
let db: PGlite
|
||||
@@ -40,15 +45,26 @@ const MANDATE_RESPONSE = [
|
||||
id: 500,
|
||||
politician: { id: 1, label: "Max Mustermann" },
|
||||
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", () => {
|
||||
it("returns bundesland, landtag info, and mandates for valid coordinates", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
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)
|
||||
expect(result.bundesland).toBe("Bayern")
|
||||
@@ -60,12 +76,24 @@ describe("detectFromCoords", () => {
|
||||
|
||||
it("returns cached result on second call for same Bundesland", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
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)
|
||||
|
||||
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)
|
||||
expect(result.mandates).toHaveLength(1)
|
||||
@@ -74,14 +102,30 @@ describe("detectFromCoords", () => {
|
||||
|
||||
it("skips cache when skipCache=true", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
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)
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
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)
|
||||
// 4 fetches: Nominatim + mandates + Nominatim + mandates (cache skipped)
|
||||
@@ -89,7 +133,11 @@ describe("detectFromCoords", () => {
|
||||
})
|
||||
|
||||
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)
|
||||
expect(result.bundesland).toBe("Unknown")
|
||||
@@ -98,7 +146,9 @@ describe("detectFromCoords", () => {
|
||||
})
|
||||
|
||||
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)
|
||||
expect(result.bundesland).toBeNull()
|
||||
@@ -107,7 +157,9 @@ describe("detectFromCoords", () => {
|
||||
|
||||
it("throws on nominatim error", async () => {
|
||||
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 () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
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)
|
||||
|
||||
@@ -133,8 +193,16 @@ describe("loadCachedResult", () => {
|
||||
describe("clearGeoCache", () => {
|
||||
it("removes cached results", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
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)
|
||||
expect(await loadCachedResult(db)).not.toBeNull()
|
||||
@@ -4,26 +4,53 @@ import {
|
||||
loadMostRecentGeoCache,
|
||||
saveGeoCache,
|
||||
} 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 type { PGlite } from "@electric-sql/pglite"
|
||||
|
||||
const BUNDESLAND_TO_PARLIAMENT: Record<string, { label: string; parliamentPeriodId: number }> = {
|
||||
"Baden-Württemberg": { label: "Landtag Baden-Württemberg", parliamentPeriodId: 163 },
|
||||
const BUNDESLAND_TO_PARLIAMENT: Record<
|
||||
string,
|
||||
{ label: string; parliamentPeriodId: number }
|
||||
> = {
|
||||
"Baden-Württemberg": {
|
||||
label: "Landtag Baden-Württemberg",
|
||||
parliamentPeriodId: 163,
|
||||
},
|
||||
Bayern: { label: "Bayerischer Landtag", parliamentPeriodId: 149 },
|
||||
Berlin: { label: "Abgeordnetenhaus Berlin", parliamentPeriodId: 133 },
|
||||
Brandenburg: { label: "Landtag Brandenburg", parliamentPeriodId: 158 },
|
||||
Bremen: { label: "Bremische Bürgerschaft", parliamentPeriodId: 146 },
|
||||
Hamburg: { label: "Hamburgische Bürgerschaft", parliamentPeriodId: 162 },
|
||||
Hessen: { label: "Hessischer Landtag", parliamentPeriodId: 150 },
|
||||
"Mecklenburg-Vorpommern": { label: "Landtag Mecklenburg-Vorpommern", parliamentPeriodId: 134 },
|
||||
Niedersachsen: { label: "Niedersächsischer Landtag", parliamentPeriodId: 143 },
|
||||
"Nordrhein-Westfalen": { label: "Landtag Nordrhein-Westfalen", parliamentPeriodId: 139 },
|
||||
"Rheinland-Pfalz": { label: "Landtag Rheinland-Pfalz", parliamentPeriodId: 164 },
|
||||
"Mecklenburg-Vorpommern": {
|
||||
label: "Landtag Mecklenburg-Vorpommern",
|
||||
parliamentPeriodId: 134,
|
||||
},
|
||||
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 },
|
||||
Sachsen: { label: "Sächsischer Landtag", parliamentPeriodId: 157 },
|
||||
"Sachsen-Anhalt": { label: "Landtag Sachsen-Anhalt", parliamentPeriodId: 131 },
|
||||
"Schleswig-Holstein": { label: "Schleswig-Holsteinischer Landtag", parliamentPeriodId: 138 },
|
||||
"Sachsen-Anhalt": {
|
||||
label: "Landtag Sachsen-Anhalt",
|
||||
parliamentPeriodId: 131,
|
||||
},
|
||||
"Schleswig-Holstein": {
|
||||
label: "Schleswig-Holsteinischer Landtag",
|
||||
parliamentPeriodId: 138,
|
||||
},
|
||||
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"
|
||||
|
||||
/** 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)
|
||||
if (!cached) return null
|
||||
return (cached as unknown as { mandates: MandateWithPolitician[] }).mandates
|
||||
}
|
||||
|
||||
/** 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)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -85,13 +116,20 @@ export async function fetchAndCacheBundestagMandates(db: PGlite): Promise<Mandat
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 res = await fetch(url, {
|
||||
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 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
|
||||
|
||||
if (!state || !entry) {
|
||||
@@ -31,5 +31,8 @@ function normalize(label: string): string {
|
||||
|
||||
export function getPartyMeta(partyLabel: string): PartyMeta {
|
||||
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}
|
||||
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>
|
||||
Zurück
|
||||
</button>
|
||||
@@ -29,7 +33,8 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
|
||||
<h2 className="text-lg font-semibold mb-4">iPhone-Einrichtung</h2>
|
||||
|
||||
<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>
|
||||
|
||||
<ol className="space-y-6">
|
||||
@@ -68,7 +73,8 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
|
||||
<div>
|
||||
<p className="text-sm font-medium">Zum Home-Bildschirm</p>
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
@@ -80,8 +86,8 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
|
||||
<div>
|
||||
<p className="text-sm font-medium">App hinzufügen</p>
|
||||
<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
|
||||
Homescreen.
|
||||
Bestätige mit <span className="font-medium">Hinzufügen</span>. Die
|
||||
App erscheint als Icon auf deinem Homescreen.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -91,9 +97,12 @@ export function NotificationGuide({ onBack }: NotificationGuideProps) {
|
||||
4
|
||||
</span>
|
||||
<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">
|
||||
Ö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>
|
||||
</div>
|
||||
</li>
|
||||
@@ -7,16 +7,26 @@ import { useFollows } from "@/shared/hooks/use-follows"
|
||||
import { usePush } from "@/shared/hooks/use-push"
|
||||
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
|
||||
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 { type GeoResult, clearGeoCache, detectFromCoords, loadCachedResult } from "../../location/lib/geo"
|
||||
import {
|
||||
type GeoResult,
|
||||
clearGeoCache,
|
||||
detectFromCoords,
|
||||
loadCachedResult,
|
||||
} from "../../location/lib/geo"
|
||||
import { NotificationGuide } from "./notification-guide"
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
return (
|
||||
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 [devTopics, setDevTopics] = useState<string | null>(null)
|
||||
const [devPoliticians, setDevPoliticians] = useState<string | null>(null)
|
||||
const [devUnfollowTopics, setDevUnfollowTopics] = useState<string | null>(null)
|
||||
const [devUnfollowPoliticians, setDevUnfollowPoliticians] = useState<string | null>(null)
|
||||
const [devUnfollowTopics, setDevUnfollowTopics] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [devUnfollowPoliticians, setDevUnfollowPoliticians] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [devReload, setDevReload] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,7 +70,12 @@ export function SettingsPage() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
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)
|
||||
} catch (e) {
|
||||
setErrorMsg(String(e))
|
||||
@@ -98,14 +117,17 @@ export function SettingsPage() {
|
||||
<div className="px-4 py-4 space-y-6 pb-4">
|
||||
{/* --- Permissions: Push + Location --- */}
|
||||
<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">
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
{VAPID_PUBLIC_KEY &&
|
||||
(push.permission === "denied" ? (
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-destructive text-sm">
|
||||
Push blockiert — bitte in den Systemeinstellungen aktivieren.
|
||||
Push blockiert — bitte in den Systemeinstellungen
|
||||
aktivieren.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -166,7 +188,11 @@ export function SettingsPage() {
|
||||
strokeWidth={2}
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
@@ -176,19 +202,28 @@ export function SettingsPage() {
|
||||
|
||||
{/* --- Info --- */}
|
||||
<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">
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
<div className="flex justify-between px-4 py-3">
|
||||
<span className="text-sm">Version</span>
|
||||
<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 ? (
|
||||
<Button size="sm" onClick={applyUpdate}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={handleCheckUpdate} disabled={checking}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? "Prüfe…" : "Prüfen"}
|
||||
</Button>
|
||||
)}
|
||||
@@ -207,7 +242,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<div className="flex justify-between px-4 py-3">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -215,14 +252,18 @@ export function SettingsPage() {
|
||||
|
||||
{/* --- Developer --- */}
|
||||
<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">
|
||||
<CardContent className="p-0 divide-y divide-border">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Backend Health</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devHealth && (
|
||||
<span className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}>
|
||||
<span
|
||||
className={`text-xs ${devHealth === "ok" ? "text-green-600" : "text-destructive"}`}
|
||||
>
|
||||
{devHealth}
|
||||
</span>
|
||||
)}
|
||||
@@ -247,7 +288,9 @@ export function SettingsPage() {
|
||||
<span className="text-sm">Test-Push</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{devPush && (
|
||||
<span className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}>
|
||||
<span
|
||||
className={`text-xs ${devPush === "ok" ? "text-green-600" : "text-destructive"}`}
|
||||
>
|
||||
{devPush}
|
||||
</span>
|
||||
)}
|
||||
@@ -275,7 +318,9 @@ export function SettingsPage() {
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Alle Themen folgen</span>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -297,7 +342,11 @@ export function SettingsPage() {
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Allen Themen entfolgen</span>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -314,7 +363,11 @@ export function SettingsPage() {
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Alle Abgeordnete folgen</span>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -338,7 +391,11 @@ export function SettingsPage() {
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Allen Abgeordneten entfolgen</span>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -355,7 +412,9 @@ export function SettingsPage() {
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">Abgeordnete neu laden</span>
|
||||
<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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -8,7 +8,9 @@ function PoliticianPage() {
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useBundestagUI } from "@/features/bundestag/store"
|
||||
import { useLandtagUI } from "@/features/landtag/store"
|
||||
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"
|
||||
|
||||
interface TabDef {
|
||||
@@ -73,10 +79,16 @@ function AppLayout() {
|
||||
const toggleShowAll = isBundestag ? toggleBundestag : toggleLandtag
|
||||
|
||||
// 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
|
||||
const configureTarget = isBundestag ? "/app/bundestag/configure" : isLandtag ? "/app/landtag/configure" : null
|
||||
const configureTarget = isBundestag
|
||||
? "/app/bundestag/configure"
|
||||
: isLandtag
|
||||
? "/app/landtag/configure"
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh max-w-lg mx-auto">
|
||||
@@ -84,7 +96,11 @@ function AppLayout() {
|
||||
{(isConfigureRoute && parentPath) || isPoliticianRoute ? (
|
||||
<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"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
@@ -97,7 +113,11 @@ function AppLayout() {
|
||||
strokeWidth={2}
|
||||
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>
|
||||
<span className="text-sm">Zurück</span>
|
||||
</button>
|
||||
@@ -105,7 +125,11 @@ function AppLayout() {
|
||||
<h1
|
||||
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>
|
||||
{isConfigureRoute && (
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
@@ -172,7 +198,11 @@ function AppLayout() {
|
||||
</DbProvider>
|
||||
</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) => {
|
||||
const active = currentPath.startsWith(tab.to)
|
||||
return (
|
||||
@@ -186,14 +216,19 @@ function AppLayout() {
|
||||
if (active) return
|
||||
e.preventDefault()
|
||||
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)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}}
|
||||
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} />
|
||||
@@ -21,13 +21,19 @@ function partyLabel(m: MandateWithPolitician): string {
|
||||
}
|
||||
|
||||
/** 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
|
||||
if (!label) return false
|
||||
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[]>()
|
||||
for (const m of mandates) {
|
||||
const key = partyLabel(m)
|
||||
@@ -118,7 +124,9 @@ export function RepresentativeList({
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const filtered = searchQuery
|
||||
? mandates.filter((m) => m.politician.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
? mandates.filter((m) =>
|
||||
m.politician.label.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: mandates
|
||||
const groups = groupByParty(filtered, userCity)
|
||||
|
||||
@@ -145,7 +153,12 @@ export function RepresentativeList({
|
||||
type="search"
|
||||
/>
|
||||
{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
|
||||
</Button>
|
||||
)}
|
||||
@@ -155,8 +168,14 @@ export function RepresentativeList({
|
||||
{groups.map((group) => {
|
||||
const meta = getPartyMeta(group.partyLabel)
|
||||
const isCollapsed = collapsed[group.partyLabel] ?? false
|
||||
const nearbyMembers = userCity ? group.members.filter((m) => isLocalConstituency(m, userCity)) : []
|
||||
const visibleMembers = isCollapsed ? [] : effectiveShowAll ? group.members : nearbyMembers
|
||||
const nearbyMembers = userCity
|
||||
? group.members.filter((m) => isLocalConstituency(m, userCity))
|
||||
: []
|
||||
const visibleMembers = isCollapsed
|
||||
? []
|
||||
: effectiveShowAll
|
||||
? group.members
|
||||
: nearbyMembers
|
||||
|
||||
return (
|
||||
<Card key={group.partyLabel} className="py-0 gap-0 overflow-hidden">
|
||||
@@ -174,29 +193,47 @@ export function RepresentativeList({
|
||||
>
|
||||
{meta.short.slice(0, 3)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{group.partyLabel}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto mr-2">{group.members.length}</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{group.partyLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto mr-2">
|
||||
{group.members.length}
|
||||
</span>
|
||||
{isCollapsed ? <ChevronRight /> : <ChevronDown />}
|
||||
</button>
|
||||
{visibleMembers.length > 0 && (
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{visibleMembers.map((m) => {
|
||||
const followed = isFollowing("politician", m.politician.id)
|
||||
const followed = isFollowing(
|
||||
"politician",
|
||||
m.politician.id,
|
||||
)
|
||||
const fn = mandateFunction(m)
|
||||
const local = userCity ? isLocalConstituency(m, userCity) : false
|
||||
const local = userCity
|
||||
? isLocalConstituency(m, userCity)
|
||||
: false
|
||||
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
|
||||
to="/app/politician/$politicianId"
|
||||
params={{ politicianId: String(m.politician.id) }}
|
||||
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 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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>
|
||||
)}
|
||||
</Link>
|
||||
@@ -206,10 +243,18 @@ export function RepresentativeList({
|
||||
onClick={() =>
|
||||
followed
|
||||
? 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-label={followed ? `${m.politician.label} entfolgen` : `${m.politician.label} folgen`}
|
||||
aria-label={
|
||||
followed
|
||||
? `${m.politician.label} entfolgen`
|
||||
: `${m.politician.label} folgen`
|
||||
}
|
||||
className="shrink-0"
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
@@ -233,7 +278,9 @@ export function RepresentativeList({
|
||||
})}
|
||||
|
||||
{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>
|
||||
@@ -8,11 +8,16 @@ interface TopicToggleListProps {
|
||||
onSearchChange: (query: string) => void
|
||||
}
|
||||
|
||||
export function TopicToggleList({ searchQuery, onSearchChange }: TopicToggleListProps) {
|
||||
export function TopicToggleList({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: TopicToggleListProps) {
|
||||
const { topics, loading, error } = useTopics()
|
||||
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 (
|
||||
<div className="pb-4">
|
||||
@@ -41,14 +46,25 @@ export function TopicToggleList({ searchQuery, onSearchChange }: TopicToggleList
|
||||
{filtered.map((topic) => {
|
||||
const followed = isFollowing("topic", topic.id)
|
||||
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>
|
||||
<Button
|
||||
size="sm"
|
||||
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-label={followed ? `${topic.label} entfolgen` : `${topic.label} folgen`}
|
||||
aria-label={
|
||||
followed
|
||||
? `${topic.label} entfolgen`
|
||||
: `${topic.label} folgen`
|
||||
}
|
||||
>
|
||||
{followed ? "Folgst du" : "Folgen"}
|
||||
</Button>
|
||||
@@ -16,6 +16,8 @@ export async function getOrCreateDeviceId(db: PGlite): Promise<string> {
|
||||
if (res.rows.length > 0) return res.rows[0].id
|
||||
|
||||
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
|
||||
}
|
||||
@@ -12,7 +12,10 @@ export async function loadCachedFeed(
|
||||
db: PGlite,
|
||||
cacheKey = DEFAULT_CACHE_KEY,
|
||||
): 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
|
||||
const row = res.rows[0]
|
||||
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(
|
||||
`INSERT INTO feed_cache (id, data, updated_at) VALUES ($1, $2, 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])
|
||||
}
|
||||
@@ -7,7 +7,9 @@ export interface 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
|
||||
}
|
||||
|
||||
@@ -17,21 +19,30 @@ export async function addFollow(
|
||||
entityId: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
await db.query("INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [
|
||||
type,
|
||||
entityId,
|
||||
label,
|
||||
])
|
||||
await db.query(
|
||||
"INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
[type, entityId, label],
|
||||
)
|
||||
}
|
||||
|
||||
export async function removeFollow(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 removeFollow(
|
||||
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> {
|
||||
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])
|
||||
}
|
||||
@@ -8,8 +8,14 @@ export interface GeoResultRow {
|
||||
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export async function loadGeoCache(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])
|
||||
export async function loadGeoCache(
|
||||
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
|
||||
const row = res.rows[0]
|
||||
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(
|
||||
db: PGlite,
|
||||
): 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
|
||||
const row = res.rows[0]
|
||||
const cachedAt = new Date(row.cached_at).getTime()
|
||||
@@ -27,7 +35,11 @@ export async function loadMostRecentGeoCache(
|
||||
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(
|
||||
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, 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
|
||||
const deviceId = localStorage.getItem(STORAGE_KEYS.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)
|
||||
}
|
||||
|
||||
@@ -14,13 +17,16 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
|
||||
const followsRaw = localStorage.getItem(STORAGE_KEYS.follows)
|
||||
if (followsRaw) {
|
||||
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) {
|
||||
await db.query("INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", [
|
||||
f.type,
|
||||
f.entity_id,
|
||||
f.label,
|
||||
])
|
||||
await db.query(
|
||||
"INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
[f.type, f.entity_id, f.label],
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// corrupt data — skip
|
||||
@@ -32,7 +38,10 @@ export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
|
||||
const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache)
|
||||
if (feedRaw) {
|
||||
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)) {
|
||||
await db.query(
|
||||
`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)
|
||||
if (geoRaw) {
|
||||
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)) {
|
||||
await db.query(
|
||||
`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"
|
||||
|
||||
export async function getPushState(db: PGlite, key: string): Promise<string | null> {
|
||||
const res = await db.query<{ value: string }>("SELECT value FROM push_state WHERE key = $1", [key])
|
||||
export async function getPushState(
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
`INSERT INTO push_state (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2`,
|
||||
@@ -71,5 +71,13 @@ export function useFollows() {
|
||||
emitChange()
|
||||
}, [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 { getPushState, removePushState, setPushState } from "@/shared/db/push-state-db"
|
||||
import { isPushSubscribed, subscribeToPush, syncFollowsToBackend, unsubscribeFromPush } from "@/shared/lib/push-client"
|
||||
import {
|
||||
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 { useDeviceId } from "./use-device-id"
|
||||
import { type Follow, useFollows } from "./use-follows"
|
||||
@@ -59,7 +68,11 @@ export function usePush() {
|
||||
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")
|
||||
if (enabled !== "true") return
|
||||
syncFollowsToBackend(deviceId, follows)
|
||||
@@ -51,14 +51,20 @@ describe("fetchTopics", () => {
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe("searchPoliticians", () => {
|
||||
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")
|
||||
expect(results).toHaveLength(1)
|
||||
@@ -71,7 +77,14 @@ describe("searchPoliticians", () => {
|
||||
describe("fetchPolls", () => {
|
||||
it("returns sorted polls", async () => {
|
||||
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)
|
||||
@@ -92,8 +105,16 @@ describe("fetchPollsByIds", () => {
|
||||
|
||||
it("fetches each poll individually", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(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: [] }]))
|
||||
.mockResolvedValueOnce(
|
||||
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])
|
||||
expect(polls).toHaveLength(2)
|
||||
@@ -96,7 +96,11 @@ export type Vote = z.infer<typeof voteSchema>
|
||||
|
||||
// --- 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}`)
|
||||
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[]> {
|
||||
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> = {
|
||||
range_end: String(rangeEnd),
|
||||
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[]> {
|
||||
if (ids.length === 0) return []
|
||||
// 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()
|
||||
}
|
||||
|
||||
export function fetchCandidacyMandates(politicianID: number): Promise<CandidacyMandate[]> {
|
||||
export function fetchCandidacyMandates(
|
||||
politicianID: number,
|
||||
): Promise<CandidacyMandate[]> {
|
||||
return request(
|
||||
"candidacies-mandates",
|
||||
{
|
||||
@@ -160,10 +177,16 @@ export function fetchCandidacyMandates(politicianID: number): Promise<CandidacyM
|
||||
}
|
||||
|
||||
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(
|
||||
"candidacies-mandates",
|
||||
{
|
||||
@@ -8,7 +8,8 @@ export const DIP_API_KEY = "GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp"
|
||||
export const BUNDESTAG_LEGISLATURE_ID = 161
|
||||
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 =
|
||||
import.meta.env.VITE_VAPID_PUBLIC_KEY ??
|
||||
"BBRownmmnmTqNCeiaKp_CzvfXXNhB6NG5LTXDQwnSDspGgdKaZv_0UoAo4Ify6HHqcl4kY3ABw7w6GYAqUHYcdQ"
|
||||
@@ -20,7 +20,11 @@ export type Vorgang = z.infer<typeof vorgangSchema>
|
||||
|
||||
// --- 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}`)
|
||||
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
|
||||
vi.mock("./constants", () => ({
|
||||
BACKEND_URL: "https://test.example.com/api",
|
||||
VAPID_PUBLIC_KEY: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REqnSw",
|
||||
VAPID_PUBLIC_KEY:
|
||||
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REqnSw",
|
||||
}))
|
||||
|
||||
describe("push-client", () => {
|
||||
@@ -17,9 +18,10 @@ describe("push-client", () => {
|
||||
vi.stubGlobal("fetch", mockFetch)
|
||||
|
||||
const { syncFollowsToBackend } = await import("./push-client")
|
||||
const result = await syncFollowsToBackend("550e8400-e29b-41d4-a716-446655440000", [
|
||||
{ type: "topic", entity_id: 1, label: "Test Topic" },
|
||||
])
|
||||
const result = await syncFollowsToBackend(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
[{ type: "topic", entity_id: 1, label: "Test Topic" }],
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
@@ -37,10 +39,16 @@ describe("push-client", () => {
|
||||
})
|
||||
|
||||
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 result = await syncFollowsToBackend("550e8400-e29b-41d4-a716-446655440000", [])
|
||||
const result = await syncFollowsToBackend(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
[],
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
@@ -74,7 +74,10 @@ export async function unsubscribeFromPush(deviceId: string): Promise<boolean> {
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export async function syncFollowsToBackend(deviceId: string, follows: Follow[]): Promise<boolean> {
|
||||
export async function syncFollowsToBackend(
|
||||
deviceId: string,
|
||||
follows: Follow[],
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/push/sync`, {
|
||||
method: "POST",
|
||||
@@ -78,15 +78,17 @@ self.addEventListener("notificationclick", (event) => {
|
||||
const url = (event.notification.data as { url?: string })?.url ?? "/agw/"
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => {
|
||||
// focus existing window if possible
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes("/agw/") && "focus" in client) {
|
||||
return client.focus()
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((windowClients) => {
|
||||
// focus existing window if possible
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes("/agw/") && "focus" in client) {
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
// otherwise open new window
|
||||
return self.clients.openWindow(url)
|
||||
}),
|
||||
// otherwise open new window
|
||||
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