Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
# Runs before every `git push`. Mirrors the fast CI steps so failures are
# caught locally before they waste a CI run.
set -e

echo "→ lint"
pnpm lint

echo "→ typecheck"
pnpm typecheck

echo "→ unit tests"
pnpm test

echo "→ audit (high+)"
pnpm audit --audit-level high

echo "✓ all checks passed"
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches: [main]
pull_request:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- run: pnpm audit --audit-level high

- run: pnpm lint

- run: pnpm typecheck

- run: pnpm test

- uses: astral-sh/setup-uv@v5

- name: Lint Python pipeline
run: uvx ruff check pipeline/scripts/

- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}

- name: Install Playwright browser + system deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium

- name: Install Playwright system deps (browser cached)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium

- run: pnpm test:e2e

- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
37 changes: 37 additions & 0 deletions .github/workflows/votations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Download votation results

on:
workflow_dispatch:
inputs:
date:
description: 'Vote date (YYYYMMDD, e.g. 20260614)'
required: true
type: string

jobs:
download:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Download votation data for ${{ inputs.date }}
run: uv run python pipeline/scripts/download_votations.py --add ${{ inputs.date }}

- name: Open pull request
uses: peter-evans/create-pull-request@v7
with:
commit-message: "data: add votation results for ${{ inputs.date }}"
title: "Add votation results for ${{ inputs.date }}"
body: |
Automated download of Swiss federal votation results for **${{ inputs.date }}**.

Please verify the data looks correct before merging:
- Check `public/votations/${{ inputs.date }}.json` for expected proposal count and structure
- Check `public/votations/index.json` includes the new date
branch: "votation/${{ inputs.date }}"
delete-branch: true
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

# testing
/coverage
/test-results
/playwright-report
/blob-report
/playwright/.cache

