abgeordnetenwatch PWA + backend

feature-based React PWA with Hono backend:
- feed from abgeordnetenwatch.de API (polls by topic + politician)
- follow topics, search and follow politicians
- geo-based politician discovery via Nominatim
- push notifications for new polls via web-push
- service worker with offline caching
- deploy to Uberspace 8 (systemd, PostgreSQL, web backend proxy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 08:14:22 +01:00
commit 4e3aa682ac
51 changed files with 4131 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
.env
*.DS_Store
src/routeTree.gen.ts
server/drizzle/
*.log

2
.mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[tools]
bun = "1.3.10"

87
AGENTS.md Normal file
View File

@@ -0,0 +1,87 @@
# Agent Instructions
These instructions are authoritative for work in this repository.
## Project Overview
Abgeordnetenwatch PWA + Backend — a progressive web app that lets users follow Bundestag/Landtag topics and politicians, displaying a personalized feed of votes with push notifications for new votes. The PWA queries the Abgeordnetenwatch API directly for browsing; the backend polls for new votes and sends Web Push notifications to subscribed devices.
## Stack — PWA (root)
| Layer | Tool |
|---|---|
| Runtime | Bun |
| Build | Vite + TanStack Router Plugin + vite-plugin-pwa (injectManifest) |
| UI | React 19 + shadcn/ui + Tailwind CSS v4 |
| Routing | TanStack Router (file-based, `src/routes/`) |
| State | Zustand (UI), localStorage (Follows) |
| Validation | Zod |
| Code Quality | Biome + simple-git-hooks + lint-staged |
| Testing | Vitest + Testing Library |
## Stack — Backend (`server/`)
| Layer | Tool |
|---|---|
| Runtime | Node 22 (production via tsx), Bun (development) |
| Framework | Hono + @hono/node-server |
| Database | PostgreSQL + Drizzle ORM |
| Job Queue | pg-boss (cron: poll-checker every 15 min) |
| Push | web-push (VAPID) |
| Validation | Zod |
| Testing | Vitest |
## Architecture
```
src/ PWA source
├── features/ Feature modules (feed, topics, politicians, location, settings)
│ └── <feature>/ components/, hooks/, lib/, index.ts
├── shared/ Shared code
│ ├── components/ui/ shadcn components
│ ├── hooks/ use-device-id, use-follows, use-push, use-pwa-update
│ └── lib/ aw-api, constants, push-client, utils
├── routes/ TanStack Router file-based routes
├── sw.ts Custom service worker (precache + push handlers)
├── app.tsx RouterProvider
├── app.css Tailwind v4 + shadcn theme
└── main.tsx Entry point
server/ Backend source
├── src/
│ ├── features/push/ Push subscription routes + service
│ ├── shared/
│ │ ├── db/ Drizzle client + schema (push_subscriptions, device_follows, seen_polls, politician_mandates)
│ │ ├── jobs/ pg-boss client + poll-checker cron
│ │ └── lib/ env validation, AW API client (server-side), web-push helper
│ ├── app.ts Hono app assembly
│ └── index.ts Entry point
├── drizzle.config.ts
├── package.json
└── .env.example DATABASE_URL, PORT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT
```
## Deployment
- PWA: static files deploy to Uberspace at `/var/www/virtual/<user>/html/agw/`
- Backend: deployed as a supervisord service on Uberspace at `~/services/agw-backend/`
- Backend API is reverse-proxied at `/agw-api/` via `uberspace web backend`
- `./deploy.sh` builds and deploys both PWA and backend
- Apache `.htaccess` handles SPA routing
## Commands — PWA (root)
- `bun dev` — start dev server
- `bun run build` — production build
- `bun run test` — run tests
- `bun run lint` — biome check
- `bun run lint:fix` — biome auto-fix
## Commands — Backend (`server/`)
- `bun dev` — start with file watching
- `bun start` — start with Node + tsx (production)
- `bun run db:generate` — generate Drizzle migrations
- `bun run db:migrate` — run Drizzle migrations
- `bun run test` — run tests
- `bun run lint` — biome check

30
biome.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 120
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "warn",
"noUnusedVariables": "warn"
}
}
},
"files": {
"ignore": ["dist/", "node_modules/", "src/routeTree.gen.ts", "src/shared/components/ui/", ".claude/"]
}
}

1978
bun.lock Normal file

File diff suppressed because it is too large Load Diff

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/shared/components",
"utils": "@/shared/lib/utils",
"ui": "@/shared/components/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"registries": {}
}

