Files
politics-tracking/web/src/app/[locale]/abstimmungen/[id]/page.tsx
T
simonandClaude Opus 5.5 456f9816f4 Redesign the web app from a written design concept
Design concept and design system in docs/design/: principles (evidence
first, neutral, fair to the people shown, plain language, time as a
dimension, accessible and private), information architecture, core
patterns and a concept for every page; tokens with checked contrast,
component catalogue, content and accessibility rules.

web:
- neutral ink-indigo accent instead of flag red; party colours only as
  small markers next to the name; light/dark theme with a switcher
- stylesheets split into tokens, base, layout, components, print
- new header (search, filter count, language, theme, mobile menu) and
  footer; breadcrumbs, page headers, tabs with counts, filter bars that
  collapse on phones, pagination, notices and empty states
- claim cards with source meter, legal stage, innocence notice inside
  the card and a permalink; claim pages with citation (/angaben/{id})
- profiles with role timeline and key figures; party and organisation
  dashboards; vote list with position chips; calendar agenda on phones;
  tables that stack into cards; loading and error pages; favicon

backend:
- GET /api/v1/claims/{id} and MCP tool get_claim for permalinks
- parties list: evaluate person_status once instead of per party
  (30 s -> 1 s on the sample database, identical result)
- parliament calendar: HTML-decode titles ("84. Sitzung")

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:21:44 +01:00

124 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
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, partyStyle} from '@/lib/format';
const POSITIONS = ['for', 'against', 'abstain', 'absent'];
type Props = {params: Promise<{locale: string; id: string}>};
async function loadVote(id: string): Promise<VoteDetail> {
try {
return await api.vote(id);
} catch (error) {
if (error instanceof NotFoundError) notFound();
throw error;
}
}
export async function generateMetadata({params}: Props): Promise<Metadata> {
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 vote = await loadVote(id);
const groups = new Map<string, VoteDetail['individual']>();
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 (
<article>
<Breadcrumbs items={[{label: t('title'), href: '/abstimmungen'}, {label: vote.item_ref ?? t('vote')}]} />
<header className="page-header">
<div className="page-header-main">
<p className="eyebrow">
<Icon name="vote" /> {vote.chamber}{vote.voted_on && <> · {formatDate(vote.voted_on, locale)}</>}
{vote.roll_call && <> · {t('rollCall')}</>}
</p>
<h1>{vote.item_title}</h1>
<p className="page-header-meta">
{vote.item_ref && <span className="mono">{vote.item_ref}</span>}
{vote.stage && <> · {vote.stage}</>}
</p>
</div>
</header>
<div className="result-banner">
<ResultBadge result={vote.result} large />
{vote.url && <ExternalLink href={vote.url} className="ext-link">{t('item')}</ExternalLink>}
{vote.protocol_url && <ExternalLink href={vote.protocol_url} className="ext-link">{t('protocol')}</ExternalLink>}
</div>
{vote.positions.length > 0 && (
<section aria-labelledby="factions">
<SectionHead id="factions" title={t('factions')} />
<ul className="cluster" style={{listStyle: 'none', padding: 0}}>
{vote.positions.map((p) => (
<li key={p.slug}><PositionChip position={p.position} party={p.party} color={p.color} /></li>
))}
</ul>
{!vote.roll_call && <p className="small muted">{t('factionOnly')}</p>}
</section>
)}
{vote.positions.length === 0 && !vote.roll_call && <Notice tone="note">{t('splitVote')}</Notice>}
{vote.individual.length > 0 && (
<section className="section" aria-labelledby="individual">
<SectionHead id="individual" title={t('individualVotes')} />
<ProportionBar
label={t('individualVotes')}
segments={POSITIONS.map((position) => ({
key: position, label: t(position as 'for'), value: count(vote.individual, position), className: `seg-${position}`
}))}
/>
{dissenters > 0 && <Notice tone="caution">{t('dissentSummary', {count: dissenters})}</Notice>}
<ul className="faction-grid">
{[...groups.entries()].map(([faction, members]) => (
<li key={faction} className="card">
<h3>
<span className="party-tag"><span className="party-dot" style={partyStyle(colors.get(faction))} aria-hidden="true" />{faction}</span>
<span className="muted small">{POSITIONS.map((p) => count(members, p)).filter(Boolean).join(' / ')}</span>
</h3>
<ul className="member-list">
{members.map((m) => (
<li key={m.slug}>
<PositionChip position={m.position} showLabel={false} />
<Link href={`/personen/${m.slug}?tab=votes`}>{m.full_name}</Link>
{m.dissent && <span className="badge badge--caution">{t('dissent')}</span>}
</li>
))}
</ul>
</li>
))}
</ul>
</section>
)}
{vote.raw_text && (
<details className="section card">
<summary className="strong">{t('protocolText')}</summary>
<blockquote className="small">{vote.raw_text}</blockquote>
</details>
)}
</article>
);
}