diff --git a/AGENTS.md b/AGENTS.md index 0843b56..054974f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ next-intl, German by default, English at `/en`. Deployed with Docker Compose on | Derive facts from stored data | [docs/contributing/enrichers.md](docs/contributing/enrichers.md) | | API endpoint or MCP tool | [docs/contributing/api.md](docs/contributing/api.md) | | UI page or new claim type in the UI | [docs/contributing/frontend.md](docs/contributing/frontend.md) | +| How the UI should look and read | [docs/design/README.md](docs/design/README.md), [design-system.md](docs/design/design-system.md) | | Tests and fixtures | [docs/contributing/testing.md](docs/contributing/testing.md) | | Why it is built this way | [docs/architecture/02-comparison-and-decisions.md](docs/architecture/02-comparison-and-decisions.md) | | Tables, claim types, identifier schemes | [docs/data-model.md](docs/data-model.md) | diff --git a/README.md b/README.md index f0cd5d9..4aed195 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ sources they trust. |---|---| | [docs/architecture/01-design-from-scratch.md](docs/architecture/01-design-from-scratch.md) | The architecture: quality goals, data model, adapter framework, serving, testing, guard-rails | | [docs/architecture/02-comparison-and-decisions.md](docs/architecture/02-comparison-and-decisions.md) | Why it is built this way (decision records) | +| [docs/design/README.md](docs/design/README.md) | Design concept: principles, information architecture, core patterns, every page | +| [docs/design/design-system.md](docs/design/design-system.md) | Design system: tokens, components, content and accessibility rules | | [docs/data-model.md](docs/data-model.md) | Tables, claim types, identifier schemes, views | | [docs/sources.md](docs/sources.md) | Every source with access method, licence/ToS assessment, and what we deliberately don't collect (incl. Telegram/WhatsApp/Signal) | | [docs/legal.md](docs/legal.md) | Copyright/TDM, GDPR, MedienG §7b (presumption of innocence), defamation | diff --git a/backend/politrack/api/main.py b/backend/politrack/api/main.py index 12af626..6fae3b5 100644 --- a/backend/politrack/api/main.py +++ b/backend/politrack/api/main.py @@ -165,6 +165,13 @@ def get_claims( limit=limit, offset=offset) +@app.get("/api/v1/claims/{claim_id}", response_model=s.Claim) +def get_claim(claim_id: int, conn: DB, sources: Sources): + """One claim with all its evidence - the target of permalinks (callers normally omit the source filter).""" + rows = queries.list_claims(conn, sources=sources, claim_id=claim_id, limit=1) + return not_found(rows[0] if rows else None, "claim") + + @app.get("/api/v1/timeline", response_model=list[s.TimelineBucket]) def get_timeline(conn: DB, sources: Sources, person: str | None = None, party: str | None = None): return queries.timeline_histogram(conn, sources=sources, person=person, party=party) diff --git a/backend/politrack/mcp_server.py b/backend/politrack/mcp_server.py index a2de6a4..4be5f7f 100644 --- a/backend/politrack/mcp_server.py +++ b/backend/politrack/mcp_server.py @@ -124,6 +124,14 @@ def list_claims( until=_date(until), limit=limit)) +@mcp.tool() +def get_claim(claim_id: int) -> str: + """One claim with all its evidence, e.g. from a permalink (/angaben/) a user shared.""" + with connection() as conn: + rows = queries.list_claims(conn, sources=SourceFilter([], 0), claim_id=claim_id, limit=1) + return _dump(rows[0] if rows else {"error": f"claim {claim_id} not found"}) + + @mcp.tool() def money_flows(kinds: list[str] | None = None, organization: str | None = None, person: str | None = None, query: str | None = None, since: str | None = None, until: str | None = None, diff --git a/backend/politrack/queries/claims.py b/backend/politrack/queries/claims.py index 2a5ea18..f9730de 100644 --- a/backend/politrack/queries/claims.py +++ b/backend/politrack/queries/claims.py @@ -21,6 +21,7 @@ def list_claims( since: date | None = None, until: date | None = None, q: str | None = None, + claim_id: int | None = None, include_pending: bool = False, limit: int = 50, offset: int = 0, @@ -28,7 +29,7 @@ def list_claims( source_sql, params = sources.sql("s") params.update({ "person": person, "party": party, "organization": organization, "claim_types": claim_types, "topics": topics, "since": since, - "until": until, "q": q, "limit": limit, "offset": offset, "include_pending": include_pending, + "until": until, "q": q, "claim_id": claim_id, "limit": limit, "offset": offset, "include_pending": include_pending, }) return conn.execute( f""" @@ -73,6 +74,7 @@ def list_claims( AND (%(since)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_to, c.valid_from, current_date) >= %(since)s) AND (%(until)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_from) <= %(until)s) AND (%(q)s::text IS NULL OR c.summary_de ILIKE '%%' || %(q)s || '%%') + AND (%(claim_id)s::bigint IS NULL OR c.id = %(claim_id)s) ORDER BY COALESCE(c.occurred_at, c.valid_from::timestamptz, c.created_at) DESC, c.id DESC LIMIT %(limit)s OFFSET %(offset)s """, diff --git a/backend/politrack/queries/organizations.py b/backend/politrack/queries/organizations.py index 06a1aad..7ff269b 100644 --- a/backend/politrack/queries/organizations.py +++ b/backend/politrack/queries/organizations.py @@ -8,13 +8,21 @@ from psycopg import Connection def list_parties(conn: Connection) -> list[dict]: return conn.execute( """ + -- person_status is an expensive view: evaluate it once, not once per party + WITH active AS ( + SELECT party_id, count(DISTINCT ps.person_id) AS n + FROM person_status ps, unnest(ps.current_party_ids) AS party_id + WHERE ps.status = 'active' + GROUP BY party_id + ) SELECT o.slug, o.name, o.short_name, o.color, o.logo_url, - (SELECT count(*) FROM person_status ps WHERE ps.status = 'active' AND o.id = ANY(ps.current_party_ids)) - AS active_officials, + COALESCE(a.n, 0) AS active_officials, count(DISTINCT r.person_id) AS total_people - FROM organization o LEFT JOIN role r ON r.party_id = o.id + FROM organization o + LEFT JOIN role r ON r.party_id = o.id + LEFT JOIN active a ON a.party_id = o.id WHERE o.kind = 'party' - GROUP BY o.id + GROUP BY o.id, a.n ORDER BY active_officials DESC, total_people DESC, o.name """ ).fetchall() diff --git a/backend/politrack/sources/parlament/news.py b/backend/politrack/sources/parlament/news.py index 75f84c6..93bfad3 100644 --- a/backend/politrack/sources/parlament/news.py +++ b/backend/politrack/sources/parlament/news.py @@ -99,6 +99,7 @@ class ParlamentCalendar(Adapter): name = "parlament.calendar" description = "Parliament calendar: plenary sessions, committees, hearings, events (tours skipped)" schedule = "30 2 * * *" + parser_version = 2 # 2: titles are HTML-decoded ("84. Sitzung") def start(self, ctx: RunContext) -> Iterator[Request]: yield list_request(600, {}, kind="list") @@ -106,12 +107,12 @@ class ParlamentCalendar(Adapter): def parse(self, ctx: RunContext, response: Response) -> Iterator[Event]: since = str(ctx.params.get("since_year", date.today().year - 2)) for row in list_rows(response.json()): - kind_label = (row.get("Terminart") or "").replace("
", " ") + kind_label = html_to_text(row.get("Terminart")) starts = next((v for k, v in row.items() if k.startswith("Datum") and k != "Datum Bis" and isinstance(v, str) and ISO_DATE.match(v)), None) if not starts or starts[:4] < since or SKIP_TERMIN.search(f"{kind_label} {row.get('Bezeichnung') or ''}"): continue - title = row.get("Bezeichnung") or kind_label + title = html_to_text(row.get("Bezeichnung")) or kind_label kind = next((k for pattern, k in TERMIN_KIND if pattern.search(f"{kind_label} {title}")), "public_event") chamber = {"Nationalrat": "NR", "Bundesrat": "BR"}.get(row.get("Gremium") or "") ends = next((v for k, v in row.items() if k.startswith("Datum Bis") and isinstance(v, str) diff --git a/backend/tests/test_parsers.py b/backend/tests/test_parsers.py index 177fd65..3421dad 100644 --- a/backend/tests/test_parsers.py +++ b/backend/tests/test_parsers.py @@ -142,3 +142,19 @@ def test_firmenbuch_number(): assert firmenbuch_number("FN 306527 a") == firmenbuch_number("FN306527A") == firmenbuch_number("306527a") == "306527a" assert firmenbuch_number("ATU12345678") is None + + +def test_parlament_calendar_titles_are_html_decoded(): + import json + + from politrack.core.runner import make_response, parse_offline + from politrack.sources.parlament.news import ParlamentCalendar + + body = {"header": [{"label": "Datum"}, {"label": "Terminart"}, {"label": "Bezeichnung"}, {"label": "Gremium"}, + {"label": "Link"}, {"label": "WENTRY_ID"}], + "rows": [["2026-06-10T09:00:00", "Plenarsitzung
NR", "84. Sitzung des Nationalrates", "Nationalrat", + "/termin/1", "1"]]} + events = parse_offline(ParlamentCalendar(), make_response("https://www.parlament.gv.at/x", json.dumps(body).encode(), kind="list"), + params={"since_year": 2026}) + assert [e.title for e in events] == ["84. Sitzung des Nationalrates"] + assert events[0].description == "Plenarsitzung NR" diff --git a/docs/architecture.md b/docs/architecture.md index bce61c6..678f47d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ parsers, archive-first, per-publisher legal policy) – see ADR-20. ## Interfaces for other agents 1. **MCP** – `http://:8001/mcp`: `search`, `find_person`, `get_person`, `person_network`, - `list_parties`, `get_party`, `find_organization`, `get_organization`, `list_claims`, `money_flows`, + `list_parties`, `get_party`, `find_organization`, `get_organization`, `list_claims`, `get_claim`, `money_flows`, `social_posts`, `wikipedia_edits`, `calendar`, `person_votes`, `former_politicians`, `list_topics`. 2. **REST** – `http://:8000/api/docs` (OpenAPI 3). 3. **SQL** – role `politrack_reader`; start with the view `claim_with_evidence`; `\d+ claim` shows the diff --git a/docs/contributing/frontend.md b/docs/contributing/frontend.md index 6a20cb6..19c26fc 100644 --- a/docs/contributing/frontend.md +++ b/docs/contributing/frontend.md @@ -1,17 +1,25 @@ # Extending the web app Next.js 15 (App Router, React Server Components) with next-intl (German default, English). Pages fetch -from the API on the server; there is almost no client-side JavaScript. +from the API on the server; there is almost no client-side JavaScript. How it should look and why: +[design/README.md](../design/README.md) (concept) and [design/design-system.md](../design/design-system.md) +(tokens, components, content rules). ``` web/ messages/de.json, en.json all UI text (keys must exist in both) src/app/[locale]//page.tsx pages (German route names: /personen, /parteien, /geld …) - src/components/ shared server components + src/app/[locale]/layout.tsx frame: header, footer, theme, stylesheets + src/app/[locale]/loading.tsx, error.tsx, not-found.tsx + src/app/icon.svg favicon + src/components/ shared components (catalogue: design-system.md §4) + src/components/nav.ts site structure for header, menu and footer + src/styles/ tokens, base, layout, components, print src/lib/api.ts typed API client (server-side only; reads the filter cookie) src/lib/api-types.ts GENERATED from the backend's response models – do not edit - src/lib/format.ts dates, money, localized names - src/lib/prefs.ts the user's source/topic filter (cookie) + src/lib/format.ts dates, periods, numbers, money, party colours + src/lib/prefs.ts the visitor's source/topic filter and theme (cookies) + src/lib/roles.ts role labels ``` ## Types come from the backend @@ -25,34 +33,54 @@ cd ../web && npm run typecheck `api.ts` re-exports the generated types (`Claim`, `MoneyFlow`, …) and adds one function per endpoint. `api.claims()`, `api.money()`, `api.social()` … automatically apply the visitor's source filter -(`withPrefs`), so every list respects "which sources do I trust". +(`withPrefs`), so every list respects "which sources do I trust". `api.claim(id)` deliberately does not: +a permalink shows the same to everyone. ## Adding a page -1. Create `src/app/[locale]//page.tsx` – an async server component: +1. Create `src/app/[locale]//page.tsx` – an async server component built from the shared parts: ```tsx + export async function generateMetadata({params}: Props): Promise { + const {locale} = await params; + const t = await getTranslations({locale, namespace: 'MyPage'}); + return {title: t('title')}; + } + export default async function Page({params, searchParams}: Props) { const {locale} = await params; setRequestLocale(locale); + const sp = await searchParams; const t = await getTranslations('MyPage'); - const rows = await api.something({...}); - return <>…; + const page = Math.max(1, Number(sp.page) || 1); + const rows = await api.something({kind: sp.kind, limit: PAGE, offset: (page - 1) * PAGE}); + return ( + <> + + + + + {rows.length === 0 ? {t('none')} :
    …
} + + + ); } ``` - Filters are plain `
` – shareable URLs, no client state. + Filters are plain GET forms – shareable URLs, no client state. 2. Add the texts to **both** `messages/de.json` and `messages/en.json`. -3. Link it from the navigation (`src/app/[locale]/layout.tsx`) or the home page's "Entdecken" cards. +3. Add it to the site structure in `src/components/nav.ts` (primary, "Mehr" or a footer group) or link it + from the home page's "Entdecken" cards. 4. Page modules may only export Next.js page fields (`default`, `generateMetadata`, …); keep helpers and constants module-local or in `src/components`. +5. Check it at 390 px and in both themes (design-system.md §7). ## Showing a new claim type -Claims render through `ClaimList`; type-specific details come from the registry in +Claims render through `ClaimList` → `ClaimCard`; type-specific details come from the registry in `src/components/ClaimDetails.tsx`: ```tsx const ambassador: Renderer = (claim, {t}) => ( -

{t('country')}: {String(claim.data.country ?? '')}

+

{t('country')}: {String(claim.data.country ?? '')}

); const DETAILS: Partial> = { @@ -61,21 +89,26 @@ const DETAILS: Partial> = { }; ``` -Also add the label under `Claims.type.` in both message files, and (if it should appear) the -type to the person page's `TABS`. +Also add an icon to `TYPE_ICONS` in `src/components/badges.tsx`, the label under +`Claims.type.` in both message files, and (if it should appear) the type to the person page's +`TABS`. ## Non-negotiables in the UI -* **Every fact shows its sources** (the "Quellen" disclosure under each claim, `[source]` links in - tables). Don't render a claim without its evidence. -* **Presumption of innocence:** `needsInnocenceNotice()` in `ClaimList.tsx` decides; never remove the - notice for anything but an undisputed final conviction. -* **Connections are not accusations:** pages that show networks or money of connected organisations +* **Every fact shows its sources** (the evidence disclosure under each claim, source chips in tables). + Don't render a claim without its evidence. +* **Presumption of innocence:** `needsInnocenceNotice()` in `ClaimList.tsx` decides; the notice stays inside + the claim card. Never remove it for anything but an undisputed final conviction. +* **Connections are not accusations:** sections that show networks or money of connected organisations carry the note (`Network.note`, `Organizations.connectionNote`). +* **Neutral colours:** party colours only through `partyStyle()` as dots and thin bars, next to the name. * Unverified social accounts are marked (Wikidata-only handles). +* No third-party scripts, fonts, embeds or trackers. ## Styling -One global stylesheet (`src/app/globals.css`) with CSS variables for light/dark mode. Reuse the existing -classes (`claims`, `claim-detail`, `chip`, `tag`, `notice`, `grid-2`, `compact`, `num`, `muted`, `small`) -before adding new ones. +Global CSS with design tokens, one section per component in `src/styles/components.css`. Use tokens +(`var(--ink-2)`, `var(--space-4)`) instead of raw values, reuse the components and layout utilities +listed in [design-system.md](../design/design-system.md), and add a new section (and a row in its +component table) when you create a component. A new token goes into all three blocks of `tokens.css` +(light, dark by preference, dark by choice). diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..d17976f --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,478 @@ +# Politrack design concept + +How Politrack looks, reads and behaves, and why. This is the concept: goals, principles, information +architecture, the core patterns and a concept for every page. The companion +[design-system.md](design-system.md) is the implementation reference: tokens, components, CSS +conventions and recipes for contributors. + +Items marked **(later)** are designed here but not built yet; everything else is implemented in `web/`. + +## 1. Goals + +Politrack is a public record of what Austrian politicians do, where every fact links to its source. +The interface has four jobs, in this order: + +1. **Make every fact checkable.** A reader is never more than one click from the document behind a + statement on the screen. +2. **Be fair to everyone shown.** The same layout, wording and weight for every person and party; the + presumption of innocence and the limits of the data are visible where they matter, not in a footer. +3. **Answer common questions fast, on a phone.** "Who is this?", "How did they vote?", "Who gave money to + whom?" in a few seconds, with no app and no account. +4. **Support deep research on a desk.** Dense tables, filters in the URL, time ranges, cross-links between + people, organisations and money, and stable links for citing. + +## 2. Audiences and their tasks + +| Audience | Context | Top tasks | What the UI must provide | +|---|---|---|---| +| Interested citizens | Phone, linked from news or social media, a few minutes | Look up a politician, a party's votes, donations | Search first, plain language, clear summaries, a readable profile on a small screen | +| Journalists and researchers | Desktop, long sessions, need to cite | Follow money between organisations, build a timeline, check a claim | Filters and date ranges in the URL, dense tables, permalinks per fact, source details, consistent numbers | +| NGOs and activists | Desktop and phone, recurring | Monitor topics and people | Topic filters, calendar, (later) alerts | +| The people profiled and their staff | Desktop | Check what is shown about them | Fair presentation, visible sources, (later) a corrections form | +| AI agents | No UI (MCP server, REST API) | Retrieve facts with evidence | Mentioned in the footer and methodology; the UI never shows anything the API doesn't | + +## 3. Principles + +Each principle comes with the rules that make it concrete. + +**P1 Evidence first.** The atom of the interface is the *claim card*: a one-sentence summary plus its +sources. Rules: the number of sources is always visible; the source list is one click away; every +external link opens the original; quotes are shown as quotes (marked, attributed); there is no UI +element that states a fact without a claim behind it. + +**P2 Neutral by design.** Rules: the interface accent is a colour no Austrian party uses (an ink indigo, +not the flag red, which reads as SPÖ red); party colours appear only as small identity markers (a dot or +a thin bar) and always next to the party's name, never as large backgrounds; every party and person gets +the same layout; lists are ordered by neutral criteria (date, size, alphabet); copy uses reporting verbs +("laut", "gemeldet", "berichtet") instead of judgement. + +**P3 Show certainty honestly.** One vocabulary for how sure we are (see §6.3), shown the same way +everywhere: single vs. multiple sources, disputed facts, unverified accounts, unconfirmed status, +publication date vs. event date. + +**P4 Fairness for legal matters.** The procedural stage is the headline of a legal claim; "Es gilt die +Unschuldsvermutung" sits inside the claim card, not in a footnote; legal claims are styled in a neutral +caution tone, never in alarm red; no portraits inside legal claim cards or feeds of allegations; connections +between people and organisations carry the note that a connection is not an accusation; profile overviews +highlight work (votes, speeches, offices), not allegations. + +**P5 Plain language, German first.** German is the default at `/`, English at `/en`. Technical terms +(namentliche Abstimmung, Klub, Ministerrat, Diversion) are explained where they appear. Long German +compounds must not break layouts: hyphenation is on and long tokens may wrap. + +**P6 Time is a first-class dimension.** Every list can be narrowed to a date range, profiles have a +timeline, a calendar shows what happened on a day, and "search in time" lists everything in a period. +Dates say what they are ("Datum = Veröffentlichung der Quelle" where we only know the publication date). + +**P7 Fast, robust, accessible, private.** Server-rendered pages that work without JavaScript; WCAG 2.2 +AA; system fonts; no third-party scripts, fonts, embeds or trackers; print stylesheets that keep source +URLs. + +**P8 Shareable and citable.** Filters live in the URL; every claim has a permalink page with a ready-made +citation; page titles name the person or topic. + +## 4. Brand and tone + +**Name and mark.** *politrack* in lowercase, set in the system font at heavy weight. The mark is a pair of +square brackets around a dot: the citation bracket of a footnote with the piece of evidence inside. It is +the favicon too. Tagline: *Österreichs Politik, belegt.* + +**Colour stance.** Near-monochrome "paper and ink" neutrals carry the interface. One accent, an ink indigo, +marks interaction (links, focus, active navigation, magnitude bars). A highlighter yellow marks quoted +evidence and search hits. Semantic colours (positive, negative, caution) appear only in small badges and +always with an icon and a word, never as colour alone. Party colours follow the rules in P2. + +**Voice.** Sachlich, precise, friendly. We address readers with "du" (as the existing copy does) and use +gender-inclusive forms with a colon (Politiker:innen). We describe what a source says, with the source: +"Laut Rechnungshof spendete …", not "X kaufte sich Einfluss". Numbers are exact where the source is exact +and labelled as reported ("Beträge wie gemeldet"). + +## 5. Information architecture + +### Sitemap + +``` +/ Start: search, key figures, latest facts, explore +/suche Search (full text and "search in time": a date range without a query) +/personen People (filters: party, level, status) + /personen/{slug} Profile with tabs: overview, timeline, votes, travel & meetings, + statements, social media, money & network, media & career, legal +/ehemalige Former politicians: what they do now +/parteien Parties + /parteien/{slug} Party: officials, votes, money, finances, affiliated organisations, channels +/organisationen Organisations (companies, associations, authorities, media, lobbies) + /organisationen/{slug} Organisation: money in and out, counterparties, connected people +/geld Money explorer: donations, contracts, ads, subsidies, party funding +/abstimmungen Votes (faction votes; roll calls) + /abstimmungen/{id} Vote: result, factions, individual votes, dissent +/kalender Calendar: sessions, committees, hearings, Council of Ministers, trips, meetings +/social Public posts from politicians' and parties' channels +/wikipedia Wikipedia edits from parliament and government networks +/angaben/{id} Claim permalink: one fact with all its sources and a citation +/einstellungen My filters: trusted sources, topics +/methodik Methodology, statuses, legal stages, sources and their terms +``` + +Route names are German in both languages (`/en/personen/…`): one URL scheme, links stay valid when +switching language. + +### Navigation + +* **Header**, primary: Personen, Parteien, Abstimmungen, Geld, Organisationen, Kalender; **Mehr**: + Social Media, Wikipedia-Bearbeitungen, Ehemalige, Methodik & Quellen. Utilities: a search field, + *Meine Filter* with the number of active filters, language, light/dark theme. Below 1024 px the + navigation collapses into a menu (a native disclosure, no JavaScript) and the search field becomes a + search button. +* **Footer**: the full sitemap in three groups (Menschen, Entscheidungen, Geld & Einfluss), the + principles in three lines, links for developers and agents. +* **Breadcrumbs** on every detail page (Start › Personen › Name). +* **Cross-links everywhere**: every person, party and organisation name is a link; money rows link both + sides; lists on profiles end with a "Alle …" link into the explorer, pre-filtered. + +### URL conventions + +All filters are query parameters on plain GET forms (`/geld?kind=donation&since=2025-01-01`), so every view +can be bookmarked and shared. Personal source/topic preferences are the only state kept outside the URL +(a cookie), and the header always shows when they are active. + +## 6. Core patterns + +### 6.1 The claim card + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ◇ SPENDE · 12. März 2026 · Österreichische Volkspartei ●●○ 2 │ type, date, subject, sources +│ │ +│ Die Muster GmbH spendete der ÖVP 25.000 €. │ summary (the fact) +│ 25.000 € · Muster GmbH → ÖVP · 2025 │ type-specific details +│ [Wirtschaft] [Parteifinanzen] │ topics +│ ▸ 2 Belege Permalink ↗ │ evidence disclosure +└──────────────────────────────────────────────────────────────────────┘ +``` + +* The header row is metadata in small type: type label with icon, date, subject (when the list mixes + subjects), and the **source meter** on the right. +* The summary is the largest text in the card. In English the English summary is used when available. +* Type-specific details (amounts, counterparts, platforms, file numbers) come from a registry, one renderer + per claim type. +* The evidence disclosure lists each source with its publisher, source kind (Amtlich, Medien, …), title, + date, stance (supports / mentions / contradicts) and quote. Quotes are set as block quotes with the + highlighter mark. +* A **permalink** opens the claim page (§7.17). +* Disputed claims get a caution edge and the word "umstritten". Claims awaiting review are never public; + if one is shown (admin views) it carries "Noch nicht redaktionell geprüft". + +### 6.2 Evidence and corroboration + +The source meter shows up to three dots: one filled dot = one source ("einfach belegt"), two or more +filled = "mehrfach belegt" with the number. A disputed claim shows a caution triangle and the word +"umstritten" instead. A tooltip gives the full wording ("bestätigt durch 3 unabhängige Quellen"). The meter +never stands alone: the number is always written next to it. + +### 6.3 Certainty vocabulary + +| Concept | Values (German UI) | Shown as | +|---|---|---| +| Claim status | einfach belegt · mehrfach belegt · umstritten | source meter; caution edge for umstritten | +| Person status | Aktiv · Ehemalig · Ohne Amt (Parteimitglied) · Verstorben | status badge; "Aktiv" without an official confirmation adds "nicht amtlich bestätigt" | +| Social account | bestätigt · nicht bestätigt | unconfirmed accounts get a dashed badge and a tooltip (source: Wikidata only) | +| Review | Noch nicht redaktionell geprüft | caution notice (admin views only) | +| Dates | Ereignisdatum · Veröffentlichungsdatum | footnote on calendar and trips: "Datum = Veröffentlichungsdatum der Quelle" | +| Amounts | wie gemeldet · Kategorie | exact amount, or the reported category ("über 50.000 €") | + +### 6.4 Legal matters + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ⚖ VERFAHREN · 3. Februar 2026 · Name ●○○ 1 │ +│ [Ermittlungsverfahren] § 302 StGB · WKStA │ stage first, as a badge +│ Summary of what the source reports … │ +│ ⓘ Es gilt die Unschuldsvermutung. │ inside the card +│ ▸ 1 Beleg │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +The notice is shown for every stage except an undisputed final conviction (including acquittals, +dropped proceedings and diversion, which confirm there is no finding of guilt). Automatically detected +legal claims stay in review until a human approves them. Profiles count legal claims only on their tab, +never in the "at a glance" figures. + +### 6.5 Money + +* Amounts in euro with locale formatting (`25.000 €`, `€25,000`); large figures in summaries and bar + lists are compact (`1,2 Mio. €`) with the exact value in the tooltip; tables show exact values. +* Direction is always explicit: **Von → An**. Both sides are links when the organisation is known. +* Every amount says what kind of money it is (Spende, öffentlicher Auftrag, Regierungsinserat, Förderung, + Parteienförderung, politische Werbung, Kredit). Different kinds are never summed into one figure + without a label. +* "Beträge wie gemeldet" and the list of registers sit under every money table. +* Money of organisations a person is *connected* to (side jobs, career) is labelled as the organisation's + money and carries the connection note. + +### 6.6 Votes + +* A vote shows date, item title, reference, stage, chamber, result and the position of every faction as + **position chips**: party dot + short name + position icon (✓ dafür, ✕ dagegen, – Enthaltung, + ○ abwesend), with a tinted background per position. +* Roll calls are marked ("namentlich"); they show individual votes grouped by faction with counts, and + members who voted against their faction are marked "gegen die eigene Fraktion". +* On a person's vote list the position says whether it is the faction's vote or the person's own vote, + because most Austrian votes are recorded per faction only. The note explaining this sits above the list. +* *Say vs. vote*: when the person spoke on the item, a link to the speech appears under the vote. + +### 6.7 Time + +* Dates in lists: `12. März 2026` (de-AT) / `12 Mar 2026` (en-GB). Months in charts: `03/2026`. +* The **activity histogram** (claims per month) on profiles links each bar to that month. +* **Date range** fields on every list that supports them; with no query, search becomes "search in + time" and lists everything in the range. +* The **role timeline** on a profile draws offices and memberships as bars on a year axis. + +### 6.8 Filters + +Two kinds, never mixed up: + +* **Page filters** (party, level, kind, date …) live in the URL. Active filters are visible in the form; + a reset link clears them. On phones the filter form is collapsed behind "Filter" with the number of + active filters. +* **My filters** (trusted sources, minimum trust, topics) are stored in a cookie and apply to every list. + When they are active, the header badge shows the count and lists show a notice with a link to change + them, so nobody is surprised by missing data. + +### 6.9 Connections + +Wherever the UI shows that a person is connected to an organisation (side job, career, meeting, party) +or shows the organisation's money, the note "Eine Verbindung ist kein Vorwurf." is part of the section. + +### 6.10 Social posts + +Posts are shown as text cards with platform, author, date and a link to the original; no embeds, no +third-party images or scripts. Accounts that only Wikidata attributes to a person are marked "Konto nicht +bestätigt". The collection policy (official APIs only; no Facebook/Instagram scraping; no WhatsApp, +Signal or private Telegram groups) is stated on the page. + +### 6.11 Empty, loading and error states + +* **Empty**: an icon, one sentence that says why ("Keine Angaben mit deinen Quellen- und Themenfiltern.") + and the most likely next step (reset filters, broaden the date range). +* **Loading**: skeleton blocks in the page's layout, no spinners. +* **Error**: what happened in one sentence, a retry button, a link home. + +## 7. Page concepts + +Every page uses one of four templates: **index** (header, filters, results, pagination, source note), +**profile** (identity header, key figures, tabs, main column plus aside), **detail** (header, verdict, +breakdown) and **prose** (methodology). + +### 7.1 Start (`/`) + +Purpose: orient, search, show that the data is alive. + +``` +┌ hero ───────────────────────────────────────────────────────────────┐ +│ Politik in Österreich – nachvollziehbar. │ +│ Lede: what Politrack is, "jede Angabe mit Quelle". │ +│ [ Person, Thema oder Stichwort … ] [Suchen] │ +│ Schnellzugriff: Parteispenden · Namentliche Abstimmungen · Inserate │ +└──────────────────────────────────────────────────────────────────────┘ +[ 1.234 Personen ] [ 56.789 Angaben ] [ 1.210 Abstimmungen ] [ 9.876 Geldflüsse ] Stand … +┌ Neueste Angaben (claim feed) ──────────┐ ┌ aside ───────────────────┐ +│ claim card … │ │ Parteien (dot, name, n) │ +│ claim card … │ │ Letzte Abstimmungen │ +└─────────────────────────────────────────┘ └──────────────────────────┘ +Entdecken: 6 cards with icon, title, one line (Geld, Organisationen, Social, Wikipedia, Ehemalige, Kalender) +So arbeitet Politrack: Belege · Unabhängige Bestätigung · Unschuldsvermutung → Methodik +``` + +### 7.2 Search (`/suche`) + +A large search field with optional date range. Results are cards with type label, date, subject and a +snippet with the query terms highlighted; document hits (news, releases) show the publisher. Without a +query but with a date range, the page lists all claims in that period ("search in time"). Empty states +suggest fewer words or a wider range. + +### 7.3 People (`/personen`) + +Index template. Filters: name, party, level (EU, Bund, Land, Gemeinde, Partei), status (default: aktiv). +Results are **person cards** in a responsive grid: portrait or initials, name, current roles (two lines +max), party tags, and the status for non-active people. Pagination at the bottom. + +### 7.4 Person profile (`/personen/{slug}`) + +``` +Start › Personen › Name +┌──────┐ [Aktiv] Profile elsewhere: +│ │ Vorname Nachname Parlament ↗ Wikidata ↗ EP ↗ +│ foto │ Abgeordnete zum Nationalrat · ● ÖVP +│ │ * 3. Mai 1970 · Kanäle: [Bluesky] [Telegram*] +└──────┘ +[Überblick] [Zeitleiste 312] [Abstimmungen 120] [Reisen & Treffen 4] [Aussagen 58] [Social Media] +[Geld & Netzwerk 6] [Medien & Werdegang 40] [Rechtliches 1] + +Overview: +┌ main ─────────────────────────────────────┐ ┌ aside ───────────────────────┐ +│ Laufbahn (role timeline on a year axis) │ │ Auf einen Blick │ +│ Neueste Angaben (claim cards) │ │ 120 Abstimmungen · 58 Reden │ +│ │ │ Funktionen & Mandate (list, │ +│ │ │ each with its sources) │ +└───────────────────────────────────────────┘ └──────────────────────────────┘ +``` + +* Tabs are links (`?tab=votes`), so every tab is shareable; each shows its claim count. +* **Zeitleiste**: activity histogram, date range, all claims in the range. +* **Abstimmungen**: the faction-vote note, then the vote list with position, own/faction vote, dissent and + say-vs-vote. +* **Geld & Netzwerk**: connected organisations (via side job, career, meeting, party) with their money, + the connection note, the organisations' money flows, then money-related claims. +* **Rechtliches**: legal claims only, each with stage and the innocence notice. +* "At a glance" counts work: votes, speeches and statements, questions, side jobs, appearances, media + reports; not legal matters. + +### 7.5 Former politicians (`/ehemalige`) + +Index template ordered by latest media coverage. Each card: name, status, last role, last office end, +latest headline with source link and date, latest career entry. Party filter. + +### 7.6 Parties (`/parteien`) + +Cards with a thin top bar in the party colour, short name, full name, active officials and people +tracked. Ordered by the number of active officials. + +### 7.7 Party (`/parteien/{slug}`) + +``` +──────── party colour bar ──────── +Start › Parteien › ÖVP +ÖVP Österreichische Volkspartei +[ 312 Funktionsträger:innen ] [ 1.020 Ehemalige ] [ 830 Abstimmungen ] [ 4 abweichende Mandatar:innen ] +┌ Stimmverhalten (proportion bar) ──┐ ┌ Geld nach Jahr und Art (table) ─────┐ +└───────────────────────────────────┘ └─────────────────────────────────────┘ +Funktionsträger:innen grouped by level (EU · Bund · Land · Gemeinde), multi-column +┌ Rechenschaftsberichte ────────────┐ ┌ Nahestehende Organisationen ────────┐ +┌ Ehemalige ────────────────────────┐ ┌ Abweichendes Stimmverhalten ────────┐ +Letzte Abstimmungen · Kanäle der Partei · Belegte Angaben +``` + +### 7.8 Organisations (`/organisationen`) + +Index template with a table: name, kind badge, paid, received (compact amounts, exact in tooltip). On +phones the table stacks into cards. Filters: name, kind. + +### 7.9 Organisation (`/organisationen/{slug}`) + +Header with kind badge and parent organisation; identifiers (Firmenbuch, ZVR, UID, Wikidata, EU +transparency register) in the aside with links where a public register page exists. Key figures (received, +paid, counterparties). Counterparties as two bar lists ("zahlte an", "erhielt von"). Connected people with +the connection note. Money flow table, then all claims. Parties redirect to their party page. + +### 7.10 Money (`/geld`) + +Explorer template. Filters: kind, name, date range, minimum amount. Unfiltered, the page opens with two +bar lists, largest payers and largest recipients, each row labelled with the kind of money. Then the money +table: date, kind, from, to, amount, sources. Footer: registers used and "Beträge wie gemeldet". + +### 7.11 Votes (`/abstimmungen`) and vote (`/abstimmungen/{id}`) + +The index is a list of vote rows: date, title, reference, roll-call marker, result badge, position chips +per faction. Filter: roll calls only. The vote page shows the verdict as a large result badge, links to the +item and the protocol, faction positions, and for roll calls a proportion bar over all individual votes and +the members grouped by faction with dissent marked. The protocol wording sits in a disclosure. + +### 7.12 Calendar (`/kalender`) + +Month grid (Monday first) with previous/next month as header actions. Each event shows its kind (colour +stripe + label), title (linked to the source) and participants. Days with only claim activity link to +"search in time" for that day. Today is highlighted. On phones the grid becomes an agenda: only days with +events, in order. A legend explains the kinds; the footnote states the date precision. + +### 7.13 Social media (`/social`) + +Feed of post cards in one or two columns. Filters: platform, party. Each card: platform badge, author +(person or party channel), date, title, text (clamped to a few lines), "Original ansehen ↗", unconfirmed +marker. The collection policy closes the page. + +### 7.14 Wikipedia edits (`/wikipedia`) + +The framing comes first: a notice that an edit from a network says nothing about which person made it. +Then a table: date, network (organisation), article (linked to the diff), size change, edit comment. + +### 7.15 My filters (`/einstellungen`) + +Two sections. **Quellen**: grouped by kind (Amtlich, NGO, Medien, Datenbanken, Soziale Medien, +Community, Abgeleitet); each source is a switch row with name, trust value as a small meter, and its terms +in the tooltip; a minimum-trust slider below. **Themen**: topic chips that toggle; none selected = all. +A sticky action bar with Speichern, Zurücksetzen and the saved status. Stored in a cookie only. + +### 7.16 Methodology (`/methodik`) + +Prose template with a table of contents: how claims and evidence work, the status vocabulary with live +badges, the legal stages glossary, the sources table (publisher, licence, access method, trust meter, terms +notes), what we deliberately don't collect, corrections. + +### 7.17 Claim permalink (`/angaben/{id}`) + +One fact on its own page: type, date, subject, the summary as the title, details, legal notice where +applicable, all sources expanded with quotes, and a citation box: +`Politrack: „{Zusammenfassung}“. Angabe #{id}, abgerufen am {Datum}. {URL}`. The page ignores personal +source filters, so a shared link shows the same content to everyone. + +### 7.18 Not found and errors + +Not found: an empty state with the search field and links to Personen, Parteien and Start. Errors: one +sentence, "Erneut versuchen", link home. + +## 8. Responsive behaviour + +| Width | Behaviour | +|---|---| +| < 640 px | one column; tables stack into labelled cards; filter forms collapse; role timeline labels above the bars; tabs scroll horizontally | +| < 768 px | the calendar becomes an agenda (only days with events) | +| 640–1023 px | two-column card grids; tables scroll horizontally inside their container; menu still collapsed | +| ≥ 1024 px | full header navigation with search field; profile pages get the aside column; two-column sections | +| ≥ 1280 px | content width caps at 76 rem; more cards per row | + +## 9. Accessibility (WCAG 2.2 AA) + +* Contrast ≥ 4.5:1 for text and ≥ 3:1 for UI parts in both themes (token values are chosen and checked + for it, see the design system). +* Never colour alone: positions, results, statuses and party identity always have text, and an icon or + a shape where possible. +* Keyboard: skip link, visible focus ring on everything, tabs and disclosures are native links and + `details` elements, no keyboard traps. +* Structure: one `h1` per page, headings in order, landmarks (header, nav, main, footer), `aria-current` + for the active page and tab, tables with header cells, charts with text equivalents. +* Language: `lang` on the document; English summaries are marked when shown in the German UI. +* Motion: no animation beyond short hover transitions; `prefers-reduced-motion` removes them. +* Targets at least 24 × 24 px; form fields have visible labels. + +## 10. Privacy and performance + +* No third-party requests from the page itself: system fonts, no analytics, no embeds, no CDNs. + Portraits and logos still come from Wikimedia and the parliament; they load lazily with + `referrerpolicy="no-referrer"`. **(later)** an image proxy so visitors' IP addresses never reach + third parties. +* Server-rendered HTML; client JavaScript only for the filter settings, the theme switch, the language + switch and active-link marking. +* Budget: under 100 kB of CSS and JavaScript per page (compressed), first content without waiting for + scripts. + +## 11. Before going public + +Not design work, but the design depends on them; flagged here so they are not forgotten: + +* **Impressum and Offenlegung** (§ 5 ECG, § 25 MedienG) and a **privacy policy**: footer links and a + prose page each. +* A **corrections and right-of-reply** path (§ 9 MedienG): a contact on the methodology page and, later, a + form linked from every claim page. +* The X deletion sync (see the roadmap) before X data is shown publicly. + +## 12. Later + +* Claim permalinks are implemented; **(later)** a "Fehler melden" button on them. +* Compare two people or parties side by side (votes, money, statements). +* Topic pages (`/themen/{slug}`): everything on one topic across people and parties. +* Alerts (e-mail or RSS) for people, parties and topics. +* A review queue for editors in the web UI (today `politrack review` on the command line). +* A map of trips and appearances. +* Share images (Open Graph) generated per profile and claim. +* CSV export of every table. diff --git a/docs/design/design-system.md b/docs/design/design-system.md new file mode 100644 index 0000000..ae769bd --- /dev/null +++ b/docs/design/design-system.md @@ -0,0 +1,305 @@ +# Politrack design system + +The implementation reference for the web app: tokens, components, CSS conventions, content rules and +recipes. The concept behind it (goals, principles, page concepts) is in [README.md](README.md); how to add +pages and claim types step by step is in [contributing/frontend.md](../contributing/frontend.md). + +## 1. Architecture + +``` +web/src/styles/ + tokens.css design tokens (custom properties), light + dark + base.css element defaults: typography, links, tables, forms, buttons, text utilities + layout.css page frame (header, menu, footer) and layout utilities + components.css one section per component in src/components + print.css print: content only, link targets written out +web/src/components/ server components (a few client components, marked 'use client') +web/src/app/icon.svg favicon (the brand mark) +``` + +All five stylesheets are imported once, in order, by `src/app/[locale]/layout.tsx`. + +* **Plain global CSS** with custom properties: no CSS framework, no CSS-in-JS, no npm dependency beyond + Next.js, React and next-intl. The UI must keep working where npm is slow or blocked. +* **Class names**: one block per component in kebab-case (`.claim`, `.claim-head`, `.vote-row`); + variants with `--` (`.badge--positive`, `.notice--legal`); states with `is-` (`.is-current`, + `.is-disputed`); `aria-current` for the active link or tab. +* **Only tokens** for colour, spacing, radius and type. The exceptions are deliberate: party colours + (from the data), platform brand colours, and the favicon. +* **Server first.** Pages and most components are async server components. Client components exist only + where the browser must act: `ThemeSwitcher`, `LocaleSwitcher`, `NavLink`, `SettingsForm`, the error page. +* **No JavaScript needed** for navigation, filters, tabs, disclosures or the mobile menu: links, GET forms, + `
` and one checkbox toggle. + +## 2. Tokens + +### Colour + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--paper` | `#f6f5f1` | `#131311` | page background | +| `--surface` | `#ffffff` | `#1b1a17` | cards, panels, tables, header | +| `--surface-2` | `#efede7` | `#24231f` | subtle fills: chips, table heads, compact stats | +| `--surface-3` | `#e4e1d9` | `#2e2c27` | avatar placeholder, skeleton highlight | +| `--line` | `#dcd8cf` | `#36332d` | borders and rules | +| `--line-strong` | `#b9b3a6` | `#57534a` | emphasised rules, decorative outlines | +| `--line-field` | `#878073` | `#7d786f` | form field borders, switch track (≥ 3:1) | +| `--ink` | `#1c1b18` | `#ecebe6` | text | +| `--ink-2` | `#47443d` | `#c9c5bb` | secondary text | +| `--ink-3` | `#66625a` | `#a29d92` | muted text, meta data | +| `--accent` | `#4338ca` | `#a5b4fc` | links, focus, active navigation, bars | +| `--accent-hover` | `#3730a3` | `#c7d2fe` | hover of links and buttons | +| `--accent-soft` / `--accent-ink` | `#ebeafd` / `#312e81` | `#25264d` / `#c7d2fe` | info notices, accent badges | +| `--on-accent` | `#ffffff` | `#16163a` | text on accent buttons | +| `--mark` / `--mark-ink` | `#fde68a` / `#1c1b18` | `#5a4b10` / `#fff6d5` | highlighter: quotes, search hits | +| `--positive` / `-soft` | `#146c43` / `#e2f2e8` | `#74d3a2` / `#11301f` | "dafür", "angenommen", verified | +| `--negative` / `-soft` | `#b3261e` / `#fbe9e6` | `#ff9d90` / `#3c1612` | "dagegen", "abgelehnt" | +| `--caution` / `-soft` | `#8a5300` / `#fcf0d4` | `#f2c35c` / `#36290c` | legal matters, disputed, dissent, review | +| `--focus` | `#4338ca` | `#a5b4fc` | focus ring | + +**Contrast** (WCAG 2.2: text ≥ 4.5, UI parts ≥ 3; computed from the values above): + +| Pair | Use | Light | Dark | +|---|---|---|---| +| `--ink` on `--paper` | body text | 15.8 | 15.6 | +| `--ink-2` on `--surface` | secondary text | 9.7 | 10.1 | +| `--ink-3` on `--paper` | muted text on the page | 5.6 | 6.9 | +| `--ink-3` on `--surface-2` | muted text on fills | 5.2 | 5.8 | +| `--accent` on `--surface` | links on cards | 7.9 | 8.7 | +| `--on-accent` on `--accent` | primary button | 7.9 | 8.7 | +| `--accent-ink` on `--accent-soft` | info notice, accent badge | 9.6 | 9.6 | +| `--mark-ink` on `--mark` | highlighted search hit | 13.8 | 7.9 | +| `--positive` on `--positive-soft` | positive badge | 5.6 | 7.9 | +| `--negative` on `--negative-soft` | negative badge | 5.6 | 8.0 | +| `--caution` on `--caution-soft` | caution badge | 5.6 | 8.6 | +| `--line-field` on `--surface` | form field border | 3.9 | 4.0 | + +`--line` and `--line-strong` are decorative (card edges, rules) and are never the only way to recognise a +control. Re-run the numbers when you change a colour; the check is twenty lines of Python (relative +luminance per WCAG). + +**Party colours** come from the data (`organization.color`) and are used only through +`partyStyle(color)` → `--party`, by `.party-dot`, `.party-band` (a 4 rem bar), `.party-card` (a 4 px top +edge) and party bars in the role timeline. Never as text colour, never as a large background, always next +to the party's name. Dots get a thin ring so dark party colours stay visible on the dark theme. + +**Platform colours** (`.platform-bluesky` …) follow the same rule: a dot next to the platform name. + +### Typography + +System font stack (`--font-sans`): fast, private (no font downloads) and native on every device. +`--font-mono` for identifiers (file numbers, Firmenbuch numbers, item references). + +| Token | Size | Use | +|---|---|---| +| `--text-xs` | 0.75 rem | meta data, badges, axis labels | +| `--text-sm` | 0.875 rem | secondary text, tables, filters | +| `--text-base` | 1 rem | body | +| `--text-md` | 1.125 rem | ledes, search field | +| `--text-lg` | 1.25 rem | h2 | +| `--text-xl` | 1.5 rem | key figures, claim title on its permalink page | +| `--text-2xl` | clamp(1.625 → 2.125 rem) | h1 | +| `--text-display` | clamp(2 → 3 rem) | home headline | + +Line height 1.55 for text, 1.2 for headings. Headings use `text-wrap: balance`. Running text, list items, +table cells and headings hyphenate (`hyphens: auto` with the document's `lang`), which keeps long German +compounds from breaking layouts on phones. + +### Spacing, shape, depth + +* Spacing on a 4 px grid: `--space-1` 0.25 rem · `-2` 0.5 · `-3` 0.75 · `-4` 1 · `-5` 1.5 · `-6` 2 · + `-7` 3 · `-8` 4 rem. +* Radius: `--radius-sm` 4 px (chips, position badges) · `--radius` 8 px (fields, buttons, notices) · + `--radius-lg` 12 px (cards, panels, tables) · `--radius-pill`. +* Depth: borders first. `--shadow-sm` on hover of link cards; `--shadow` only for overlays (menus, + sticky action bar). + +### Layout + +* `--page-max` 76 rem content width, `--prose-max` 44 rem for reading, `--gutter` clamp(1–2 rem), + `--header-h` 3.5 rem. +* Breakpoints (media queries can't use custom properties, so they are written out): **640 px** (stacked + tables, collapsed filters, one-column role timeline), **768 px** (calendar → agenda), **1024 px** (full + header navigation, aside columns), **1360 px** (header tool labels). + +### Theming + +The light theme is defined on `:root`. The dark theme is defined twice with the same values: once in +`@media (prefers-color-scheme: dark)` for `:root:not([data-theme='light'])`, once for +`:root[data-theme='dark']`. `ThemeSwitcher` sets `data-theme` and the `pt_theme` cookie; the layout reads +the cookie, so the server renders the chosen theme without a flash. **A new token goes into all three +blocks.** + +## 3. Icons + +`` (`src/components/Icon.tsx`): inline SVG on a 24 px grid, 1.75 px strokes in +`currentColor`, decorative (`aria-hidden`) unless you pass `label`. Sizes follow the text (`1em`) unless a +component sets them. + +Available: `search person people flag building euro vote calendar message book history sliders external +document check checks alert scale globe sun moon monitor arrow-right arrow-left chevron-down +chevron-right link quote mic plane briefcase landmark info close menu pin news question pencil pie +activity shield code`. + +To add one, append a list of path strings (and `c:cx,cy,r` for circles) to `PATHS`. Draw it yourself from +simple geometry rather than copying a set with a licence to track. Claim types map to icons in +`TYPE_ICONS` (`badges.tsx`). + +`` is the logo: brackets around a dot (a citation with its evidence). `src/app/icon.svg` is the +same mark as the favicon. + +## 4. Components + +| Component | File | Use | +|---|---|---| +| `SiteHeader`, `SiteFooter` | `SiteHeader.tsx`, `SiteFooter.tsx` | page frame; navigation from `nav.ts` | +| `NavLink` (client) | `NavLink.tsx` | navigation link with `aria-current` for the section | +| `ThemeSwitcher`, `LocaleSwitcher` (client) | | system → light → dark; same page in the other language | +| `PageHeader`, `Breadcrumbs`, `SectionHead` | `PageHeader.tsx` | title block; crumbs (home is added); h2 with an "Alle …" link | +| `Tabs` | `Tabs.tsx` | link tabs with counts; scroll shadows when they overflow | +| `FilterBar`, `Field`, `countActive` | `FilterBar.tsx` | GET filter form, collapses on phones | +| `Pagination` | `Pagination.tsx` | previous / page n / next (the API has no totals) | +| `Notice`, `EmptyState` | `Notice.tsx` | notes with tone `note`/`info`/`caution`/`legal`; empty results | +| `ExternalLink` | `ExternalLink.tsx` | link to a source: new tab, no referrer, announced to screen readers | +| `TypeLabel`, `SourceMeter` | `badges.tsx` | claim type with icon; source count / disputed | +| `PartyTag`, `StatusBadge` | `badges.tsx` | party dot + short name; person status | +| `PositionChip`, `ResultBadge` | `badges.tsx` | vote position (optionally per party); accepted/rejected | +| `PlatformBadge`, `platformName` | `badges.tsx` | social platform | +| `Avatar`, `PersonCard` | `Avatar.tsx`, `PersonCard.tsx` | portrait or initials; person in a grid | +| `StatGrid`, `BarList`, `ProportionBar` | `charts.tsx` | key figures; ranked bars; shares of a whole | +| `Timeline`, `DateRangeForm` | `Timeline.tsx` | activity per month (bars link to the month); date filter | +| `RoleTimeline` | `RoleTimeline.tsx` | offices and memberships on a year axis | +| `ClaimList`, `ClaimCard`, `EvidenceList` | `ClaimList.tsx` | the claim card and its sources | +| `ClaimDetails`, `OrgLink` | `ClaimDetails.tsx` | type-specific details (registry `DETAILS`) | +| `MoneyTable` | `MoneyTable.tsx` | money flows in four columns (date + kind, from, to, amount + source chips); `self` mutes the page's own organisation | +| `VoteList`, `VoteLegend` | `VoteList.tsx` | votes (vote items or one person's votes); `compact` for panels | +| `SocialFeed` | `SocialFeed.tsx` | post cards, no embeds | +| `SearchForm` | `SearchForm.tsx` | the big search field (optional date range) | +| `SettingsForm` (client) | `SettingsForm.tsx` | "Meine Filter" | + +### The claim card + +`ClaimCard` renders, in this order: head (type label, date, subject, source meter), legal stage row, +summary, `ClaimDetails`, innocence notice (`needsInnocenceNotice()`), review notice, topics, and the foot +with the evidence disclosure and the permalink. `standalone` is the permalink page variant: the summary +becomes the `h1` and the sources are expanded. Rules: + +* Never render a claim without its evidence. +* Don't remove or move the innocence notice; it belongs inside the card. +* Show the subject only when the list mixes subjects (`showSubject={false}` on profiles). +* When the English UI shows a German summary, it is marked `lang="de"`. + +### Filters + +```tsx + +``` + +`active` drives the count badge, the reset link and whether the phone toggle starts open. Hidden values +keep context (an organisation, the current tab) across submits. + +### Tables + +```tsx +
+ + … + … +
{t('date')}
…
+
+``` + +`.table-wrap` gives the frame and horizontal scrolling. `.table-stack` turns rows into labelled cards +below 640 px, using each cell's `data-label`. Numbers: `className="num"` (right-aligned, tabular). +Amounts: ``. Keep tables to four or five columns: secondary values (a kind badge, source +chips) go into the cell they belong to rather than into a column of their own. + +### Charts + +All charts are HTML and CSS, with the numbers as text: + +* `StatGrid` is a `
`; values can link to the list behind them. +* `BarList` scales bars to the largest value in the list. Use it for rankings of the same unit only, and + label each row's kind (`meta`). +* `ProportionBar` has `role="img"` with a text alternative, plus a legend with counts. +* `Timeline` bars are links with labels ("März 2026: 12"). +* `RoleTimeline` labels every bar with its years. Roles without a start date are listed in the roles panel + only. + +## 5. Layout utilities + +| Class | Does | +|---|---| +| `.page` | the main column (set by the layout) | +| `.stack`, `.cluster` | vertical stack with gap; wrapping row with gap | +| `.grid-2` | two columns ≥ 22 rem, one column below | +| `.grid-cards` | auto-fill card grid; `--card-min` (default 17 rem) | +| `.layout-aside` | main + 20 rem aside from 1024 px | +| `.section`, `.section-head` | vertical rhythm between sections; h2 row with a "more" link | +| `.prose` | 44 rem reading width | +| `.panel` | aside box with an h2 | +| `.link-list`, `.name-list`, `.meta-list` | ruled list; multi-column names; definition grid | +| `.muted`, `.small`, `.xsmall`, `.strong`, `.num`, `.mono`, `.nowrap`, `.block`, `.visually-hidden` | text utilities | + +## 6. Content + +### Voice and wording + +Sachlich and precise; "du"; gender-inclusive with a colon (Politiker:innen, Funktionär:innen). Say what the +source says, with the source. + +| Instead of | Write | +|---|---| +| "X kassierte 25.000 €" | "Laut Rechnungshof erhielt X 25.000 € (Spende)" | +| "Skandal um …", "dubios" | the fact and its source; no adjectives that judge | +| "X ist korrupt", "Anklage!" | the procedural stage ("Ermittlungsverfahren") + the innocence notice | +| "X und Y sind verbandelt" | "X ist laut Firmenbuch Geschäftsführer von Y" + the connection note | +| "X hat 33.000 Follower" (from somewhere) | only data we collected, with its source and date | + +### Numbers, dates, money + +* Use `formatDate`, `formatMonth`, `formatPeriod`, `formatNumber`, `formatMoney`, `formatMoneyCompact` + from `lib/format.ts`. They use de-AT and en-GB conventions (`12. März 2026`, `€ 25.000`, + `1,2 Mio. €`, `2. Halbjahr 2025`). +* Exact amounts in tables, compact amounts in summaries and bar lists (exact value in the `title`). +* Pass numbers to ICU messages (`{count, plural, …}`), never pre-formatted strings. Pass IDs as strings, + so they don't get thousands separators. +* Never add up different kinds of money into one figure without saying so. + +### Interface text + +* Every key goes into both `messages/de.json` and `messages/en.json`; German first. +* Labels are short nouns ("Quellen", "Filter"); buttons are verbs ("Anwenden", "Speichern"). +* Links say where they go ("Alle Abstimmungen", "Original ansehen"), never "hier klicken". +* Keep a dash with the preceding word using a non-breaking space where a line break would start a line + with "–" (the home headline does this). + +## 7. Accessibility checklist (for every UI change) + +- [ ] Headings in order, one `h1`; landmarks intact; new sections have `aria-labelledby`. +- [ ] Everything works with the keyboard and shows the focus ring. +- [ ] Nothing relies on colour alone: badges have words, positions have icons, charts have numbers. +- [ ] Contrast of new colours checked (§2). +- [ ] External links use `ExternalLink` (new tab announced). +- [ ] Images: `alt=""` when decorative (the name is next to the portrait), real text otherwise. +- [ ] Phone width (390 px): no horizontal scrolling, tables stack, nothing cut off. +- [ ] Both themes look right; both languages fit (English is shorter, German compounds are longer). + +## 8. Recipes + +**An index page.** Use `PageHeader` → `FilterBar` → results (`grid-cards`, a `table-wrap`, or +`ClaimList`) → `Pagination` → a source `Notice`. The persons and organisations pages are the templates. + +**A profile section.** Use a `section` with `aria-labelledby` and a `SectionHead` (with `more` pointing to +the pre-filtered explorer), then the list component. Put secondary facts into a `.panel` in the aside. + +**Showing a new claim type.** Add a renderer to `DETAILS` in `ClaimDetails.tsx`, an icon to `TYPE_ICONS` +in `badges.tsx`, and the label `Claims.type.` in both message files. + +**A new chart.** Prefer `StatGrid`, `BarList` or `ProportionBar`. If you need something else, build it from +HTML/CSS with the numbers as text, add its section to `components.css`, and document it here. + +**A new token.** Add it to all three blocks in `tokens.css`, check its contrast, and add it to §2. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1394058..4b7c484 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -34,6 +34,12 @@ Next.js frontend (de/en), Docker Compose for Unraid. - [x] Pages: organisations, money explorer, social feed, Wikipedia edits, person network ("follow the money"), say-vs-vote on roll calls +**Design (v2)** – see [design/README.md](design/README.md): +- [x] Design concept and design system (neutral palette, light/dark, tokens with checked contrast, icons) +- [x] Redesigned pages: header with search and filter count, profiles with role timeline and key figures, + party and organisation dashboards, vote list with position chips, calendar agenda on phones +- [x] Claim permalink pages with citation (`/angaben/{id}`, `GET /api/v1/claims/{id}`) + ## Next **Data** @@ -53,6 +59,10 @@ Next.js frontend (de/en), Docker Compose for Unraid. - [ ] Wayback Machine archiving of evidence URLs (`document.archive_url`) **Product** +- [ ] Before going public: Impressum/Offenlegung (§ 5 ECG, § 25 MedienG), privacy policy, a corrections contact +- [ ] Image proxy for portraits and logos (no visitor IPs to Wikimedia/parliament) +- [ ] Compare two people or parties; topic pages (`/themen/{slug}`); Open Graph share images; CSV export +- [ ] Review queue for editors in the web UI (today `politrack review`) - [ ] Community link submissions and a corrections/right-of-reply form - [ ] Alerts (e-mail/RSS) for followed politicians - [ ] Optional accounts via reverse-proxy SSO using `app_user` diff --git a/web/messages/de.json b/web/messages/de.json index 94b015e..4987006 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -4,7 +4,6 @@ "description": "Was Österreichs Politikerinnen und Politiker tun: Abstimmungen, Geld, Auftritte, Aussagen – jede Angabe mit Quelle." }, "Nav": { - "home": "Start", "persons": "Personen", "parties": "Parteien", "votes": "Abstimmungen", @@ -16,22 +15,51 @@ "former": "Ehemalige", "organizations": "Organisationen", "money": "Geld", - "social": "Social Media" + "social": "Social Media", + "wikipedia": "Wikipedia-Bearbeitungen" }, "Home": { - "headline": "Politik in Österreich – nachvollziehbar.", + "headline": "Politik in Österreich – nachvollziehbar.", "intro": "Politrack sammelt öffentlich zugängliche Informationen über Politikerinnen, Politiker und Parteien und verlinkt jede Angabe mit ihren Quellen. Du entscheidest, welchen Quellen du vertraust.", - "stats": "{persons} Personen ({active} aktiv) · {claims} belegte Angaben, davon {corroborated} mehrfach bestätigt · {votes} Abstimmungen ({rollCalls} namentlich) · {money} Geldflüsse", "lastUpdate": "Zuletzt aktualisiert: {date}", "latest": "Neueste Angaben", - "filtersActive": "Deine Filter sind aktiv: {sources} Quellen ausgeblendet, {topics} Themen gewählt.", + "filtersActive": "Deine Filter sind aktiv ({count}) – manche Angaben sind ausgeblendet.", "explore": "Entdecken", "exploreMoney": "Geld: Spenden, Aufträge, Inserate, Parteienförderung", "exploreOrganizations": "Unternehmen, Vereine, Behörden – und ihre Verbindungen", "exploreSocial": "Was Politiker:innen öffentlich posten", "exploreWiki": "Wikipedia-Bearbeitungen aus Parlament und Regierung", "exploreFormer": "Was machen Ehemalige heute?", - "exploreCalendar": "Kalender: Sitzungen, Reisen, Treffen" + "exploreCalendar": "Sitzungen, Ausschüsse, Ministerrat, Reisen und Treffen", + "quick": "Schnellzugriff", + "quickDonations": "Parteispenden", + "quickRollCalls": "Namentliche Abstimmungen", + "quickAds": "Regierungsinserate", + "quickCalendar": "Kalender", + "statPersons": "Personen, davon {active} aktiv", + "statClaims": "belegte Angaben, {corroborated} mehrfach belegt", + "statVotes": "Abstimmungen, {rollCalls} namentlich", + "statMoney": "Geldflüsse aus amtlichen Registern", + "changeFilters": "Filter ändern", + "searchInTime": "Suche in der Zeit", + "parties": "Parteien", + "activeOfficials": "{count} aktiv", + "latestVotes": "Letzte Abstimmungen", + "allVotes": "Alle Abstimmungen", + "exploreMoneyTitle": "Geld", + "exploreOrganizationsTitle": "Organisationen", + "exploreSocialTitle": "Social Media", + "exploreCalendarTitle": "Kalender", + "exploreFormerTitle": "Ehemalige", + "exploreWikiTitle": "Wikipedia-Bearbeitungen", + "howTitle": "So arbeitet Politrack", + "howMore": "Methodik & Quellen", + "howEvidenceTitle": "Jede Angabe mit Beleg", + "howEvidence": "Angaben stammen aus amtlichen Daten, offenen Registern und Medienberichten. Jede verlinkt ihre Quellen – du kannst alles nachprüfen.", + "howCorroborationTitle": "Unabhängig bestätigt", + "howCorroboration": "Berichten mehrere unabhängige Quellen dasselbe, gilt eine Angabe als mehrfach belegt. Widerspricht eine Quelle, wird sie als umstritten markiert.", + "howInnocenceTitle": "Fair und neutral", + "howInnocence": "Bei Verfahren zeigen wir immer den Stand. Bis zu einer rechtskräftigen Verurteilung gilt die Unschuldsvermutung." }, "Search": { "placeholder": "Person, Thema oder Stichwort …", @@ -39,7 +67,13 @@ "results": "Ergebnisse für „{q}“", "none": "Keine Treffer.", "from": "Von", - "to": "Bis" + "to": "Bis", + "title": "Suche", + "intro": "Suche nach Personen, Themen und Stichworten in allen Angaben und Dokumenten – oder gib nur einen Zeitraum an, um alles zu sehen, was in dieser Zeit passiert ist.", + "label": "Suchbegriff", + "inTime": "Angaben im Zeitraum {range}", + "tips": "Tipp: Gib nur ein Datum oder einen Zeitraum ein (ohne Suchbegriff), um alle Angaben aus dieser Zeit zu sehen.", + "details": "Angabe ansehen" }, "Persons": { "title": "Personen", @@ -64,14 +98,17 @@ "deceased": "Verstorben", "all": "Alle" }, - "lastOffice": "Letztes Amt bis {date}" + "lastOffice": "Letztes Amt bis {date}", + "intro": "Abgeordnete, Regierungsmitglieder, Landes- und Gemeindepolitiker:innen sowie Funktionär:innen der Parteien.", + "name": "Name", + "namePlaceholder": "z. B. Nachname", + "unverifiedStatus": "Aktuelles Amt nur laut Wikidata – nicht amtlich bestätigt", + "roleMp": "Abgeordnete:r" }, "Person": { "roles": "Funktionen & Mandate", - "current": "aktuell", "since": "seit {date}", "period": "{from} – {to}", - "social": "Kanäle", "tabs": { "overview": "Überblick", "votes": "Abstimmungen", @@ -84,21 +121,38 @@ "social": "Social Media" }, "factionVoteNote": "Im Nationalrat wird meist per Aufstehen abgestimmt; offiziell protokolliert ist dann nur das Stimmverhalten der Fraktion. Bei namentlichen Abstimmungen zeigen wir die eigene Stimme – und ob sie von der Fraktion abweicht.", - "noVotes": "Keine Abstimmungen erfasst.", - "faction": "Fraktion {faction}", - "statusNote": "Status wird aus den Mandaten und Funktionen berechnet: „aktiv“ nur mit bestätigtem aktuellem Amt.", "roleCorroborated": "mehrfach belegt", "unverified": "laut Wikidata – nicht amtlich bestätigt", - "accountUnverified": "Laut Wikidata – nicht amtlich bestätigt" + "accountUnverified": "Laut Wikidata – nicht amtlich bestätigt", + "metaDescription": "{name}: Funktionen, Abstimmungen, Geldflüsse, Aussagen und Medienberichte – jede Angabe mit Quelle.", + "member": "Mitglied", + "born": "geboren {date}", + "died": "gestorben {date}", + "elsewhere": "Profile und Kanäle", + "parliamentProfile": "Parlament", + "epProfile": "Europäisches Parlament", + "sections": "Bereiche", + "career": "Laufbahn", + "latest": "Neueste Angaben", + "allClaims": "Alle Angaben", + "glanceTitle": "Auf einen Blick", + "glance": { + "votes": "Abstimmungen", + "statements": "Reden & Aussagen", + "questions": "Anfragen", + "appearances": "Termine & Reisen", + "sideJobs": "Nebentätigkeiten", + "media": "Medienberichte" + }, + "moneyClaims": "Angaben zu Geld", + "legalNote": "Verfahren werden mit ihrem Stand gezeigt; automatisch erkannte erscheinen erst nach redaktioneller Prüfung. Es gilt die Unschuldsvermutung.", + "datePrecision": "Bei Reisen und Treffen ist das Datum oft das Veröffentlichungsdatum der Quelle." }, "Parties": { "title": "Parteien", - "activeOfficials": "{count} aktive Funktionsträger:innen", + "activeOfficials": "{count, plural, one {# aktive:r Funktionsträger:in} other {# aktive Funktionsträger:innen}}", "members": "Aktuelle Funktionsträger:innen", - "donations": "Gemeldete Spenden (Rechnungshof)", "year": "Jahr", - "total": "Summe", - "count": "Anzahl", "voteStats": "Stimmverhalten", "recentVotes": "Letzte Abstimmungen", "claims": "Belegte Angaben", @@ -115,7 +169,20 @@ "ownership": "Beteiligung" }, "channels": "Kanäle der Partei und ihrer Mitglieder", - "allPosts": "Alle Beiträge" + "allPosts": "Alle Beiträge", + "intro": "Parteien mit ihren aktuellen Funktionsträger:innen, Abstimmungen, Finanzen und nahestehenden Organisationen.", + "tracked": "{count, plural, one {# Person erfasst} other {# Personen erfasst}}", + "statOfficials": "aktuelle Funktionsträger:innen", + "statFormer": "Ehemalige", + "statVotes": "erfasste Fraktionsstimmen", + "statDissent": "Mandatar:innen mit abweichenden Stimmen", + "voteStatsNote": "Stimmverhalten der Fraktion in allen erfassten Abstimmungen.", + "noMoney": "Keine Geldflüsse erfasst.", + "allMembers": "Alle {count} anzeigen", + "relatedNote": "Laut Rechenschaftsbericht der Partei.", + "allFormer": "Alle Ehemaligen", + "lastMention": "zuletzt in den Medien {date}", + "dissentCount": "{count, plural, one {# abweichende Stimme} other {# abweichende Stimmen}}" }, "Votes": { "title": "Abstimmungen", @@ -129,13 +196,22 @@ "item": "Gegenstand", "rollCall": "namentliche Abstimmung", "dissent": "gegen die eigene Fraktion", - "factionVote": "Fraktion", "ownVote": "eigene Stimme", "individualVotes": "Einzelstimmen", "onlyRollCall": "Nur namentliche Abstimmungen", "splitVote": "Getrennte Abstimmung – Details im Protokoll", "spoke": "Rede dazu", - "speech": "Rede" + "speech": "Rede", + "intro": "Wie die Fraktionen im Nationalrat und Bundesrat abgestimmt haben – und bei namentlichen Abstimmungen jede einzelne Stimme.", + "none": "Keine Abstimmungen gefunden.", + "legend": "Legende", + "factionVoteOf": "Stimme der Fraktion {faction}", + "vote": "Abstimmung", + "factions": "Fraktionen", + "factionOnly": "Protokolliert ist das Stimmverhalten der Fraktionen, nicht einzelner Abgeordneter.", + "dissentSummary": "{count, plural, one {# Abgeordnete:r stimmte} other {# Abgeordnete stimmten}} gegen die eigene Fraktion.", + "protocolText": "Wortlaut im Protokoll", + "filteredParty": "Gefiltert nach Partei: {party}" }, "Claims": { "sources": "{count, plural, one {# Quelle} other {# Quellen}}", @@ -149,7 +225,6 @@ "mentions": "erwähnt", "contradicts": "widerspricht", "none": "Keine Angaben mit deinen Quellen- und Themenfiltern.", - "more": "Weitere laden", "type": { "role": "Funktion", "vote": "Abstimmung", @@ -188,7 +263,11 @@ "proceedings_dropped": "Verfahren eingestellt", "diversion": "Diversion (keine Verurteilung)" }, - "pending": "Noch nicht redaktionell geprüft" + "pending": "Noch nicht redaktionell geprüft", + "evidence": "{count, plural, one {# Beleg} other {# Belege}}", + "permalink": "Permalink", + "permalinkHint": "Eigene Seite dieser Angabe – zum Teilen und Zitieren", + "archived": "archivierte Kopie" }, "Settings": { "title": "Meine Filter", @@ -208,7 +287,9 @@ "aggregator": "Datenbanken", "user": "Community", "derived": "Abgeleitet" - } + }, + "sourcesHint": "Ausgeschaltete Quellen werden überall ausgeblendet. Die Zahl ist der voreingestellte Vertrauenswert (0–100).", + "trust": "Vertrauenswert" }, "Method": { "title": "Methodik & Quellen", @@ -217,11 +298,65 @@ "p3": "Zu strafrechtlichen Angelegenheiten zeigen wir stets den Verfahrensstand. Bis zu einer rechtskräftigen Verurteilung gilt die Unschuldsvermutung.", "license": "Lizenz", "access": "Zugriff", - "trust": "Vertrauen" + "trust": "Vertrauen", + "intro": "Wie Politrack Angaben sammelt, prüft und zeigt.", + "toc": "Inhalt", + "section": { + "claims": "Angaben und Belege", + "certainty": "Wie sicher eine Angabe ist", + "legal": "Rechtliche Verfahren", + "sources": "Quellen", + "notCollected": "Was wir nicht erfassen", + "data": "Daten für Entwickler:innen und KI-Agenten", + "corrections": "Korrekturen" + }, + "certaintyIntro": "Diese Kennzeichen bedeuten überall auf Politrack dasselbe:", + "reported": "Eine Quelle berichtet die Angabe.", + "corroborated": "Mehrere unabhängige Quellen bestätigen die Angabe.", + "disputed": "Mindestens eine Quelle widerspricht – beide Seiten sind verlinkt.", + "activeVerified": "Aktuelles Amt laut Parlament oder Europäischem Parlament bestätigt.", + "activeUnverified": "Aktuelles Amt nur laut Wikidata, nicht amtlich bestätigt.", + "accountUnverified": "Social-Media-Konto nur laut Wikidata, von keiner offiziellen Seite bestätigt.", + "stage": { + "allegation_reported": "Ein Medium oder eine andere Quelle berichtet über einen Vorwurf; ein Verfahren ist nicht bekannt.", + "investigation": "Staatsanwaltschaft oder Polizei ermitteln. Das sagt nichts über Schuld aus – viele Ermittlungen werden eingestellt.", + "immunity_waiver_requested": "Eine Behörde ersucht das Parlament, die Immunität aufzuheben, um ermitteln zu können.", + "immunity_waived": "Das Parlament hat die Immunität aufgehoben; Ermittlungen sind damit möglich, eine Anklage gibt es dadurch nicht.", + "immunity_refused": "Das Parlament hat die Aufhebung der Immunität abgelehnt.", + "indicted": "Die Staatsanwaltschaft hat Anklage erhoben; ein Gericht entscheidet.", + "trial": "Das Gerichtsverfahren läuft.", + "convicted_not_final": "Ein Gericht hat verurteilt, das Urteil ist aber nicht rechtskräftig (Rechtsmittel möglich).", + "convicted_final": "Rechtskräftige Verurteilung – das Verfahren ist abgeschlossen.", + "acquitted": "Das Gericht hat freigesprochen.", + "proceedings_dropped": "Das Verfahren wurde eingestellt.", + "diversion": "Erledigung ohne Verurteilung (z. B. Geldbuße oder gemeinnützige Leistung); kein Schuldspruch, kein Strafregistereintrag." + }, + "sourcesIntro": "Alle Quellen mit Lizenz, Zugriffsart und voreingestelltem Vertrauenswert. Unter „Meine Filter“ kannst du einzelne Quellen ausblenden oder einen Mindestwert festlegen.", + "source": "Quelle", + "kind": "Art", + "notCollected": "Wir umgehen keine Bezahlschranken, Logins oder technischen Sperren und halten uns an robots.txt und Nutzungsbedingungen. Von Medien speichern wir nur Überschrift, Teaser und Link.", + "data": "Die Betreiber:innen dieser Instanz stellen die Daten auch maschinenlesbar bereit: als REST-API mit OpenAPI-Dokumentation, als MCP-Server für KI-Agenten und als Lesezugriff auf die Datenbank. Jede Angabe kommt dort mit denselben Belegen wie hier.", + "corrections": "Fehler gefunden? Wir korrigieren falsche Angaben, markieren widersprochene als umstritten und löschen nichts stillschweigend. Bitte nenne die Nummer der Angabe (siehe Permalink) und eine Quelle für die Korrektur." }, "Common": { - "loadError": "Daten konnten nicht geladen werden.", - "notFound": "Nicht gefunden." + "loadError": "Die Daten konnten nicht geladen werden. Bitte versuche es gleich noch einmal.", + "notFound": "Diese Seite gibt es nicht (mehr). Vielleicht hilft die Suche weiter.", + "home": "Start", + "persons": "Personen", + "search": "Suche", + "breadcrumb": "Navigationspfad", + "filters": "Filter", + "apply": "Anwenden", + "reset": "Zurücksetzen", + "previous": "Zurück", + "next": "Weiter", + "page": "Seite {page}", + "pagination": "Seiten", + "newTab": "öffnet in neuem Tab", + "loading": "Wird geladen …", + "retry": "Erneut versuchen", + "errorTitle": "Das hat nicht geklappt", + "notFoundTitle": "Seite nicht gefunden" }, "Timeline": { "from": "Von", @@ -233,9 +368,9 @@ "Calendar": { "title": "Kalender", "intro": "Sitzungen, Ausschüsse, Reisen, Treffen und Auftritte – mit Quellen.", - "prev": "← Vormonat", - "next": "Nächster Monat →", - "person": "Person (Kurzname)", + "prev": "Vormonat", + "next": "Nächster Monat", + "person": "Person", "none": "Keine Termine in diesem Zeitraum.", "activity": "{count} Angaben", "kind": { @@ -252,21 +387,42 @@ "hearing": "Befragung (U-Ausschuss)", "government_meeting": "Ministerrat" }, - "datePrecision": "Datum = Veröffentlichungsdatum der Quelle" + "datePrecision": "Datum = Veröffentlichungsdatum der Quelle", + "today": "Heute", + "personPlaceholder": "Name", + "kindLabel": "Art", + "allKinds": "Alle Arten", + "legend": "Legende", + "group": { + "parliament": "Parlament", + "government": "Regierung", + "travel": "Reisen & Treffen", + "public": "Öffentliche Auftritte" + }, + "more": "+{count} weitere", + "activityHint": "Alle Angaben von diesem Tag" }, "Former": { "title": "Was machen ehemalige Politikerinnen und Politiker?", "intro": "Ehemalige Mandatarinnen, Mandatare und Funktionsträger:innen – sortiert nach der jüngsten Berichterstattung.", "lastRole": "Zuletzt: {role}", "latestNews": "Zuletzt in den Medien", - "career": "Werdegang" + "career": "Werdegang", + "none": "Keine ehemaligen Politiker:innen gefunden." }, "ClaimDetails": { "with": "mit", "reference": "Ref.", "article": "Artikel", "fileNumber": "GZ", - "financeDetails": "Details Rechenschaftsbericht {year}" + "financeDetails": "Details Rechenschaftsbericht {year}", + "metric": { + "likes": "Likes", + "reposts": "Reposts", + "favourites": "Favoriten", + "reblogs": "Boosts", + "views": "Aufrufe" + } }, "Money": { "title": "Geld", @@ -301,7 +457,10 @@ "side_income": "Nebeneinkommen", "sponsorship": "Sponsoring", "other": "Sonstiges" - } + }, + "flows": "Einzelne Geldflüsse", + "nameLabel": "Name", + "asReported": "Beträge wie gemeldet." }, "Organizations": { "title": "Organisationen", @@ -350,7 +509,14 @@ "authority": "Behörde", "eu_institution": "EU-Institution", "other": "Sonstige" - } + }, + "receivedTotal": "erhalten (gesamt)", + "paidTotal": "gezahlt (gesamt)", + "counterpartyCount": "Gegenparteien", + "receivedFromTitle": "Erhielt Geld von", + "paidToTitle": "Zahlte an", + "noMoney": "Keine Geldflüsse erfasst.", + "identifiers": "Kennungen" }, "Network": { "title": "Netzwerk: verbundene Organisationen", @@ -366,7 +532,10 @@ "role": "Funktion", "lobbying": "Lobbying", "party": "Partei" - } + }, + "organization": "Organisation", + "connection": "Verbindung", + "money": "Geld dieser Organisation" }, "Social": { "title": "Social Media", @@ -389,6 +558,44 @@ "network": "Netzwerk", "article": "Artikel", "change": "Änderung", - "note": "Eine Bearbeitung aus einem Netzwerk sagt nichts darüber, welche Person sie vorgenommen hat. IP-Bereiche: config/ip_ranges.yaml." + "note": "Eine Bearbeitung aus einem Netzwerk sagt nichts darüber, welche Person sie vorgenommen hat.", + "ranges": "Die IP-Bereiche stehen in config/ip_ranges.yaml im Quellcode." + }, + "Layout": { + "skip": "Zum Inhalt springen", + "homeLabel": "Politrack – Startseite", + "mainNav": "Hauptnavigation", + "footerNav": "Seitenübersicht", + "more": "Mehr", + "menu": "Menü", + "searchLabel": "Suche", + "searchPlaceholder": "Suchen …", + "filtersActive": "Meine Filter: {count} aktiv", + "themeLabel": "Farbschema: {current} – wechseln zu {next}", + "theme": { + "system": "wie System", + "light": "hell", + "dark": "dunkel" + }, + "group": { + "people": "Menschen", + "decisions": "Entscheidungen", + "influence": "Geld & Einfluss", + "about": "Über Politrack" + } + }, + "Footer": { + "tagline": "Österreichs Politik, belegt. Ein unabhängiges Projekt, das öffentlich zugängliche Informationen über Politikerinnen, Politiker und Parteien sammelt – jede Angabe mit Quelle.", + "principleEvidence": "Jede Angabe mit Beleg", + "principleInnocence": "Es gilt die Unschuldsvermutung", + "principlePrivacy": "Keine Werbung, kein Tracking", + "developers": "Daten, API & KI-Agenten", + "bottom": "Angaben ohne Gewähr. Jede Angabe verweist auf ihre Quellen – maßgeblich sind diese." + }, + "ClaimPage": { + "crumb": "Angabe #{id}", + "cite": "Zitieren", + "citation": "Politrack: „{summary}“. Angabe #{id}, abgerufen am {date}. {url}", + "filtersIgnored": "Diese Seite zeigt die Angabe mit allen Quellen – unabhängig von deinen Filtern, damit ein geteilter Link für alle gleich aussieht." } } diff --git a/web/messages/en.json b/web/messages/en.json index 603db71..28dc2df 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -4,7 +4,6 @@ "description": "What Austria's politicians do: votes, money, appearances, statements – every claim with its sources." }, "Nav": { - "home": "Home", "persons": "People", "parties": "Parties", "votes": "Votes", @@ -16,22 +15,51 @@ "former": "Former politicians", "organizations": "Organisations", "money": "Money", - "social": "Social media" + "social": "Social media", + "wikipedia": "Wikipedia edits" }, "Home": { - "headline": "Austrian politics – traceable.", + "headline": "Austrian politics – traceable.", "intro": "Politrack collects publicly available information about politicians and parties and links every claim to its sources. You decide which sources you trust.", - "stats": "{persons} people ({active} active) · {claims} sourced claims, {corroborated} corroborated · {votes} votes ({rollCalls} roll calls) · {money} money flows", "lastUpdate": "Last updated: {date}", "latest": "Latest claims", - "filtersActive": "Your filters are active: {sources} sources hidden, {topics} topics selected.", + "filtersActive": "Your filters are active ({count}) – some facts are hidden.", "explore": "Explore", "exploreMoney": "Money: donations, contracts, ads, party funding", "exploreOrganizations": "Companies, associations, authorities – and their connections", "exploreSocial": "What politicians post publicly", "exploreWiki": "Wikipedia edits from parliament and government", "exploreFormer": "What are former politicians doing?", - "exploreCalendar": "Calendar: sessions, trips, meetings" + "exploreCalendar": "Sessions, committees, Council of Ministers, trips and meetings", + "quick": "Quick links", + "quickDonations": "Party donations", + "quickRollCalls": "Roll-call votes", + "quickAds": "Government ads", + "quickCalendar": "Calendar", + "statPersons": "people, {active} in office", + "statClaims": "sourced facts, {corroborated} corroborated", + "statVotes": "votes, {rollCalls} roll calls", + "statMoney": "money flows from official registers", + "changeFilters": "Change filters", + "searchInTime": "Search in time", + "parties": "Parties", + "activeOfficials": "{count} active", + "latestVotes": "Latest votes", + "allVotes": "All votes", + "exploreMoneyTitle": "Money", + "exploreOrganizationsTitle": "Organisations", + "exploreSocialTitle": "Social media", + "exploreCalendarTitle": "Calendar", + "exploreFormerTitle": "Former politicians", + "exploreWikiTitle": "Wikipedia edits", + "howTitle": "How Politrack works", + "howMore": "Methodology & sources", + "howEvidenceTitle": "Every fact has a source", + "howEvidence": "Facts come from official data, open registers and media reports. Each links to its sources, so you can check everything.", + "howCorroborationTitle": "Independently confirmed", + "howCorroboration": "When several independent sources report the same thing, a fact counts as corroborated. If a source contradicts it, it is marked as disputed.", + "howInnocenceTitle": "Fair and neutral", + "howInnocence": "For legal proceedings we always show the stage. Until a final conviction, the presumption of innocence applies." }, "Search": { "placeholder": "Person, topic or keyword …", @@ -39,7 +67,13 @@ "results": "Results for “{q}”", "none": "No results.", "from": "From", - "to": "To" + "to": "To", + "title": "Search", + "intro": "Search people, topics and keywords across all facts and documents – or enter only a date range to see everything that happened in that period.", + "label": "Search term", + "inTime": "Facts from {range}", + "tips": "Tip: enter only a date or a date range (no search term) to see all facts from that time.", + "details": "View fact" }, "Persons": { "title": "People", @@ -64,14 +98,17 @@ "deceased": "Deceased", "all": "All" }, - "lastOffice": "Last office until {date}" + "lastOffice": "Last office until {date}", + "intro": "MPs, members of government, state and municipal politicians, and party officials.", + "name": "Name", + "namePlaceholder": "e.g. last name", + "unverifiedStatus": "Current office only according to Wikidata – not officially confirmed", + "roleMp": "Member of parliament" }, "Person": { "roles": "Offices & mandates", - "current": "current", "since": "since {date}", "period": "{from} – {to}", - "social": "Channels", "tabs": { "overview": "Overview", "votes": "Votes", @@ -84,21 +121,38 @@ "social": "Social media" }, "factionVoteNote": "The National Council mostly votes by standing up; then only each faction's vote is recorded. For roll-call votes we show the person's own vote – and whether it differs from their faction.", - "noVotes": "No votes recorded.", - "faction": "Faction {faction}", - "statusNote": "Status is derived from mandates and functions: \"active\" only with a verified current office.", "roleCorroborated": "corroborated", "unverified": "per Wikidata – not officially confirmed", - "accountUnverified": "According to Wikidata – not officially confirmed" + "accountUnverified": "According to Wikidata – not officially confirmed", + "metaDescription": "{name}: offices, votes, money, statements and media reports – every fact with its source.", + "member": "Member", + "born": "born {date}", + "died": "died {date}", + "elsewhere": "Profiles and channels", + "parliamentProfile": "Parliament", + "epProfile": "European Parliament", + "sections": "Sections", + "career": "Career", + "latest": "Latest facts", + "allClaims": "All facts", + "glanceTitle": "At a glance", + "glance": { + "votes": "votes", + "statements": "speeches & statements", + "questions": "questions", + "appearances": "appointments & trips", + "sideJobs": "side jobs", + "media": "media reports" + }, + "moneyClaims": "Money-related facts", + "legalNote": "Proceedings are shown with their stage; automatically detected ones appear only after editorial review. The presumption of innocence applies.", + "datePrecision": "For trips and meetings the date is often the publication date of the source." }, "Parties": { "title": "Parties", - "activeOfficials": "{count} active officials", + "activeOfficials": "{count, plural, one {# active official} other {# active officials}}", "members": "Current officials", - "donations": "Reported donations (Court of Audit)", "year": "Year", - "total": "Total", - "count": "Count", "voteStats": "Voting record", "recentVotes": "Recent votes", "claims": "Sourced claims", @@ -115,7 +169,20 @@ "ownership": "holding" }, "channels": "Channels of the party and its members", - "allPosts": "All posts" + "allPosts": "All posts", + "intro": "Parties with their current officials, votes, finances and affiliated organisations.", + "tracked": "{count, plural, one {# person tracked} other {# people tracked}}", + "statOfficials": "current officials", + "statFormer": "former members", + "statVotes": "faction votes recorded", + "statDissent": "members who voted against their faction", + "voteStatsNote": "The faction's positions in all recorded votes.", + "noMoney": "No money flows recorded.", + "allMembers": "Show all {count}", + "relatedNote": "According to the party's annual report.", + "allFormer": "All former members", + "lastMention": "last in the news {date}", + "dissentCount": "{count, plural, one {# dissenting vote} other {# dissenting votes}}" }, "Votes": { "title": "Votes", @@ -129,13 +196,22 @@ "item": "Item", "rollCall": "roll-call vote", "dissent": "against own faction", - "factionVote": "faction", "ownVote": "own vote", "individualVotes": "Individual votes", "onlyRollCall": "Roll-call votes only", "splitVote": "Split vote – see transcript", "spoke": "Spoke on this", - "speech": "Speech" + "speech": "Speech", + "intro": "How the factions voted in the Nationalrat and Bundesrat – and, in roll-call votes, every individual vote.", + "none": "No votes found.", + "legend": "Legend", + "factionVoteOf": "vote of the {faction} faction", + "vote": "Vote", + "factions": "Factions", + "factionOnly": "The record shows how each faction voted, not individual MPs.", + "dissentSummary": "{count, plural, one {# member voted} other {# members voted}} against their faction.", + "protocolText": "Wording in the record", + "filteredParty": "Filtered by party: {party}" }, "Claims": { "sources": "{count, plural, one {# source} other {# sources}}", @@ -149,7 +225,6 @@ "mentions": "mentions", "contradicts": "contradicts", "none": "No claims match your source and topic filters.", - "more": "Load more", "type": { "role": "Office", "vote": "Vote", @@ -188,7 +263,11 @@ "proceedings_dropped": "Proceedings dropped", "diversion": "Diversion (no conviction)" }, - "pending": "Not yet reviewed" + "pending": "Not yet reviewed", + "evidence": "{count, plural, one {# source} other {# sources}}", + "permalink": "Permalink", + "permalinkHint": "This fact's own page – for sharing and citing", + "archived": "archived copy" }, "Settings": { "title": "My filters", @@ -208,7 +287,9 @@ "aggregator": "Databases", "user": "Community", "derived": "Derived" - } + }, + "sourcesHint": "Sources you switch off are hidden everywhere. The number is the default trust score (0–100).", + "trust": "Trust score" }, "Method": { "title": "Method & sources", @@ -217,11 +298,65 @@ "p3": "For criminal matters we always show the procedural stage. The presumption of innocence applies until a final conviction.", "license": "Licence", "access": "Access", - "trust": "Trust" + "trust": "Trust", + "intro": "How Politrack collects, checks and presents facts.", + "toc": "Contents", + "section": { + "claims": "Facts and sources", + "certainty": "How certain a fact is", + "legal": "Legal proceedings", + "sources": "Sources", + "notCollected": "What we don't collect", + "data": "Data for developers and AI agents", + "corrections": "Corrections" + }, + "certaintyIntro": "These labels mean the same everywhere on Politrack:", + "reported": "One source reports the fact.", + "corroborated": "Several independent sources confirm the fact.", + "disputed": "At least one source contradicts it – both sides are linked.", + "activeVerified": "Current office confirmed by the Austrian or European Parliament.", + "activeUnverified": "Current office only according to Wikidata, not officially confirmed.", + "accountUnverified": "Social media account only according to Wikidata, not confirmed by an official source.", + "stage": { + "allegation_reported": "A media outlet or another source reports an allegation; no proceedings are known.", + "investigation": "Prosecutors or police are investigating. This says nothing about guilt – many investigations are dropped.", + "immunity_waiver_requested": "An authority asks parliament to lift immunity so it can investigate.", + "immunity_waived": "Parliament has lifted immunity; this allows an investigation and is not an indictment.", + "immunity_refused": "Parliament has refused to lift immunity.", + "indicted": "Prosecutors have brought charges; a court will decide.", + "trial": "The trial is ongoing.", + "convicted_not_final": "A court has convicted, but the judgment is not final (an appeal is possible).", + "convicted_final": "Final conviction – the proceedings are closed.", + "acquitted": "The court has acquitted.", + "proceedings_dropped": "The proceedings were dropped.", + "diversion": "Settled without a conviction (e.g. a fine or community service); no finding of guilt, no criminal record." + }, + "sourcesIntro": "All sources with their licence, access method and default trust score. Under “My filters” you can hide individual sources or set a minimum score.", + "source": "Source", + "kind": "Kind", + "notCollected": "We don't get around paywalls, logins or technical barriers, and we respect robots.txt and terms of use. From media we store only the headline, teaser and link.", + "data": "The operators of this instance also provide the data in machine-readable form: a REST API with OpenAPI documentation, an MCP server for AI agents and read access to the database. Every fact comes with the same sources as here.", + "corrections": "Found a mistake? We correct wrong facts, mark contradicted ones as disputed and never delete anything silently. Please quote the fact's number (see its permalink) and a source for the correction." }, "Common": { - "loadError": "Could not load data.", - "notFound": "Not found." + "loadError": "The data could not be loaded. Please try again in a moment.", + "notFound": "This page doesn't exist (any more). The search may help.", + "home": "Home", + "persons": "People", + "search": "Search", + "breadcrumb": "Breadcrumb", + "filters": "Filters", + "apply": "Apply", + "reset": "Reset", + "previous": "Previous", + "next": "Next", + "page": "Page {page}", + "pagination": "Pages", + "newTab": "opens in a new tab", + "loading": "Loading …", + "retry": "Try again", + "errorTitle": "Something went wrong", + "notFoundTitle": "Page not found" }, "Timeline": { "from": "From", @@ -233,9 +368,9 @@ "Calendar": { "title": "Calendar", "intro": "Sessions, committees, trips, meetings and appearances – with sources.", - "prev": "← Previous month", - "next": "Next month →", - "person": "Person (slug)", + "prev": "Previous month", + "next": "Next month", + "person": "Person", "none": "No events in this period.", "activity": "{count} claims", "kind": { @@ -252,21 +387,42 @@ "hearing": "Hearing (inquiry committee)", "government_meeting": "Council of Ministers" }, - "datePrecision": "Date = publication date of the source" + "datePrecision": "Date = publication date of the source", + "today": "Today", + "personPlaceholder": "Name", + "kindLabel": "Kind", + "allKinds": "All kinds", + "legend": "Legend", + "group": { + "parliament": "Parliament", + "government": "Government", + "travel": "Trips & meetings", + "public": "Public appearances" + }, + "more": "+{count} more", + "activityHint": "All facts from this day" }, "Former": { "title": "What are former politicians up to?", "intro": "Former office holders – sorted by most recent news coverage.", "lastRole": "Last: {role}", "latestNews": "Latest coverage", - "career": "Career" + "career": "Career", + "none": "No former politicians found." }, "ClaimDetails": { "with": "with", "reference": "Ref.", "article": "Article", "fileNumber": "File no.", - "financeDetails": "Annual report {year} details" + "financeDetails": "Annual report {year} details", + "metric": { + "likes": "likes", + "reposts": "reposts", + "favourites": "favourites", + "reblogs": "boosts", + "views": "views" + } }, "Money": { "title": "Money", @@ -301,7 +457,10 @@ "side_income": "Side income", "sponsorship": "Sponsorship", "other": "Other" - } + }, + "flows": "Individual money flows", + "nameLabel": "Name", + "asReported": "Amounts as reported." }, "Organizations": { "title": "Organisations", @@ -350,7 +509,14 @@ "authority": "Authority", "eu_institution": "EU institution", "other": "Other" - } + }, + "receivedTotal": "received (total)", + "paidTotal": "paid (total)", + "counterpartyCount": "counterparties", + "receivedFromTitle": "Received money from", + "paidToTitle": "Paid to", + "noMoney": "No money flows recorded.", + "identifiers": "Identifiers" }, "Network": { "title": "Network: connected organisations", @@ -366,7 +532,10 @@ "role": "office", "lobbying": "lobbying", "party": "party" - } + }, + "organization": "Organisation", + "connection": "Connection", + "money": "This organisation's money" }, "Social": { "title": "Social media", @@ -389,6 +558,44 @@ "network": "Network", "article": "Article", "change": "Change", - "note": "An edit from a network says nothing about which person made it. IP ranges: config/ip_ranges.yaml." + "note": "An edit from a network says nothing about which person made it.", + "ranges": "The IP ranges are listed in config/ip_ranges.yaml in the source code." + }, + "Layout": { + "skip": "Skip to content", + "homeLabel": "Politrack – home", + "mainNav": "Main navigation", + "footerNav": "Site map", + "more": "More", + "menu": "Menu", + "searchLabel": "Search", + "searchPlaceholder": "Search …", + "filtersActive": "My filters: {count} active", + "themeLabel": "Colour scheme: {current} – switch to {next}", + "theme": { + "system": "system", + "light": "light", + "dark": "dark" + }, + "group": { + "people": "People", + "decisions": "Decisions", + "influence": "Money & influence", + "about": "About Politrack" + } + }, + "Footer": { + "tagline": "Austrian politics, sourced. An independent project that collects public information on politicians and parties – every fact with its source.", + "principleEvidence": "Every fact with its source", + "principleInnocence": "Presumption of innocence", + "principlePrivacy": "No ads, no tracking", + "developers": "Data, API & AI agents", + "bottom": "No liability for the data. Every fact links to its sources, which are authoritative." + }, + "ClaimPage": { + "crumb": "Fact #{id}", + "cite": "Cite", + "citation": "Politrack: “{summary}”. Fact #{id}, retrieved {date}. {url}", + "filtersIgnored": "This page shows the fact with all its sources – regardless of your filters, so a shared link looks the same for everyone." } } diff --git a/web/src/app/[locale]/abstimmungen/[id]/page.tsx b/web/src/app/[locale]/abstimmungen/[id]/page.tsx index fdc3b10..cf528b0 100644 --- a/web/src/app/[locale]/abstimmungen/[id]/page.tsx +++ b/web/src/app/[locale]/abstimmungen/[id]/page.tsx @@ -1,66 +1,123 @@ +import type {Metadata} from 'next'; import {notFound} from 'next/navigation'; -import {getLocale, getTranslations, setRequestLocale} from 'next-intl/server'; +import {getTranslations, setRequestLocale} from 'next-intl/server'; +import {PositionChip, ResultBadge} from '@/components/badges'; +import {ProportionBar} from '@/components/charts'; +import {ExternalLink} from '@/components/ExternalLink'; +import {Icon} from '@/components/Icon'; +import {Notice} from '@/components/Notice'; +import {Breadcrumbs, SectionHead} from '@/components/PageHeader'; import {Link} from '@/i18n/navigation'; import {api, NotFoundError, type VoteDetail} from '@/lib/api'; -import {formatDate} from '@/lib/format'; +import {formatDate, partyStyle} from '@/lib/format'; + +const POSITIONS = ['for', 'against', 'abstain', 'absent']; type Props = {params: Promise<{locale: string; id: string}>}; +async function loadVote(id: string): Promise { + try { + return await api.vote(id); + } catch (error) { + if (error instanceof NotFoundError) notFound(); + throw error; + } +} + +export async function generateMetadata({params}: Props): Promise { + const {id} = await params; + const vote = await loadVote(id); + return {title: vote.item_title}; +} + export default async function VotePage({params}: Props) { const {locale, id} = await params; setRequestLocale(locale); const t = await getTranslations('Votes'); - const loc = await getLocale(); - let vote: VoteDetail; - try { - vote = await api.vote(id); - } catch (error) { - if (error instanceof NotFoundError) notFound(); - throw error; - } + const vote = await loadVote(id); const groups = new Map(); for (const v of vote.individual) { const key = v.faction ?? '–'; groups.set(key, [...(groups.get(key) ?? []), v]); } + const colors = new Map(vote.positions.map((p) => [p.party, p.color])); + const count = (list: VoteDetail['individual'], position: string) => list.filter((v) => v.position === position).length; + const dissenters = vote.individual.filter((v) => v.dissent).length; return (
-

{vote.item_title}

-

- {formatDate(vote.voted_on, loc)} · {vote.chamber} - {vote.item_ref && <> · {vote.item_ref}} - {vote.stage && <> · {vote.stage}} - {vote.roll_call && <> · {t('rollCall')}} - {' · '}{vote.result !== 'unknown' && t(vote.result)} -

-

- {vote.url && {t('item')}} - {vote.protocol_url && <> · {t('protocol')}} -

- {vote.raw_text &&
{vote.raw_text}
} + +
+
+

+ {vote.chamber}{vote.voted_on && <> · {formatDate(vote.voted_on, locale)}} + {vote.roll_call && <> · {t('rollCall')}} +

+

{vote.item_title}

+

+ {vote.item_ref && {vote.item_ref}} + {vote.stage && <> · {vote.stage}} +

+
+
+ +
+ + {vote.url && {t('item')}} + {vote.protocol_url && {t('protocol')}} +
+ + {vote.positions.length > 0 && ( +
+ +
    + {vote.positions.map((p) => ( +
  • + ))} +
+ {!vote.roll_call &&

{t('factionOnly')}

} +
+ )} + {vote.positions.length === 0 && !vote.roll_call && {t('splitVote')}} {vote.individual.length > 0 && ( -
-

{t('individualVotes')}

-
+
+ + ({ + key: position, label: t(position as 'for'), value: count(vote.individual, position), className: `seg-${position}` + }))} + /> + {dissenters > 0 && {t('dissentSummary', {count: dissenters})}} +
    {[...groups.entries()].map(([faction, members]) => ( -
    -

    {faction}

    -
      +
    • +

      + + {POSITIONS.map((p) => count(members, p)).filter(Boolean).join(' / ')} +

      +
        {members.map((m) => (
      • - {t(m.position as 'for')}{' '} - {m.full_name} - {m.dissent && {t('dissent')}} + + {m.full_name} + {m.dissent && {t('dissent')}}
      • ))}
      -
    + ))} -
+
)} + + {vote.raw_text && ( +
+ {t('protocolText')} +
{vote.raw_text}
+
+ )}
); } diff --git a/web/src/app/[locale]/abstimmungen/page.tsx b/web/src/app/[locale]/abstimmungen/page.tsx index df0ade5..a078054 100644 --- a/web/src/app/[locale]/abstimmungen/page.tsx +++ b/web/src/app/[locale]/abstimmungen/page.tsx @@ -1,35 +1,44 @@ +import type {Metadata} from 'next'; import {getTranslations, setRequestLocale} from 'next-intl/server'; -import {VoteTable} from '@/components/VoteTable'; -import {Link} from '@/i18n/navigation'; +import {countActive, FilterBar} from '@/components/FilterBar'; +import {PageHeader} from '@/components/PageHeader'; +import {Pagination} from '@/components/Pagination'; +import {VoteLegend, VoteList} from '@/components/VoteList'; import {api} from '@/lib/api'; +const PAGE = 50; + type Props = { params: Promise<{locale: string}>; searchParams: Promise<{party?: string; page?: string; roll_call?: string}>; }; +export async function generateMetadata({params}: Props): Promise { + const {locale} = await params; + const t = await getTranslations({locale, namespace: 'Votes'}); + return {title: t('title')}; +} + export default async function VotesPage({params, searchParams}: Props) { const {locale} = await params; setRequestLocale(locale); const sp = await searchParams; const t = await getTranslations('Votes'); const page = Math.max(1, Number(sp.page) || 1); - const votes = await api.votes({party: sp.party, roll_call: sp.roll_call ? 'true' : undefined, limit: 50, offset: (page - 1) * 50}); + const votes = await api.votes({party: sp.party, roll_call: sp.roll_call ? 'true' : undefined, limit: PAGE, offset: (page - 1) * PAGE}); return ( <> -

{t('title')}

- - {sp.party && } -