84
deploy.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# deploy.sh — build and deploy PWA + backend to Uberspace 8
# Usage: ./deploy.sh [uberspace-user@host] (default: serve)
set -euo pipefail
REMOTE="${1:-serve}"
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
# reuse a single SSH connection to avoid rate limiting
SSH_SOCK="/tmp/deploy-ssh-$$"
ssh -fNM -o ControlMaster=yes -S "$SSH_SOCK" "$REMOTE"
trap 'ssh -S "$SSH_SOCK" -O exit "$REMOTE" 2>/dev/null' EXIT
r() { ssh -S "$SSH_SOCK" "$REMOTE" "$@"; }
REMOTE_USER=$(r 'echo $USER')
REMOTE_AGW_DIR="/var/www/virtual/$REMOTE_USER/html/agw"
REMOTE_SERVICE_DIR="/home/$REMOTE_USER/services/agw-backend"
SERVICE_NAME="agw-backend"
BACKEND_PORT=3002
# --- PWA ---
echo "==> Building PWA"
cd "$LOCAL_DIR"
bun install --frozen-lockfile
bun run build
echo "==> Deploying static files to $REMOTE:$REMOTE_AGW_DIR"
r "mkdir -p $REMOTE_AGW_DIR"
rsync -avz --delete -e "ssh -S $SSH_SOCK" \
"$LOCAL_DIR/dist/" "$REMOTE:$REMOTE_AGW_DIR/"
# --- Backend ---
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='.env' \
"$LOCAL_DIR/server/" "$REMOTE:$REMOTE_SERVICE_DIR/"
echo "==> Installing backend dependencies"
r "cd $REMOTE_SERVICE_DIR && npm 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"
# --- systemd user service (Uberspace 8) ---
echo "==> Configuring systemd user service"
r "mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/$SERVICE_NAME.service << EOF
[Unit]
Description=Abgeordnetenwatch Backend
After=network.target postgresql.service
[Service]
Type=simple
WorkingDirectory=$REMOTE_SERVICE_DIR
EnvironmentFile=$REMOTE_SERVICE_DIR/.env
ExecStart=$REMOTE_SERVICE_DIR/node_modules/.bin/tsx src/index.ts
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
EOF"
echo "==> Restarting backend service"
r "systemctl --user daemon-reload && systemctl --user enable $SERVICE_NAME && systemctl --user restart $SERVICE_NAME"
sleep 2
r "systemctl --user status $SERVICE_NAME --no-pager" || true
# --- Web backend (reverse proxy) ---
echo "==> Configuring web backend for /agw-api/"
r "uberspace web backend add /agw-api PORT $BACKEND_PORT --remove-prefix --force --wait" 2>/dev/null || true
echo ""
echo "Done."
echo " PWA: https://$REMOTE_USER.uber.space/agw/"
echo " Backend: https://$REMOTE_USER.uber.space/agw-api/"

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1d4ed8" />
<link rel="icon" type="image/svg+xml" href="/agw/icons/icon.svg" />
<title>Abgeordnetenwatch</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "abgeordnetenwatch-pwa",
"version": "2026.03.01",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome check .",
"lint:fix": "biome check --fix .",
"prepare": "simple-git-hooks"
},
"dependencies": {
"@tanstack/react-router": "^1.120.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.3",
"zustand": "^5.0.5"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@tailwindcss/vite": "^4.1.4",
"@tanstack/router-plugin": "^1.120.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^25.3.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.5.1",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.0",
"shadcn": "^3.8.5",
"simple-git-hooks": "^2.11.1",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.2.1",
"workbox-core": "^7.4.0",
"workbox-expiration": "^7.4.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
},
"simple-git-hooks": {
"pre-commit": "bunx lint-staged"
},
"lint-staged": {
"*.{ts,tsx,js,json,css}": ["biome check --fix --no-errors-on-unmatched"]
}
}

9
public/.htaccess Normal file
View File

@@ -0,0 +1,9 @@
RewriteEngine On
RewriteBase /agw/
# If the requested file or directory exists, serve it directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Otherwise, rewrite to index.html for SPA routing
RewriteRule ^ index.html [L]

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

17
public/icons/icon.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#1d4ed8"/>
<!-- Parliament building simplified -->
<rect x="20" y="60" width="60" height="20" fill="white" opacity="0.95"/>
<rect x="25" y="45" width="50" height="18" fill="white" opacity="0.9"/>
<!-- Columns -->
<rect x="28" y="47" width="6" height="16" fill="#1d4ed8"/>
<rect x="38" y="47" width="6" height="16" fill="#1d4ed8"/>
<rect x="48" y="47" width="6" height="16" fill="#1d4ed8"/>
<rect x="58" y="47" width="6" height="16" fill="#1d4ed8"/>
<!-- Roof / pediment -->
<polygon points="22,45 50,28 78,45" fill="white" opacity="0.95"/>
<!-- Dome -->
<circle cx="50" cy="27" r="8" fill="white" opacity="0.85"/>
<!-- Eagle / emblem hint -->
<rect x="44" y="62" width="12" height="16" fill="#1d4ed8"/>
</svg>

After

Width:  |  Height:  |  Size: 873 B

110
src/app.css Normal file
View File

@@ -0,0 +1,110 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.451 0.211 262.881);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.451 0.211 262.881);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.585 0.233 262.881);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.585 0.233 262.881);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
}
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}

