Files
politics-tracking/web/src/app/[locale]/parteien/[slug]/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

243 lines
11 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 {ProportionBar, StatGrid} from '@/components/charts';
import {ClaimList} from '@/components/ClaimList';
import {Notice} from '@/components/Notice';
import {Breadcrumbs, SectionHead} from '@/components/PageHeader';
import {SocialFeed} from '@/components/SocialFeed';
import {VoteLegend, VoteList} from '@/components/VoteList';
import {Link} from '@/i18n/navigation';
import {api, NotFoundError, type PartyDetail} from '@/lib/api';
import {formatDate, formatMoney, formatMoneyCompact, formatNumber, partyStyle} from '@/lib/format';
const LEVEL_ORDER = ['eu', 'federal', 'state', 'district', 'municipal', 'party', 'other'];
const POSITIONS = ['for', 'against', 'abstain', 'absent'];
const RELATED_SHOWN = 12;
type Props = {params: Promise<{locale: string; slug: string}>};
async function loadParty(slug: string): Promise<PartyDetail> {
try {
return await api.party(slug);
} catch (error) {
if (error instanceof NotFoundError) notFound();
throw error;
}
}
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {slug} = await params;
const party = await loadParty(slug);
return {title: party.short_name ?? party.name};
}
export default async function PartyPage({params}: Props) {
const {locale, slug} = await params;
setRequestLocale(locale);
const t = await getTranslations('Parties');
const tv = await getTranslations('Votes');
const tl = await getTranslations('Persons');
const tm = await getTranslations('Money');
const party = await loadParty(slug);
const [votes, claims, posts] = await Promise.all([
api.votes({party: slug, limit: 10}),
api.claims({party: slug, types: ['donation', 'media_mention', 'legal_proceeding', 'money_flow', 'lobbying'], limit: 20}),
api.social({party: slug, limit: 6})
]);
const years = [...new Set(party.money_by_year.map((row) => row.year))].sort((a, b) => b - a).slice(0, 8);
const kinds = [...new Set(party.money_by_year.map((row) => row.kind))];
const cell = (year: number, kind: string) => party.money_by_year.find((row) => row.year === year && row.kind === kind);
const totalVotes = party.vote_stats.reduce((acc, s) => acc + s.count, 0);
const byLevel = LEVEL_ORDER.map((level) => [level, party.members.filter((m) => m.level === level)] as const)
.filter(([, members]) => members.length > 0);
return (
<article>
<Breadcrumbs items={[{label: t('title'), href: '/parteien'}, {label: party.short_name ?? party.name}]} />
<header className="page-header">
<div className="page-header-main">
<div className="party-band" style={partyStyle(party.color)} aria-hidden="true" />
<h1>{party.short_name ?? party.name}</h1>
{party.short_name && <p className="lede">{party.name}</p>}
</div>
</header>
<StatGrid
items={[
{label: t('statOfficials'), value: formatNumber(party.members.length, locale)},
{label: t('statFormer'), value: formatNumber(party.former_members.length, locale), href: `/ehemalige?party=${slug}`},
{label: t('statVotes'), value: formatNumber(totalVotes, locale), href: `/abstimmungen?party=${slug}`},
{label: t('statDissent'), value: formatNumber(party.dissent.length, locale)}
]}
/>
<div className="grid-2">
<section aria-labelledby="vote-stats">
<SectionHead id="vote-stats" title={t('voteStats')} />
<ProportionBar
label={t('voteStats')}
segments={POSITIONS.map((position) => ({
key: position,
label: tv(position as 'for'),
value: party.vote_stats.find((s) => s.position === position)?.count ?? 0,
className: `seg-${position}`
}))}
/>
<p className="small muted">{t('voteStatsNote')}</p>
</section>
<section aria-labelledby="money">
<SectionHead id="money" title={t('money')} more={{href: `/geld?org=${slug}`, label: t('allMoney')}} />
{years.length ? (
<div className="table-wrap">
<table>
<thead>
<tr><th scope="col">{t('year')}</th>{kinds.map((kind) => <th key={kind} scope="col" className="num">{tm(`kind.${kind}` as 'kind.other')}</th>)}</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<th scope="row">{year}</th>
{kinds.map((kind) => {
const row = cell(year, kind);
return (
<td key={kind} className="num" title={row?.total ? `${formatMoney(row.total, locale)} · ${row.count}×` : undefined}>
{row?.total ? formatMoneyCompact(row.total, locale) : row ? `${row.count}×` : '–'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : <p className="muted">{t('noMoney')}</p>}
</section>
</div>
{byLevel.length > 0 && (
<section className="section" aria-labelledby="members">
<SectionHead id="members" title={`${t('members')} (${formatNumber(party.members.length, locale)})`} />
{byLevel.map(([level, members]) => (
<div key={level}>
<h3>{tl(`level.${level}` as 'level.other')} <span className="muted small">({members.length})</span></h3>
<ul className="name-list">
{members.slice(0, 120).map((m) => (
<li key={`${m.slug}-${m.role}`}>
<Link href={`/personen/${m.slug}`}>{m.full_name}</Link>{' '}
<span className="muted">{m.role === 'mp' ? m.body : m.role}{m.region && ` · ${m.region}`}</span>
</li>
))}
</ul>
{members.length > 120 && (
<p className="small"><Link href={`/personen?party=${slug}&level=${level}`}>{t('allMembers', {count: members.length})}</Link></p>
)}
</div>
))}
</section>
)}
{(party.finance.length > 0 || party.related.length > 0) && (
<div className="grid-2 section">
{party.finance.length > 0 && (
<section aria-labelledby="finance">
<SectionHead id="finance" title={t('finance')} />
<div className="table-wrap">
<table>
<thead><tr><th scope="col">{t('year')}</th><th scope="col" className="num">{t('income')}</th><th scope="col" className="num">{t('expenses')}</th></tr></thead>
<tbody>
{party.finance.map((f) => (
<tr key={String(f.data.year ?? f.valid_from)}>
<th scope="row">{String(f.data.year ?? formatDate(f.valid_from, locale))}</th>
<td className="num">{f.data.total_income ? formatMoney(Number(f.data.total_income), locale) : '–'}</td>
<td className="num">{f.data.total_expenses ? formatMoney(Number(f.data.total_expenses), locale) : '–'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{party.related.length > 0 && (
<section aria-labelledby="related">
<SectionHead id="related" title={t('related')} />
<Notice tone="note">{t('relatedNote')}</Notice>
<RelatedList orgs={party.related.slice(0, RELATED_SHOWN)} />
{party.related.length > RELATED_SHOWN && (
<details>
<summary className="small strong">{t('allMembers', {count: party.related.length})}</summary>
<RelatedList orgs={party.related.slice(RELATED_SHOWN)} />
</details>
)}
</section>
)}
</div>
)}
<div className="grid-2 section">
<section aria-labelledby="former">
<SectionHead id="former" title={t('formerMembers')} more={{href: `/ehemalige?party=${slug}`, label: t('allFormer')}} />
<ul className="link-list">
{party.former_members.slice(0, 12).map((m) => (
<li key={m.slug}>
<Link href={`/personen/${m.slug}?tab=media`}>{m.full_name}</Link>{' '}
<span className="muted xsmall">
{m.last_office_end && tl('lastOffice', {date: formatDate(m.last_office_end, locale)})}
{m.last_mention && ` · ${t('lastMention', {date: formatDate(m.last_mention, locale)})}`}
</span>
</li>
))}
</ul>
</section>
{party.dissent.length > 0 && (
<section aria-labelledby="dissent">
<SectionHead id="dissent" title={t('dissent')} />
<ul className="link-list">
{party.dissent.map((d) => (
<li key={d.slug}>
<Link href={`/personen/${d.slug}?tab=votes`}>{d.full_name}</Link>{' '}
<span className="muted xsmall">{t('dissentCount', {count: d.dissenting_votes})}</span>
</li>
))}
</ul>
</section>
)}
</div>
<section className="section" aria-labelledby="votes">
<SectionHead id="votes" title={t('recentVotes')} more={{href: `/abstimmungen?party=${slug}`, label: tv('title')}} />
<VoteLegend />
<VoteList votes={votes} />
</section>
{posts.length > 0 && (
<section className="section" aria-labelledby="channels">
<SectionHead id="channels" title={t('channels')} more={{href: `/social?party=${slug}`, label: t('allPosts')}} />
<SocialFeed posts={posts} />
</section>
)}
<section className="section" aria-labelledby="claims">
<SectionHead id="claims" title={t('claims')} />
<ClaimList claims={claims} showSubject={false} />
</section>
</article>
);
}
async function RelatedList({orgs}: {orgs: PartyDetail['related']}) {
const t = await getTranslations('Parties');
return (
<ul className="link-list">
{orgs.map((org) => (
<li key={`${org.claim_type}-${org.slug}`}>
<Link href={`/organisationen/${org.slug}`}>{org.name}</Link>{' '}
<span className="badge">{t(`relation.${org.claim_type}` as 'relation.affiliation')}</span>
</li>
))}
</ul>
);
}