# next.js
/.next/
Expand Down
27 changes: 24 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ swiss-maps/
│ ├── i18n.ts All UI translations (5 languages)
│ └── utils.ts shadcn cn() helper
├── public/
│ ├── geo/ Generated GeoJSON (gitignored, from pipeline)
│ ├── geo/ Generated GeoJSON (committed — regenerate with pipeline)
│ │ ├── cantons.geojson
│ │ ├── districts.geojson
│ │ └── municipalities.geojson
│ └── votations/ Votation JSON files (gitignored, from pipeline)
│ ├── index.json ← COMMITTED — lists available dates
│ └── votations/ Votation JSON files (committed — static votation results)
│ ├── index.json ← lists available dates
│ ├── 20260614.json
│ ├── 20260308.json
│ ├── 20251130.json
Expand Down Expand Up @@ -175,6 +175,27 @@ pnpm build # production build
pnpm typecheck # TypeScript check
```

## Testing

```bash
pnpm test # unit + component tests (Vitest + React Testing Library, run once)
pnpm test:watch # same, in watch mode
pnpm test:e2e # Playwright e2e — requires `pnpm dev` running with real pipeline data
```

- **Unit tests** (`lib/*.test.ts`) — pure functions in `lib/`, co-located with the source file.
- **Component tests** (`components/*.test.tsx`) — Vitest + React Testing Library, jsdom environment.
Use `renderWithProviders` from `test/test-utils.tsx` for components that call `useLanguage()`
(it wraps in `LanguageProvider`); plain `render` from `@testing-library/react` is fine for
components with no context dependency.
- **Config**: `vitest.config.ts` (jsdom env, `@` alias matches `tsconfig.json`, excludes `e2e/`),
`vitest.setup.ts` (jest-dom matchers + RTL `cleanup()` after each test).
- **E2E specs** (`e2e/*.spec.ts`) — Playwright, config in `playwright.config.ts`. Runs against the
local dev server (`pnpm dev`); `public/geo` and `public/votations` are committed so no pipeline
setup is needed.
- **CI** (`.github/workflows/ci.yml`) — runs `pnpm lint`, `pnpm typecheck`, `pnpm test`, and
`pnpm test:e2e` on push/PR.

## Running the pipeline

```bash
Expand Down
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ Interactive visualization of Swiss federal votation results and demographic stat
- Defaults to the most recent past votation (skips future dates with no data)

**Demographics map (`/demographics`)**
- 37 indicators across 10 groups: context (urban/periurban/rural typology), population, age, vital statistics (birth/death/marriage/divorce), land use, economy (employment by sector), housing, social, religion (2000 census), and politics
- 45 indicators across 11 groups: context (urban/periurban/rural typology), population, age, vital statistics (birth/death/marriage/divorce), land use, economy (employment by sector + taxable income), housing, social, religion (2000 census), language (official linguistic region + 2000 census home language), and politics
- Urban/rural typology: categorical 3-class map (dark blue = urban, medium = periurban, light = rural) with swatch legend
- Religion: Catholic %, Reformed %, Muslim %, no religion %, other — from 2000 census (last available at municipality level)
- Language: official linguistic region (German/French/Italian/Romansh, categorical) plus home-language shares from the 2000 census
- Income: median and average taxable income per commune (CHF, 2022)
- Party votes shown individually plus a computed left–right index (diverging red↔blue scale)
- Sequential blue scale for continuous indicators; P5–P95 domain per topic

Expand Down Expand Up @@ -65,13 +67,31 @@ uv run python scripts/download_votations.py --add YYYYMMDD # add a new date
# Trade data (BAZG official 2024 actuals — re-run to refresh)
uv run python scripts/download_trade.py

# Demographic indicators — run all three, in order:
uv run python scripts/download_demographics.py # BFS Regionalportraits 2021 (30 indicators, 2019 data)
# Demographic indicators — run in order:
uv run python scripts/download_income.py # ESTV taxable income 2022 (median + average)
uv run python scripts/download_language.py # BFS linguistic region + 2000 census home language
uv run python scripts/download_demographics.py # BFS Regionalportraits 2021 + merges income/language/typology/religion
uv run python scripts/download_typology.py # swisstopo urban/periurban/rural classification
uv run python scripts/download_religion.py # BFS 2000 census religion data (2000 data only)
```

See `pipeline/DATA_SOURCES.md` for a full breakdown of every dataset, indicator, reference year, and known limitations (income/unemployment/post-2000 religion are not available via API).
See `pipeline/DATA_SOURCES.md` for a full breakdown of every dataset, indicator, reference year, and known limitations (unemployment and post-2000 religion/language are not available via API).

## Testing

```bash
pnpm test # unit + component tests (Vitest + React Testing Library)
pnpm test:watch # same, in watch mode

pnpm exec playwright install chromium # one-time browser install
pnpm dev # in one terminal — needs geo + votation data, see pipeline above
pnpm test:e2e # in another terminal (Playwright)
```

Unit/component tests are co-located with the source as `*.test.ts(x)` (e.g. `lib/votation.test.ts`,
`components/result-block.test.tsx`). E2E specs live in `e2e/*.spec.ts` and run against the local dev
server. CI runs lint, typecheck, and `pnpm test` on every push/PR; `pnpm test:e2e` is local-only for
now since it depends on the gitignored `public/geo` and `public/votations` pipeline output.

## Data sources

Expand All @@ -82,6 +102,9 @@ See `pipeline/DATA_SOURCES.md` for a full breakdown of every dataset, indicator,
- **Demographic indicators**: [BFS Regionalportraits 2021](https://opendata.swiss/en/dataset/regionalportrats-2021-kennzahlen-aller-gemeinden) — 30 indicators, ref. year 2019
- **Religion**: BFS Volkszählung 2000 (PxWeb `px-x-4003000000_122`) — **2000 data only**, no municipality-level religion data exists post-2000
- **Urban/rural typology**: swisstopo `g1a22` agglomeration shapefile — 3 classes (urban/periurban/rural)
- **Taxable income**: [ESTV Steuerstatistik der direkten Bundessteuer](https://www.estv.admin.ch) — nationwide municipality xlsx, tax year 2022, median + average per commune
- **Linguistic region**: BFS Raumgliederungen der Gemeinden (`agvchapp` API, `SPRGEB2020`) — current official German/French/Italian/Romansh language area per commune
- **Home language**: BFS Volkszählung 2000 (PxWeb `px-x-4003000000_123`) — **2000 data only**, same limitation as religion

## Tech stack

Expand Down
5 changes: 4 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@
- add email/link/feedback
- clean erlauterungen text extraction with llm + add en translation
- improve UI
- refactor code
- refactor code
- add logs/analytics/monitoring
- set up CI data strategy (run pipeline in CI or commit minimal fixtures) so `pnpm test:e2e` can run in GitHub Actions
- add hooks
Binary file added app/_favicon.ico
Binary file not shown.
Binary file modified app/favicon.ico
Binary file not shown.
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--muted-foreground: oklch(0.50 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
Expand Down
8 changes: 7 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Geist, Geist_Mono, Inter } from "next/font/google"
import type { Metadata } from "next"
import { Geist_Mono, Inter } from "next/font/google"

import "./globals.css"
import { ThemeProvider } from "@/components/theme-provider"
import { LanguageProvider } from "@/contexts/language"
import { cn } from "@/lib/utils";

export const metadata: Metadata = {
title: 'Swiss Maps',
description: 'Interactive visualization of Swiss federal votation results, demographics, and trade statistics.',
}

const inter = Inter({subsets:['latin'],variable:'--font-sans'})

const fontMono = Geist_Mono({
Expand Down
36 changes: 36 additions & 0 deletions components/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { usePathname } from 'next/navigation'
import { renderWithProviders, screen } from '@/test/test-utils'
import { AppHeader } from './app-header'

vi.mock('next/navigation', () => ({
usePathname: vi.fn(),
}))

describe('AppHeader', () => {
it('renders the brand and all nav links', () => {
vi.mocked(usePathname).mockReturnValue('/')
renderWithProviders(<AppHeader onToggleSidebar={() => {}} />)
expect(screen.getByText('Swiss Maps')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Votations' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'Demographics' })).toHaveAttribute('href', '/demographics')
expect(screen.getByRole('link', { name: 'Trade' })).toHaveAttribute('href', '/trade')
expect(screen.getByRole('link', { name: 'Data' })).toHaveAttribute('href', '/data')
})

it('highlights the link matching the current path', () => {
vi.mocked(usePathname).mockReturnValue('/trade')
renderWithProviders(<AppHeader onToggleSidebar={() => {}} />)
expect(screen.getByRole('link', { name: 'Trade' })).toHaveClass('text-foreground')
expect(screen.getByRole('link', { name: 'Votations' })).toHaveClass('text-muted-foreground')
})

it('calls onToggleSidebar when the mobile menu button is clicked', async () => {
vi.mocked(usePathname).mockReturnValue('/')
const onToggleSidebar = vi.fn()
renderWithProviders(<AppHeader onToggleSidebar={onToggleSidebar} />)
await userEvent.click(screen.getByRole('button', { name: 'Toggle sidebar' }))
expect(onToggleSidebar).toHaveBeenCalledOnce()
})
})
9 changes: 6 additions & 3 deletions components/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation'
import { Map, Menu } from 'lucide-react'
import { LanguageSwitcher } from './language-switcher'
import { useLanguage } from '@/contexts/language'
import { Button } from './ui/button'

interface AppHeaderProps {
onToggleSidebar: () => void
Expand All @@ -16,13 +17,15 @@ export function AppHeader({ onToggleSidebar }: AppHeaderProps) {

return (
<header className="flex h-12 shrink-0 items-center gap-2 border-b bg-background px-3">
<button
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleSidebar}
className="rounded p-1.5 hover:bg-muted md:hidden"
className="md:hidden"
aria-label="Toggle sidebar"
>
<Menu className="h-4 w-4" />
</button>
</Button>

<Map className="h-4 w-4 shrink-0 text-primary" />
<span className="font-semibold tracking-tight">Swiss Maps</span>
Expand Down
Loading
Loading