23
src/app.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { routeTree } from "./routeTree.gen"
const router = createRouter({
routeTree,
basepath: "/agw",
defaultErrorComponent: ({ error }) => (
<div style={{ padding: 20, color: "red" }}>
<h2>Error</h2>
<pre>{error instanceof Error ? error.message : String(error)}</pre>
</div>
),
})
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
export function App() {
return <RouterProvider router={router} />
}

View File

@@ -0,0 +1,8 @@
export function FeedEmpty() {
return (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground px-6 text-center">
<p className="text-lg font-medium">Your feed is empty</p>
<p className="text-sm mt-2">Follow topics or politicians in the other tabs to see polls here.</p>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { Badge } from "@/shared/components/ui/badge"
import type { FeedItem as FeedItemType } from "../lib/assemble-feed"
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" })
}
export function FeedItemCard({ item }: { item: FeedItemType }) {
return (
<article className="p-4">
<div className="flex items-start justify-between gap-2">
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-base font-medium leading-snug hover:underline"
>
{item.title}
</a>
) : (
<h2 className="text-base font-medium leading-snug">{item.title}</h2>
)}
{item.date && (
<time dateTime={item.date} className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{formatDate(item.date)}
</time>
)}
</div>
{item.topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2" aria-label="Topics">
{item.topics.map((topic) =>
topic.url ? (
<a key={topic.label} href={topic.url} target="_blank" rel="noopener noreferrer">
<Badge variant="secondary" className="hover:bg-accent-foreground/10 cursor-pointer">
{topic.label}
</Badge>
</a>
) : (
<Badge key={topic.label} variant="secondary">
{topic.label}
</Badge>
),
)}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">{item.source}</p>
</article>
)
}

View File

@@ -0,0 +1,15 @@
import type { FeedItem } from "../lib/assemble-feed"
import { FeedEmpty } from "./feed-empty"
import { FeedItemCard } from "./feed-item"
export function FeedList({ items }: { items: FeedItem[] }) {
if (items.length === 0) return <FeedEmpty />
return (
<main className="divide-y divide-border">
{items.map((item) => (
<FeedItemCard key={item.id} item={item} />
))}
</main>
)
}

View File

@@ -0,0 +1,36 @@
import { useFollows } from "@/shared/hooks/use-follows"
import { useCallback, useEffect, useMemo, useState } from "react"
import { type FeedItem, assembleFeed } from "../lib/assemble-feed"
export function useFeed() {
const { follows } = useFollows()
const [items, setItems] = useState<FeedItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | 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 refresh = useCallback(async () => {
if (topicIDs.length === 0 && politicianIDs.length === 0) {
setItems([])
return
}
setLoading(true)
setError(null)
try {
const feed = await assembleFeed(topicIDs, politicianIDs)
setItems(feed)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}, [topicIDs, politicianIDs])
useEffect(() => {
refresh()
}, [refresh])
return { items, loading, error, refresh }
}

View File

@@ -0,0 +1,2 @@
export { FeedList } from "./components/feed-list"
export { useFeed } from "./hooks/use-feed"

View File

@@ -0,0 +1,3 @@
export { loadCachedResult, clearGeoCache, detectFromCoords } from "./lib/geo"
export type { GeoResult } from "./lib/geo"
export { getPartyMeta } from "./lib/parties"

View File

@@ -0,0 +1,160 @@
import { Button } from "@/shared/components/ui/button"
import { Card } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { useFollows } from "@/shared/hooks/use-follows"
import type { MandateWithPolitician } from "@/shared/lib/aw-api"
import { Link } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react"
import { type GeoResult, loadCachedResult } from "../../location/lib/geo"
import { getPartyMeta } from "../../location/lib/parties"
interface PartyGroup {
partyLabel: string
members: MandateWithPolitician[]
}
/** Extract party/fraction label for grouping. Prefers party, falls back to current fraction. */
function partyLabel(m: MandateWithPolitician): string {
if (m.party?.label) return m.party.label
// fraction label is e.g. "CDU/CSU (Bundestag 2025 - 2029)" — strip the parliament suffix
const current = m.fraction_membership?.find((f) => !f.valid_until)
if (current) return current.fraction.label.replace(/\s*\([^)]+\)\s*$/, "")
return "parteilos"
}
function groupByParty(mandates: MandateWithPolitician[]): PartyGroup[] {
const map = new Map<string, MandateWithPolitician[]>()
for (const m of mandates) {
const key = partyLabel(m)
const list = map.get(key)
if (list) {
list.push(m)
} else {
map.set(key, [m])
}
}
return Array.from(map.entries())
.sort((a, b) => b[1].length - a[1].length)
.map(([partyLabel, members]) => ({
partyLabel,
members: members.sort((a, b) => a.politician.label.localeCompare(b.politician.label)),
}))
}
function mandateFunction(m: MandateWithPolitician): string | null {
const won = m.electoral_data?.mandate_won
const constituency = m.electoral_data?.constituency?.label
if (!won) return null
if (won === "constituency" && constituency) {
const clean = constituency.replace(/\s*\([^)]+\)\s*$/, "")
return `Wahlkreis ${clean}`
}
if (won === "list") return "Landesliste"
if (won === "moved_up") return "Nachgerückt"
return won
}
export function PoliticianSearch() {
const [result, setResult] = useState<GeoResult | null>(null)
const [search, setSearch] = useState("")
const { isFollowing, follow, unfollow } = useFollows()
useEffect(() => {
const cached = loadCachedResult()
if (cached) setResult(cached)
}, [])
const groups = useMemo(() => {
if (!result) return []
const filtered = search
? result.mandates.filter((m) => m.politician.label.toLowerCase().includes(search.toLowerCase()))
: result.mandates
return groupByParty(filtered)
}, [result, search])
if (!result || result.mandates.length === 0) {
return (
<main className="p-8 text-center">
<p className="text-muted-foreground text-sm mb-4">
No parliament members loaded yet. Detect your location in Settings first.
</p>
<Link to="/settings" className="text-primary text-sm underline">
Go to Settings
</Link>
</main>
)
}
return (
<main>
<div className="p-4 border-b border-border">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by name…"
aria-label="Filter representatives"
/>
</div>
<div className="p-4 space-y-3">
{groups.map((group) => {
const meta = getPartyMeta(group.partyLabel)
return (
<Card key={group.partyLabel}>
<div
className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border"
style={{ borderLeftWidth: 4, borderLeftColor: meta.color }}
>
<span
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-[10px] font-bold text-white shrink-0"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
>
{meta.short.slice(0, 3)}
</span>
<span className="text-sm font-semibold">{group.partyLabel}</span>
<span className="text-xs text-muted-foreground ml-auto">{group.members.length}</span>
</div>
<ul className="divide-y divide-border">
{group.members.map((m) => {
const followed = isFollowing("politician", m.politician.id)
const fn = mandateFunction(m)
return (
<li
key={m.id}
className="flex items-center justify-between pl-4 pr-4 py-2.5"
style={{ borderLeftWidth: 3, borderLeftColor: meta.color }}
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{m.politician.label}</p>
{fn && <p className="text-xs text-muted-foreground mt-0.5">{fn}</p>}
</div>
<Button
variant={followed ? "default" : "outline"}
size="sm"
onClick={() =>
followed
? unfollow("politician", m.politician.id)
: follow("politician", m.politician.id, m.politician.label)
}
aria-pressed={followed}
aria-label={followed ? `Unfollow ${m.politician.label}` : `Follow ${m.politician.label}`}
className="ml-4 shrink-0 rounded-full"
>
{followed ? "Following" : "Follow"}
</Button>
</li>
)
})}
</ul>
</Card>
)
})}
{groups.length === 0 && search && (
<p className="text-center text-sm text-muted-foreground py-6">No members matching "{search}"</p>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1 @@
export { PoliticianSearch } from "./components/politician-search"

View File

@@ -0,0 +1,105 @@
import { Button } from "@/shared/components/ui/button"
interface NotificationGuideProps {
onBack: () => void
}
export function NotificationGuide({ onBack }: NotificationGuideProps) {
return (
<main className="p-4">
<button type="button" onClick={onBack} className="flex items-center gap-1 text-primary text-sm mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Einstellungen
</button>
<h2 className="text-lg font-semibold mb-2">Benachrichtigungen einrichten</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.
</p>
<ol className="space-y-6">
<li className="flex gap-3">
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
1
</span>
<div>
<p className="text-sm font-medium">Teilen-Menü öffnen</p>
<p className="text-sm text-muted-foreground mt-1">
Tippe auf das Teilen-Symbol{" "}
<svg
xmlns="http://www.w3.org/2000/svg"
className="inline w-4 h-4 align-text-bottom"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>{" "}
in der Safari-Leiste unten.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
2
</span>
<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 text-foreground">Zum Home-Bildschirm</span>.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
3
</span>
<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 text-foreground">Hinzufügen</span>. Die App erscheint als Icon
auf deinem Homescreen.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold shrink-0">
4
</span>
<div>
<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.
</p>
</div>
</li>
</ol>
<div className="mt-8">
<Button variant="outline" onClick={onBack} className="w-full">
Zurück zu den Einstellungen
</Button>
</div>
</main>
)
}

View File

@@ -0,0 +1,291 @@
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Switch } from "@/shared/components/ui/switch"
import { useDeviceId } from "@/shared/hooks/use-device-id"
import { usePush } from "@/shared/hooks/use-push"
import { usePwaUpdate } from "@/shared/hooks/use-pwa-update"
import { VAPID_PUBLIC_KEY } from "@/shared/lib/constants"
import { useCallback, useEffect, useState } from "react"
import { type GeoResult, clearGeoCache, detectFromCoords, loadCachedResult } from "../../location/lib/geo"
import { NotificationGuide } from "./notification-guide"
function formatCacheAge(cachedAt: number): string {
const minutes = Math.floor((Date.now() - cachedAt) / 60_000)
if (minutes < 1) return "gerade eben"
if (minutes < 60) return `vor ${minutes} Min.`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `vor ${hours} Std.`
const days = Math.floor(hours / 24)
return `vor ${days} T.`
}
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)
)
}
export function SettingsPage() {
const deviceId = useDeviceId()
const { needRefresh, checkForUpdate, applyUpdate } = usePwaUpdate()
const push = usePush()
const [checking, setChecking] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<GeoResult | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [showGuide, setShowGuide] = useState(false)
useEffect(() => {
const cached = loadCachedResult()
if (cached) setResult(cached)
}, [])
const detect = useCallback((skipCache: boolean) => {
if (!navigator.geolocation) {
setErrorMsg("Standortbestimmung wird nicht unterstützt")
return
}
setLoading(true)
setErrorMsg(null)
navigator.geolocation.getCurrentPosition(
async (pos) => {
try {
const r = await detectFromCoords(pos.coords.latitude, pos.coords.longitude, skipCache)
setResult(r)
} catch (e) {
setErrorMsg(String(e))
} finally {
setLoading(false)
}
},
(err) => {
setErrorMsg(err.message)
setLoading(false)
},
)
}, [])
function handleClearCache() {
clearGeoCache()
setResult(null)
}
async function handleCheckUpdate() {
setChecking(true)
try {
await checkForUpdate()
} finally {
setChecking(false)
}
}
const hasLocation = result && result.mandates.length > 0
const standalone = isStandalone()
if (showGuide) {
return <NotificationGuide onBack={() => setShowGuide(false)} />
}
return (
<main className="p-4 space-y-6">
{/* --- Notifications --- */}
{VAPID_PUBLIC_KEY && (
<section>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">
Benachrichtigungen
</h2>
<Card>
<CardContent className="p-0">
{push.permission === "denied" ? (
<div className="px-4 py-3">
<p className="text-sm text-destructive">
Benachrichtigungen sind blockiert. Bitte in den Systemeinstellungen aktivieren.
</p>
</div>
) : (
<div className="flex items-center justify-between px-4 py-3">
<label htmlFor="push-toggle" className="text-sm font-normal">
Push-Benachrichtigungen
</label>
<Switch
id="push-toggle"
checked={push.subscribed}
disabled={push.loading}
onCheckedChange={(checked) => {
if (checked) push.subscribe()
else push.unsubscribe()
}}
/>
</div>
)}
{!standalone && (
<>
<div className="border-t border-border mx-4" />
<button
type="button"
onClick={() => setShowGuide(true)}
className="flex items-center justify-between w-full px-4 py-3 text-left"
>
<span className="text-sm">Einrichtung auf dem iPhone</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</>
)}
</CardContent>
</Card>
</section>
)}
{/* --- Location --- */}
<section>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Standort</h2>
{hasLocation && result.bundesland ? (
<Card className="mb-3">
<CardContent className="p-0">
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Bundesland</span>
<span className="text-sm text-muted-foreground">{result.bundesland}</span>
</div>
</div>
{result.landtag_label && (
<>
<div className="border-t border-border mx-4" />
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Landtag</span>
<span className="text-sm text-muted-foreground">{result.landtag_label}</span>
</div>
</div>
</>
)}
<div className="border-t border-border mx-4" />
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Abgeordnete geladen</span>
<span className="text-sm text-muted-foreground">{result.mandates.length}</span>
</div>
</div>
{result.cachedAt && (
<>
<div className="border-t border-border mx-4" />
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Zwischengespeichert</span>
<span className="text-sm text-muted-foreground">{formatCacheAge(result.cachedAt)}</span>
</div>
</div>
</>
)}
</CardContent>
</Card>
) : null}
{loading && (
<output className="flex items-center justify-center h-16 mb-3" aria-label="Standort wird bestimmt">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="ml-3 text-sm text-muted-foreground">{hasLocation ? "Aktualisiere…" : "Erkenne…"}</span>
</output>
)}
{errorMsg && (
<div className="mb-3 p-3 bg-destructive/10 rounded-lg text-destructive text-sm" role="alert">
{errorMsg}
</div>
)}
<div className="flex flex-col gap-2">
{!hasLocation && !loading && (
<Button onClick={() => detect(false)} className="w-full">
Standort erkennen
</Button>
)}
{hasLocation && (
<>
<Button variant="outline" size="sm" onClick={() => detect(true)} disabled={loading} className="w-full">
Abgeordnete neu laden
</Button>
<Button variant="outline" size="sm" onClick={handleClearCache} disabled={loading} className="w-full">
Cache löschen
</Button>
</>
)}
</div>
</section>
{/* --- App Update --- */}
<section>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">App-Update</h2>
<Card>
<CardContent className="p-0">
{needRefresh ? (
<>
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Neue Version verfügbar</span>
<Button size="sm" onClick={applyUpdate}>
Jetzt aktualisieren
</Button>
</div>
</div>
</>
) : (
<>
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">App ist aktuell</span>
<Button variant="outline" size="xs" onClick={handleCheckUpdate} disabled={checking}>
{checking ? "Prüfe…" : "Prüfen"}
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</section>
{/* --- About --- */}
<section>
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-1">Info</h2>
<Card>
<CardContent className="p-0">
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Datenquelle</span>
<a
href="https://www.abgeordnetenwatch.de"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary"
>
abgeordnetenwatch.de
</a>
</div>
</div>
<div className="border-t border-border mx-4" />
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-sm">Geräte-ID</span>
<span className="text-xs text-muted-foreground font-mono max-w-[50%] truncate">{deviceId}</span>
</div>
</div>
</CardContent>
</Card>
</section>
</main>
)
}

View File

@@ -0,0 +1 @@
export { SettingsPage } from "./components/settings-page"

View File

@@ -0,0 +1,60 @@
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { useFollows } from "@/shared/hooks/use-follows"
import { useState } from "react"
import { useTopics } from "../hooks/use-topics"
export function TopicList() {
const { topics, loading, error } = useTopics()
const { isFollowing, follow, unfollow } = useFollows()
const [search, setSearch] = useState("")
const filtered = topics.filter((t) => t.label.toLowerCase().includes(search.toLowerCase()))
return (
<main>
<div className="p-4 border-b border-border">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search topics…"
aria-label="Search topics"
/>
</div>
{loading && (
<output className="flex items-center justify-center h-48" aria-label="Loading topics">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</output>
)}
{error && (
<div className="p-4 text-destructive" role="alert">
{error}
</div>
)}
<ul className="divide-y divide-border">
{filtered.map((topic) => {
const followed = isFollowing("topic", topic.id)
return (
<li key={topic.id} className="flex items-center justify-between px-4 py-3">
<span className="text-sm">{topic.label}</span>
<Button
variant={followed ? "default" : "outline"}
size="sm"
onClick={() => (followed ? unfollow("topic", topic.id) : follow("topic", topic.id, topic.label))}
aria-pressed={followed}
aria-label={followed ? `Unfollow ${topic.label}` : `Follow ${topic.label}`}
className="ml-4 shrink-0 rounded-full"
>
{followed ? "Following" : "Follow"}
</Button>
</li>
)
})}
</ul>
</main>
)
}

View File

@@ -0,0 +1,18 @@
import { type Topic, fetchTopics } from "@/shared/lib/aw-api"
import { useEffect, useState } from "react"
export function useTopics() {
const [topics, setTopics] = useState<Topic[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
fetchTopics()
.then(setTopics)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
return { topics, loading, error }
}

View File

@@ -0,0 +1 @@
export { TopicList } from "./components/topic-list"

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "@/app.css"
import { App } from "@/app"
// biome-ignore lint/style/noNonNullAssertion: root element always exists in index.html
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)

88
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { Link, Outlet, createRootRoute, useMatches } from "@tanstack/react-router"
interface TabDef {
to: string
label: string
icon: string
ariaLabel: string
}
const TABS: TabDef[] = [
{
to: "/feed",
label: "Feed",
ariaLabel: "Feed",
icon: "M3 7h18M3 12h18M3 17h18",
},
{
to: "/topics",
label: "Topics",
ariaLabel: "Topics",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
},
{
to: "/politicians",
label: "People",
ariaLabel: "Politicians",
icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z",
},
{
to: "/settings",
label: "Settings",
ariaLabel: "Settings",
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
},
]
function RootComponent() {
const matches = useMatches()
const currentPath = matches.at(-1)?.pathname ?? "/feed"
const currentTab = TABS.find((t) => currentPath.endsWith(t.to)) ?? TABS[0]
return (
<div className="flex flex-col h-dvh max-w-lg mx-auto">
<header className="flex items-center px-4 py-3 bg-card border-b border-border shadow-sm safe-area-top">
<h1 className="text-base font-semibold text-card-foreground">Abgeordnetenwatch {currentTab.label}</h1>
</header>
<div className="flex-1 overflow-y-auto">
<Outlet />
</div>
<nav className="flex bg-card border-t border-border safe-area-bottom" role="tablist" aria-label="Main navigation">
{TABS.map((tab) => {
const active = currentPath.endsWith(tab.to)
return (
<Link
key={tab.to}
to={tab.to}
role="tab"
aria-selected={active}
aria-label={tab.ariaLabel}
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"
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={active ? 2.5 : 1.5}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d={tab.icon} />
</svg>
<span className="text-[10px] font-medium">{tab.label}</span>
</Link>
)
})}
</nav>
</div>
)
}
export const Route = createRootRoute({
component: RootComponent,
})

29
src/routes/feed.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { FeedList, useFeed } from "@/features/feed"
import { createFileRoute } from "@tanstack/react-router"
function FeedPage() {
const { items, loading, error } = useFeed()
if (loading) {
return (
<output className="flex items-center justify-center h-48" aria-label="Loading feed">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</output>
)
}
if (error) {
return (
<div className="p-4 text-destructive" role="alert">
<p className="font-semibold">Error loading feed</p>
<p className="text-sm mt-1">{error}</p>
</div>
)
}
return <FeedList items={items} />
}
export const Route = createFileRoute("/feed")({
component: FeedPage,
})

5
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/")({
component: () => <Navigate to="/feed" />,
})

View File

@@ -0,0 +1,6 @@
import { PoliticianSearch } from "@/features/politicians"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/politicians")({
component: PoliticianSearch,
})

6
src/routes/settings.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { SettingsPage } from "@/features/settings"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/settings")({
component: SettingsPage,
})

6
src/routes/topics.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { TopicList } from "@/features/topics"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/topics")({
component: TopicList,
})

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/shared/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/shared/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/shared/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/shared/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/shared/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/shared/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,33 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { useState } from "react"
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
function getOrCreateDeviceId(): string {
let id = localStorage.getItem(STORAGE_KEYS.deviceId)
if (!id) {
id = generateUUID()
localStorage.setItem(STORAGE_KEYS.deviceId, id)
}
return id
}
let _cached: string | null = null
export function useDeviceId(): string {
const [deviceId] = useState<string>(() => {
if (_cached) return _cached
_cached = getOrCreateDeviceId()
return _cached
})
return deviceId
}

View File

@@ -0,0 +1,74 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { act, renderHook } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { _resetFollowsCache, useFollows } from "./use-follows"
beforeEach(() => {
localStorage.clear()
_resetFollowsCache()
})
describe("useFollows", () => {
it("starts with empty follows", () => {
const { result } = renderHook(() => useFollows())
expect(result.current.follows).toEqual([])
})
it("can follow a topic", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 1, "Umwelt")
})
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 1, label: "Umwelt" }])
expect(result.current.isFollowing("topic", 1)).toBe(true)
})
it("can follow a politician", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("politician", 42, "Angela Merkel")
})
expect(result.current.isFollowing("politician", 42)).toBe(true)
expect(result.current.isFollowing("topic", 42)).toBe(false)
})
it("can unfollow", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 1, "Umwelt")
result.current.follow("topic", 2, "Bildung")
})
act(() => {
result.current.unfollow("topic", 1)
})
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 2, label: "Bildung" }])
expect(result.current.isFollowing("topic", 1)).toBe(false)
})
it("does not duplicate follows", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 1, "Umwelt")
result.current.follow("topic", 1, "Umwelt")
})
expect(result.current.follows).toHaveLength(1)
})
it("persists to localStorage", () => {
const { result } = renderHook(() => useFollows())
act(() => {
result.current.follow("topic", 5, "Wirtschaft")
})
const raw = localStorage.getItem(STORAGE_KEYS.follows)
expect(raw).toBeTruthy()
// biome-ignore lint/style/noNonNullAssertion: asserted truthy above
const parsed = JSON.parse(raw!)
expect(parsed).toEqual([{ type: "topic", entity_id: 5, label: "Wirtschaft" }])
})
it("loads from existing localStorage", () => {
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify([{ type: "politician", entity_id: 99, label: "Test" }]))
const { result } = renderHook(() => useFollows())
expect(result.current.follows).toEqual([{ type: "politician", entity_id: 99, label: "Test" }])
})
})

View File

@@ -0,0 +1,73 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
import { useCallback, useSyncExternalStore } from "react"
export interface Follow {
type: "topic" | "politician"
entity_id: number
label: string
}
type Listener = () => void
const listeners = new Set<Listener>()
let cachedRaw: string | null = null
let cachedFollows: Follow[] = []
function emitChange() {
cachedRaw = null
for (const listener of listeners) listener()
}
function subscribe(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
function getSnapshot(): Follow[] {
const raw = localStorage.getItem(STORAGE_KEYS.follows)
if (raw === cachedRaw) return cachedFollows
cachedRaw = raw
if (!raw) {
cachedFollows = []
} else {
try {
cachedFollows = JSON.parse(raw) as Follow[]
} catch {
cachedFollows = []
}
}
return cachedFollows
}
function setFollows(follows: Follow[]) {
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify(follows))
emitChange()
}
export function _resetFollowsCache() {
cachedRaw = null
cachedFollows = []
}
export function useFollows() {
const follows = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const isFollowing = useCallback(
(type: "topic" | "politician", entityId: number) =>
follows.some((f) => f.type === type && f.entity_id === entityId),
[follows],
)
const follow = useCallback((type: "topic" | "politician", entityId: number, label: string) => {
const current = getSnapshot()
if (current.some((f) => f.type === type && f.entity_id === entityId)) return
setFollows([...current, { type, entity_id: entityId, label }])
}, [])
const unfollow = useCallback((type: "topic" | "politician", entityId: number) => {
const current = getSnapshot()
setFollows(current.filter((f) => !(f.type === type && f.entity_id === entityId)))
}, [])
return { follows, isFollowing, follow, unfollow }
}

View File

@@ -0,0 +1,68 @@
import { STORAGE_KEYS } from "@/shared/lib/constants"
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"
type PushPermission = "default" | "granted" | "denied"
function getPermission(): PushPermission {
if (typeof Notification === "undefined") return "denied"
return Notification.permission as PushPermission
}
export function usePush() {
const deviceId = useDeviceId()
const { follows } = useFollows()
const [permission, setPermission] = useState<PushPermission>(getPermission)
const [subscribed, setSubscribed] = useState(false)
const [loading, setLoading] = useState(false)
// check subscription status on mount
useEffect(() => {
isPushSubscribed().then(setSubscribed)
}, [])
const subscribe = useCallback(async () => {
setLoading(true)
try {
const ok = await subscribeToPush(deviceId)
if (ok) {
setSubscribed(true)
setPermission(getPermission())
localStorage.setItem(STORAGE_KEYS.pushEnabled, "true")
// sync current follows immediately
await syncFollowsToBackend(deviceId, follows)
}
return ok
} finally {
setLoading(false)
}
}, [deviceId, follows])
const unsubscribe = useCallback(async () => {
setLoading(true)
try {
await unsubscribeFromPush(deviceId)
setSubscribed(false)
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
} finally {
setLoading(false)
}
}, [deviceId])
// auto-sync follows to backend when they change (only if subscribed)
useEffect(() => {
if (!subscribed) return
syncFollowsToBackend(deviceId, follows)
}, [follows, subscribed, deviceId])
return { permission, subscribed, loading, subscribe, unsubscribe }
}
// standalone sync function for use outside React hooks
export function triggerPushSync(deviceId: string, follows: Follow[]) {
const enabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
if (enabled !== "true") return
syncFollowsToBackend(deviceId, follows)
}

View File

@@ -0,0 +1,50 @@
import { registerSW } from "virtual:pwa-register"
import { useEffect, useState } from "react"
let updateSW: ((reloadPage?: boolean) => Promise<void>) | null = null
let hasUpdate = false
const listeners = new Set<() => void>()
function notifyListeners() {
for (const fn of listeners) fn()
}
// Register the SW once at module level
if (typeof window !== "undefined") {
updateSW = registerSW({
onNeedRefresh() {
hasUpdate = true
notifyListeners()
},
})
}
export function usePwaUpdate() {
const [needRefresh, setNeedRefresh] = useState(hasUpdate)
useEffect(() => {
const handler = () => setNeedRefresh(true)
listeners.add(handler)
return () => {
listeners.delete(handler)
}
}, [])
async function checkForUpdate() {
// Re-check with the server by re-registering
const reg = await navigator.serviceWorker?.getRegistration()
if (reg) {
await reg.update()
}
}
async function applyUpdate() {
if (updateSW) {
await updateSW(true)
} else {
window.location.reload()
}
}
return { needRefresh, checkForUpdate, applyUpdate }
}

92
src/sw.ts Normal file
View File

@@ -0,0 +1,92 @@
/// <reference lib="webworker" />
import { clientsClaim } from "workbox-core"
import { ExpirationPlugin } from "workbox-expiration"
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"
import { registerRoute } from "workbox-routing"
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies"
declare let self: ServiceWorkerGlobalScope
// allow the app to trigger skipWaiting via postMessage
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting()
}
})
// precache all assets injected by vite-plugin-pwa
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
// runtime cache: AW API
registerRoute(
/^https:\/\/www\.abgeordnetenwatch\.de\/api\/v2\//,
new StaleWhileRevalidate({
cacheName: "aw-api-cache",
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 60 * 15,
}),
],
}),
)
// runtime cache: Nominatim
registerRoute(
/^https:\/\/nominatim\.openstreetmap\.org\//,
new CacheFirst({
cacheName: "nominatim-cache",
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24,
}),
],
}),
)
// --- Push notification handlers ---
interface PushPayload {
title: string
body: string
url?: string
tag?: string
}
self.addEventListener("push", (event) => {
if (!event.data) return
const payload = event.data.json() as PushPayload
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
tag: payload.tag,
data: { url: payload.url },
icon: "/agw/icons/icon-192.png",
badge: "/agw/icons/icon-192.png",
}),
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
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()
}
}
// otherwise open new window
return self.clients.openWindow(url)
}),
)
})

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vite-plugin-pwa/client"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

65
vite.config.ts Normal file
View File

@@ -0,0 +1,65 @@
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import tailwindcss from "@tailwindcss/vite"
import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { VitePWA } from "vite-plugin-pwa"
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
base: "/agw/",
plugins: [
tanstackRouter({
routesDirectory: "src/routes",
generatedRouteTree: "src/routeTree.gen.ts",
}),
react(),
tailwindcss(),
VitePWA({
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
registerType: "prompt",
manifest: {
name: "Abgeordnetenwatch",
short_name: "Abgwatch",
description: "Track Bundestag and Landtag votes, follow topics and politicians.",
start_url: "/agw/",
scope: "/agw/",
display: "standalone",
orientation: "portrait-primary",
theme_color: "#1d4ed8",
background_color: "#f9fafb",
lang: "en",
icons: [
{
src: "/agw/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any maskable",
},
{
src: "/agw/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
],
},
injectManifest: {
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
},
}),
],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
})

20
vitest.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { resolve } from "node:path"
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { defineConfig } from "vitest/config"
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: [],
exclude: ["node_modules/**", "server/**"],
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
})