diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..ca6466df --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs` +255c58d0436bf36964f8e58ceeebdb062c5e10cf # prettier, 399 files diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a6464165..554fa373 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,7 +11,7 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 5 @@ -20,7 +20,7 @@ updates: update-types: [minor, patch] - package-ecosystem: github-actions - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..1ed5ea51 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Build output and vendored trees — formatting these is noise. +node_modules +.next +dist +build +out +coverage +.turbo +.vercel +*.min.js +*.min.css + +# Lockfiles are generated; prettier would rewrite them wholesale. +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Markdown is deliberately out of scope for now. Prettier rewraps prose, which +# is where it is most opinionated and least useful, and it would bury the real +# diff. Remove this line when you want docs formatted too. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..616247af --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 3fa50943..8e6a71fb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,43 +1,46 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, { rules: { - "@typescript-eslint/no-unused-vars": ["warn", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - destructuredArrayIgnorePattern: "^_", - }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }, + ], }, }, // Pipeline/migration scripts — relax any-type rule. // These one-off scripts manipulate JSONB blobs where exact types aren't needed. { - files: ["scripts/**/*.ts", "src/scripts/**/*.ts"], + files: ['scripts/**/*.ts', 'src/scripts/**/*.ts'], rules: { - "@typescript-eslint/no-explicit-any": "off", + '@typescript-eslint/no-explicit-any': 'off', }, }, // Legacy JS scripts using CommonJS require() — exclude from ESM import rule. { - files: ["src/scripts/**/*.js"], + files: ['src/scripts/**/*.js'], rules: { - "@typescript-eslint/no-require-imports": "off", + '@typescript-eslint/no-require-imports': 'off', }, }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', // Auto-generated file — never lint - "src/lib/config/foundations/stiftungen-generated.ts", + 'src/lib/config/foundations/stiftungen-generated.ts', ]), ]); diff --git a/messages/de.json b/messages/de.json index c8036d02..a7abb565 100644 --- a/messages/de.json +++ b/messages/de.json @@ -14,30 +14,72 @@ }, "nav": { "ueberUns": { "label": "Über uns" }, - "strategie": { "label": "Mission & Strategie", "desc": "Warum wir existieren & was wir erreichen wollen" }, + "strategie": { + "label": "Mission & Strategie", + "desc": "Warum wir existieren & was wir erreichen wollen" + }, "team": { "label": "Team", "desc": "Wer wir sind & wie wir arbeiten" }, - "wieWirArbeiten": { "label": "Wie wir arbeiten", "desc": "Die Wertschöpfungskaskade — maximale Wertschöpfung pro Gerät" }, - "finanzen": { "label": "Finanzen {range}", "desc": "Transparente Einnahmen & Ausgaben ({label})" }, - "wirkung": { "label": "Wirkung & Impact", "desc": "CO₂ eingespart, Geräte refurbiert, Menschen erreicht" }, - "preismodell": { "label": "Preismodell", "desc": "Solidarische Preise (4 Stufen nach Einkommen)" }, + "wieWirArbeiten": { + "label": "Wie wir arbeiten", + "desc": "Die Wertschöpfungskaskade — maximale Wertschöpfung pro Gerät" + }, + "finanzen": { + "label": "Finanzen {range}", + "desc": "Transparente Einnahmen & Ausgaben ({label})" + }, + "wirkung": { + "label": "Wirkung & Impact", + "desc": "CO₂ eingespart, Geräte refurbiert, Menschen erreicht" + }, + "preismodell": { + "label": "Preismodell", + "desc": "Solidarische Preise (4 Stufen nach Einkommen)" + }, "methodik": { "label": "Methodik", "desc": "Wie wir messen & berechnen (Transparenzreport)" }, "zukunft": { "label": "Zukunft 2030" }, "vision": { "label": "Vision" }, - "revamp2030": { "label": "Revamp 2030 — Die Vision", "desc": "Wo wir 2030 stehen wollen: Hub, Bildung, Impact" }, + "revamp2030": { + "label": "Revamp 2030 — Die Vision", + "desc": "Wo wir 2030 stehen wollen: Hub, Bildung, Impact" + }, "projekte": { "label": "Konkrete Projekte (2026–2028)" }, "hub": { "label": "Hub {space}", "desc": "🏢 Werkstatt + AI Lab + Hackerspace + Kulturraum" }, - "bildung": { "label": "Bildungsprogramm", "desc": "🎓 2× Programmleiter für Train-the-Trainer & Workshops" }, + "bildung": { + "label": "Bildungsprogramm", + "desc": "🎓 2× Programmleiter für Train-the-Trainer & Workshops" + }, "fundraising": { "label": "Fundraising" }, "budgetStatus": { "label": "Budget & Status" }, - "plan": { "label": "Fundraising-Plan", "desc": "📊 Budget-Übersicht, Finanzierungsbedarf, Timeline" }, - "dashboard": { "label": "Pipeline-Dashboard", "desc": "📈 KPI-Übersicht: Eingereicht, Zugesagt, Erfolgsquote" }, + "plan": { + "label": "Fundraising-Plan", + "desc": "📊 Budget-Übersicht, Finanzierungsbedarf, Timeline" + }, + "dashboard": { + "label": "Pipeline-Dashboard", + "desc": "📈 KPI-Übersicht: Eingereicht, Zugesagt, Erfolgsquote" + }, "stiftungenSection": { "label": "Stiftungen" }, - "stiftungen": { "label": "{count} Stiftungen", "desc": "🔍 Mit Fit-Score, Themen, Deadlines, Beträgen" }, - "vorlagen": { "label": "Gesuch-Vorlagen", "desc": "📝 {count} Referenz-Vorlagen nach Typ (A/B/C/D)" }, + "stiftungen": { + "label": "{count} Stiftungen", + "desc": "🔍 Mit Fit-Score, Themen, Deadlines, Beträgen" + }, + "vorlagen": { + "label": "Gesuch-Vorlagen", + "desc": "📝 {count} Referenz-Vorlagen nach Typ (A/B/C/D)" + }, "gesuche": { "label": "Meine Gesuche", "desc": "📝 Bearbeitete Gesuch-Entwürfe im Überblick" }, - "pipeline": { "label": "Gesuch-Pipeline", "desc": "📋 Stiftungsgesuche verwalten und nachverfolgen" }, - "scoring": { "label": "Scoring-Methodik", "desc": "⚙️ Wie Fit, Bereitschaft und Priorität berechnet werden" }, - "pipelineMethodik": { "label": "Pipeline-Methodik", "desc": "🔬 Wie wir aus {universe} Stiftungen die richtigen finden" }, + "pipeline": { + "label": "Gesuch-Pipeline", + "desc": "📋 Stiftungsgesuche verwalten und nachverfolgen" + }, + "scoring": { + "label": "Scoring-Methodik", + "desc": "⚙️ Wie Fit, Bereitschaft und Priorität berechnet werden" + }, + "pipelineMethodik": { + "label": "Pipeline-Methodik", + "desc": "🔬 Wie wir aus {universe} Stiftungen die richtigen finden" + }, "dokumente": { "label": "Dokumente" }, "plattform": { "label": "Plattform" } } diff --git a/messages/en.json b/messages/en.json index 4e134412..f731e4f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -14,30 +14,66 @@ }, "nav": { "ueberUns": { "label": "About us" }, - "strategie": { "label": "Mission & strategy", "desc": "Why we exist & what we want to achieve" }, + "strategie": { + "label": "Mission & strategy", + "desc": "Why we exist & what we want to achieve" + }, "team": { "label": "Team", "desc": "Who we are & how we work" }, - "wieWirArbeiten": { "label": "How we work", "desc": "The value cascade — maximum value per device" }, + "wieWirArbeiten": { + "label": "How we work", + "desc": "The value cascade — maximum value per device" + }, "finanzen": { "label": "Finances {range}", "desc": "Transparent income & expenses ({label})" }, "wirkung": { "label": "Impact", "desc": "CO₂ saved, devices refurbished, people reached" }, "preismodell": { "label": "Pricing model", "desc": "Solidarity pricing (4 tiers by income)" }, - "methodik": { "label": "Methodology", "desc": "How we measure & calculate (transparency report)" }, + "methodik": { + "label": "Methodology", + "desc": "How we measure & calculate (transparency report)" + }, "zukunft": { "label": "Future 2030" }, "vision": { "label": "Vision" }, - "revamp2030": { "label": "Revamp 2030 — the vision", "desc": "Where we want to be in 2030: hub, education, impact" }, + "revamp2030": { + "label": "Revamp 2030 — the vision", + "desc": "Where we want to be in 2030: hub, education, impact" + }, "projekte": { "label": "Concrete projects (2026–2028)" }, - "hub": { "label": "Hub {space}", "desc": "🏢 Workshop + AI lab + hackerspace + cultural space" }, - "bildung": { "label": "Education program", "desc": "🎓 2× program leads for train-the-trainer & workshops" }, + "hub": { + "label": "Hub {space}", + "desc": "🏢 Workshop + AI lab + hackerspace + cultural space" + }, + "bildung": { + "label": "Education program", + "desc": "🎓 2× program leads for train-the-trainer & workshops" + }, "fundraising": { "label": "Fundraising" }, "budgetStatus": { "label": "Budget & status" }, "plan": { "label": "Fundraising plan", "desc": "📊 Budget overview, funding needs, timeline" }, - "dashboard": { "label": "Pipeline dashboard", "desc": "📈 KPI overview: submitted, awarded, success rate" }, + "dashboard": { + "label": "Pipeline dashboard", + "desc": "📈 KPI overview: submitted, awarded, success rate" + }, "stiftungenSection": { "label": "Foundations" }, - "stiftungen": { "label": "{count} foundations", "desc": "🔍 With fit scores, themes, deadlines, amounts" }, - "vorlagen": { "label": "Application templates", "desc": "📝 {count} reference templates by type (A/B/C/D)" }, + "stiftungen": { + "label": "{count} foundations", + "desc": "🔍 With fit scores, themes, deadlines, amounts" + }, + "vorlagen": { + "label": "Application templates", + "desc": "📝 {count} reference templates by type (A/B/C/D)" + }, "gesuche": { "label": "My applications", "desc": "📝 Edited application drafts at a glance" }, - "pipeline": { "label": "Application pipeline", "desc": "📋 Manage and track foundation applications" }, - "scoring": { "label": "Scoring methodology", "desc": "⚙️ How fit, readiness, and priority are computed" }, - "pipelineMethodik": { "label": "Pipeline methodology", "desc": "🔬 How we find the right ones among {universe} foundations" }, + "pipeline": { + "label": "Application pipeline", + "desc": "📋 Manage and track foundation applications" + }, + "scoring": { + "label": "Scoring methodology", + "desc": "⚙️ How fit, readiness, and priority are computed" + }, + "pipelineMethodik": { + "label": "Pipeline methodology", + "desc": "🔬 How we find the right ones among {universe} foundations" + }, "dokumente": { "label": "Documents" }, "plattform": { "label": "Platform" } } diff --git a/messages/fr.json b/messages/fr.json index 83d228f1..4ff602dc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -14,30 +14,78 @@ }, "nav": { "ueberUns": { "label": "À propos" }, - "strategie": { "label": "Mission & stratégie", "desc": "Pourquoi nous existons & ce que nous voulons accomplir" }, + "strategie": { + "label": "Mission & stratégie", + "desc": "Pourquoi nous existons & ce que nous voulons accomplir" + }, "team": { "label": "Équipe", "desc": "Qui nous sommes & comment nous travaillons" }, - "wieWirArbeiten": { "label": "Notre façon de travailler", "desc": "La cascade de valeur — valorisation maximale par appareil" }, - "finanzen": { "label": "Finances {range}", "desc": "Recettes & dépenses transparentes ({label})" }, - "wirkung": { "label": "Impact", "desc": "CO₂ économisé, appareils reconditionnés, personnes touchées" }, - "preismodell": { "label": "Modèle de prix", "desc": "Prix solidaires (4 niveaux selon le revenu)" }, - "methodik": { "label": "Méthodologie", "desc": "Comment nous mesurons & calculons (rapport de transparence)" }, + "wieWirArbeiten": { + "label": "Notre façon de travailler", + "desc": "La cascade de valeur — valorisation maximale par appareil" + }, + "finanzen": { + "label": "Finances {range}", + "desc": "Recettes & dépenses transparentes ({label})" + }, + "wirkung": { + "label": "Impact", + "desc": "CO₂ économisé, appareils reconditionnés, personnes touchées" + }, + "preismodell": { + "label": "Modèle de prix", + "desc": "Prix solidaires (4 niveaux selon le revenu)" + }, + "methodik": { + "label": "Méthodologie", + "desc": "Comment nous mesurons & calculons (rapport de transparence)" + }, "zukunft": { "label": "Avenir 2030" }, "vision": { "label": "Vision" }, - "revamp2030": { "label": "Revamp 2030 — la vision", "desc": "Où nous voulons être en 2030 : hub, formation, impact" }, + "revamp2030": { + "label": "Revamp 2030 — la vision", + "desc": "Où nous voulons être en 2030 : hub, formation, impact" + }, "projekte": { "label": "Projets concrets (2026–2028)" }, - "hub": { "label": "Hub {space}", "desc": "🏢 Atelier + AI lab + hackerspace + espace culturel" }, - "bildung": { "label": "Programme de formation", "desc": "🎓 2× responsables pour train-the-trainer & ateliers" }, + "hub": { + "label": "Hub {space}", + "desc": "🏢 Atelier + AI lab + hackerspace + espace culturel" + }, + "bildung": { + "label": "Programme de formation", + "desc": "🎓 2× responsables pour train-the-trainer & ateliers" + }, "fundraising": { "label": "Levée de fonds" }, "budgetStatus": { "label": "Budget & statut" }, - "plan": { "label": "Plan de levée de fonds", "desc": "📊 Vue d'ensemble du budget, besoins, calendrier" }, - "dashboard": { "label": "Tableau de bord", "desc": "📈 Vue KPI : soumis, accordés, taux de succès" }, + "plan": { + "label": "Plan de levée de fonds", + "desc": "📊 Vue d'ensemble du budget, besoins, calendrier" + }, + "dashboard": { + "label": "Tableau de bord", + "desc": "📈 Vue KPI : soumis, accordés, taux de succès" + }, "stiftungenSection": { "label": "Fondations" }, - "stiftungen": { "label": "{count} fondations", "desc": "🔍 Avec scores de fit, thèmes, délais, montants" }, - "vorlagen": { "label": "Modèles de demande", "desc": "📝 {count} modèles de référence par type (A/B/C/D)" }, + "stiftungen": { + "label": "{count} fondations", + "desc": "🔍 Avec scores de fit, thèmes, délais, montants" + }, + "vorlagen": { + "label": "Modèles de demande", + "desc": "📝 {count} modèles de référence par type (A/B/C/D)" + }, "gesuche": { "label": "Mes demandes", "desc": "📝 Brouillons de demandes en un coup d'œil" }, - "pipeline": { "label": "Pipeline des demandes", "desc": "📋 Gérer et suivre les demandes aux fondations" }, - "scoring": { "label": "Méthodologie de scoring", "desc": "⚙️ Comment fit, préparation et priorité sont calculés" }, - "pipelineMethodik": { "label": "Méthodologie du pipeline", "desc": "🔬 Comment trouver les bonnes parmi {universe} fondations" }, + "pipeline": { + "label": "Pipeline des demandes", + "desc": "📋 Gérer et suivre les demandes aux fondations" + }, + "scoring": { + "label": "Méthodologie de scoring", + "desc": "⚙️ Comment fit, préparation et priorité sont calculés" + }, + "pipelineMethodik": { + "label": "Méthodologie du pipeline", + "desc": "🔬 Comment trouver les bonnes parmi {universe} fondations" + }, "dokumente": { "label": "Documents" }, "plattform": { "label": "Plateforme" } } diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a6..644e5801 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import './.next/types/routes.d.ts'; +import './.next/types/root-params.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index 1a215e8c..b3b5a297 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,46 +1,46 @@ -import type { NextConfig } from "next"; -import createNextIntlPlugin from "next-intl/plugin"; +import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); // Security headers for every response (formerly in vercel.json — the app is // self-hosted now, so Next.js itself must apply them; Caddy only proxies). const SECURITY_HEADERS = [ - { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "X-Frame-Options", value: "DENY" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, - { key: "X-DNS-Prefetch-Control", value: "on" }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: 'X-DNS-Prefetch-Control', value: 'on' }, ]; // Legacy /pages/* URL shapes from the pre-App-Router site (formerly vercel.json). const LEGACY_REDIRECTS = [ - { source: "/pages/finanzen", destination: "/finanzen" }, - { source: "/pages/kennzahlen", destination: "/wirkung" }, - { source: "/kennzahlen", destination: "/wirkung" }, - { source: "/pages/wirkung", destination: "/wirkung" }, - { source: "/pages/methodik", destination: "/methodik" }, - { source: "/pages/transparenz", destination: "/methodik" }, - { source: "/transparenz", destination: "/methodik" }, - { source: "/pages/preismodell", destination: "/preismodell" }, - { source: "/pages/strategie", destination: "/strategie" }, - { source: "/pages/team", destination: "/team" }, - { source: "/pages/operations", destination: "/operations" }, - { source: "/pages/dokumente", destination: "/dokumente" }, - { source: "/pages/fundraising", destination: "/fundraising" }, - { source: "/pages/fundraising/stiftungen", destination: "/fundraising/stiftungen" }, - { source: "/pages/fundraising/stiftungen/:slug", destination: "/fundraising/stiftungen/:slug" }, - { source: "/en/platform", destination: "/plattform" }, + { source: '/pages/finanzen', destination: '/finanzen' }, + { source: '/pages/kennzahlen', destination: '/wirkung' }, + { source: '/kennzahlen', destination: '/wirkung' }, + { source: '/pages/wirkung', destination: '/wirkung' }, + { source: '/pages/methodik', destination: '/methodik' }, + { source: '/pages/transparenz', destination: '/methodik' }, + { source: '/transparenz', destination: '/methodik' }, + { source: '/pages/preismodell', destination: '/preismodell' }, + { source: '/pages/strategie', destination: '/strategie' }, + { source: '/pages/team', destination: '/team' }, + { source: '/pages/operations', destination: '/operations' }, + { source: '/pages/dokumente', destination: '/dokumente' }, + { source: '/pages/fundraising', destination: '/fundraising' }, + { source: '/pages/fundraising/stiftungen', destination: '/fundraising/stiftungen' }, + { source: '/pages/fundraising/stiftungen/:slug', destination: '/fundraising/stiftungen/:slug' }, + { source: '/en/platform', destination: '/plattform' }, ].map((r) => ({ ...r, permanent: true })); const nextConfig: NextConfig = { - output: "standalone", - serverExternalPackages: ["pg"], + output: 'standalone', + serverExternalPackages: ['pg'], turbopack: { root: __dirname, }, async headers() { - return [{ source: "/(.*)", headers: SECURITY_HEADERS }]; + return [{ source: '/(.*)', headers: SECURITY_HEADERS }]; }, async redirects() { return LEGACY_REDIRECTS; diff --git a/package-lock.json b/package-lock.json index 3b9e739f..87092681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "eslint": "^9", "eslint-config-next": "16.3.3", "pg": "^8.23.0", + "prettier": "3.9.6", "tailwindcss": "^4", "tsx": "^4.23.12", "typescript": "^5", @@ -8731,6 +8732,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index 9de1e687..cda956c4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest", "lint": "eslint", "typecheck": "tsc --noEmit", - "verify": "npm run lint && npm run lint:umlauts && npm run typecheck && npm run test", + "verify": "npm run format:check && npm run lint && npm run lint:umlauts && npm run typecheck && npm run test", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", @@ -32,7 +32,9 @@ "pipeline:upsert": "tsx scripts/foundation-upsert.ts", "zefix:download": "tsx scripts/zefix-download.ts", "zefix:ingest": "tsx scripts/zefix-ingest.ts", - "enrich:bulk": "tsx scripts/bulk-enrich.ts" + "enrich:bulk": "tsx scripts/bulk-enrich.ts", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -65,6 +67,7 @@ "eslint": "^9", "eslint-config-next": "16.3.3", "pg": "^8.23.0", + "prettier": "3.9.6", "tailwindcss": "^4", "tsx": "^4.23.12", "typescript": "^5", diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e36849..297374d8 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/scripts/audit-pipeline.ts b/scripts/audit-pipeline.ts index 4caebcca..624ad541 100644 --- a/scripts/audit-pipeline.ts +++ b/scripts/audit-pipeline.ts @@ -7,7 +7,8 @@ * This is the SSOT for all pipeline stats cited in CLAUDE.md and docs. * Run it after any sync/rescore and copy the output to update those docs. */ -import { config } from 'dotenv'; config({ path: '.env.local' }); +import { config } from 'dotenv'; +config({ path: '.env.local' }); import { sql } from './lib/db'; import { getAllFoundations } from './lib/foundations'; import { hasGesuchPage } from '../src/lib/domain/foundation-helpers'; @@ -39,7 +40,13 @@ async function main() { `; // ── 2. Priority breakdown (sync-eligible only — matches generated file) ── - const prios = await sql<{ priority: number; total: string | number; with_app_url: string | number; with_web_url: string | number; with_email: string | number }>` + const prios = await sql<{ + priority: number; + total: string | number; + with_app_url: string | number; + with_web_url: string | number; + with_email: string | number; + }>` SELECT priority, COUNT(*) AS total, @@ -109,25 +116,53 @@ async function main() { console.log(` deep (human-verified) .......... ${totals.depth_deep}`); console.log(``); - const p1 = prios.find(r => r.priority === 1) ?? { priority: 1, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 }; - const p2 = prios.find(r => r.priority === 2) ?? { priority: 2, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 }; - const p3 = prios.find(r => r.priority === 3) ?? { priority: 3, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 }; - const p4 = prios.find(r => r.priority === 4) ?? { priority: 4, total: 0, with_app_url: 0, with_web_url: 0, with_email: 0 }; + const p1 = prios.find((r) => r.priority === 1) ?? { + priority: 1, + total: 0, + with_app_url: 0, + with_web_url: 0, + with_email: 0, + }; + const p2 = prios.find((r) => r.priority === 2) ?? { + priority: 2, + total: 0, + with_app_url: 0, + with_web_url: 0, + with_email: 0, + }; + const p3 = prios.find((r) => r.priority === 3) ?? { + priority: 3, + total: 0, + with_app_url: 0, + with_web_url: 0, + with_email: 0, + }; + const p4 = prios.find((r) => r.priority === 4) ?? { + priority: 4, + total: 0, + with_app_url: 0, + with_web_url: 0, + with_email: 0, + }; const p13total = Number(p1.total) + Number(p2.total) + Number(p3.total); const pct = (n: number | string, d: number | string) => - d === 0 ? '—' : `${Math.round(Number(n)/Number(d)*100)}%`; + d === 0 ? '—' : `${Math.round((Number(n) / Number(d)) * 100)}%`; console.log(`PRIORITY BREAKDOWN (sync-eligible, non-archived)`); - console.log(` ${''.padEnd(4)} ${'total'.padEnd(6)} ${'appUrl'.padEnd(12)} ${'webUrl'.padEnd(12)} ${'email'.padEnd(10)}`); - console.log(` ${''.padEnd(4)} ${'-----'.padEnd(6)} ${'------'.padEnd(12)} ${'------'.padEnd(12)} ${'-----'.padEnd(10)}`); - for (const r of [p1,p2,p3,p4]) { - const label = `P${prios.indexOf(r)+1}`; + console.log( + ` ${''.padEnd(4)} ${'total'.padEnd(6)} ${'appUrl'.padEnd(12)} ${'webUrl'.padEnd(12)} ${'email'.padEnd(10)}`, + ); + console.log( + ` ${''.padEnd(4)} ${'-----'.padEnd(6)} ${'------'.padEnd(12)} ${'------'.padEnd(12)} ${'-----'.padEnd(10)}`, + ); + for (const r of [p1, p2, p3, p4]) { + const label = `P${prios.indexOf(r) + 1}`; console.log( ` ${label.padEnd(4)} ${String(r.total).padEnd(6)}` + - ` ${String(r.with_app_url).padEnd(4)} (${pct(r.with_app_url, r.total).padEnd(4)})` + - ` ${String(r.with_web_url).padEnd(4)} (${pct(r.with_web_url, r.total).padEnd(4)})` + - ` ${String(r.with_email).padEnd(4)} (${pct(r.with_email, r.total)})` + ` ${String(r.with_app_url).padEnd(4)} (${pct(r.with_app_url, r.total).padEnd(4)})` + + ` ${String(r.with_web_url).padEnd(4)} (${pct(r.with_web_url, r.total).padEnd(4)})` + + ` ${String(r.with_email).padEnd(4)} (${pct(r.with_email, r.total)})`, ); } console.log(` P1-P3 total: ${p13total}`); @@ -137,7 +172,9 @@ async function main() { if (emailGaps.length === 0) { console.log(`EMAIL GAPS (truly unreachable) — none ✓`); } else { - console.log(`EMAIL GAPS (no email AND no appUrl) — ${emailGaps.length} P1-P3 truly unreachable`); + console.log( + `EMAIL GAPS (no email AND no appUrl) — ${emailGaps.length} P1-P3 truly unreachable`, + ); console.log(` Note: foundations with appUrl/online-form are reachable; not listed here.`); for (const r of emailGaps) { console.log(` P${r.priority} fit=${r.fit_score} | ${r.id} (method: ${r.method ?? '—'})`); @@ -152,13 +189,17 @@ async function main() { console.log(` ${''.padEnd(4)} ${'id'.padEnd(40)} ${'method'.padEnd(12)} website`); console.log(` ${''.padEnd(4)} ${'--'.padEnd(40)} ${'------'.padEnd(12)} -------`); for (const r of gaps) { - const web = r.website ? (r.website as string).replace(/^https?:\/\//, '').slice(0, 40) : '(none)'; - console.log(` P${r.priority} ${String(r.id).padEnd(40)} ${String(r.method ?? '—').padEnd(12)} ${web}`); + const web = r.website + ? (r.website as string).replace(/^https?:\/\//, '').slice(0, 40) + : '(none)'; + console.log( + ` P${r.priority} ${String(r.id).padEnd(40)} ${String(r.method ?? '—').padEnd(12)} ${web}`, + ); } } // ── 5. Gesuch quality ───────────────────────────────────────────────────── - const gesuchFoundations = foundations.filter(f => hasGesuchPage(f)); + const gesuchFoundations = foundations.filter((f) => hasGesuchPage(f)); let gesuchPerfect = 0; const gesuchIssues: { p: number; issues: string[] }[] = []; for (const f of gesuchFoundations) { @@ -170,22 +211,24 @@ async function main() { if (issues.length === 0) gesuchPerfect++; else gesuchIssues.push({ p: computePriorityScore(f).level, issues }); } - const gByP = [1,2,3].map(p => { - const items = gesuchIssues.filter(i => i.p === p); - const total = gesuchFoundations.filter(f => computePriorityScore(f).level === p).length; + const gByP = [1, 2, 3].map((p) => { + const items = gesuchIssues.filter((i) => i.p === p); + const total = gesuchFoundations.filter((f) => computePriorityScore(f).level === p).length; return { p, issues: items.length, total }; }); console.log(`GESUCH QUALITY (from generated file)`); - console.log(` Total gesuch pages: ${gesuchFoundations.length} (perfect: ${gesuchPerfect}, with issues: ${gesuchIssues.length})`); + console.log( + ` Total gesuch pages: ${gesuchFoundations.length} (perfect: ${gesuchPerfect}, with issues: ${gesuchIssues.length})`, + ); for (const g of gByP) { const perfect = g.total - g.issues; - const pct = g.total > 0 ? `${Math.round(perfect/g.total*100)}%` : '—'; + const pct = g.total > 0 ? `${Math.round((perfect / g.total) * 100)}%` : '—'; console.log(` P${g.p}: ${perfect}/${g.total} perfect (${pct})`); } - const issueTypes = ['no-contact','thin-notes','thin-purpose','no-website']; + const issueTypes = ['no-contact', 'thin-notes', 'thin-purpose', 'no-website']; for (const t of issueTypes) { - const n = gesuchIssues.filter(i => i.issues.includes(t)).length; + const n = gesuchIssues.filter((i) => i.issues.includes(t)).length; if (n > 0) console.log(` └─ ${t}: ${n}`); } console.log(``); diff --git a/scripts/audit-themes.ts b/scripts/audit-themes.ts index 1cef23e7..58eaaa46 100644 --- a/scripts/audit-themes.ts +++ b/scripts/audit-themes.ts @@ -24,7 +24,15 @@ interface ThemeDefinition { const THEME_DEFINITIONS: ThemeDefinition[] = [ { id: 'klima', - keywords: ['klima', 'klimaschutz', 'co2', 'emission', 'treibhausgas', 'nachhaltigkeit', 'energie'], + keywords: [ + 'klima', + 'klimaschutz', + 'co2', + 'emission', + 'treibhausgas', + 'nachhaltigkeit', + 'energie', + ], excludeKeywords: [], }, { @@ -35,12 +43,30 @@ const THEME_DEFINITIONS: ThemeDefinition[] = [ { id: 'soziale-integration', // partizipation = direct synonym for Teilhabe in German social-integration contexts - keywords: ['integration', 'sozial', 'benachteiligt', 'inklusion', 'gemeinschaft', 'teilhabe', 'zusammenleben', 'partizipation'], + keywords: [ + 'integration', + 'sozial', + 'benachteiligt', + 'inklusion', + 'gemeinschaft', + 'teilhabe', + 'zusammenleben', + 'partizipation', + ], excludeKeywords: [], }, { id: 'digitale-bildung', - keywords: ['digital', 'bildung', 'schul', 'ausbildung', 'lern', 'pädagog', 'unterricht', 'medien'], + keywords: [ + 'digital', + 'bildung', + 'schul', + 'ausbildung', + 'lern', + 'pädagog', + 'unterricht', + 'medien', + ], excludeKeywords: ['medizinstudium', 'medizinische ausbildung'], // avoid medical school false positives; 'medizin' alone is too broad (fires on velux "medizinische Gebiete" which correctly has digitale-bildung) }, { @@ -57,7 +83,16 @@ const THEME_DEFINITIONS: ThemeDefinition[] = [ }, { id: 'arbeitsintegration', - keywords: ['arbeit', 'beschäftig', 'arbeitslos', 'beruf', 'job', 'arbeitsvermittl', 'arbeitseinglieder', 'arbeitsplätz'], + keywords: [ + 'arbeit', + 'beschäftig', + 'arbeitslos', + 'beruf', + 'job', + 'arbeitsvermittl', + 'arbeitseinglieder', + 'arbeitsplätz', + ], excludeKeywords: [], }, ]; @@ -73,7 +108,7 @@ interface SuspiciousAssignment { } function checkThemeMatch(theme: string, purpose: string): { matches: boolean; reason: string } { - const themeDef = THEME_DEFINITIONS.find(t => t.id === theme); + const themeDef = THEME_DEFINITIONS.find((t) => t.id === theme); if (!themeDef) { return { matches: true, reason: 'Unknown theme (skipped)' }; } @@ -84,34 +119,31 @@ function checkThemeMatch(theme: string, purpose: string): { matches: boolean; re if (themeDef.excludeKeywords) { for (const exclude of themeDef.excludeKeywords) { if (purposeLower.includes(exclude.toLowerCase())) { - return { - matches: false, - reason: `Contains excluded keyword '${exclude}'` + return { + matches: false, + reason: `Contains excluded keyword '${exclude}'`, }; } } } // Check if ANY keyword matches - const matchedKeywords = themeDef.keywords.filter(kw => - purposeLower.includes(kw.toLowerCase()) - ); + const matchedKeywords = themeDef.keywords.filter((kw) => purposeLower.includes(kw.toLowerCase())); if (matchedKeywords.length === 0) { - return { - matches: false, - reason: `No matching keywords (expected: ${themeDef.keywords.join(', ')})` + return { + matches: false, + reason: `No matching keywords (expected: ${themeDef.keywords.join(', ')})`, }; } - return { - matches: true, - reason: `Matched: ${matchedKeywords.join(', ')}` + return { + matches: true, + reason: `Matched: ${matchedKeywords.join(', ')}`, }; } async function auditThemes() { - console.log('\n📊 Auditing theme assignments for P1+P2 foundations...\n'); // Fetch P1 and P2 foundations with themes @@ -162,7 +194,8 @@ async function auditThemes() { const purposeSummary = (row.config_data?.purposeSummary ?? '') as string; const researchNotes = (row.config_data?.researchNotes ?? '') as string; const region = (row.config_data?.region ?? '') as string; - const address = ((row.config_data?.contact as { address?: string } | null)?.address ?? '') as string; + const address = ((row.config_data?.contact as { address?: string } | null)?.address ?? + '') as string; const haystack = [officialPurpose, purposeSummary, researchNotes, region, address] .filter(Boolean) .join(' '); @@ -183,8 +216,9 @@ async function auditThemes() { priority: row.priority ?? 0, theme, reason: check.reason, - officialPurpose: (purposeSummary || officialPurpose).slice(0, 200) - + ((purposeSummary || officialPurpose).length > 200 ? '...' : ''), + officialPurpose: + (purposeSummary || officialPurpose).slice(0, 200) + + ((purposeSummary || officialPurpose).length > 200 ? '...' : ''), allThemes: themes, }); totalSuspicious++; @@ -195,7 +229,9 @@ async function auditThemes() { console.log(`✅ Audit complete:`); console.log(` Total theme assignments: ${totalThemeAssignments}`); console.log(` Suspicious assignments: ${totalSuspicious}`); - console.log(` Suspicious rate: ${((totalSuspicious / totalThemeAssignments) * 100).toFixed(1)}%\n`); + console.log( + ` Suspicious rate: ${((totalSuspicious / totalThemeAssignments) * 100).toFixed(1)}%\n`, + ); if (suspicious.length === 0) { console.log('🎉 No suspicious theme assignments found!\n'); @@ -214,7 +250,8 @@ async function auditThemes() { console.log('📋 Suspicious assignments by theme:\n'); for (const [theme, items] of byTheme.entries()) { console.log(`\n🔴 ${theme} (${items.length} suspicious):\n`); - for (const item of items.slice(0, 3)) { // Show first 3 per theme + for (const item of items.slice(0, 3)) { + // Show first 3 per theme console.log(` ${item.foundationName} (P${item.priority})`); console.log(` ID: ${item.foundationId}`); console.log(` All themes: ${item.allThemes.join(', ')}`); diff --git a/scripts/auto-research.ts b/scripts/auto-research.ts index f2f9574c..d5ea6f5a 100644 --- a/scripts/auto-research.ts +++ b/scripts/auto-research.ts @@ -34,7 +34,7 @@ import { ThemeId } from '../src/lib/schemas/foundation'; type ThemeIdType = z.infer; const VALID_THEME_IDS: Set = new Set(ThemeId.options); function filterValidThemes(ids: string[]): ThemeIdType[] { - return ids.filter(t => VALID_THEME_IDS.has(t)) as ThemeIdType[]; + return ids.filter((t) => VALID_THEME_IDS.has(t)) as ThemeIdType[]; } // ============================================================================ @@ -42,15 +42,18 @@ function filterValidThemes(ids: string[]): ThemeIdType[] { // ============================================================================ const args = process.argv.slice(2); -const inputFile = args.find(a => !a.startsWith('--')); +const inputFile = args.find((a) => !a.startsWith('--')); const DRY_RUN = args.includes('--dry-run'); -const LIMIT = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '0', 10) || Infinity; -const OFFSET = parseInt(args.find(a => a.startsWith('--offset='))?.split('=')[1] || '0', 10) || 0; -const DELAY_MS = parseInt(args.find(a => a.startsWith('--delay='))?.split('=')[1] || '2000', 10); -const MODEL_OVERRIDE = args.find(a => a.startsWith('--model='))?.split('=')[1]; +const LIMIT = + parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10) || Infinity; +const OFFSET = parseInt(args.find((a) => a.startsWith('--offset='))?.split('=')[1] || '0', 10) || 0; +const DELAY_MS = parseInt(args.find((a) => a.startsWith('--delay='))?.split('=')[1] || '2000', 10); +const MODEL_OVERRIDE = args.find((a) => a.startsWith('--model='))?.split('=')[1]; if (!inputFile) { - console.error('Usage: npx tsx scripts/auto-research.ts [--dry-run] [--limit=N] [--offset=N] [--model=]'); + console.error( + 'Usage: npx tsx scripts/auto-research.ts [--dry-run] [--limit=N] [--offset=N] [--model=]', + ); process.exit(1); } @@ -83,7 +86,7 @@ interface TriageOutput { // ============================================================================ const AVAILABLE_THEMES = Object.values(THEMES) - .map(t => ` "${t.id}": ${t.label} — ${t.description}`) + .map((t) => ` "${t.id}": ${t.label} — ${t.description}`) .join('\n'); const SYSTEM_PROMPT = `Du bist ein Schweizer Stiftungsexperte. Du analysierst Stiftungen und bewertest deren Passung für Revamp-IT, ein Sozialunternehmen in Zürich. @@ -160,7 +163,11 @@ function buildUserPrompt(item: TriageItem, websiteContent: string | null): strin // Draft builder — maps Groq output to ResearchDraft (compatible with upsert) // ============================================================================ -function buildDraft(item: TriageItem, analysis: ResearchAnalysis, websiteContent: string | null): ResearchDraft { +function buildDraft( + item: TriageItem, + analysis: ResearchAnalysis, + websiteContent: string | null, +): ResearchDraft { return { slug: item.slug, name: item.name, @@ -209,7 +216,9 @@ async function main() { const triage: TriageOutput = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); const allItems = triage.items.slice(OFFSET, OFFSET + LIMIT); - console.log(`Auto-Research: ${allItems.length} foundations (offset=${OFFSET}, limit=${LIMIT === Infinity ? 'all' : LIMIT})`); + console.log( + `Auto-Research: ${allItems.length} foundations (offset=${OFFSET}, limit=${LIMIT === Infinity ? 'all' : LIMIT})`, + ); if (DRY_RUN) console.log(' DRY RUN — will process first 3 only, no files written'); const items = DRY_RUN ? allItems.slice(0, 3) : allItems; @@ -231,7 +240,11 @@ async function main() { const progress = `[${i + 1}/${items.length}]`; // Skip if no website (can't fetch content, LLM has nothing to work with beyond ESA purpose) - if (!item.websiteUrl || item.websiteUrl.includes('zefix.ch') || item.websiteUrl.includes('uid.admin.ch')) { + if ( + !item.websiteUrl || + item.websiteUrl.includes('zefix.ch') || + item.websiteUrl.includes('uid.admin.ch') + ) { if (item.officialPurpose && item.officialPurpose.length > 100) { // ESA purpose is substantial enough to analyze without website console.log(` ${progress} ${item.name} — no website, using ESA purpose only`); @@ -283,7 +296,7 @@ async function main() { // Parse and validate against ResearchAnalysisSchema const parsed = ResearchAnalysisSchema.safeParse(cleaned); if (!parsed.success) { - const issues = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); + const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); console.error(` ${progress} ${item.name} — validation error: ${issues}`); // Try to salvage: fill in missing fields with defaults @@ -293,7 +306,12 @@ async function main() { const draft = buildDraft(item, salvaged, websiteContent); writeDraft(outDir, draft, DRY_RUN, progress); success++; - results.push({ slug: item.slug, status: 'salvaged', fitScore: salvaged.suggestedFit, priority: salvaged.suggestedPriority }); + results.push({ + slug: item.slug, + status: 'salvaged', + fitScore: salvaged.suggestedFit, + priority: salvaged.suggestedPriority, + }); } else { errors++; results.push({ slug: item.slug, status: `validation-error: ${issues}` }); @@ -307,11 +325,18 @@ async function main() { writeDraft(outDir, draft, DRY_RUN, progress); success++; - results.push({ slug: item.slug, status: 'ok', fitScore: analysis.suggestedFit, priority: analysis.suggestedPriority }); + results.push({ + slug: item.slug, + status: 'ok', + fitScore: analysis.suggestedFit, + priority: analysis.suggestedPriority, + }); if (groqResult.usage) { const tokens = groqResult.usage.prompt_tokens + groqResult.usage.completion_tokens; - console.log(` → isFunder=${analysis.isFunder}, themes=[${analysis.themes.join(',')}], fit=${analysis.suggestedFit}, P${analysis.suggestedPriority} (${tokens} tokens)`); + console.log( + ` → isFunder=${analysis.isFunder}, themes=[${analysis.themes.join(',')}], fit=${analysis.suggestedFit}, P${analysis.suggestedPriority} (${tokens} tokens)`, + ); } // Rate limit: don't hammer Groq @@ -330,15 +355,22 @@ async function main() { // Write summary file const summaryFile = path.join(outDir, '_summary.json'); - fs.writeFileSync(summaryFile, JSON.stringify({ - date: today, - source: inputFile, - total: items.length, - success, - errors, - skipped, - results, - }, null, 2)); + fs.writeFileSync( + summaryFile, + JSON.stringify( + { + date: today, + source: inputFile, + total: items.length, + success, + errors, + skipped, + results, + }, + null, + 2, + ), + ); } } @@ -365,7 +397,9 @@ function stripNulls(obj: Record): Record { function writeDraft(outDir: string, draft: ResearchDraft, dryRun: boolean, progress: string): void { if (dryRun) { console.log(` ${progress} ${draft.name} — DRY RUN (would write ${draft.slug}.json)`); - console.log(` Analysis: isFunder=${draft.analysis.isFunder}, themes=[${draft.analysis.themes.join(',')}]`); + console.log( + ` Analysis: isFunder=${draft.analysis.isFunder}, themes=[${draft.analysis.themes.join(',')}]`, + ); console.log(` Purpose: ${draft.analysis.purposeSummary.substring(0, 80)}...`); return; } @@ -386,12 +420,19 @@ function salvageAnalysis(raw: Record, item: TriageItem): Resear funderConfidence: (raw.funderConfidence as 'high' | 'medium' | 'low') || 'low', reasoning: (raw.reasoning as string) || 'Auto-salvaged from partial Groq response', themes: filterValidThemes(Array.isArray(raw.themes) ? raw.themes : item.themes), - suggestedType: (['A', 'B', 'C', 'D', 'network'].includes(raw.suggestedType as string) ? raw.suggestedType : 'C') as ResearchAnalysis['suggestedType'], - suggestedFit: typeof raw.suggestedFit === 'number' ? Math.min(3, Math.max(0, raw.suggestedFit)) : 1, - suggestedPriority: typeof raw.suggestedPriority === 'number' ? Math.min(4, Math.max(1, raw.suggestedPriority)) : item.priority, + suggestedType: (['A', 'B', 'C', 'D', 'network'].includes(raw.suggestedType as string) + ? raw.suggestedType + : 'C') as ResearchAnalysis['suggestedType'], + suggestedFit: + typeof raw.suggestedFit === 'number' ? Math.min(3, Math.max(0, raw.suggestedFit)) : 1, + suggestedPriority: + typeof raw.suggestedPriority === 'number' + ? Math.min(4, Math.max(1, raw.suggestedPriority)) + : item.priority, purposeSummary: (raw.purposeSummary as string) || '', researchNotes: (raw.researchNotes as string) || '', - applicationMethod: (raw.applicationMethod as ResearchAnalysis['applicationMethod']) || 'unknown', + applicationMethod: + (raw.applicationMethod as ResearchAnalysis['applicationMethod']) || 'unknown', contactInfo: { email: (raw.contactInfo as Record)?.email || item.contactEmail || undefined, phone: (raw.contactInfo as Record)?.phone || item.contactPhone || undefined, @@ -402,7 +443,9 @@ function salvageAnalysis(raw: Record, item: TriageItem): Resear max: (raw.grantRange as Record)?.max || undefined, typical: (raw.grantRange as Record)?.typical || undefined, }, - warnings: Array.isArray(raw.warnings) ? raw.warnings.filter((w): w is string => typeof w === 'string') : [], + warnings: Array.isArray(raw.warnings) + ? raw.warnings.filter((w): w is string => typeof w === 'string') + : [], }; // Must have purposeSummary to be useful @@ -416,7 +459,7 @@ function salvageAnalysis(raw: Record, item: TriageItem): Resear } } -main().catch(err => { +main().catch((err) => { console.error('Auto-research failed:', err); process.exit(1); }); diff --git a/scripts/bulk-enrich.ts b/scripts/bulk-enrich.ts index 88500071..7a7ec5ef 100644 --- a/scripts/bulk-enrich.ts +++ b/scripts/bulk-enrich.ts @@ -48,12 +48,32 @@ const FUNDRAISO_PATH = path.join(process.cwd(), 'research', 'fundraiso-discovery // Canton full names for address building const CANTON_NAMES: Record = { - ZH: 'Zürich', BE: 'Bern', LU: 'Luzern', UR: 'Uri', SZ: 'Schwyz', - OW: 'Obwalden', NW: 'Nidwalden', GL: 'Glarus', ZG: 'Zug', FR: 'Freiburg', - SO: 'Solothurn', BS: 'Basel-Stadt', BL: 'Basel-Landschaft', SH: 'Schaffhausen', - AR: 'Appenzell Ausserrhoden', AI: 'Appenzell Innerrhoden', SG: 'St. Gallen', - GR: 'Graubünden', AG: 'Aargau', TG: 'Thurgau', TI: 'Tessin', VD: 'Waadt', - VS: 'Wallis', NE: 'Neuenburg', GE: 'Genf', JU: 'Jura', + ZH: 'Zürich', + BE: 'Bern', + LU: 'Luzern', + UR: 'Uri', + SZ: 'Schwyz', + OW: 'Obwalden', + NW: 'Nidwalden', + GL: 'Glarus', + ZG: 'Zug', + FR: 'Freiburg', + SO: 'Solothurn', + BS: 'Basel-Stadt', + BL: 'Basel-Landschaft', + SH: 'Schaffhausen', + AR: 'Appenzell Ausserrhoden', + AI: 'Appenzell Innerrhoden', + SG: 'St. Gallen', + GR: 'Graubünden', + AG: 'Aargau', + TG: 'Thurgau', + TI: 'Tessin', + VD: 'Waadt', + VS: 'Wallis', + NE: 'Neuenburg', + GE: 'Genf', + JU: 'Jura', }; // ============================================================================ @@ -134,7 +154,9 @@ function normalizeName(name: string): string { .replace(/[ìíîï]/g, 'i') .replace(/[òóôõöø]/g, 'o') .replace(/[ùúûü]/g, 'u') - .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') .replace(/ß/g, 'ss') .replace(/[^a-z0-9]/g, ''); } @@ -157,7 +179,13 @@ function getQualityTier(f: Foundation): string { } function computeTierCounts(foundations: Foundation[]): TierCounts { - const counts: TierCounts = { verzeichnet: 0, erfasst: 0, profiliert: 0, recherchiert: 0, anwendungsbereit: 0 }; + const counts: TierCounts = { + verzeichnet: 0, + erfasst: 0, + profiliert: 0, + recherchiert: 0, + anwendungsbereit: 0, + }; for (const f of foundations) { const tier = getQualityTier(f) as keyof TierCounts; counts[tier]++; @@ -185,7 +213,10 @@ function loadESA(): Map { return byUid; } -function loadFundraiso(): { byName: Map; bySlug: Map } { +function loadFundraiso(): { + byName: Map; + bySlug: Map; +} { if (!fs.existsSync(FUNDRAISO_PATH)) { console.log(' Fundraiso discovery not found, skipping Fundraiso matching'); return { byName: new Map(), bySlug: new Map() }; @@ -243,7 +274,8 @@ function enrichFoundation( if (!enriched.officialPurpose && esa?.purpose) { enriched.officialPurpose = esa.purpose; changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'officialPurpose', from: '(empty)', to: `ESA purpose (${esa.purpose.length} chars)`, @@ -257,7 +289,8 @@ function enrichFoundation( if (classified.length > 0) { enriched.themes = classified; changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'themes', from: '[]', to: JSON.stringify(classified), @@ -271,7 +304,8 @@ function enrichFoundation( enriched.contact = enriched.contact || {}; enriched.contact.address = address; changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'contact.address', from: '(empty)', to: address, @@ -282,7 +316,8 @@ function enrichFoundation( if ((!enriched.region || enriched.region === 'Schweiz') && esa?.city) { enriched.region = esa.city; changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'region', from: enriched.region || 'Schweiz', to: esa.city, @@ -296,7 +331,8 @@ function enrichFoundation( enriched.isOperative = isOperative; if (isOperative) { changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'isOperative', from: 'undefined', to: `true (funder=${scores.funder}, operator=${scores.operator})`, @@ -310,7 +346,8 @@ function enrichFoundation( const newType = classifyType(purposeText, enriched.name, scores.funder, scores.operator); if (newType !== 'C') { changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'type', from: enriched.type, to: newType, @@ -322,10 +359,12 @@ function enrichFoundation( // --- 4. Fundraiso Website URL Matching --- if (isRegistryUrl(enriched.websiteUrl ?? '')) { const normalizedName = normalizeName(enriched.name); - const fundraisoMatch = fundraisoBySlug.get(enriched.slug) || fundraisoByName.get(normalizedName); + const fundraisoMatch = + fundraisoBySlug.get(enriched.slug) || fundraisoByName.get(normalizedName); if (fundraisoMatch?.url) { changes.push({ - slug: f.slug, name: f.name, + slug: f.slug, + name: f.name, field: 'websiteUrl', from: enriched.websiteUrl || '(empty)', to: `Fundraiso: ${fundraisoMatch.url}`, @@ -335,7 +374,7 @@ function enrichFoundation( } // --- Recompute fit score if themes changed --- - if (changes.some(c => c.field === 'themes')) { + if (changes.some((c) => c.field === 'themes')) { const esa2 = f.uid ? esaByUid.get(f.uid) : undefined; const { fitScore } = computeFitScore({ themes: enriched.themes as string[], @@ -401,12 +440,17 @@ function buildDraft(enriched: Foundation, esa: ESAEntry | undefined) { reasoning: 'Bulk enrichment from ESA register + Fundraiso cross-reference', themes: enriched.themes, suggestedType: enriched.type, - suggestedFit: fitScoreToDisplay(enriched.fitScore || 0, (enriched.researchDepth || 'rapid') === 'rapid'), + suggestedFit: fitScoreToDisplay( + enriched.fitScore || 0, + (enriched.researchDepth || 'rapid') === 'rapid', + ), suggestedPriority: enriched.priority, purposeSummary: enriched.purposeSummary || buildMinimalPurposeSummary(enriched, esa), researchNotes: enriched.researchNotes || buildMinimalResearchNotes(enriched, esa), - applicationMethod: enriched.applicationMethod === 'post' ? 'unknown' - : (enriched.applicationMethod as 'online' | 'email' | 'unknown') || 'unknown', + applicationMethod: + enriched.applicationMethod === 'post' + ? 'unknown' + : (enriched.applicationMethod as 'online' | 'email' | 'unknown') || 'unknown', contactInfo: { email: enriched.contact?.email, phone: enriched.contact?.phone, @@ -429,9 +473,8 @@ function buildMinimalPurposeSummary(f: Foundation, esa?: ESAEntry): string { const city = esa?.city || f.region || 'Schweiz'; const purpose = f.officialPurpose || ''; // Truncate purpose to first 200 chars for summary - const excerpt = purpose.length > 200 - ? purpose.substring(0, 197).replace(/\s+\S*$/, '') + '...' - : purpose; + const excerpt = + purpose.length > 200 ? purpose.substring(0, 197).replace(/\s+\S*$/, '') + '...' : purpose; return `${f.name} (${city}): ${excerpt || 'Keine detaillierte Zweckbeschreibung verfügbar. Weitere Recherche empfohlen.'}`; } @@ -464,7 +507,7 @@ async function main() { const dbRows = await loadFoundationsFromDB(); console.log(` Database: ${dbRows.length} foundations`); - const foundations = dbRows.map(r => r.config_data); + const foundations = dbRows.map((r) => r.config_data); const esaByUid = loadESA(); const { byName: fundraisoByName, bySlug: fundraisoBySlug } = loadFundraiso(); @@ -510,13 +553,15 @@ async function main() { processed++; if (processed % 5000 === 0) { - console.log(` Processed ${processed.toLocaleString()}/${foundations.length.toLocaleString()}...`); + console.log( + ` Processed ${processed.toLocaleString()}/${foundations.length.toLocaleString()}...`, + ); } } // Compute after-enrichment tier counts - const afterFoundations = foundations.map(f => { - const enriched = enrichedFoundations.find(e => e.slug === f.slug); + const afterFoundations = foundations.map((f) => { + const enriched = enrichedFoundations.find((e) => e.slug === f.slug); return enriched || f; }); const afterCounts = computeTierCounts(afterFoundations); @@ -536,18 +581,26 @@ async function main() { } console.log('\n Tier Distribution (Before → After):'); - const tiers = ['verzeichnet', 'erfasst', 'profiliert', 'recherchiert', 'anwendungsbereit'] as const; + const tiers = [ + 'verzeichnet', + 'erfasst', + 'profiliert', + 'recherchiert', + 'anwendungsbereit', + ] as const; for (const tier of tiers) { const before = beforeCounts[tier]; const after = afterCounts[tier]; const delta = after - before; const deltaStr = delta > 0 ? ` (+${delta})` : delta < 0 ? ` (${delta})` : ''; - console.log(` ${tier.padEnd(20)} ${before.toLocaleString().padStart(7)} → ${after.toLocaleString().padStart(7)}${deltaStr}`); + console.log( + ` ${tier.padEnd(20)} ${before.toLocaleString().padStart(7)} → ${after.toLocaleString().padStart(7)}${deltaStr}`, + ); } // Count funder/operator classification - const operativeCount = enrichedFoundations.filter(f => f.isOperative === true).length; - const funderCount = enrichedFoundations.filter(f => f.isOperative === false).length; + const operativeCount = enrichedFoundations.filter((f) => f.isOperative === true).length; + const funderCount = enrichedFoundations.filter((f) => f.isOperative === false).length; console.log(`\n Funder/Operator: ${funderCount} funders, ${operativeCount} operatives detected`); // Write drafts (unless dry run) @@ -573,7 +626,9 @@ async function main() { if (allChanges.length > 0) { console.log('\n Sample changes (first 20):'); for (const c of allChanges.slice(0, 20)) { - console.log(` ${c.name.substring(0, 40).padEnd(42)} ${c.field.padEnd(20)} ${c.from.substring(0, 20)} → ${c.to.substring(0, 40)}`); + console.log( + ` ${c.name.substring(0, 40).padEnd(42)} ${c.field.padEnd(20)} ${c.from.substring(0, 20)} → ${c.to.substring(0, 40)}`, + ); } if (allChanges.length > 20) { console.log(` ... and ${allChanges.length - 20} more changes`); @@ -586,7 +641,7 @@ async function main() { console.log('\nDone.\n'); } -main().catch(err => { +main().catch((err) => { console.error('Error:', err); process.exit(1); }); diff --git a/scripts/bulk-import-batches.ts b/scripts/bulk-import-batches.ts index e114e25a..dd87d25b 100644 --- a/scripts/bulk-import-batches.ts +++ b/scripts/bulk-import-batches.ts @@ -3,7 +3,8 @@ * The import script is idempotent — already-imported records are no-ops. * Runs batches 39-94 (claude-agent results from 2026-04-22). */ -import { config } from 'dotenv'; config({ path: '.env.local' }); +import { config } from 'dotenv'; +config({ path: '.env.local' }); import { sql } from './lib/db'; import { readdirSync, readFileSync } from 'fs'; import { resolve } from 'path'; @@ -15,17 +16,24 @@ const DRY_RUN = process.argv.includes('--dry-run'); const RESULTS_DIR = resolve(__dirname, '../research/chatgpt-results'); -async function importFile(file: string): Promise<{ written: number; skipped: number; notFound: number }> { +async function importFile( + file: string, +): Promise<{ written: number; skipped: number; notFound: number }> { const raw = JSON.parse(readFileSync(file, 'utf8')); - const records: Record[] = Array.isArray(raw) ? raw : raw.items ?? []; - let written = 0, skipped = 0, notFound = 0; + const records: Record[] = Array.isArray(raw) ? raw : (raw.items ?? []); + let written = 0, + skipped = 0, + notFound = 0; for (const rec of records) { const slug = rec.slug as string; if (!slug) continue; const [row] = await sql`SELECT id, config_data FROM fundraising_foundations WHERE id = ${slug}`; - if (!row) { notFound++; continue; } + if (!row) { + notFound++; + continue; + } const cd = row.config_data as Record; const fields: string[] = []; @@ -33,7 +41,10 @@ async function importFile(file: string): Promise<{ written: number; skipped: num // Never overwrite existing non-empty values (except researchDepth upgrade) const setIfEmpty = (key: string, val: unknown) => { if (val == null || val === '') return; - if (cd[key] == null || cd[key] === '') { cd[key] = val; fields.push(key); } + if (cd[key] == null || cd[key] === '') { + cd[key] = val; + fields.push(key); + } }; // Website URL (skip registry/directory URLs) @@ -58,10 +69,22 @@ async function importFile(file: string): Promise<{ written: number; skipped: num // Contact (nested) const contact = (cd.contact as Record | null) ?? {}; let contactChanged = false; - if (rec.email && !contact.email) { contact.email = rec.email; contactChanged = true; } - if (rec.phone && !contact.phone) { contact.phone = rec.phone; contactChanged = true; } - if (rec.address && !contact.address) { contact.address = rec.address; contactChanged = true; } - if (contactChanged) { cd.contact = contact; fields.push('contact'); } + if (rec.email && !contact.email) { + contact.email = rec.email; + contactChanged = true; + } + if (rec.phone && !contact.phone) { + contact.phone = rec.phone; + contactChanged = true; + } + if (rec.address && !contact.address) { + contact.address = rec.address; + contactChanged = true; + } + if (contactChanged) { + cd.contact = contact; + fields.push('contact'); + } // About setIfEmpty('purposeSummary', rec.purposeSummary); @@ -73,10 +96,12 @@ async function importFile(file: string): Promise<{ written: number; skipped: num // Application info if (rec.applicationMethod && !cd.applicationMethod) { - cd.applicationMethod = rec.applicationMethod; fields.push('applicationMethod'); + cd.applicationMethod = rec.applicationMethod; + fields.push('applicationMethod'); } if (rec.acceptsApplications && !cd.acceptsApplications) { - cd.acceptsApplications = rec.acceptsApplications; fields.push('acceptsApplications'); + cd.acceptsApplications = rec.acceptsApplications; + fields.push('acceptsApplications'); } setIfEmpty('deadlineText', rec.deadlineText); setIfEmpty('applicationProcess', rec.applicationProcess); @@ -84,15 +109,27 @@ async function importFile(file: string): Promise<{ written: number; skipped: num // Amount const amount = (cd.amount as Record | null) ?? {}; let amountChanged = false; - if (rec.amountMin != null && !amount.min) { amount.min = rec.amountMin; amountChanged = true; } - if (rec.amountMax != null && !amount.max) { amount.max = rec.amountMax; amountChanged = true; } - if (rec.amountText && !amount.text) { amount.text = rec.amountText; amountChanged = true; } - if (amountChanged) { cd.amount = amount; fields.push('amount'); } + if (rec.amountMin != null && !amount.min) { + amount.min = rec.amountMin; + amountChanged = true; + } + if (rec.amountMax != null && !amount.max) { + amount.max = rec.amountMax; + amountChanged = true; + } + if (rec.amountText && !amount.text) { + amount.text = rec.amountText; + amountChanged = true; + } + if (amountChanged) { + cd.amount = amount; + fields.push('amount'); + } // Themes — append new ones, never shrink if (Array.isArray(rec.themes) && rec.themes.length > 0) { const existing = new Set((cd.themes as string[] | null) ?? []); - const newThemes = (rec.themes as string[]).filter(t => !existing.has(t)); + const newThemes = (rec.themes as string[]).filter((t) => !existing.has(t)); if (newThemes.length > 0) { cd.themes = [...existing, ...newThemes]; fields.push('themes'); @@ -101,7 +138,8 @@ async function importFile(file: string): Promise<{ written: number; skipped: num // fitScore — only upgrade, never downgrade if (typeof rec.fitScore === 'number' && rec.fitScore > ((cd.fitScore as number) ?? 0)) { - cd.fitScore = rec.fitScore; fields.push('fitScore'); + cd.fitScore = rec.fitScore; + fields.push('fitScore'); } // Mark research method (upgrade only) @@ -114,8 +152,14 @@ async function importFile(file: string): Promise<{ written: number; skipped: num fields.push('applicationResearchMethod'); } - if (fields.length === 0) { skipped++; continue; } - if (DRY_RUN) { written++; continue; } + if (fields.length === 0) { + skipped++; + continue; + } + if (DRY_RUN) { + written++; + continue; + } await sql` UPDATE fundraising_foundations @@ -130,21 +174,30 @@ async function importFile(file: string): Promise<{ written: number; skipped: num async function main() { const files = readdirSync(RESULTS_DIR) - .filter(f => f.match(/^batch\d+-claude-agent-2026-04-22\.json$/)) + .filter((f) => f.match(/^batch\d+-claude-agent-2026-04-22\.json$/)) .sort() - .map(f => resolve(RESULTS_DIR, f)); + .map((f) => resolve(RESULTS_DIR, f)); - console.log(`${DRY_RUN ? '[DRY RUN] ' : ''}Processing ${files.length} claude-agent batch files...\n`); + console.log( + `${DRY_RUN ? '[DRY RUN] ' : ''}Processing ${files.length} claude-agent batch files...\n`, + ); - let totalWritten = 0, totalSkipped = 0, totalNotFound = 0; + let totalWritten = 0, + totalSkipped = 0, + totalNotFound = 0; for (const file of files) { const { written, skipped, notFound } = await importFile(file); totalWritten += written; totalSkipped += skipped; totalNotFound += notFound; - if (written > 0) process.stdout.write(` ✅ ${file.split('/').pop()} — ${written} written, ${skipped} skipped\n`); + if (written > 0) + process.stdout.write( + ` ✅ ${file.split('/').pop()} — ${written} written, ${skipped} skipped\n`, + ); } - console.log(`\nTotal: ${totalWritten} written, ${totalSkipped} skipped, ${totalNotFound} not found`); + console.log( + `\nTotal: ${totalWritten} written, ${totalSkipped} skipped, ${totalNotFound} not found`, + ); } main().catch(console.error); diff --git a/scripts/dedupe-by-uid.ts b/scripts/dedupe-by-uid.ts index 4b4420b9..3e62800f 100644 --- a/scripts/dedupe-by-uid.ts +++ b/scripts/dedupe-by-uid.ts @@ -22,7 +22,8 @@ * npx tsx scripts/dedupe-by-uid.ts --dry-run * npx tsx scripts/dedupe-by-uid.ts */ -import { config } from 'dotenv'; config({ path: '.env.local' }); +import { config } from 'dotenv'; +config({ path: '.env.local' }); import { sql } from './lib/db'; const DRY_RUN = process.argv.includes('--dry-run'); @@ -42,8 +43,8 @@ function isRegistryUrl(url: string | undefined | null): boolean { function dataScore(f: Foundation): number { // Higher score = better keeper candidate. const cd = f.config_data; - const purposeLen = (cd.purposeSummary as string | null ?? '').length; - const notesLen = (cd.researchNotes as string | null ?? '').length; + const purposeLen = ((cd.purposeSummary as string | null) ?? '').length; + const notesLen = ((cd.researchNotes as string | null) ?? '').length; const hasRealWeb = !isRegistryUrl(cd.websiteUrl as string | null); const contact = cd.contact as Record | null; const hasEmail = !!contact?.email; @@ -78,7 +79,12 @@ function mergeConfig( const sl = typeof sv === 'string' ? sv.length : 0; const dl = typeof dv === 'string' ? dv.length : 0; if (dl < sl) merged[k] = sv; - } else if (dv == null || dv === '' || dv === undefined || (Array.isArray(dv) && dv.length === 0)) { + } else if ( + dv == null || + dv === '' || + dv === undefined || + (Array.isArray(dv) && dv.length === 0) + ) { merged[k] = sv; } } @@ -117,34 +123,40 @@ async function main() { `; console.log(`${DRY_RUN ? '[DRY RUN] ' : ''}Processing ${clusters.length} UID clusters...`); - let merged = 0, archived = 0, errors = 0; + let merged = 0, + archived = 0, + errors = 0; for (const cluster of clusters) { const ids = cluster.ids as string[]; if (ids.length !== 2) { console.log(` SKIP cluster ${cluster.uid}: size ${ids.length}`); continue; } - const rows = await sql` + const rows = (await sql` SELECT id, config_data, fit_score, priority FROM fundraising_foundations WHERE id IN (${ids[0]}, ${ids[1]}) - ` as Foundation[]; - if (rows.length !== 2) { errors++; continue; } + `) as Foundation[]; + if (rows.length !== 2) { + errors++; + continue; + } const [a, b] = rows; - const aScore = dataScore(a), bScore = dataScore(b); + const aScore = dataScore(a), + bScore = dataScore(b); const keeper = aScore >= bScore ? a : b; const loser = aScore >= bScore ? b : a; const newConfig = mergeConfig(keeper.config_data, loser.config_data); if (DRY_RUN) { - const purposeBefore = (keeper.config_data.purposeSummary as string ?? '').length; - const purposeAfter = (newConfig.purposeSummary as string ?? '').length; - const notesBefore = (keeper.config_data.researchNotes as string ?? '').length; - const notesAfter = (newConfig.researchNotes as string ?? '').length; + const purposeBefore = ((keeper.config_data.purposeSummary as string) ?? '').length; + const purposeAfter = ((newConfig.purposeSummary as string) ?? '').length; + const notesBefore = ((keeper.config_data.researchNotes as string) ?? '').length; + const notesAfter = ((newConfig.researchNotes as string) ?? '').length; console.log( ` ${keeper.id} ← ${loser.id}` + - (purposeBefore !== purposeAfter ? ` purpose ${purposeBefore}→${purposeAfter}` : '') + - (notesBefore !== notesAfter ? ` notes ${notesBefore}→${notesAfter}` : ''), + (purposeBefore !== purposeAfter ? ` purpose ${purposeBefore}→${purposeAfter}` : '') + + (notesBefore !== notesAfter ? ` notes ${notesBefore}→${notesAfter}` : ''), ); } else { await sql` @@ -161,7 +173,9 @@ async function main() { merged++; archived++; } - console.log(`\n${DRY_RUN ? '[DRY RUN] ' : ''}Done — ${merged} clusters merged, ${archived} entries archived, ${errors} errors`); + console.log( + `\n${DRY_RUN ? '[DRY RUN] ' : ''}Done — ${merged} clusters merged, ${archived} entries archived, ${errors} errors`, + ); } main().catch(console.error); diff --git a/scripts/esa-download-and-parse.ts b/scripts/esa-download-and-parse.ts index f061e0cf..7f77c821 100644 --- a/scripts/esa-download-and-parse.ts +++ b/scripts/esa-download-and-parse.ts @@ -27,7 +27,8 @@ import * as XLSX from 'xlsx'; // ESA Foundation Registry - updated 14.01.2026 // Note: This URL may change when they publish a new version. Check https://www.esa.admin.ch/de/stiftungsverzeichnis for updates. -const ESA_EXCEL_URL = 'https://backend.esa.admin.ch/fileservice/sdweb-docs-prod-esaadminch-files/files/2026/01/20/ca6bcb70-475e-445c-8cae-898ae8768858.xlsx'; +const ESA_EXCEL_URL = + 'https://backend.esa.admin.ch/fileservice/sdweb-docs-prod-esaadminch-files/files/2026/01/20/ca6bcb70-475e-445c-8cae-898ae8768858.xlsx'; const OUTPUT_DIR = path.join(process.cwd(), 'research'); const TEMP_FILE = path.join(OUTPUT_DIR, 'esa-temp.xlsx'); @@ -48,32 +49,34 @@ function downloadFile(url: string, dest: string): Promise { return new Promise((resolve, reject) => { const file = fs.createWriteStream(dest); - https.get(url, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - // Follow redirect - const redirectUrl = response.headers.location; - if (redirectUrl) { - console.log(`Following redirect to: ${redirectUrl}`); - downloadFile(redirectUrl, dest).then(resolve).catch(reject); - return; + https + .get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Follow redirect + const redirectUrl = response.headers.location; + if (redirectUrl) { + console.log(`Following redirect to: ${redirectUrl}`); + downloadFile(redirectUrl, dest).then(resolve).catch(reject); + return; + } } - } - if (response.statusCode !== 200) { - reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); - return; - } + if (response.statusCode !== 200) { + reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + return; + } - response.pipe(file); + response.pipe(file); - file.on('finish', () => { - file.close(); - resolve(); + file.on('finish', () => { + file.close(); + resolve(); + }); + }) + .on('error', (err) => { + fs.unlink(dest, () => {}); // Delete partial file + reject(err); }); - }).on('error', (err) => { - fs.unlink(dest, () => {}); // Delete partial file - reject(err); - }); }); } @@ -100,7 +103,10 @@ function parseExcel(filePath: string): ESAFoundation[] { for (const row of rawData) { // Try to identify columns by common headers // Note: Exact header names may vary - adjust based on actual file - const uid = (row['UID'] || row['UID-Nummer'] || row['Unternehmens-Identifikationsnummer'] || '') as string; + const uid = (row['UID'] || + row['UID-Nummer'] || + row['Unternehmens-Identifikationsnummer'] || + '') as string; const name = (row['Name'] || row['Stiftungsname'] || row['Stiftung'] || '') as string; const purpose = (row['Zweck'] || row['Stiftungszweck'] || row['Purpose'] || '') as string; const canton = (row['Kanton'] || row['Canton'] || '') as string; diff --git a/scripts/foundation-add.ts b/scripts/foundation-add.ts index 5d0e481d..7d78dec7 100644 --- a/scripts/foundation-add.ts +++ b/scripts/foundation-add.ts @@ -21,7 +21,14 @@ import * as readline from 'readline'; import { getAllFoundations } from './lib/foundations'; import { slugify } from './lib/utilities'; -import type { Foundation, FoundationType, FoundationStatus, SourceId, ThemeId, ApplicationMethod } from '../src/lib/schemas/foundation'; +import type { + Foundation, + FoundationType, + FoundationStatus, + SourceId, + ThemeId, + ApplicationMethod, +} from '../src/lib/schemas/foundation'; const rl = readline.createInterface({ input: process.stdin, @@ -120,7 +127,10 @@ async function promptForEntry(foundations: Foundation[]): Promise t.trim()).filter(Boolean); + const themes = themesInput + .split(',') + .map((t) => t.trim()) + .filter(Boolean); console.log('\nApplication Method:'); console.log(' online, post, email, contact, direct, personal, partnership,'); @@ -132,7 +142,7 @@ async function promptForEntry(foundations: Foundation[]): Promise - f.location?.includes('Zürich') || - f.location?.includes('Winterthur') || - (f.foundVia && f.foundVia.length >= 2) // Multi-match signal + .filter( + (f: Candidate) => + f.location?.includes('Zürich') || + f.location?.includes('Winterthur') || + (f.foundVia && f.foundVia.length >= 2), // Multi-match signal ) .slice(0, 20); // Limit to top 20 for manageable batch size } @@ -229,7 +230,13 @@ function processBatch(candidates: Candidate[]): BatchJob { // Generate prompt for each candidate candidates.forEach((candidate, index) => { const prompt = generatePromptForCandidate(candidate, index + 1); - const fileName = `${String(index + 1).padStart(2, '0')}-${candidate.slug || candidate.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30)}.md`; + const fileName = `${String(index + 1).padStart(2, '0')}-${ + candidate.slug || + candidate.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .substring(0, 30) + }.md`; const filePath = path.join(outputDir, fileName); fs.writeFileSync(filePath, prompt, 'utf-8'); diff --git a/scripts/foundation-research-assistant.ts b/scripts/foundation-research-assistant.ts index c9b03dbb..afe49ff0 100644 --- a/scripts/foundation-research-assistant.ts +++ b/scripts/foundation-research-assistant.ts @@ -74,8 +74,9 @@ interface ResearchOutput { function loadESARegister(): ESARegister | null { const researchDir = path.join(process.cwd(), 'research'); - const files = fs.readdirSync(researchDir) - .filter(f => f.startsWith('esa-register-') && f.endsWith('.json')) + const files = fs + .readdirSync(researchDir) + .filter((f) => f.startsWith('esa-register-') && f.endsWith('.json')) .sort() .reverse(); @@ -92,12 +93,14 @@ function loadESARegister(): ESARegister | null { function findInESA(register: ESARegister | null, name: string): ESAFoundation | null { if (!register) return null; - const normalized = name.toLowerCase() + const normalized = name + .toLowerCase() .replace(/stiftung|foundation|fondation|fondazione/gi, '') .replace(/[^a-z0-9]/g, ''); for (const foundation of register.foundations) { - const esaNormalized = foundation.name.toLowerCase() + const esaNormalized = foundation.name + .toLowerCase() .replace(/stiftung|foundation|fondation|fondazione/gi, '') .replace(/[^a-z0-9]/g, ''); @@ -115,24 +118,26 @@ function findInESA(register: ESARegister | null, name: string): ESAFoundation | function fetchWebsite(url: string): Promise { return new Promise((resolve, reject) => { - https.get(url, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - const redirectUrl = response.headers.location; - if (redirectUrl) { - fetchWebsite(redirectUrl).then(resolve).catch(reject); - return; + https + .get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + fetchWebsite(redirectUrl).then(resolve).catch(reject); + return; + } } - } - if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}`)); - return; - } + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } - let data = ''; - response.on('data', (chunk) => data += chunk); - response.on('end', () => resolve(data)); - }).on('error', reject); + let data = ''; + response.on('data', (chunk) => (data += chunk)); + response.on('end', () => resolve(data)); + }) + .on('error', reject); }); } @@ -140,8 +145,14 @@ function fetchWebsite(url: string): Promise { // ANALYSIS PROMPT TEMPLATES // ============================================================================ -function buildAnalysisPrompt(input: ResearchInput, esa: ESAFoundation | null, websiteContent?: string): string { - const availableThemes = Object.values(THEMES).map(t => `${t.id}: ${t.label} - ${t.description}`).join('\n'); +function buildAnalysisPrompt( + input: ResearchInput, + esa: ESAFoundation | null, + websiteContent?: string, +): string { + const availableThemes = Object.values(THEMES) + .map((t) => `${t.id}: ${t.label} - ${t.description}`) + .join('\n'); return `# Foundation Research Analysis Task @@ -159,18 +170,26 @@ You are analyzing a Swiss foundation to determine if it's a potential funder for ${input.websiteUrl ? `**Website**: ${input.websiteUrl}` : ''} ${input.fundraisoUrl ? `**Fundraiso**: ${input.fundraisoUrl}` : ''} -${esa ? `### ESA Official Data +${ + esa + ? `### ESA Official Data **UID**: ${esa.uid} **Official Name**: ${esa.name} **City**: ${esa.city} **Status**: ${esa.status} **Official Purpose (Stiftungszweck)**: ${esa.purpose} -` : '**ESA Data**: Not found in federal register (may be under cantonal supervision or not a formal Stiftung)'} +` + : '**ESA Data**: Not found in federal register (may be under cantonal supervision or not a formal Stiftung)' +} -${websiteContent ? `### Website Content (First 3000 chars) +${ + websiteContent + ? `### Website Content (First 3000 chars) ${websiteContent.substring(0, 3000)}... -` : '**Website**: Not available'} +` + : '**Website**: Not available' +} ## Available Themes ${availableThemes} @@ -266,7 +285,7 @@ Please provide your analysis in a structured format following the sections above async function analyzeFundation( input: ResearchInput, esa: ESAFoundation | null, - websiteContent?: string + websiteContent?: string, ): Promise { const prompt = buildAnalysisPrompt(input, esa, websiteContent); @@ -357,7 +376,9 @@ async function main() { } if (!name && !url) { - console.error('❌ Usage: npm run research:foundation -- --name="Foundation Name" --url=https://...\n'); + console.error( + '❌ Usage: npm run research:foundation -- --name="Foundation Name" --url=https://...\n', + ); process.exit(1); } @@ -379,7 +400,8 @@ async function main() { fs.mkdirSync(outputDir, { recursive: true }); } - const slug = name!.toLowerCase() + const slug = name! + .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); const outputPath = path.join(outputDir, `${slug}.json`); diff --git a/scripts/foundation-research-queue.ts b/scripts/foundation-research-queue.ts index 969fdfe7..6f599d0b 100644 --- a/scripts/foundation-research-queue.ts +++ b/scripts/foundation-research-queue.ts @@ -52,8 +52,39 @@ const THEME_WEIGHTS: Record = { }; // Zürich-area cities for geographic bonus -const ZURICH_AREA = ['zürich', 'zurich', 'winterthur', 'dietikon', 'dübendorf', 'uster', 'wädenswil', 'kloten', 'opfikon', 'bülach']; -const GERMAN_CH_CANTONS = ['ZH', 'BE', 'LU', 'ZG', 'SZ', 'AG', 'SG', 'TG', 'SH', 'GL', 'BS', 'BL', 'SO', 'NW', 'OW', 'UR', 'GR', 'AR', 'AI']; +const ZURICH_AREA = [ + 'zürich', + 'zurich', + 'winterthur', + 'dietikon', + 'dübendorf', + 'uster', + 'wädenswil', + 'kloten', + 'opfikon', + 'bülach', +]; +const GERMAN_CH_CANTONS = [ + 'ZH', + 'BE', + 'LU', + 'ZG', + 'SZ', + 'AG', + 'SG', + 'TG', + 'SH', + 'GL', + 'BS', + 'BL', + 'SO', + 'NW', + 'OW', + 'UR', + 'GR', + 'AR', + 'AI', +]; // ============================================================================ // TYPES @@ -105,7 +136,7 @@ function computeThemeScore(themes: string[]): number { function computeGeoScore(city: string, canton: string): number { const cityLower = city.toLowerCase(); - if (ZURICH_AREA.some(c => cityLower.includes(c))) return 2.0; + if (ZURICH_AREA.some((c) => cityLower.includes(c))) return 2.0; if (canton === 'ZH') return 1.8; if (GERMAN_CH_CANTONS.includes(canton)) return 1.0; // French/Italian Swiss — still valid but lower priority @@ -180,7 +211,11 @@ function missingForNextTier(f: Foundation): string[] { return missing; } -function computeEV(f: Foundation, city: string, canton: string): { +function computeEV( + f: Foundation, + city: string, + canton: string, +): { ev: number; themeScore: number; geoScore: number; @@ -213,7 +248,14 @@ function computeEV(f: Foundation, city: string, canton: string): { // ESA LOOKUP (for city/canton when not in config_data) // ============================================================================ -interface ESAEntry { uid: string; name: string; purpose: string; canton: string; city: string; status: string; } +interface ESAEntry { + uid: string; + name: string; + purpose: string; + canton: string; + city: string; + status: string; +} function loadESAIndex(): Map { const esaPath = path.join(process.cwd(), 'research', 'esa-register-2026-02-16.json'); @@ -232,9 +274,9 @@ function loadESAIndex(): Map { async function main() { const args = process.argv.slice(2); - const limitArg = args.find(a => a.startsWith('--limit=')); + const limitArg = args.find((a) => a.startsWith('--limit=')); const limit = limitArg ? parseInt(limitArg.split('=')[1], 10) : 500; - const minEvArg = args.find(a => a.startsWith('--min-ev=')); + const minEvArg = args.find((a) => a.startsWith('--min-ev=')); const minEv = minEvArg ? parseFloat(minEvArg.split('=')[1]) : 0.5; console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); @@ -356,8 +398,10 @@ async function main() { // EV distribution if (queue.length > 0) { - const evValues = queue.map(q => q.evScore); - console.log(`\n EV range: ${evValues[evValues.length - 1].toFixed(2)} — ${evValues[0].toFixed(2)}`); + const evValues = queue.map((q) => q.evScore); + console.log( + `\n EV range: ${evValues[evValues.length - 1].toFixed(2)} — ${evValues[0].toFixed(2)}`, + ); console.log(` EV median: ${evValues[Math.floor(evValues.length / 2)].toFixed(2)}`); } @@ -368,10 +412,10 @@ async function main() { const themes = item.themes.slice(0, 3).join(', '); console.log( ` ${String(i + 1).padStart(3)}. [EV ${item.evScore.toFixed(2).padStart(5)}] ` + - `[${item.currentTier.padEnd(12)}] ` + - `${item.name.substring(0, 45).padEnd(47)} ` + - `${item.city.substring(0, 15).padEnd(17)} ` + - `{${themes}}` + `[${item.currentTier.padEnd(12)}] ` + + `${item.name.substring(0, 45).padEnd(47)} ` + + `${item.city.substring(0, 15).padEnd(17)} ` + + `{${themes}}`, ); } @@ -393,7 +437,7 @@ async function main() { console.log('📋 Next: Use foundation-batch-research.ts with this queue\n'); } -main().catch(err => { +main().catch((err) => { console.error('Error:', err); process.exit(1); }); diff --git a/scripts/foundation-screen.ts b/scripts/foundation-screen.ts index a14b9651..a450865d 100644 --- a/scripts/foundation-screen.ts +++ b/scripts/foundation-screen.ts @@ -96,8 +96,9 @@ interface ScreeningReport { function loadESARegister(): ESARegister | null { const researchDir = path.join(process.cwd(), 'research'); - const files = fs.readdirSync(researchDir) - .filter(f => f.startsWith('esa-register-') && f.endsWith('.json')) + const files = fs + .readdirSync(researchDir) + .filter((f) => f.startsWith('esa-register-') && f.endsWith('.json')) .sort() .reverse(); @@ -128,14 +129,18 @@ function loadCandidates(filePath: string): Candidate[] { // ============================================================================ function normalizeName(name: string): string { - return name - .toLowerCase() - // Convert umlauts to ae/oe/ue BEFORE stripping non-ASCII, so both - // 'Unterstützungs' and 'Unterstuetzungs' normalize identically. - .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') - .replace(/stiftung|foundation|fondation|fondazione/gi, '') - .replace(/[^a-z0-9]/g, '') - .trim(); + return ( + name + .toLowerCase() + // Convert umlauts to ae/oe/ue BEFORE stripping non-ASCII, so both + // 'Unterstützungs' and 'Unterstuetzungs' normalize identically. + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/stiftung|foundation|fondation|fondazione/gi, '') + .replace(/[^a-z0-9]/g, '') + .trim() + ); } function levenshteinDistance(a: string, b: string): number { @@ -157,7 +162,7 @@ function levenshteinDistance(a: string, b: string): number { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, matrix[i - 1][j] + 1, - matrix[i][j - 1] + 1 + matrix[i][j - 1] + 1, ); } } @@ -229,9 +234,11 @@ function findInESA(register: ESARegister, name: string): ESAFoundation | null { for (const foundation of register.foundations) { const esaNormalized = normalizeName(foundation.name); - if (esaNormalized === normalized || - foundation.name.toLowerCase() === name.toLowerCase() || - levenshteinDistance(normalized, esaNormalized) <= 2) { + if ( + esaNormalized === normalized || + foundation.name.toLowerCase() === name.toLowerCase() || + levenshteinDistance(normalized, esaNormalized) <= 2 + ) { return foundation; } } @@ -250,7 +257,8 @@ function hasInternationalDevelopmentFocus(purpose: string, _city: string): boole 'entwicklungsländer', 'entwicklungszusammenarbeit', 'ländern des südens', - 'dritte welt', 'drittwelt', // NEW v3.1 - old German term for "Third World" + 'dritte welt', + 'drittwelt', // NEW v3.1 - old German term for "Third World" 'global south', 'developing countries', 'emerging countries', @@ -281,8 +289,8 @@ function hasInternationalDevelopmentFocus(purpose: string, _city: string): boole 'basel', ]; - const hasInternationalKeywords = internationalKeywords.some(kw => purposeLower.includes(kw)); - const hasSwissFocus = swissFocusKeywords.some(kw => purposeLower.includes(kw)); + const hasInternationalKeywords = internationalKeywords.some((kw) => purposeLower.includes(kw)); + const hasSwissFocus = swissFocusKeywords.some((kw) => purposeLower.includes(kw)); // Only exclude if strong international focus and NO Swiss mention return hasInternationalKeywords && !hasSwissFocus; @@ -301,22 +309,37 @@ function scoreFunderOperator(purpose: string): { // Funder keywords (German, French, Italian) const funderKeywords = [ - 'fördert', 'unterstützt projekte', 'vergibt beiträge', - 'projektförderung', 'gesuchsverfahren', 'antragsformular', - 'finanzielle unterstützung', 'zuwendungen', 'finanziert', + 'fördert', + 'unterstützt projekte', + 'vergibt beiträge', + 'projektförderung', + 'gesuchsverfahren', + 'antragsformular', + 'finanzielle unterstützung', + 'zuwendungen', + 'finanziert', 'unterstützung und förderung', // Fondation Assura pattern // French - 'soutient des projets', 'octroie des contributions', - 'soutien financier', 'finance', + 'soutient des projets', + 'octroie des contributions', + 'soutien financier', + 'finance', // Italian - 'sostiene progetti', 'contributi finanziari', + 'sostiene progetti', + 'contributi finanziari', ]; // Operator keywords (German, French, Italian) - ENHANCED in v3.1 const operatorKeywords = [ - 'betreibt', 'führt durch', 'bietet an', 'dienstleister', - 'eigene programme', 'direkt tätig', 'selbst tätig', - 'angebote', 'leistungen erbringen', + 'betreibt', + 'führt durch', + 'bietet an', + 'dienstleister', + 'eigene programme', + 'direkt tätig', + 'selbst tätig', + 'angebote', + 'leistungen erbringen', // Strong operator signals from Phase 3 v2.0 analysis 'erbringung von dienstleistungen', 'geführten betriebe', @@ -332,28 +355,48 @@ function scoreFunderOperator(purpose: string): { 'durchführung', // Execution/carrying out 'serviceleistung', // Services // NEW from Phase 3 final 8 analysis (v3.1) - 'trägerin', 'träger', // Operator/carrier of institution + 'trägerin', + 'träger', // Operator/carrier of institution 'informationsaustausch', // Information exchange (platforms) - 'plattform', 'portal', // Platform/portal - 'wohnheim', 'heim', // Residential home + 'plattform', + 'portal', // Platform/portal + 'wohnheim', + 'heim', // Residential home 'wohn- und begegnungsort', // Residential facility // French - 'exploite', 'offre des services', 'propres programmes', - 'prestation de services', 'organise', 'plateforme', + 'exploite', + 'offre des services', + 'propres programmes', + 'prestation de services', + 'organise', + 'plateforme', // Italian - 'gestisce', 'offre servizi', 'programmi propri', - 'erogazione di servizi', 'organizza', 'piattaforma', + 'gestisce', + 'offre servizi', + 'programmi propri', + 'erogazione di servizi', + 'organizza', + 'piattaforma', ]; // Social dimension keywords (NEW in v3.0) const socialKeywords = [ - 'sozial', 'social', 'sociale', - 'integration', 'integrazione', + 'sozial', + 'social', + 'sociale', + 'integration', + 'integrazione', 'arbeitsintegration', - 'bildung', 'éducation', 'educazione', - 'armut', 'pauvreté', 'povertà', - 'benachteiligt', 'défavorisé', - 'vulnerable', 'vulnerabile', + 'bildung', + 'éducation', + 'educazione', + 'armut', + 'pauvreté', + 'povertà', + 'benachteiligt', + 'défavorisé', + 'vulnerable', + 'vulnerabile', 'chancengleichheit', ]; @@ -397,19 +440,24 @@ function scoreFunderOperator(purpose: string): { function isFrenchItalianOnly(city: string, purpose: string): boolean { // FIXED: Added more Geneva variants, ensure case-insensitive matching const frenchCities = [ - 'genève', 'geneva', 'geneve', 'canton de genève', - 'lausanne', 'neuchâtel', 'fribourg', 'meyrin', - ]; - const italianCities = [ - 'lugano', 'locarno', 'bellinzona', 'mendrisio', + 'genève', + 'geneva', + 'geneve', + 'canton de genève', + 'lausanne', + 'neuchâtel', + 'fribourg', + 'meyrin', ]; + const italianCities = ['lugano', 'locarno', 'bellinzona', 'mendrisio']; const cityLower = city.toLowerCase(); const purposeLower = purpose.toLowerCase(); // Check if city is French/Italian - const isFrenchItalianCity = frenchCities.some(fc => cityLower.includes(fc)) || - italianCities.some(ic => cityLower.includes(ic)); + const isFrenchItalianCity = + frenchCities.some((fc) => cityLower.includes(fc)) || + italianCities.some((ic) => cityLower.includes(ic)); if (!isFrenchItalianCity) { return false; @@ -432,13 +480,21 @@ function hasSocialDimension(purpose: string, socialScore: number): boolean { // Climate/environment keywords const climateKeywords = [ - 'klima', 'climate', 'climat', - 'umwelt', 'environment', 'environnement', - 'energie', 'energy', 'énergie', - 'natur', 'nature', 'natura', + 'klima', + 'climate', + 'climat', + 'umwelt', + 'environment', + 'environnement', + 'energie', + 'energy', + 'énergie', + 'natur', + 'nature', + 'natura', ]; - const isClimateOnly = climateKeywords.some(kw => purposeLower.includes(kw)); + const isClimateOnly = climateKeywords.some((kw) => purposeLower.includes(kw)); // If climate-focused, require social dimension if (isClimateOnly && socialScore === 0) { @@ -464,7 +520,7 @@ function isHealthcareOnly(purpose: string): boolean { 'gesundheitsförderung', ]; - const hasHealthcare = healthcareKeywords.some(kw => purposeLower.includes(kw)); + const hasHealthcare = healthcareKeywords.some((kw) => purposeLower.includes(kw)); if (!hasHealthcare) { return false; // Not healthcare-focused @@ -472,13 +528,22 @@ function isHealthcareOnly(purpose: string): boolean { // Check if it also has our core themes const coreThemeKeywords = [ - 'arbeitsintegration', 'soziale integration', 'berufliche integration', - 'kreislaufwirtschaft', 'recycling', 'wiederverwendung', 'secondhand', - 'digitale bildung', 'digitale kompetenz', 'informatik', - 'bildung', 'ausbildung', 'erziehung', // Education (broader) + 'arbeitsintegration', + 'soziale integration', + 'berufliche integration', + 'kreislaufwirtschaft', + 'recycling', + 'wiederverwendung', + 'secondhand', + 'digitale bildung', + 'digitale kompetenz', + 'informatik', + 'bildung', + 'ausbildung', + 'erziehung', // Education (broader) ]; - const hasCoreTheme = coreThemeKeywords.some(kw => purposeLower.includes(kw)); + const hasCoreTheme = coreThemeKeywords.some((kw) => purposeLower.includes(kw)); // Healthcare-only if has healthcare keywords but NO core themes return hasHealthcare && !hasCoreTheme; @@ -501,7 +566,7 @@ function getCoreThemeScore(purpose: string): number { 'benachteiligte', 'chancengleichheit', ]; - if (socialIntegrationKeywords.some(kw => purposeLower.includes(kw))) { + if (socialIntegrationKeywords.some((kw) => purposeLower.includes(kw))) { score += 3; // High value } @@ -513,23 +578,19 @@ function getCoreThemeScore(purpose: string): number { 'secondhand', 'upcycling', ]; - if (circularEconomyKeywords.some(kw => purposeLower.includes(kw))) { + if (circularEconomyKeywords.some((kw) => purposeLower.includes(kw))) { score += 3; // High value } // Digital literacy - const digitalKeywords = [ - 'digitale bildung', - 'digitale kompetenz', - 'informatik', - ]; - if (digitalKeywords.some(kw => purposeLower.includes(kw))) { + const digitalKeywords = ['digitale bildung', 'digitale kompetenz', 'informatik']; + if (digitalKeywords.some((kw) => purposeLower.includes(kw))) { score += 3; // High value } // Education (broader, lower value) const educationKeywords = ['bildung', 'ausbildung', 'erziehung']; - if (educationKeywords.some(kw => purposeLower.includes(kw)) && score === 0) { + if (educationKeywords.some((kw) => purposeLower.includes(kw)) && score === 0) { score += 1; // Some value if no stronger match } @@ -557,7 +618,7 @@ function getThematicFocusScore(purpose: string): number { let sectorCount = 0; for (const sectorKeywords of sectors) { - if (sectorKeywords.some(kw => purposeLower.includes(kw))) { + if (sectorKeywords.some((kw) => purposeLower.includes(kw))) { sectorCount++; } } @@ -572,11 +633,7 @@ function getThematicFocusScore(purpose: string): number { // MAIN SCREENING LOGIC // ============================================================================ -function screenCandidate( - candidate: Candidate, - esaRegister: ESARegister -): ScreeningResult { - +function screenCandidate(candidate: Candidate, esaRegister: ESARegister): ScreeningResult { // Filter 1: Duplicate in the foundation DB if (isDuplicateInDB(candidate)) { return { @@ -604,13 +661,16 @@ function screenCandidate( // They need research but should NOT be excluded — ESA only covers federal foundations if (!esaMatch) { const candidateCity = candidate.location?.replace(/, ?CH$/, '').toLowerCase() || ''; - const isZurichArea = candidateCity.includes('zürich') || candidateCity.includes('winterthur') - || candidateCity.includes('schlieren') || candidateCity.includes('maur'); + const isZurichArea = + candidateCity.includes('zürich') || + candidateCity.includes('winterthur') || + candidateCity.includes('schlieren') || + candidateCity.includes('maur'); return { candidate, decision: 'pass' as const, - tier: isZurichArea ? 3 as const : 4 as const, + tier: isZurichArea ? (3 as const) : (4 as const), details: `Not in ESA (cantonal/other supervision) - ${isZurichArea ? 'ZH area' : 'needs location check'}`, scores: { funderScore: 0, operatorScore: 0, socialScore: 0, confidence: 'low' as const }, }; @@ -688,7 +748,7 @@ function screenCandidate( const focusScore = getThematicFocusScore(esaMatch.purpose); const isZurichBased = esaMatch.city.toLowerCase().includes('zürich'); const isNearZurich = ['winterthur', 'zug', 'baden', 'aarau', 'freienbach', 'wetzikon'].some( - city => esaMatch.city.toLowerCase().includes(city) + (city) => esaMatch.city.toLowerCase().includes(city), ); // Start with base tier @@ -760,19 +820,21 @@ function processBatch(candidates: Candidate[], esaRegister: ESARegister): Screen } } - const passed = results.filter(r => r.decision === 'pass').length; - const excluded = results.filter(r => r.decision === 'exclude').length; + const passed = results.filter((r) => r.decision === 'pass').length; + const excluded = results.filter((r) => r.decision === 'exclude').length; console.log(`\n📊 Screening complete!`); - console.log(` ✅ Passed: ${passed} (${Math.round(passed / candidates.length * 100)}%)`); - console.log(` ❌ Excluded: ${excluded} (${Math.round(excluded / candidates.length * 100)}%)\n`); + console.log(` ✅ Passed: ${passed} (${Math.round((passed / candidates.length) * 100)}%)`); + console.log( + ` ❌ Excluded: ${excluded} (${Math.round((excluded / candidates.length) * 100)}%)\n`, + ); console.log(`📈 Exclusion breakdown:`); Object.entries(exclusionBreakdown) .filter(([, count]) => count > 0) .sort(([, a], [, b]) => b - a) .forEach(([reason, count]) => { - console.log(` ${reason}: ${count} (${Math.round(count / candidates.length * 100)}%)`); + console.log(` ${reason}: ${count} (${Math.round((count / candidates.length) * 100)}%)`); }); console.log(`\n🎯 Tier breakdown:`); @@ -821,7 +883,9 @@ async function main() { process.exit(1); } - console.log(`\n📚 Loaded ESA register: ${esaRegister.count} foundations (${esaRegister.downloadDate})`); + console.log( + `\n📚 Loaded ESA register: ${esaRegister.count} foundations (${esaRegister.downloadDate})`, + ); // Load candidates const args = process.argv.slice(2); @@ -845,11 +909,7 @@ async function main() { const report = processBatch(candidates, esaRegister); // Save report - const outputPath = path.join( - process.cwd(), - 'research', - `screening-v3.1-${report.date}.json` - ); + const outputPath = path.join(process.cwd(), 'research', `screening-v3.1-${report.date}.json`); fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8'); diff --git a/scripts/foundation-upsert.ts b/scripts/foundation-upsert.ts index fd7d9d07..89bc1f05 100644 --- a/scripts/foundation-upsert.ts +++ b/scripts/foundation-upsert.ts @@ -24,7 +24,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { sql } from './lib/db'; import { ResearchDraftSchema } from './lib/research-types'; -import { foundationSchema, type Foundation, type FoundationRegistry } from '../src/lib/schemas/foundation'; +import { + foundationSchema, + type Foundation, + type FoundationRegistry, +} from '../src/lib/schemas/foundation'; import { computeFitScore } from '../src/lib/domain/fit-scoring'; import { computePriorityScore } from '../src/lib/domain/foundation-scores'; @@ -46,12 +50,17 @@ async function main() { // Support directory argument: expand to all .json files in it if (files.length === 1 && fs.existsSync(files[0]) && fs.statSync(files[0]).isDirectory()) { const dir = path.resolve(files[0]); - files = fs.readdirSync(dir).filter(f => f.endsWith('.json')).map(f => path.join(dir, f)); + files = fs + .readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => path.join(dir, f)); console.log(` Expanding directory: ${files.length} JSON files`); } if (files.length === 0) { - console.error('Usage: npx tsx scripts/foundation-upsert.ts [dir/] [more-files...]'); + console.error( + 'Usage: npx tsx scripts/foundation-upsert.ts [dir/] [more-files...]', + ); process.exit(1); } @@ -88,10 +97,12 @@ async function main() { const today = now.split('T')[0]; // Read ZHAW metadata if present (from zhaw-crossref.ts) - const zhaw = (raw as Record)._zhaw as { - einreichungstermin?: string; - zielgruppe?: string; - } | undefined; + const zhaw = (raw as Record)._zhaw as + | { + einreichungstermin?: string; + zielgruppe?: string; + } + | undefined; // --- Compute deadline text --- const deadlineText = zhaw?.einreichungstermin || 'Unbekannt'; @@ -110,9 +121,10 @@ async function main() { address: a.contactInfo.address, }, acceptsApplications: undefined, - applicationMethod: a.applicationMethod === 'invitation' - ? 'contact' - : a.applicationMethod as FoundationRegistry['applicationMethod'], + applicationMethod: + a.applicationMethod === 'invitation' + ? 'contact' + : (a.applicationMethod as FoundationRegistry['applicationMethod']), isOperative: !a.isFunder, status: 'rolling', deadlineText, @@ -131,14 +143,18 @@ async function main() { // --- Compute researchDepth --- // Note: hasWebsite intentionally includes Zefix/registry URLs — the strict // check (requires real website + email/phone for 'standard') is below. - const hasWebsite = !!(draft.queueItem.websiteUrl); + const hasWebsite = !!draft.queueItem.websiteUrl; const hasRealWebsite = hasWebsite && !isZefixUrl(draft.queueItem.websiteUrl || ''); const hasEmail = !!a.contactInfo.email; const hasPhone = !!a.contactInfo.phone; const hasDeadline = !!(zhaw?.einreichungstermin && zhaw.einreichungstermin !== 'Unbekannt'); const hasGrantRange = !!(a.grantRange.min || a.grantRange.max); const researchDepth = computeResearchDepth({ - hasRealWebsite, hasEmail, hasPhone, hasDeadline, hasGrantRange, + hasRealWebsite, + hasEmail, + hasPhone, + hasDeadline, + hasGrantRange, }); depthCounts[researchDepth]++; @@ -221,7 +237,9 @@ async function main() { } } - console.log(` ${draft.name} → DB (newFit=${fitScore}, newP=${finalPriority}, depth=${researchDepth})`); + console.log( + ` ${draft.name} → DB (newFit=${fitScore}, newP=${finalPriority}, depth=${researchDepth})`, + ); success++; } catch (err) { console.error(` ${draft.name}: ${err instanceof Error ? err.message : err}`); @@ -230,7 +248,9 @@ async function main() { } console.log(`\nDone: ${success} upserted, ${errors} errors`); - console.log(` Research depth: rapid=${depthCounts.rapid}, standard=${depthCounts.standard}, deep=${depthCounts.deep}`); + console.log( + ` Research depth: rapid=${depthCounts.rapid}, standard=${depthCounts.standard}, deep=${depthCounts.deep}`, + ); if (success > 0) { console.log('Next: npm run sync && npm run build'); } diff --git a/scripts/foundation-validate.ts b/scripts/foundation-validate.ts index 4cbd963e..00b436b2 100644 --- a/scripts/foundation-validate.ts +++ b/scripts/foundation-validate.ts @@ -155,7 +155,7 @@ function levenshteinDistance(a: string, b: string): number { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, - matrix[i - 1][j] + 1 + matrix[i - 1][j] + 1, ); } } @@ -231,7 +231,7 @@ function validateDuplicates(foundations: Foundation[]) { // receive its UID) if (distance > 0) { if (foundation.uid && existing.uid && foundation.uid !== existing.uid) { - continue; // different UIDs = different foundations + continue; // different UIDs = different foundations } } addIssue({ diff --git a/scripts/gesuch-audit.ts b/scripts/gesuch-audit.ts index 6f569e29..34f0b8ab 100644 --- a/scripts/gesuch-audit.ts +++ b/scripts/gesuch-audit.ts @@ -27,7 +27,8 @@ import { isRegistryUrl } from '../src/lib/config/registry-domains'; // ============================================================================ const args = process.argv.slice(2); -const PRIORITY_FILTER = parseInt(args.find(a => a.startsWith('--priority='))?.split('=')[1] || '0', 10) || 0; +const PRIORITY_FILTER = + parseInt(args.find((a) => a.startsWith('--priority='))?.split('=')[1] || '0', 10) || 0; const JSON_OUTPUT = args.includes('--json'); // ============================================================================ @@ -48,7 +49,7 @@ interface AuditIssue { async function main() { const foundations = await getAllFoundations(); - const gesuchFoundations = foundations.filter(f => { + const gesuchFoundations = foundations.filter((f) => { if (!hasGesuchPage(f)) return false; if (PRIORITY_FILTER > 0) { const computed = computePriorityScore(f); @@ -77,8 +78,11 @@ async function main() { // Check 2: Does foundationBridge mention the foundation name? if (composed.ready) { const bridge = composed.foundationBridge.toLowerCase(); - const nameParts = foundation.name.toLowerCase().split(/[\s-]+/).filter(p => p.length > 3); - const mentionsFoundation = nameParts.some(part => bridge.includes(part)); + const nameParts = foundation.name + .toLowerCase() + .split(/[\s-]+/) + .filter((p) => p.length > 3); + const mentionsFoundation = nameParts.some((part) => bridge.includes(part)); if (!mentionsFoundation) { issues.push(`Bridge text does not mention foundation name (generic bridge)`); } @@ -107,12 +111,16 @@ async function main() { // Check 5: Research notes quality if (!foundation.researchNotes || foundation.researchNotes.length < 250) { - issues.push(`Research notes thin (${foundation.researchNotes?.length || 0} chars, need 250+)`); + issues.push( + `Research notes thin (${foundation.researchNotes?.length || 0} chars, need 250+)`, + ); } // Check 6: Purpose summary quality if (!foundation.purposeSummary || foundation.purposeSummary.length < 150) { - issues.push(`Purpose summary thin (${foundation.purposeSummary?.length || 0} chars, need 150+)`); + issues.push( + `Purpose summary thin (${foundation.purposeSummary?.length || 0} chars, need 150+)`, + ); } // Check 7: Website URL present and not a registry/directory site @@ -123,11 +131,11 @@ async function main() { // Compute quality score (100 = perfect, 0 = terrible) let score = 100; if (!composed.ready) score -= 50; - if (issues.some(i => i.includes('Bridge text'))) score -= 15; - if (issues.some(i => i.includes('No contact'))) score -= 10; - if (issues.some(i => i.includes('Research notes thin'))) score -= 10; - if (issues.some(i => i.includes('Purpose summary thin'))) score -= 10; - if (issues.some(i => i.includes('No real website'))) score -= 5; + if (issues.some((i) => i.includes('Bridge text'))) score -= 15; + if (issues.some((i) => i.includes('No contact'))) score -= 10; + if (issues.some((i) => i.includes('Research notes thin'))) score -= 10; + if (issues.some((i) => i.includes('Purpose summary thin'))) score -= 10; + if (issues.some((i) => i.includes('No real website'))) score -= 5; score = Math.max(0, score); if (issues.length > 0) { @@ -149,13 +157,20 @@ async function main() { const outDir = path.resolve('research/audit'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const outFile = path.join(outDir, `${today}.json`); - fs.writeFileSync(outFile, JSON.stringify({ - date: today, - totalChecked: gesuchFoundations.length, - totalIssues: auditResults.length, - perfect: gesuchFoundations.length - auditResults.length, - results: auditResults, - }, null, 2)); + fs.writeFileSync( + outFile, + JSON.stringify( + { + date: today, + totalChecked: gesuchFoundations.length, + totalIssues: auditResults.length, + perfect: gesuchFoundations.length - auditResults.length, + results: auditResults, + }, + null, + 2, + ), + ); console.log(`Audit written to: ${outFile}`); return; } @@ -166,7 +181,7 @@ async function main() { // Group by priority for (const p of [1, 2, 3]) { - const items = auditResults.filter(r => r.priority === p); + const items = auditResults.filter((r) => r.priority === p); if (items.length === 0) continue; console.log(`--- P${p} (${items.length} with issues) ---`); @@ -181,7 +196,7 @@ async function main() { // Customization candidates (P1/P2 foundations with generic bridges) const needsCustomization = auditResults.filter( - r => r.priority <= 2 && r.issues.some(i => i.includes('Bridge text')) + (r) => r.priority <= 2 && r.issues.some((i) => i.includes('Bridge text')), ); if (needsCustomization.length > 0) { console.log(`=== CUSTOMIZATION NEEDED (${needsCustomization.length} P1/P2 foundations) ===`); diff --git a/scripts/ingest-triage.ts b/scripts/ingest-triage.ts index 2276ba1c..8c5339a4 100644 --- a/scripts/ingest-triage.ts +++ b/scripts/ingest-triage.ts @@ -14,7 +14,7 @@ import { computeFitScore } from '../src/lib/domain/fit-scoring.js'; import { ThemeId } from '../src/lib/schemas/foundation.js'; const DRY_RUN = process.argv.includes('--dry-run'); -const INPUT_FILE = process.argv.find(a => !a.startsWith('-') && a.endsWith('.json')); +const INPUT_FILE = process.argv.find((a) => !a.startsWith('-') && a.endsWith('.json')); const VALID_THEME_IDS: Set = new Set(ThemeId.options); @@ -56,7 +56,7 @@ async function main() { for (const entry of entries) { // Validate themes - const validThemes = entry.themes.filter(t => VALID_THEME_IDS.has(t)); + const validThemes = entry.themes.filter((t) => VALID_THEME_IDS.has(t)); // Skip entries without useful data if (!entry.purposeSummary || entry.purposeSummary.length < 30) { @@ -148,12 +148,18 @@ async function main() { const existing = JSON.parse(fs.readFileSync(resultsFile, 'utf-8')); const existingSlugs = new Set(existing.items.map((r: any) => r.slug)); const newItems = entries - .filter(e => !existingSlugs.has(e.slug) && e.purposeSummary?.length >= 30) - .map(e => ({ + .filter((e) => !existingSlugs.has(e.slug) && e.purposeSummary?.length >= 30) + .map((e) => ({ slug: e.slug, name: e.name, - fitScore: computeFitScore({ themes: e.themes.filter(t => VALID_THEME_IDS.has(t)), canton: '', city: '', applicationMethod: e.applicationMethod || 'unknown', isFunder: e.isFunder }).fitScore, - themes: e.themes.filter(t => VALID_THEME_IDS.has(t)), + fitScore: computeFitScore({ + themes: e.themes.filter((t) => VALID_THEME_IDS.has(t)), + canton: '', + city: '', + applicationMethod: e.applicationMethod || 'unknown', + isFunder: e.isFunder, + }).fitScore, + themes: e.themes.filter((t) => VALID_THEME_IDS.has(t)), purposeSummary: e.purposeSummary, researchNotes: e.researchNotes || '', isFunder: e.isFunder, @@ -166,4 +172,7 @@ async function main() { console.log(`\nDone: ${upserted} upserted, ${skipped} skipped, ${errors} errors`); } -main().catch(err => { console.error(err); process.exit(1); }); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/lib/contact-extractor.ts b/scripts/lib/contact-extractor.ts index 3149fd38..2a035f27 100644 --- a/scripts/lib/contact-extractor.ts +++ b/scripts/lib/contact-extractor.ts @@ -42,8 +42,8 @@ export function extractEmails(text: string): string[] { const matches = text.match(EMAIL_REGEX) || []; // Deduplicate and filter out blacklisted patterns - const unique = Array.from(new Set(matches.map(e => e.toLowerCase()))); - return unique.filter(email => { + const unique = Array.from(new Set(matches.map((e) => e.toLowerCase()))); + return unique.filter((email) => { // Filter out image/file extensions mistakenly captured if (/\.(png|jpg|jpeg|gif|svg|css|js|pdf)$/i.test(email)) return false; // Filter out blacklisted prefixes @@ -62,19 +62,22 @@ export function extractEmails(text: string): string[] { // +41 XX XXX XX XX, +41 (0)XX XXX XX XX // 0XX XXX XX XX, 0XX/XXX XX XX // Also accepts dots and dashes as separators -const SWISS_PHONE_REGEX = /(?:\+41|0041)[\s.]?\(?\d?\)?[\s./\-]?\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}|0\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}/g; +const SWISS_PHONE_REGEX = + /(?:\+41|0041)[\s.]?\(?\d?\)?[\s./\-]?\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}|0\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}/g; export function extractPhones(text: string): string[] { const matches = text.match(SWISS_PHONE_REGEX) || []; // Normalize: collapse whitespace, standardize format - const normalized = matches.map(p => { - // Remove all whitespace/separators to get digits - const digits = p.replace(/[^\d+]/g, ''); - // Skip if too short (false positive) - if (digits.replace('+', '').length < 10) return null; - return p.trim(); - }).filter(Boolean) as string[]; + const normalized = matches + .map((p) => { + // Remove all whitespace/separators to get digits + const digits = p.replace(/[^\d+]/g, ''); + // Skip if too short (false positive) + if (digits.replace('+', '').length < 10) return null; + return p.trim(); + }) + .filter(Boolean) as string[]; return Array.from(new Set(normalized)); } @@ -124,7 +127,9 @@ const EMAIL_METHOD_KEYWORDS = [ 'par courriel', ]; -export function detectApplicationMethod(text: string): 'online' | 'email' | 'invitation' | 'unknown' { +export function detectApplicationMethod( + text: string, +): 'online' | 'email' | 'invitation' | 'unknown' { const lower = text.toLowerCase(); // Check invitation first — it's the strongest signal @@ -156,8 +161,8 @@ function parseSwissAmount(raw: string): number | null { // Extract just the numeric part const numStr = raw .replace(/CHF|Fr\.?|Franken/gi, '') - .replace(/['\s]/g, '') // Swiss thousand separator is apostrophe - .replace(/,(\d{2})$/, '.$1') // Convert comma decimal to dot + .replace(/['\s]/g, '') // Swiss thousand separator is apostrophe + .replace(/,(\d{2})$/, '.$1') // Convert comma decimal to dot .trim(); const num = parseFloat(numStr); @@ -169,10 +174,21 @@ export function extractGrantRange(text: string): { min?: number; max?: number } // Only look near funding-related context const fundingContext = text.toLowerCase(); const hasFundingKeywords = [ - 'beitrag', 'beiträge', 'förder', 'unterstütz', 'zuwendung', - 'grant', 'contribution', 'subvention', 'betrag', - 'minimum', 'maximum', 'höchstens', 'mindestens', 'bis zu', - ].some(kw => fundingContext.includes(kw)); + 'beitrag', + 'beiträge', + 'förder', + 'unterstütz', + 'zuwendung', + 'grant', + 'contribution', + 'subvention', + 'betrag', + 'minimum', + 'maximum', + 'höchstens', + 'mindestens', + 'bis zu', + ].some((kw) => fundingContext.includes(kw)); if (!hasFundingKeywords) return {}; diff --git a/scripts/lib/directory-fetcher.ts b/scripts/lib/directory-fetcher.ts index de99d6e1..5f041c65 100644 --- a/scripts/lib/directory-fetcher.ts +++ b/scripts/lib/directory-fetcher.ts @@ -12,12 +12,21 @@ const SPHERIQ_BASE = 'https://app.spheriq.ch/organisation'; const SPHERIQ_PHONE = '+41 61 278 93 83'; // Spheriq's own phone, filter it const NOISE_EMAILS = new Set([ - 'office@spheriq.ch', 'support@spheriq.ch', 'kontakt@freihandlabor.com', + 'office@spheriq.ch', + 'support@spheriq.ch', + 'kontakt@freihandlabor.com', ]); const NOISE_DOMAINS = new Set([ - 'spheriq.ch', 'stiftungschweiz.ch', 'google.com', 'facebook.com', - 'twitter.com', 'linkedin.com', 'sentry.io', 'wixpress.com', - 'example.com', 'wordpress.com', + 'spheriq.ch', + 'stiftungschweiz.ch', + 'google.com', + 'facebook.com', + 'twitter.com', + 'linkedin.com', + 'sentry.io', + 'wixpress.com', + 'example.com', + 'wordpress.com', ]); export interface DirectoryResult { @@ -57,14 +66,21 @@ function parseSpheriqHtml(html: string): Omit p.trim()))].filter(p => p !== SPHERIQ_PHONE); + const phoneMatches = + html.match( + /(?:\+41|0041)[\s.]?\(?\d?\)?[\s./\-]?\d{2}[\s./\-]?\d{3}[\s./\-]?\d{2}[\s./\-]?\d{2}/g, + ) || []; + const unique = [...new Set(phoneMatches.map((p) => p.trim()))].filter((p) => p !== SPHERIQ_PHONE); if (unique.length > 0) result.phone = unique[0]; return result; @@ -87,7 +103,10 @@ async function fetchWithTimeout(url: string, timeoutMs = 8000): Promise { +export async function fetchSpheriqProfile( + slug: string, + uid?: string, +): Promise { // Try direct slug match const directRes = await fetchWithTimeout(`${SPHERIQ_BASE}/${slug}`); if (directRes?.ok) { @@ -106,7 +125,9 @@ export async function fetchSpheriqProfile(slug: string, uid?: string): Promise callGroqOnce(link.model, apiKey, systemPrompt, userPrompt, callOnceOptions), + attempt: (link) => + callGroqOnce(link.model, apiKey, systemPrompt, userPrompt, callOnceOptions), }); return { ok: true, content, usage }; } catch (err) { diff --git a/scripts/lib/research-types.ts b/scripts/lib/research-types.ts index 522f0662..81039d86 100644 --- a/scripts/lib/research-types.ts +++ b/scripts/lib/research-types.ts @@ -25,8 +25,22 @@ export const ResearchAnalysisSchema = z.object({ suggestedFit: z.number().int().min(0).max(3), // 0 = not assessed (bulk enrichment) suggestedPriority: z.number().int().min(1).max(4), purposeSummary: z.string().min(20), // relaxed for auto-generated summaries - researchNotes: z.string().min(20), // relaxed for auto-generated notes - applicationMethod: z.enum(['online', 'email', 'invitation', 'unknown', 'post', 'contact', 'direct', 'personal', 'partnership', 'via_partner', 'membership', 'contract', 'none']), + researchNotes: z.string().min(20), // relaxed for auto-generated notes + applicationMethod: z.enum([ + 'online', + 'email', + 'invitation', + 'unknown', + 'post', + 'contact', + 'direct', + 'personal', + 'partnership', + 'via_partner', + 'membership', + 'contract', + 'none', + ]), contactInfo: z.object({ email: z.string().optional(), phone: z.string().optional(), @@ -67,12 +81,14 @@ export const ResearchQueueItemSchema = z.object({ foundVia: z.array(z.string()).optional(), url: z.string().optional(), }), - scores: z.object({ - funderScore: z.number(), - operatorScore: z.number(), - socialScore: z.number(), - confidence: z.enum(['high', 'medium', 'low']), - }).optional(), + scores: z + .object({ + funderScore: z.number(), + operatorScore: z.number(), + socialScore: z.number(), + confidence: z.enum(['high', 'medium', 'low']), + }) + .optional(), }); export type ResearchQueueItem = z.infer; @@ -81,14 +97,16 @@ export type ResearchQueueItem = z.infer; // RESEARCH DRAFT — Full draft ready for human review // ============================================================================ -export const ResearchDraftSchema = z.object({ - slug: z.string(), - name: z.string(), - timestamp: z.string(), - queueItem: ResearchQueueItemSchema, - websiteContent: z.string().optional(), - analysis: ResearchAnalysisSchema, -}).passthrough(); // Allow _meta from batch-generated drafts +export const ResearchDraftSchema = z + .object({ + slug: z.string(), + name: z.string(), + timestamp: z.string(), + queueItem: ResearchQueueItemSchema, + websiteContent: z.string().optional(), + analysis: ResearchAnalysisSchema, + }) + .passthrough(); // Allow _meta from batch-generated drafts export type ResearchDraft = z.infer; diff --git a/scripts/lib/source-reconciler.ts b/scripts/lib/source-reconciler.ts index 5a15ed19..b192a77f 100644 --- a/scripts/lib/source-reconciler.ts +++ b/scripts/lib/source-reconciler.ts @@ -8,8 +8,8 @@ export interface ContactCandidate { email?: string; phone?: string; - source: string; // e.g., 'spheriq', 'website:/kontakt', 'website:/impressum' - sourceUrl: string; // the URL where this was found + source: string; // e.g., 'spheriq', 'website:/kontakt', 'website:/impressum' + sourceUrl: string; // the URL where this was found } export type Confidence = 'high' | 'medium' | 'low'; @@ -22,13 +22,21 @@ export interface ReconciledContact { phoneSource?: string; phoneConfidence?: Confidence; allSources: ContactCandidate[]; - conflicts: string[]; // descriptions of disagreements + conflicts: string[]; // descriptions of disagreements } // Known garbage patterns const GARBAGE_EMAIL = /sentry|wixpress|gta-design|\.png|\.jpg|\.webp|\.css|\.js|[{}\\]/i; const GARBAGE_PHONE = /\+41 000|\+41 123456|00000|99999/; -const PREFERRED_PREFIXES = ['info@', 'kontakt@', 'contact@', 'stiftung@', 'sekretariat@', 'office@', 'mail@']; +const PREFERRED_PREFIXES = [ + 'info@', + 'kontakt@', + 'contact@', + 'stiftung@', + 'sekretariat@', + 'office@', + 'mail@', +]; function isGarbageEmail(email: string): boolean { return GARBAGE_EMAIL.test(email) || email.length < 5 || email.length > 60 || !email.includes('@'); @@ -41,7 +49,7 @@ function isGarbagePhone(phone: string): boolean { /** Score an email for preference (lower = better) */ function emailPreferenceScore(email: string): number { const lower = email.toLowerCase(); - const idx = PREFERRED_PREFIXES.findIndex(p => lower.startsWith(p)); + const idx = PREFERRED_PREFIXES.findIndex((p) => lower.startsWith(p)); return idx >= 0 ? idx : PREFERRED_PREFIXES.length; } @@ -59,30 +67,32 @@ export function reconcileContacts(candidates: ContactCandidate[]): ReconciledCon // Collect all non-garbage emails with their sources const emails = candidates - .filter(c => c.email && !isGarbageEmail(c.email)) - .map(c => ({ email: c.email!.toLowerCase(), source: c.source, sourceUrl: c.sourceUrl })); + .filter((c) => c.email && !isGarbageEmail(c.email)) + .map((c) => ({ email: c.email!.toLowerCase(), source: c.source, sourceUrl: c.sourceUrl })); // Collect all non-garbage phones const phones = candidates - .filter(c => c.phone && !isGarbagePhone(c.phone)) - .map(c => ({ phone: c.phone!, source: c.source, sourceUrl: c.sourceUrl })); + .filter((c) => c.phone && !isGarbagePhone(c.phone)) + .map((c) => ({ phone: c.phone!, source: c.source, sourceUrl: c.sourceUrl })); // Reconcile emails if (emails.length > 0) { - const uniqueEmails = [...new Set(emails.map(e => e.email))]; + const uniqueEmails = [...new Set(emails.map((e) => e.email))]; if (uniqueEmails.length === 1) { // All sources agree result.email = uniqueEmails[0]; - result.emailSource = emails.map(e => e.source).join('+'); + result.emailSource = emails.map((e) => e.source).join('+'); result.emailConfidence = emails.length > 1 ? 'high' : 'medium'; } else { // Sources disagree — pick best, flag conflict result.conflicts.push( - `Email conflict: ${uniqueEmails.map((e, _i) => `${e} (${emails.find(x => x.email === e)!.source})`).join(' vs ')}` + `Email conflict: ${uniqueEmails.map((e, _i) => `${e} (${emails.find((x) => x.email === e)!.source})`).join(' vs ')}`, ); // Pick by preference (info@ > kontakt@ > first found) - const sorted = emails.sort((a, b) => emailPreferenceScore(a.email) - emailPreferenceScore(b.email)); + const sorted = emails.sort( + (a, b) => emailPreferenceScore(a.email) - emailPreferenceScore(b.email), + ); result.email = sorted[0].email; result.emailSource = sorted[0].source; result.emailConfidence = 'low'; @@ -93,15 +103,15 @@ export function reconcileContacts(candidates: ContactCandidate[]): ReconciledCon if (phones.length > 0) { // Normalize for comparison const normalize = (p: string) => p.replace(/[\s.\-()]/g, ''); - const uniquePhones = [...new Set(phones.map(p => normalize(p.phone)))]; + const uniquePhones = [...new Set(phones.map((p) => normalize(p.phone)))]; if (uniquePhones.length === 1) { result.phone = phones[0].phone; - result.phoneSource = phones.map(p => p.source).join('+'); + result.phoneSource = phones.map((p) => p.source).join('+'); result.phoneConfidence = phones.length > 1 ? 'high' : 'medium'; } else { result.conflicts.push( - `Phone conflict: ${phones.map(p => `${p.phone} (${p.source})`).join(' vs ')}` + `Phone conflict: ${phones.map((p) => `${p.phone} (${p.source})`).join(' vs ')}`, ); result.phone = phones[0].phone; result.phoneSource = phones[0].source; diff --git a/scripts/lib/theme-classifier.ts b/scripts/lib/theme-classifier.ts index 2e36995f..58a59473 100644 --- a/scripts/lib/theme-classifier.ts +++ b/scripts/lib/theme-classifier.ts @@ -16,52 +16,122 @@ import type { ThemeId } from '../../src/lib/schemas/foundation'; export const THEME_RULES: Record = { arbeitsintegration: { - keywords: ['arbeitsintegration', 'berufliche integration', 'arbeitsmarkt', - 'wiedereingliederung', 'beschäftigungsprogramm', 'arbeitstraining', - 'langzeitarbeitslos', 'sozialfirma', 'sozialer betrieb', - 'berufseinstieg', 'lehrstelle', 'berufsbildung', 'berufsausbildung', - 'umschulung', 'weiterbildung', 'qualifizierung', - 'sozialer arbeitsmarkt', 'beschäftigungsmassnahme', - 'sozialunternehmen', 'werkstätte'], + keywords: [ + 'arbeitsintegration', + 'berufliche integration', + 'arbeitsmarkt', + 'wiedereingliederung', + 'beschäftigungsprogramm', + 'arbeitstraining', + 'langzeitarbeitslos', + 'sozialfirma', + 'sozialer betrieb', + 'berufseinstieg', + 'lehrstelle', + 'berufsbildung', + 'berufsausbildung', + 'umschulung', + 'weiterbildung', + 'qualifizierung', + 'sozialer arbeitsmarkt', + 'beschäftigungsmassnahme', + 'sozialunternehmen', + 'werkstätte', + ], weight: 3, }, kreislaufwirtschaft: { - keywords: ['kreislaufwirtschaft', 'recycling', 'wiederverwend', 'reparatur', - 'refurbish', 'ressourcenschon', 'zirkulär', 'elektroschrott', 'e-waste', - 'secondhand', 'second-hand', 'wiederaufbereitung', 'abfallvermeidung', - 'zero waste', 'materialkreislauf'], + keywords: [ + 'kreislaufwirtschaft', + 'recycling', + 'wiederverwend', + 'reparatur', + 'refurbish', + 'ressourcenschon', + 'zirkulär', + 'elektroschrott', + 'e-waste', + 'secondhand', + 'second-hand', + 'wiederaufbereitung', + 'abfallvermeidung', + 'zero waste', + 'materialkreislauf', + ], weight: 3, }, 'soziale-integration': { - keywords: ['soziale integration', 'sozial benachteiligt', - 'migrant', 'migration', 'flüchtling', 'asyl', - 'marginalisiert', 'randgruppen', - 'existenzsicher', 'sozialhilfeempfänger', - 'arbeitsintegration', 'berufliche integration', - 'soziale teilhabe', 'chancengleichheit', - 'inklusion', 'teilhabe', 'benachteiligt', - 'armut', 'existenzsicherung'], + keywords: [ + 'soziale integration', + 'sozial benachteiligt', + 'migrant', + 'migration', + 'flüchtling', + 'asyl', + 'marginalisiert', + 'randgruppen', + 'existenzsicher', + 'sozialhilfeempfänger', + 'arbeitsintegration', + 'berufliche integration', + 'soziale teilhabe', + 'chancengleichheit', + 'inklusion', + 'teilhabe', + 'benachteiligt', + 'armut', + 'existenzsicherung', + ], weight: 2, }, 'digitale-bildung': { - keywords: ['informatik', 'programmier', 'software', - 'medienkompetenz', 'it-kompetenz', 'digital literacy', - 'digitale kompetenz', 'digitale bildung', - 'ict', 'informationstechnologie', 'mint', 'stem', - 'digitale teilhabe', 'it-bildung', 'it-ausbildung'], + keywords: [ + 'informatik', + 'programmier', + 'software', + 'medienkompetenz', + 'it-kompetenz', + 'digital literacy', + 'digitale kompetenz', + 'digitale bildung', + 'ict', + 'informationstechnologie', + 'mint', + 'stem', + 'digitale teilhabe', + 'it-bildung', + 'it-ausbildung', + ], weight: 2, }, 'digitale-souveraenitaet': { - keywords: ['open source', 'quelloffen', 'linux', 'datensouveränität', - 'digitale selbstbestimmung', 'freie software', 'open data', - 'datensicher', 'souverän'], + keywords: [ + 'open source', + 'quelloffen', + 'linux', + 'datensouveränität', + 'digitale selbstbestimmung', + 'freie software', + 'open data', + 'datensicher', + 'souverän', + ], weight: 3, }, klima: { - keywords: ['klima', 'klimaschutz', 'klimawandel', - 'co2', 'kohlendioxid', 'treibhausgas', 'emission', - 'erneuerbar', 'erneuerbare energie', - 'dekarbonisier', 'energiewende'], + keywords: [ + 'klima', + 'klimaschutz', + 'klimawandel', + 'co2', + 'kohlendioxid', + 'treibhausgas', + 'emission', + 'erneuerbar', + 'erneuerbare energie', + 'dekarbonisier', + 'energiewende', + ], weight: 1, }, zuerich: { @@ -79,36 +149,51 @@ export function classifyThemes(purposeLower: string, nameLower: string): ThemeId const fullText = `${nameLower} ${purposeLower}`; for (const [theme, rule] of Object.entries(THEME_RULES)) { - if (rule.keywords.some(kw => fullText.includes(kw))) { + if (rule.keywords.some((kw) => fullText.includes(kw))) { themes.push(theme); } } // Compound: education + digital → digitale-bildung (STRICT: must be IT/tech education) if (!themes.includes('digitale-bildung')) { - const hasEdu = ['bildung', 'ausbildung', 'weiterbildung', 'berufsbildung', 'schulung'].some(kw => fullText.includes(kw)); - const hasDig = ['informatik', 'computer', 'it-', 'software', 'programmier', 'coding'].some(kw => fullText.includes(kw)); + const hasEdu = ['bildung', 'ausbildung', 'weiterbildung', 'berufsbildung', 'schulung'].some( + (kw) => fullText.includes(kw), + ); + const hasDig = ['informatik', 'computer', 'it-', 'software', 'programmier', 'coding'].some( + (kw) => fullText.includes(kw), + ); if (hasEdu && hasDig) themes.push('digitale-bildung'); } // Compound: work + integration → arbeitsintegration (STRICT: must mention integration/reintegration) if (!themes.includes('arbeitsintegration')) { - const hasWork = ['beruf', 'arbeit', 'erwerbstätig', 'beschäftig'].some(kw => fullText.includes(kw)); - const hasInt = ['arbeitsintegration', 'berufliche integration', 'eingliederung', 'wiedereingliederung'].some(kw => fullText.includes(kw)); + const hasWork = ['beruf', 'arbeit', 'erwerbstätig', 'beschäftig'].some((kw) => + fullText.includes(kw), + ); + const hasInt = [ + 'arbeitsintegration', + 'berufliche integration', + 'eingliederung', + 'wiedereingliederung', + ].some((kw) => fullText.includes(kw)); if (hasWork && hasInt) themes.push('arbeitsintegration'); } // Compound: beschäftigung + integration → arbeitsintegration (STRICT) if (!themes.includes('arbeitsintegration')) { const hasBeschaeftigung = fullText.includes('beschäftigung'); - const hasInt = ['integration', 'eingliederung', 'massnahme'].some(kw => fullText.includes(kw)); + const hasInt = ['integration', 'eingliederung', 'massnahme'].some((kw) => + fullText.includes(kw), + ); if (hasBeschaeftigung && hasInt) themes.push('arbeitsintegration'); } // Compound: nachhaltigkeit + consumption/resource signal → kreislaufwirtschaft if (!themes.includes('kreislaufwirtschaft')) { const hasNachhaltigkeit = fullText.includes('nachhaltigkeit'); - const hasResourceSignal = ['konsum', 'ressource', 'produkt', 'kreislauf'].some(kw => fullText.includes(kw)); + const hasResourceSignal = ['konsum', 'ressource', 'produkt', 'kreislauf'].some((kw) => + fullText.includes(kw), + ); if (hasNachhaltigkeit && hasResourceSignal) themes.push('kreislaufwirtschaft'); } @@ -124,27 +209,56 @@ export function classifyThemes(purposeLower: string, nameLower: string): ThemeId // ============================================================================ const FUNDER_KW = [ - 'fördert', 'förderung', 'unterstützt', 'unterstützung', - 'beiträge', 'zuwendungen', 'zuschüsse', 'finanzielle hilfe', - 'projektförderung', 'stipend', 'ausricht', - 'gewährt', 'vergab', 'vergibt', 'zusprech', - 'finanziell', 'gemeinnützig', + 'fördert', + 'förderung', + 'unterstützt', + 'unterstützung', + 'beiträge', + 'zuwendungen', + 'zuschüsse', + 'finanzielle hilfe', + 'projektförderung', + 'stipend', + 'ausricht', + 'gewährt', + 'vergab', + 'vergibt', + 'zusprech', + 'finanziell', + 'gemeinnützig', ]; const OPERATOR_KW = [ - 'betreibt', 'führt', 'unterhält', 'verwaltet', - 'betrieb von', 'heim', 'klinik', 'spital', - 'pflegeheim', 'altersheim', 'werkstatt', 'wohnheim', - 'kinderheim', 'tagesstätte', 'krippe', 'trägerin', - 'museum', 'theater', + 'betreibt', + 'führt', + 'unterhält', + 'verwaltet', + 'betrieb von', + 'heim', + 'klinik', + 'spital', + 'pflegeheim', + 'altersheim', + 'werkstatt', + 'wohnheim', + 'kinderheim', + 'tagesstätte', + 'krippe', + 'trägerin', + 'museum', + 'theater', ]; export function scoreFunderOperator(purpose: string): { funder: number; operator: number } { const p = purpose.toLowerCase(); let funder = 0; let operator = 0; - for (const kw of FUNDER_KW) { if (p.includes(kw)) funder++; } - for (const kw of OPERATOR_KW) { if (p.includes(kw)) operator++; } + for (const kw of FUNDER_KW) { + if (p.includes(kw)) funder++; + } + for (const kw of OPERATOR_KW) { + if (p.includes(kw)) operator++; + } return { funder, operator }; } @@ -155,7 +269,9 @@ export function scoreFunderOperator(purpose: string): { funder: number; operator export function toSlug(name: string): string { return name .toLowerCase() - .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 60); @@ -165,11 +281,29 @@ export function toSlug(name: string): string { // APPLICATION METHOD DETECTION // ============================================================================ -export function detectApplicationMethod(purpose: string): 'online' | 'email' | 'invitation' | 'unknown' { +export function detectApplicationMethod( + purpose: string, +): 'online' | 'email' | 'invitation' | 'unknown' { const p = purpose.toLowerCase(); - if (p.includes('online') && (p.includes('gesuch') || p.includes('antrag') || p.includes('bewerbung'))) return 'online'; - if (p.includes('auf einladung') || p.includes('nur auf') || p.includes('kein gesuch') || p.includes('keine bewerbung')) return 'invitation'; - if (p.includes('schriftlich') || p.includes('per mail') || p.includes('per post') || p.includes('per e-mail')) return 'email'; + if ( + p.includes('online') && + (p.includes('gesuch') || p.includes('antrag') || p.includes('bewerbung')) + ) + return 'online'; + if ( + p.includes('auf einladung') || + p.includes('nur auf') || + p.includes('kein gesuch') || + p.includes('keine bewerbung') + ) + return 'invitation'; + if ( + p.includes('schriftlich') || + p.includes('per mail') || + p.includes('per post') || + p.includes('per e-mail') + ) + return 'email'; return 'unknown'; } @@ -186,18 +320,36 @@ export function classifyType( const p = purpose.toLowerCase(); const n = name.toLowerCase(); - if (n.includes('ag') || n.includes('group') || n.includes('corporate') || n.includes('company')) return 'D'; - if (n.includes('verband') || n.includes('verein') || n.includes('netzwerk') || n.includes('dachverband')) return 'network'; + if (n.includes('ag') || n.includes('group') || n.includes('corporate') || n.includes('company')) + return 'D'; + if ( + n.includes('verband') || + n.includes('verein') || + n.includes('netzwerk') || + n.includes('dachverband') + ) + return 'network'; - if (funderScore >= 3 && purpose.length > 200 && ( - p.includes('förderrichtlinien') || p.includes('programm') || - p.includes('bewerbungsverfahren') || p.includes('ausschreibung') || p.includes('gesuche') - )) return 'A'; + if ( + funderScore >= 3 && + purpose.length > 200 && + (p.includes('förderrichtlinien') || + p.includes('programm') || + p.includes('bewerbungsverfahren') || + p.includes('ausschreibung') || + p.includes('gesuche')) + ) + return 'A'; - if (funderScore >= 2 && purpose.length > 100 && ( - p.includes('famili') || n.includes('famili') || - /\b[A-Z][a-z]+-[A-Z][a-z]+\b/.test(name) || n.includes('-stiftung') - )) return 'B'; + if ( + funderScore >= 2 && + purpose.length > 100 && + (p.includes('famili') || + n.includes('famili') || + /\b[A-Z][a-z]+-[A-Z][a-z]+\b/.test(name) || + n.includes('-stiftung')) + ) + return 'B'; if (operatorScore >= 3) return 'network'; return purpose.length > 200 && funderScore >= 2 ? 'B' : 'C'; diff --git a/scripts/lib/web-extract.ts b/scripts/lib/web-extract.ts index 51ecab34..c2de90b1 100644 --- a/scripts/lib/web-extract.ts +++ b/scripts/lib/web-extract.ts @@ -45,7 +45,9 @@ export function fetchRaw(url: string, redirectCount = 0): Promise { const absolute = redirectUrl.startsWith('http') ? redirectUrl : new URL(redirectUrl, url).href; - fetchRaw(absolute, redirectCount + 1).then(resolve).catch(reject); + fetchRaw(absolute, redirectCount + 1) + .then(resolve) + .catch(reject); return; } } @@ -56,7 +58,9 @@ export function fetchRaw(url: string, redirectCount = 0): Promise { } let data = ''; - response.on('data', (chunk: Buffer) => { data += chunk; }); + response.on('data', (chunk: Buffer) => { + data += chunk; + }); response.on('end', () => resolve(data)); }); @@ -72,24 +76,26 @@ export function fetchRaw(url: string, redirectCount = 0): Promise { * Strip HTML tags, scripts, styles, and normalize whitespace. */ export function stripHtml(html: string): string { - return html - // Remove script/style blocks entirely - .replace(/]*>[\s\S]*?<\/script>/gi, '') - .replace(/]*>[\s\S]*?<\/style>/gi, '') - // Remove HTML comments - .replace(//g, '') - // Remove all remaining tags - .replace(/<[^>]+>/g, ' ') - // Decode common HTML entities - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - // Collapse whitespace - .replace(/\s+/g, ' ') - .trim(); + return ( + html + // Remove script/style blocks entirely + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + // Remove HTML comments + .replace(//g, '') + // Remove all remaining tags + .replace(/<[^>]+>/g, ' ') + // Decode common HTML entities + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + // Collapse whitespace + .replace(/\s+/g, ' ') + .trim() + ); } /** @@ -98,7 +104,12 @@ export function stripHtml(html: string): string { */ export function headRequest(url: string, redirectCount = 0): Promise { if (redirectCount > 5) { - return Promise.resolve({ ok: false, statusCode: 0, finalUrl: url, error: 'Too many redirects' }); + return Promise.resolve({ + ok: false, + statusCode: 0, + finalUrl: url, + error: 'Too many redirects', + }); } const client = url.startsWith('https') ? https : http; @@ -130,7 +141,12 @@ export function headRequest(url: string, redirectCount = 0): Promise }); req.on('timeout', () => { req.destroy(); - resolve({ ok: false, statusCode: 0, finalUrl: url, error: `Timeout after ${HEAD_TIMEOUT_MS}ms` }); + resolve({ + ok: false, + statusCode: 0, + finalUrl: url, + error: `Timeout after ${HEAD_TIMEOUT_MS}ms`, + }); }); req.end(); }); diff --git a/scripts/lib/web-search.ts b/scripts/lib/web-search.ts index e25edb4d..29dc556c 100644 --- a/scripts/lib/web-search.ts +++ b/scripts/lib/web-search.ts @@ -36,7 +36,9 @@ export async function searchFoundationWebsite( } // No search API configured — degrade gracefully - console.warn(' [search] No search API configured (set BRAVE_API_KEY or GOOGLE_API_KEY+GOOGLE_CX)'); + console.warn( + ' [search] No search API configured (set BRAVE_API_KEY or GOOGLE_API_KEY+GOOGLE_CX)', + ); return []; } @@ -50,14 +52,16 @@ async function braveSearch(query: string, maxResults: number): Promise } }; - return (data.web?.results || []).map(r => ({ + const data = (await res.json()) as { + web?: { results?: Array<{ title: string; url: string; description: string }> }; + }; + return (data.web?.results || []).map((r) => ({ title: r.title, url: r.url, description: r.description, @@ -81,8 +85,10 @@ async function googleSearch(query: string, maxResults: number): Promise }; - return (data.items || []).map(r => ({ + const data = (await res.json()) as { + items?: Array<{ title: string; link: string; snippet: string }>; + }; + return (data.items || []).map((r) => ({ title: r.title, url: r.link, description: r.snippet, diff --git a/scripts/lib/website-verifier.ts b/scripts/lib/website-verifier.ts index 6c82b338..9bf8ff87 100644 --- a/scripts/lib/website-verifier.ts +++ b/scripts/lib/website-verifier.ts @@ -65,8 +65,8 @@ export async function verifyWebsite( .replace(/stiftung|foundation|fondation|fondazione/gi, '') .trim() .split(/[\s-]+/) - .filter(w => w.length > 2); - const hasNameInTitle = nameWords.some(w => titleLower.includes(w)); + .filter((w) => w.length > 2); + const hasNameInTitle = nameWords.some((w) => titleLower.includes(w)); const hasStiftungInTitle = /stiftung|foundation|fondation|fondazione/i.test(titleLower); if (hasNameInTitle && hasStiftungInTitle) { @@ -110,7 +110,9 @@ ${contentPreview}`; return { url: candidateUrl, verified: hasNameInTitle, - reason: hasNameInTitle ? 'Name im Titel gefunden (LLM nicht verfügbar)' : 'Konnte nicht verifiziert werden', + reason: hasNameInTitle + ? 'Name im Titel gefunden (LLM nicht verfügbar)' + : 'Konnte nicht verifiziert werden', pageTitle, }; } diff --git a/scripts/pipeline-graduate.ts b/scripts/pipeline-graduate.ts index 88f462be..6772a199 100644 --- a/scripts/pipeline-graduate.ts +++ b/scripts/pipeline-graduate.ts @@ -58,11 +58,13 @@ import { ThemeId } from '../src/lib/schemas/foundation'; const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const INGEST_ONLY = args.includes('--ingest-only'); -const PHASE_ARG = parseInt(args.find(a => a.startsWith('--phase='))?.split('=')[1] || '0', 10); -const LIMIT = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '0', 10) || Infinity; -const DELAY_MS = parseInt(args.find(a => a.startsWith('--delay='))?.split('=')[1] || '2000', 10); -const MODEL_ARG = args.find(a => a.startsWith('--model='))?.split('=')[1] || ''; -const GROQ_MODEL_OVERRIDE = MODEL_ARG === '8b' ? GROQ_MODELS['8b'] : MODEL_ARG === '70b' ? GROQ_MODELS['70b'] : undefined; +const PHASE_ARG = parseInt(args.find((a) => a.startsWith('--phase='))?.split('=')[1] || '0', 10); +const LIMIT = + parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10) || Infinity; +const DELAY_MS = parseInt(args.find((a) => a.startsWith('--delay='))?.split('=')[1] || '2000', 10); +const MODEL_ARG = args.find((a) => a.startsWith('--model='))?.split('=')[1] || ''; +const GROQ_MODEL_OVERRIDE = + MODEL_ARG === '8b' ? GROQ_MODELS['8b'] : MODEL_ARG === '70b' ? GROQ_MODELS['70b'] : undefined; // ============================================================================ // TYPES @@ -113,7 +115,7 @@ async function phase1KeywordScreen(sql: SqlClient): Promise { console.log('\n━━━ Phase 1: Keyword Screen (0 tokens) ━━━━━━━━━━━━━━━━━━━━━━━'); // Read rapid foundations that have officialPurpose - const rows = await sql` + const rows = (await sql` SELECT f.id, f.name, @@ -126,14 +128,12 @@ async function phase1KeywordScreen(sql: SqlClient): Promise { WHERE f.research_depth = 'rapid' AND f.archived = false ORDER BY f.id - ` as RapidFoundation[]; + `) as RapidFoundation[]; console.log(` Rapid foundations in DB: ${rows.length}`); // Filter: must have some purpose text to screen - const withPurpose = rows.filter(r => - r.official_purpose && r.official_purpose.length > 30 - ); + const withPurpose = rows.filter((r) => r.official_purpose && r.official_purpose.length > 30); console.log(` With officialPurpose (>30 chars): ${withPurpose.length}`); // Keyword classify each one @@ -175,7 +175,11 @@ async function phase1KeywordScreen(sql: SqlClient): Promise { // Compute fit score const applicationMethod = detectApplicationMethod(purpose); const { fitScore } = computeFitScore({ - themes, canton, city, applicationMethod, isFunder, + themes, + canton, + city, + applicationMethod, + isFunder, }); candidates.push({ @@ -196,7 +200,7 @@ async function phase1KeywordScreen(sql: SqlClient): Promise { // Deduplicate by slug (LEFT JOIN can produce duplicates) const seen = new Set(); const duplicates = candidates.length; - const deduped = candidates.filter(c => { + const deduped = candidates.filter((c) => { if (seen.has(c.slug)) return false; seen.add(c.slug); return true; @@ -217,9 +221,9 @@ async function phase1KeywordScreen(sql: SqlClient): Promise { console.log(` Candidates surviving keyword screen: ${deduped.length}`); // Stats breakdown - const withThemes = deduped.filter(c => c.themes.length > 0).length; - const highFit = deduped.filter(c => c.fitScore >= 4).length; - const funderCount = deduped.filter(c => c.isFunder).length; + const withThemes = deduped.filter((c) => c.themes.length > 0).length; + const highFit = deduped.filter((c) => c.fitScore >= 4).length; + const funderCount = deduped.filter((c) => c.isFunder).length; console.log(` With themes: ${withThemes}`); console.log(` FitScore ≥ 4: ${highFit}`); @@ -232,14 +236,21 @@ async function phase1KeywordScreen(sql: SqlClient): Promise { const phase1File = path.join(outDir, `phase1-${today}.json`); if (!DRY_RUN) { - fs.writeFileSync(phase1File, JSON.stringify({ - date: today, - totalRapid: rows.length, - withPurpose: withPurpose.length, - eliminated: eliminated + operativeOnly, - candidates: deduped.length, - items: deduped, - }, null, 2)); + fs.writeFileSync( + phase1File, + JSON.stringify( + { + date: today, + totalRapid: rows.length, + withPurpose: withPurpose.length, + eliminated: eliminated + operativeOnly, + candidates: deduped.length, + items: deduped, + }, + null, + 2, + ), + ); console.log(` Results written to: ${phase1File}`); } @@ -259,18 +270,25 @@ function saveResults(outDir: string, today: string, results: TriageResult[]): vo if (fs.existsSync(resultsFile)) { const existing = JSON.parse(fs.readFileSync(resultsFile, 'utf-8')) as { items: TriageResult[] }; const existingSlugs = new Set(existing.items.map((r: TriageResult) => r.slug)); - const newResults = results.filter(r => !existingSlugs.has(r.slug)); + const newResults = results.filter((r) => !existingSlugs.has(r.slug)); allResults = [...existing.items, ...newResults]; } - fs.writeFileSync(resultsFile, JSON.stringify({ - date: today, - total: allResults.length, - items: allResults, - }, null, 2)); + fs.writeFileSync( + resultsFile, + JSON.stringify( + { + date: today, + total: allResults.length, + items: allResults, + }, + null, + 2, + ), + ); } const AVAILABLE_THEMES = Object.values(THEMES) - .map(t => ` "${t.id}": ${t.label} — ${t.description}`) + .map((t) => ` "${t.id}": ${t.label} — ${t.description}`) .join('\n'); const TRIAGE_SYSTEM_PROMPT = `Du bist ein Schweizer Stiftungsexperte. Bewerte STRENG, ob diese Stiftung für Revamp-IT relevant ist. @@ -326,7 +344,7 @@ WICHTIG: function buildTriagePrompt(candidate: ScreenCandidate): string { const lines = [ `# ${candidate.name}`, - `Bisherige Themen (Keyword): ${candidate.themes.map(t => THEME_LABELS[t] || t).join(', ') || 'keine'}`, + `Bisherige Themen (Keyword): ${candidate.themes.map((t) => THEME_LABELS[t] || t).join(', ') || 'keine'}`, `Keyword-FitScore: ${candidate.fitScore}/10`, `Funder-Signal: ${candidate.funderScore} (Operator: ${candidate.operatorScore})`, '', @@ -358,14 +376,15 @@ async function phase2LlmTriage( // Filter: only candidates worth an LLM call // Must have themes OR high funder score - const triageable = candidates.filter(c => - (c.themes.length > 0 || c.funderScore >= 2) && - !processedSlugs.has(c.slug) + const triageable = candidates.filter( + (c) => (c.themes.length > 0 || c.funderScore >= 2) && !processedSlugs.has(c.slug), ); const toProcess = triageable.slice(0, Math.min(triageable.length, LIMIT)); console.log(` Candidates to triage: ${triageable.length}`); - console.log(` Processing this run: ${toProcess.length}${LIMIT < Infinity ? ` (limit=${LIMIT})` : ''}`); + console.log( + ` Processing this run: ${toProcess.length}${LIMIT < Infinity ? ` (limit=${LIMIT})` : ''}`, + ); if (toProcess.length === 0) { console.log(' Nothing to process.'); @@ -395,10 +414,12 @@ async function phase2LlmTriage( const prompt = buildTriagePrompt(candidate); console.log(` ${progress} ${candidate.name}...`); - const groqResult = await callGroqJSON>( - TRIAGE_SYSTEM_PROMPT, prompt, - { maxTokens: 512, temperature: 0.2, timeoutMs: 30_000, model: GROQ_MODEL_OVERRIDE }, - ); + const groqResult = await callGroqJSON>(TRIAGE_SYSTEM_PROMPT, prompt, { + maxTokens: 512, + temperature: 0.2, + timeoutMs: 30_000, + model: GROQ_MODEL_OVERRIDE, + }); if (!groqResult.ok) { console.error(` ERROR: ${groqResult.error}`); @@ -407,7 +428,9 @@ async function phase2LlmTriage( if (groqResult.error?.includes('429') || groqResult.error?.includes('Rate limit')) { const isDaily = groqResult.error?.includes('per day') || groqResult.error?.includes('TPD'); if (isDaily) { - console.error(' Daily token limit reached. Stopping Phase 2. Try --model=8b or wait until tomorrow.'); + console.error( + ' Daily token limit reached. Stopping Phase 2. Try --model=8b or wait until tomorrow.', + ); break; } rateLimitHits++; @@ -432,8 +455,8 @@ async function phase2LlmTriage( // Extract and validate themes const rawThemes = Array.isArray(raw.themes) ? raw.themes : candidate.themes; - const validThemes = rawThemes.filter((t: unknown) => - typeof t === 'string' && VALID_THEME_IDS.has(t) + const validThemes = rawThemes.filter( + (t: unknown) => typeof t === 'string' && VALID_THEME_IDS.has(t), ) as string[]; // Compute real fitScore from LLM themes (algorithmic) @@ -450,7 +473,10 @@ async function phase2LlmTriage( // LLM's contextual assessment (0-3 scale → map to 0-10) // 0→0, 1→4, 2→6, 3→8 (conservative: LLM "3" doesn't auto-become 10) const LLM_FIT_MAP = [0, 4, 6, 8] as const; - const suggestedFit = typeof raw.suggestedFit === 'number' ? Math.min(3, Math.max(0, Math.round(raw.suggestedFit))) : 0; + const suggestedFit = + typeof raw.suggestedFit === 'number' + ? Math.min(3, Math.max(0, Math.round(raw.suggestedFit))) + : 0; const _llmScore = LLM_FIT_MAP[suggestedFit] ?? 0; // kept for reference; algo score used below // Use algorithmic score — LLM themes already feed into algo via computeFitScore. @@ -482,7 +508,9 @@ async function phase2LlmTriage( rateLimitHits = 0; // Reset after successful call const fitLabel = fitScore >= 7 ? '★★★' : fitScore >= 4 ? '★★☆' : '★☆☆'; - console.log(` → fit=${fitScore} ${fitLabel}, P${priority}, themes=[${validThemes.join(',')}], funder=${isFunder}`); + console.log( + ` → fit=${fitScore} ${fitLabel}, P${priority}, themes=[${validThemes.join(',')}], funder=${isFunder}`, + ); // Save progress every 25 results (avoid losing work on interrupts) if (!DRY_RUN && success % 25 === 0) { @@ -496,7 +524,9 @@ async function phase2LlmTriage( } } - console.log(`\n Phase 2 complete: ${success} triaged, ${errors} errors, ${tokensUsed} tokens used`); + console.log( + `\n Phase 2 complete: ${success} triaged, ${errors} errors, ${tokensUsed} tokens used`, + ); // Final save if (!DRY_RUN) { @@ -511,10 +541,7 @@ async function phase2LlmTriage( // PHASE 3: UPSERT + SYNC (close the loop) // ============================================================================ -async function phase3Upsert( - sql: SqlClient, - triageResults: TriageResult[], -): Promise { +async function phase3Upsert(sql: SqlClient, triageResults: TriageResult[]): Promise { console.log('\n━━━ Phase 3: Upsert + Sync ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); const now = new Date().toISOString(); @@ -622,7 +649,7 @@ function findPendingAutoResearch(): string[] { // Has results but not yet upserted if (fs.existsSync(summaryFile) && !fs.existsSync(upsertedMarker)) { // Check there are actual draft files (not just summary) - const drafts = fs.readdirSync(dir).filter(f => f.endsWith('.json') && !f.startsWith('_')); + const drafts = fs.readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('_')); if (drafts.length > 0) { pending.push(dir); } @@ -648,7 +675,6 @@ async function main() { process.exit(1); } - // Ingest-only mode: just close the loop on pending drafts if (INGEST_ONLY) { await phase3Upsert(sql, []); @@ -696,8 +722,8 @@ async function main() { console.log(` Phase 1: ${candidates.length} candidates from keyword screen`); } if (triageResults.length > 0) { - const highFit = triageResults.filter(r => r.fitScore >= 4).length; - const p1p2 = triageResults.filter(r => r.priority <= 2).length; + const highFit = triageResults.filter((r) => r.fitScore >= 4).length; + const p1p2 = triageResults.filter((r) => r.priority <= 2).length; console.log(` Phase 2: ${triageResults.length} triaged via LLM`); console.log(` FitScore ≥ 4: ${highFit}`); console.log(` P1 + P2: ${p1p2}`); diff --git a/scripts/pipeline-incremental.ts b/scripts/pipeline-incremental.ts index 755854c8..7abc60e8 100644 --- a/scripts/pipeline-incremental.ts +++ b/scripts/pipeline-incremental.ts @@ -62,7 +62,7 @@ import type { ResearchDepth } from './lib/utilities'; function generatePurposeSummary(entry: EsaEntry): string { const cleanPurpose = entry.purpose.replace(/\s+/g, ' ').trim(); const location = entry.canton ? `${entry.city}, Kanton ${entry.canton}` : entry.city || 'Schweiz'; - const clauses = cleanPurpose.split(/[.;]/).filter(s => s.trim().length > 20); + const clauses = cleanPurpose.split(/[.;]/).filter((s) => s.trim().length > 20); const firstClause = clauses[0]?.trim() || cleanPurpose.substring(0, 200); let summary = `${entry.name} (${location}): ${firstClause}.`; @@ -76,8 +76,12 @@ function generatePurposeSummary(entry: EsaEntry): string { if (third && third.length > 15) summary += ` ${third}.`; } if (summary.length < 150 && cleanPurpose.length > firstClause.length) { - const rest = cleanPurpose.substring(firstClause.length + 1).replace(/^[.;\s]+/, '').trim(); - if (rest.length > 10) summary += ` ${rest.substring(0, Math.max(150 - summary.length + 80, 120))}`; + const rest = cleanPurpose + .substring(firstClause.length + 1) + .replace(/^[.;\s]+/, '') + .trim(); + if (rest.length > 10) + summary += ` ${rest.substring(0, Math.max(150 - summary.length + 80, 120))}`; } if (summary.length < 150) { summary += ` Die Stiftung hat Sitz in ${location} und ist im ESA-Stiftungsverzeichnis eingetragen.`; @@ -86,7 +90,13 @@ function generatePurposeSummary(entry: EsaEntry): string { return summary.substring(0, 600); } -function generateResearchNotes(entry: EsaEntry, themes: string[], type: string, funder: number, operator: number): string { +function generateResearchNotes( + entry: EsaEntry, + themes: string[], + type: string, + funder: number, + operator: number, +): string { const purposeLower = entry.purpose.toLowerCase(); const parts: string[] = []; @@ -94,13 +104,16 @@ function generateResearchNotes(entry: EsaEntry, themes: string[], type: string, if (purposeLower.includes('förderung')) activities.push('Förderung'); if (purposeLower.includes('forschung')) activities.push('Forschung'); if (purposeLower.includes('unterstützung')) activities.push('Unterstützung'); - if (purposeLower.includes('ausbildung') || purposeLower.includes('bildung')) activities.push('Bildung'); + if (purposeLower.includes('ausbildung') || purposeLower.includes('bildung')) + activities.push('Bildung'); if (purposeLower.includes('projekt')) activities.push('Projektarbeit'); if (purposeLower.includes('stipend')) activities.push('Stipendien'); if (purposeLower.includes('sozial')) activities.push('Sozialarbeit'); if (activities.length > 0) { - parts.push(`${entry.name}: Tätigkeitsschwerpunkte gemäss Stiftungszweck — ${activities.slice(0, 4).join(', ')}.`); + parts.push( + `${entry.name}: Tätigkeitsschwerpunkte gemäss Stiftungszweck — ${activities.slice(0, 4).join(', ')}.`, + ); } else { parts.push(`${entry.name}: Stiftung mit Sitz in ${entry.city || 'der Schweiz'}.`); } @@ -115,7 +128,9 @@ function generateResearchNotes(entry: EsaEntry, themes: string[], type: string, } if (themes.length > 0) { - parts.push(`Thematische Anknüpfungspunkte: ${themes.map(t => THEME_LABELS[t] || t).join(', ')}.`); + parts.push( + `Thematische Anknüpfungspunkte: ${themes.map((t) => THEME_LABELS[t] || t).join(', ')}.`, + ); } else { parts.push('Keine direkten thematischen Anknüpfungspunkte für Revamp-IT erkannt.'); } @@ -135,7 +150,9 @@ function generateResearchNotes(entry: EsaEntry, themes: string[], type: string, }; if (strategies[type]) parts.push(strategies[type]); - parts.push('Automatisch aus ESA-Register importiert. Manuelle Recherche für Website, Kontaktdaten und aktuelle Förderprioritäten empfohlen.'); + parts.push( + 'Automatisch aus ESA-Register importiert. Manuelle Recherche für Website, Kontaktdaten und aktuelle Förderprioritäten empfohlen.', + ); return parts.join(' '); } @@ -158,8 +175,11 @@ async function upsertEntry( const isFunder = funder > operator; const { fitScore } = computeFitScore({ - themes, canton: entry.canton || '', city: entry.city || '', - applicationMethod, isFunder, + themes, + canton: entry.canton || '', + city: entry.city || '', + applicationMethod, + isFunder, }); const researchDepth: ResearchDepth = 'rapid'; const fitDisplay = fitScoreToDisplay(fitScore, researchDepth === 'rapid'); @@ -235,7 +255,7 @@ async function upsertEntry( async function main() { const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); - const limitArg = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '0', 10); + const limitArg = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' Pipeline Incremental — Diff ESA → generate → upsert → sync'); @@ -258,7 +278,7 @@ async function main() { const existingUids = new Set( existingRows .map((r) => r.uid) - .filter((uid): uid is string => !!uid && uid !== 'CHE-XXX.XXX.XXX') + .filter((uid): uid is string => !!uid && uid !== 'CHE-XXX.XXX.XXX'), ); console.log(` Existing in DB: ${existingSlugs.size} slugs`); diff --git a/scripts/research-agent.ts b/scripts/research-agent.ts index e990c1a9..9c08b9f6 100644 --- a/scripts/research-agent.ts +++ b/scripts/research-agent.ts @@ -42,7 +42,7 @@ import { extractWebContent } from './lib/web-extract'; import { isRegistryUrl } from '../src/lib/config/registry-domains'; const DRY_RUN = process.argv.includes('--dry-run'); -const LIMIT = parseInt(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1] || '0'); +const LIMIT = parseInt(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0'); const DELAY_MS = 500; // Contact subpages to try on verified websites @@ -68,11 +68,11 @@ interface ResearchResult { websiteVerified: boolean; sourceLinks: { source: string; url: string; label?: string }[]; conflicts: string[]; - phases: string[]; // log of what happened + phases: string[]; // log of what happened } async function sleep(ms: number) { - return new Promise(r => setTimeout(r, ms)); + return new Promise((r) => setTimeout(r, ms)); } /** @@ -141,7 +141,7 @@ async function extract( // Extract from verified website (homepage + contact pages) if (verifiedUrl) { const baseUrl = verifiedUrl.replace(/\/$/, ''); - const pagesToTry = [baseUrl, ...CONTACT_PAGES.map(p => baseUrl + p)]; + const pagesToTry = [baseUrl, ...CONTACT_PAGES.map((p) => baseUrl + p)]; for (const pageUrl of pagesToTry) { const extracted = await extractWebContent(pageUrl); @@ -183,7 +183,11 @@ async function researchFoundation(f: FoundationInput): Promise { if (spheriq) { result.phases.push(` Spheriq: ${spheriq.profileUrl} (email=${spheriq.email || 'none'})`); - result.sourceLinks.push({ source: 'stiftungschweiz', url: spheriq.profileUrl, label: 'StiftungSchweiz Profil' }); + result.sourceLinks.push({ + source: 'stiftungschweiz', + url: spheriq.profileUrl, + label: 'StiftungSchweiz Profil', + }); } else { result.phases.push(' Spheriq: not found'); } @@ -208,7 +212,7 @@ async function researchFoundation(f: FoundationInput): Promise { } const { verifiedUrl, verificationLog } = await verify(f, candidateUrls.slice(0, 5)); - result.phases.push(...verificationLog.map(l => ` ${l}`)); + result.phases.push(...verificationLog.map((l) => ` ${l}`)); if (verifiedUrl) { result.website = verifiedUrl; @@ -219,7 +223,9 @@ async function researchFoundation(f: FoundationInput): Promise { // Phase 3: Extract contacts from verified sources result.phases.push('Phase 3: Extract'); const candidates = await extract(verifiedUrl, spheriq); - result.phases.push(` Found ${candidates.length} contact candidates from ${candidates.map(c => c.source).join(', ') || 'none'}`); + result.phases.push( + ` Found ${candidates.length} contact candidates from ${candidates.map((c) => c.source).join(', ') || 'none'}`, + ); // Phase 4: Reconcile result.phases.push('Phase 4: Reconcile'); @@ -232,7 +238,9 @@ async function researchFoundation(f: FoundationInput): Promise { result.conflicts = reconciled.conflicts; if (reconciled.email) { - result.phases.push(` Email: ${reconciled.email} (${reconciled.emailConfidence}, from ${reconciled.emailSource})`); + result.phases.push( + ` Email: ${reconciled.email} (${reconciled.emailConfidence}, from ${reconciled.emailSource})`, + ); } if (reconciled.phone) { result.phases.push(` Phone: ${reconciled.phone} (from ${reconciled.phoneSource})`); @@ -251,7 +259,7 @@ async function main() { console.log(''); // Get foundations missing email, prioritized - const rows = await sql` + const rows = (await sql` SELECT id, name, config_data->>'uid' as uid, COALESCE(config_data->>'officialPurpose', config_data->>'purposeSummary', '') as purpose, @@ -263,7 +271,7 @@ async function main() { AND (archived = false OR archived IS NULL) ORDER BY COALESCE(fit_score, 0) DESC, name ${LIMIT ? sql`LIMIT ${LIMIT}` : sql``} - ` as { id: string; name: string; uid: string | null; purpose: string; website: string | null }[]; + `) as { id: string; name: string; uid: string | null; purpose: string; website: string | null }[]; console.log(`Foundations to research: ${rows.length}\n`); @@ -293,7 +301,9 @@ async function main() { results.push(result); const status = result.email ? '✅' : result.phone ? '📞' : '❌'; - console.log(` ${status} email=${result.email || '-'} phone=${result.phone || '-'} web=${result.websiteVerified ? '✓' : '-'}`); + console.log( + ` ${status} email=${result.email || '-'} phone=${result.phone || '-'} web=${result.websiteVerified ? '✓' : '-'}`, + ); if (result.email) foundEmail++; if (result.phone) foundPhone++; @@ -328,7 +338,7 @@ async function main() { console.log('\n=== SUMMARY ==='); console.log(`Researched: ${rows.length}`); - console.log(`Found email: ${foundEmail} (${Math.round(foundEmail * 100 / rows.length)}%)`); + console.log(`Found email: ${foundEmail} (${Math.round((foundEmail * 100) / rows.length)}%)`); console.log(`Found phone: ${foundPhone}`); console.log(`Verified website: ${foundWebsite}`); if (!DRY_RUN) { @@ -338,7 +348,7 @@ async function main() { // Write summary log if (!DRY_RUN) { - const summary = results.map(r => ({ + const summary = results.map((r) => ({ slug: r.slug, email: r.email || null, emailConfidence: r.emailConfidence || null, diff --git a/scripts/seed-org-content.ts b/scripts/seed-org-content.ts index 12fe57c9..18e77cda 100644 --- a/scripts/seed-org-content.ts +++ b/scripts/seed-org-content.ts @@ -12,7 +12,13 @@ import { sql } from './lib/db'; import { ORG_PROFILE } from '../src/lib/config/org-profile'; import { SCORING_ENGINE, READINESS_ENGINE } from '../src/lib/config/fit-scoring'; -import { CORE_FACTS, GESUCH_TEXT, WHY, ANSCHREIBEN_TEMPLATES, PARTNER_HIGHLIGHTS } from '../src/lib/config/stories'; +import { + CORE_FACTS, + GESUCH_TEXT, + WHY, + ANSCHREIBEN_TEMPLATES, + PARTNER_HIGHLIGHTS, +} from '../src/lib/config/stories'; import { NUMBERS_REGISTRY } from '../src/lib/config/numbers'; import { SCHWERPUNKTE } from '../src/lib/config/schwerpunkte'; import { TEMPLATE_FOUNDATIONS } from '../src/lib/config/gesuch-templates'; @@ -64,7 +70,9 @@ async function main() { (SELECT COUNT(*) FROM org_content)::text AS content, (SELECT COUNT(*) FROM org_scoring)::text AS scoring `; - console.log(`\nDone. org_profiles=${counts.profiles} org_content=${counts.content} org_scoring=${counts.scoring}\n`); + console.log( + `\nDone. org_profiles=${counts.profiles} org_content=${counts.content} org_scoring=${counts.scoring}\n`, + ); } main().catch((err) => { diff --git a/scripts/set-confidence.ts b/scripts/set-confidence.ts index a4fb6dc9..452c3395 100644 --- a/scripts/set-confidence.ts +++ b/scripts/set-confidence.ts @@ -32,7 +32,6 @@ interface ConfidenceStats { } async function setConfidence() { - console.log(`\n📊 Setting data confidence levels... (${DRY_RUN ? 'DRY RUN' : 'LIVE'})\n`); const stats: ConfidenceStats = { @@ -45,8 +44,10 @@ async function setConfidence() { if (DRY_RUN) { // Count what WOULD change, without updating - const [deep] = await sql`SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE research_depth='deep' AND (data_confidence IS NULL OR data_confidence != 'human-verified')`; - const [standard] = await sql`SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE research_depth='standard' AND (data_confidence IS NULL OR data_confidence != 'ai-assessed')`; + const [deep] = + await sql`SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE research_depth='deep' AND (data_confidence IS NULL OR data_confidence != 'human-verified')`; + const [standard] = + await sql`SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE research_depth='standard' AND (data_confidence IS NULL OR data_confidence != 'ai-assessed')`; const [enrichedRapid] = await sql` SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE research_depth='rapid' @@ -78,7 +79,9 @@ async function setConfidence() { RETURNING 1 `; stats.deepToVerified = r1.length; - console.log(`✅ Set ${stats.deepToVerified} foundations to 'human-verified' (research_depth=deep)`); + console.log( + `✅ Set ${stats.deepToVerified} foundations to 'human-verified' (research_depth=deep)`, + ); // Rule 2: standard → ai-assessed const r2 = await sql` @@ -89,7 +92,9 @@ async function setConfidence() { RETURNING 1 `; stats.standardToAiAssessed = r2.length; - console.log(`✅ Set ${stats.standardToAiAssessed} foundations to 'ai-assessed' (research_depth=standard)`); + console.log( + `✅ Set ${stats.standardToAiAssessed} foundations to 'ai-assessed' (research_depth=standard)`, + ); // Rule 3: rapid + has applicationResearchMethod set → ai-assessed // (these were explicitly researched by claude-agent/chatgpt-agent etc.) @@ -103,7 +108,9 @@ async function setConfidence() { RETURNING 1 `; stats.enrichedRapidToAiAssessed = r3.length; - console.log(`✅ Set ${stats.enrichedRapidToAiAssessed} foundations to 'ai-assessed' (enriched rapid)`); + console.log( + `✅ Set ${stats.enrichedRapidToAiAssessed} foundations to 'ai-assessed' (enriched rapid)`, + ); // Rule 4: rapid + no enrichment signal + not yet assessed → unverified // Only marks NULL entries — never demotes existing ai-assessed foundations. @@ -119,10 +126,13 @@ async function setConfidence() { RETURNING 1 `; stats.unenrichedRapidToUnverified = r4.length; - console.log(`✅ Set ${stats.unenrichedRapidToUnverified} foundations to 'unverified' (unenriched rapid)`); + console.log( + `✅ Set ${stats.unenrichedRapidToUnverified} foundations to 'unverified' (unenriched rapid)`, + ); // Count remaining NULL (should be ~0 after running all rules) - const [nullResult] = await sql`SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE data_confidence IS NULL`; + const [nullResult] = + await sql`SELECT COUNT(*) as cnt FROM fundraising_foundations WHERE data_confidence IS NULL`; stats.unchanged = Number(nullResult.cnt); console.log(`\n📈 Summary:`); diff --git a/scripts/sync-org-numbers.ts b/scripts/sync-org-numbers.ts index b95a8b74..d451621e 100644 --- a/scripts/sync-org-numbers.ts +++ b/scripts/sync-org-numbers.ts @@ -11,20 +11,20 @@ * Run this 1-2x/year during number audits, then commit the generated file. */ -import { config } from 'dotenv' -config({ path: '.env.local' }) +import { config } from 'dotenv'; +config({ path: '.env.local' }); -import { Pool } from 'pg' -import { writeFileSync } from 'fs' -import { resolve } from 'path' +import { Pool } from 'pg'; +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; -const DATABASE_URL = process.env.DATABASE_URL +const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { - console.error('DATABASE_URL environment variable is required.') - console.error('Set it to the shared revampit PostgreSQL connection string.') - console.error('Example: DATABASE_URL=postgresql://... npx tsx scripts/sync-org-numbers.ts') - process.exit(1) + console.error('DATABASE_URL environment variable is required.'); + console.error('Set it to the shared revampit PostgreSQL connection string.'); + console.error('Example: DATABASE_URL=postgresql://... npx tsx scripts/sync-org-numbers.ts'); + process.exit(1); } // Maps org_numbers DB key → NUMBERS_REGISTRY key in revamp-info @@ -38,67 +38,67 @@ const SHARED_KEYS: Record = { team_fte: 'TEAM_CORE_FTE', avg_device_price_chf: 'AVG_DEVICE_PRICE', devices_sold_per_year: 'DEVICES_YEAR_CURRENT', -} +}; -const DB_KEYS = Object.keys(SHARED_KEYS) +const DB_KEYS = Object.keys(SHARED_KEYS); interface OrgNumberRow { - key: string - value: string - numeric_value: number | null + key: string; + value: string; + numeric_value: number | null; } async function sync() { - const pool = new Pool({ connectionString: DATABASE_URL }) + const pool = new Pool({ connectionString: DATABASE_URL }); try { - const placeholders = DB_KEYS.map((_, i) => `$${i + 1}`).join(', ') + const placeholders = DB_KEYS.map((_, i) => `$${i + 1}`).join(', '); const result = await pool.query( `SELECT key, value, numeric_value FROM org_numbers WHERE key IN (${placeholders})`, - DB_KEYS - ) + DB_KEYS, + ); if (result.rows.length === 0) { - console.error('No matching rows found in org_numbers table.') - console.error('Expected keys:', DB_KEYS.join(', ')) - process.exit(1) + console.error('No matching rows found in org_numbers table.'); + console.error('Expected keys:', DB_KEYS.join(', ')); + process.exit(1); } // Build the values object - const entries: string[] = [] - const found = new Set() + const entries: string[] = []; + const found = new Set(); for (const row of result.rows) { - const registryKey = SHARED_KEYS[row.key] - if (!registryKey) continue - found.add(row.key) + const registryKey = SHARED_KEYS[row.key]; + if (!registryKey) continue; + found.add(row.key); // Use the display value (string) for non-numeric entries like "100+" // Use numeric_value for numeric entries if (row.numeric_value != null) { - entries.push(` ${registryKey}: ${row.numeric_value},`) + entries.push(` ${registryKey}: ${row.numeric_value},`); } else { - entries.push(` ${registryKey}: '${row.value}',`) + entries.push(` ${registryKey}: '${row.value}',`); } } // Warn about missing keys - const missing = DB_KEYS.filter(k => !found.has(k)) + const missing = DB_KEYS.filter((k) => !found.has(k)); if (missing.length > 0) { - console.warn(`WARNING: ${missing.length} keys not found in DB:`, missing.join(', ')) + console.warn(`WARNING: ${missing.length} keys not found in DB:`, missing.join(', ')); } // Special case: PEOPLE_HELPED uses the display value "100+" not numeric 100 // Find the people_helped_total row and override with display value - const peopleRow = result.rows.find(r => r.key === 'people_helped_total') + const peopleRow = result.rows.find((r) => r.key === 'people_helped_total'); if (peopleRow) { - const idx = entries.findIndex(e => e.includes('PEOPLE_HELPED')) + const idx = entries.findIndex((e) => e.includes('PEOPLE_HELPED')); if (idx !== -1) { - entries[idx] = ` PEOPLE_HELPED: '${peopleRow.value}',` + entries[idx] = ` PEOPLE_HELPED: '${peopleRow.value}',`; } } - const now = new Date().toISOString().split('T')[0] + const now = new Date().toISOString().split('T')[0]; const output = `// AUTO-GENERATED — DO NOT EDIT MANUALLY // Source: org_numbers table (shared revampit PostgreSQL) // Last synced: ${now} @@ -107,22 +107,25 @@ async function sync() { export const SHARED_ORG_NUMBERS = { ${entries.join('\n')} } as const -` - - const outPath = resolve(__dirname, '../src/lib/config/shared-org-numbers.generated.ts') - writeFileSync(outPath, output, 'utf-8') - console.log(`Synced ${found.size}/${DB_KEYS.length} values to ${outPath}`) - console.log('Values:', Object.fromEntries( - result.rows - .filter(r => found.has(r.key)) - .map(r => [SHARED_KEYS[r.key], r.numeric_value ?? r.value]) - )) +`; + + const outPath = resolve(__dirname, '../src/lib/config/shared-org-numbers.generated.ts'); + writeFileSync(outPath, output, 'utf-8'); + console.log(`Synced ${found.size}/${DB_KEYS.length} values to ${outPath}`); + console.log( + 'Values:', + Object.fromEntries( + result.rows + .filter((r) => found.has(r.key)) + .map((r) => [SHARED_KEYS[r.key], r.numeric_value ?? r.value]), + ), + ); } catch (error) { - console.error('Sync failed:', error) - process.exit(1) + console.error('Sync failed:', error); + process.exit(1); } finally { - await pool.end() + await pool.end(); } } -sync() +sync(); diff --git a/scripts/web-enrich.ts b/scripts/web-enrich.ts index ee978074..db5ae733 100644 --- a/scripts/web-enrich.ts +++ b/scripts/web-enrich.ts @@ -74,7 +74,9 @@ interface EnrichmentResult { // Fetching + extraction // ============================================================================ -async function fetchAndExtract(url: string): Promise<{ text: string; contact: ExtractedContact } | null> { +async function fetchAndExtract( + url: string, +): Promise<{ text: string; contact: ExtractedContact } | null> { try { const html = await fetchRaw(url); const text = stripHtml(html); @@ -100,7 +102,8 @@ function mergeContacts(a: ExtractedContact, b: ExtractedContact): ExtractedConta return { emails: Array.from(new Set([...a.emails, ...b.emails])), phones: Array.from(new Set([...a.phones, ...b.phones])), - applicationMethod: a.applicationMethod !== 'unknown' ? a.applicationMethod : b.applicationMethod, + applicationMethod: + a.applicationMethod !== 'unknown' ? a.applicationMethod : b.applicationMethod, grantRange: { min: a.grantRange.min ?? b.grantRange.min, max: a.grantRange.max ?? b.grantRange.max, @@ -108,7 +111,9 @@ function mergeContacts(a: ExtractedContact, b: ExtractedContact): ExtractedConta }; } -async function scrapeFoundation(url: string): Promise<{ contact: ExtractedContact; pagesScraped: string[] }> { +async function scrapeFoundation( + url: string, +): Promise<{ contact: ExtractedContact; pagesScraped: string[] }> { const pagesScraped: string[] = []; let combined: ExtractedContact = { emails: [], @@ -158,8 +163,10 @@ function buildEnrichmentDraft(result: EnrichmentResult, existing: Foundation) { const c = result.contact; const email = c.emails[0] || existing.contact?.email; const phone = c.phones[0] || existing.contact?.phone; - const method = c.applicationMethod !== 'unknown' ? c.applicationMethod - : (existing.applicationMethod || 'unknown'); + const method = + c.applicationMethod !== 'unknown' + ? c.applicationMethod + : existing.applicationMethod || 'unknown'; return { slug: result.slug, @@ -198,10 +205,17 @@ function buildEnrichmentDraft(result: EnrichmentResult, existing: Foundation) { reasoning: `Web enrichment: scraped ${result.pagesScraped.length} page(s) from ${result.url}`, themes: existing.themes || [], suggestedType: existing.type || 'C', - suggestedFit: fitScoreToDisplay(existing.fitScore || 0, (existing.researchDepth || 'rapid') === 'rapid'), + suggestedFit: fitScoreToDisplay( + existing.fitScore || 0, + (existing.researchDepth || 'rapid') === 'rapid', + ), suggestedPriority: existing.priority || 4, - purposeSummary: existing.purposeSummary || `${result.name}: Keine detaillierte Zweckbeschreibung verfügbar. Website: ${result.url}. Weitere Recherche empfohlen.`, - researchNotes: existing.researchNotes || `${result.name}: Automatisch angereichert via Web-Scraping. Website: ${result.url}. ${c.emails.length > 0 ? `E-Mail gefunden: ${c.emails[0]}. ` : ''}${c.phones.length > 0 ? `Telefon gefunden: ${c.phones[0]}. ` : ''}${c.applicationMethod !== 'unknown' ? `Bewerbungsmethode: ${c.applicationMethod}. ` : ''}Manuelle Verifikation empfohlen.`, + purposeSummary: + existing.purposeSummary || + `${result.name}: Keine detaillierte Zweckbeschreibung verfügbar. Website: ${result.url}. Weitere Recherche empfohlen.`, + researchNotes: + existing.researchNotes || + `${result.name}: Automatisch angereichert via Web-Scraping. Website: ${result.url}. ${c.emails.length > 0 ? `E-Mail gefunden: ${c.emails[0]}. ` : ''}${c.phones.length > 0 ? `Telefon gefunden: ${c.phones[0]}. ` : ''}${c.applicationMethod !== 'unknown' ? `Bewerbungsmethode: ${c.applicationMethod}. ` : ''}Manuelle Verifikation empfohlen.`, applicationMethod: method as 'online' | 'email' | 'invitation' | 'unknown', contactInfo: { email, @@ -229,9 +243,9 @@ function buildEnrichmentDraft(result: EnrichmentResult, existing: Foundation) { async function main() { const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); - const limitArg = args.find(a => a.startsWith('--limit=')); + const limitArg = args.find((a) => a.startsWith('--limit=')); const limit = limitArg ? parseInt(limitArg.split('=')[1], 10) : undefined; - const urlsArg = args.find(a => a.startsWith('--urls=')); + const urlsArg = args.find((a) => a.startsWith('--urls=')); const urlsFile = urlsArg ? urlsArg.split('=')[1] : undefined; console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); @@ -245,7 +259,6 @@ async function main() { process.exit(1); } - // Load discovered URLs if provided const discoveredUrls = new Map(); if (urlsFile) { @@ -265,18 +278,18 @@ async function main() { // - Have a real website URL (not zefix/uid.admin) OR have a discovered URL // - Missing direct contact (email/phone) console.log('\nQuerying foundations for web enrichment...'); - const rows = await sql` + const rows = (await sql` SELECT id, name, config_data FROM fundraising_foundations WHERE config_data IS NOT NULL AND archived = false ORDER BY id - ` as unknown as DBRow[]; + `) as unknown as DBRow[]; // Filter to foundations that: // 1. Have a real website (or discovered URL) AND // 2. Are missing email or phone - const enrichable = rows.filter(row => { + const enrichable = rows.filter((row) => { const url = discoveredUrls.get(row.id) || row.config_data?.websiteUrl; if (!url || isRegistryUrl(url)) return false; @@ -315,7 +328,8 @@ async function main() { const foundNewEmail = contact.emails.length > 0 && !hasEmailBefore; const foundNewPhone = contact.phones.length > 0 && !hasPhoneBefore; const foundNewMethod = contact.applicationMethod !== 'unknown'; - const foundNewRange = contact.grantRange.min !== undefined || contact.grantRange.max !== undefined; + const foundNewRange = + contact.grantRange.min !== undefined || contact.grantRange.max !== undefined; if (foundNewEmail || foundNewPhone || foundNewMethod || foundNewRange) { enriched++; @@ -338,7 +352,9 @@ async function main() { processed += batch.length; if (processed % PROGRESS_INTERVAL === 0 || processed === toProcess.length) { - console.log(` Scraped ${processed}/${toProcess.length}, enriched ${enriched} (${results.length} with new data)`); + console.log( + ` Scraped ${processed}/${toProcess.length}, enriched ${enriched} (${results.length} with new data)`, + ); } } @@ -349,10 +365,16 @@ async function main() { console.log(` Foundations scraped: ${scraped}`); console.log(` Enriched (new data): ${results.length}`); - const newEmails = results.filter(r => r.contact.emails.length > 0 && !r.hadDataBefore.email).length; - const newPhones = results.filter(r => r.contact.phones.length > 0 && !r.hadDataBefore.phone).length; - const newMethods = results.filter(r => r.contact.applicationMethod !== 'unknown').length; - const newRanges = results.filter(r => r.contact.grantRange.min !== undefined || r.contact.grantRange.max !== undefined).length; + const newEmails = results.filter( + (r) => r.contact.emails.length > 0 && !r.hadDataBefore.email, + ).length; + const newPhones = results.filter( + (r) => r.contact.phones.length > 0 && !r.hadDataBefore.phone, + ).length; + const newMethods = results.filter((r) => r.contact.applicationMethod !== 'unknown').length; + const newRanges = results.filter( + (r) => r.contact.grantRange.min !== undefined || r.contact.grantRange.max !== undefined, + ).length; console.log(` New emails found: ${newEmails}`); console.log(` New phones found: ${newPhones}`); @@ -365,7 +387,8 @@ async function main() { const parts = []; if (r.contact.emails.length > 0) parts.push(`email: ${r.contact.emails[0]}`); if (r.contact.phones.length > 0) parts.push(`phone: ${r.contact.phones[0]}`); - if (r.contact.applicationMethod !== 'unknown') parts.push(`method: ${r.contact.applicationMethod}`); + if (r.contact.applicationMethod !== 'unknown') + parts.push(`method: ${r.contact.applicationMethod}`); console.log(` ${r.name.substring(0, 40).padEnd(42)} ${parts.join(', ')}`); } if (results.length > 15) { @@ -381,7 +404,7 @@ async function main() { let written = 0; for (const result of results) { - const existing = rows.find(r => r.id === result.slug)!; + const existing = rows.find((r) => r.id === result.slug)!; const draft = buildEnrichmentDraft(result, existing.config_data); const outPath = path.join(outDir, `${result.slug}.json`); fs.writeFileSync(outPath, JSON.stringify(draft, null, 2)); @@ -400,7 +423,7 @@ async function main() { console.log('\nDone.\n'); } -main().catch(err => { +main().catch((err) => { console.error('Error:', err); process.exit(1); }); diff --git a/scripts/zefix-download.ts b/scripts/zefix-download.ts index 9eae0609..03f74a4a 100644 --- a/scripts/zefix-download.ts +++ b/scripts/zefix-download.ts @@ -67,14 +67,14 @@ async function sparqlQuery(query: string): Promise[] }; }; return data.results.bindings; @@ -104,7 +104,7 @@ async function downloadBatch(offset: number, limit: number): Promise ({ + return bindings.map((b) => ({ name: b.name?.value || '', uid: formatUid(b.uid?.value || ''), city: b.city?.value || '', @@ -120,7 +120,7 @@ async function downloadBatch(offset: number, limit: number): Promise a.startsWith('--limit='))?.split('=')[1] || '0', 10); + const limitArg = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' Zefix Download — Fetch ALL Stiftungen via SPARQL'); @@ -177,7 +177,9 @@ async function main() { const dedupedEntries = Array.from(seen.values()); const duplicatesRemoved = allEntries.length - dedupedEntries.length; if (duplicatesRemoved > 0) { - console.log(` After dedup: ${dedupedEntries.length} unique (${duplicatesRemoved} duplicates removed)`); + console.log( + ` After dedup: ${dedupedEntries.length} unique (${duplicatesRemoved} duplicates removed)`, + ); } if (dryRun) { diff --git a/scripts/zefix-ingest.ts b/scripts/zefix-ingest.ts index ca7b451c..852b2457 100644 --- a/scripts/zefix-ingest.ts +++ b/scripts/zefix-ingest.ts @@ -20,11 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { sql, type SqlClient } from './lib/db'; import { computeFitScore, fitScoreToDisplay } from '../src/lib/domain/fit-scoring'; -import { - classifyThemes, - toSlug, - THEME_LABELS, -} from './lib/theme-classifier'; +import { classifyThemes, toSlug, THEME_LABELS } from './lib/theme-classifier'; // ============================================================================ // Types @@ -99,7 +95,7 @@ const EXCLUDE_PATTERNS = [ ]; function isExcluded(name: string): boolean { - return EXCLUDE_PATTERNS.some(pattern => pattern.test(name)); + return EXCLUDE_PATTERNS.some((pattern) => pattern.test(name)); } // ============================================================================ @@ -110,8 +106,9 @@ function findLatestRegister(): string | null { const researchDir = path.join(process.cwd(), 'research'); if (!fs.existsSync(researchDir)) return null; - const files = fs.readdirSync(researchDir) - .filter(f => f.startsWith('zefix-register-') && f.endsWith('.json')) + const files = fs + .readdirSync(researchDir) + .filter((f) => f.startsWith('zefix-register-') && f.endsWith('.json')) .sort() .reverse(); @@ -127,16 +124,15 @@ function generateNameOnlySummary(entry: ZefixEntry): string { return `${entry.name} (${location}): Stiftung gemäss Handelsregister. Keine detaillierte Zweckbeschreibung aus Zefix verfügbar. Weitere Recherche zu Förderzweck und Aktivitäten empfohlen.`; } -function generateNameOnlyResearchNotes( - entry: ZefixEntry, - themes: string[], -): string { +function generateNameOnlyResearchNotes(entry: ZefixEntry, themes: string[]): string { const parts: string[] = []; parts.push(`${entry.name}: Aus Zefix-Handelsregister importiert.`); if (themes.length > 0) { - parts.push(`Mögliche thematische Anknüpfungspunkte (aus Name abgeleitet): ${themes.map(t => THEME_LABELS[t] || t).join(', ')}.`); + parts.push( + `Mögliche thematische Anknüpfungspunkte (aus Name abgeleitet): ${themes.map((t) => THEME_LABELS[t] || t).join(', ')}.`, + ); } else { parts.push('Keine thematischen Anknüpfungspunkte aus dem Namen erkennbar.'); } @@ -149,7 +145,9 @@ function generateNameOnlyResearchNotes( parts.push(`Kantonaler Handelsregisterauszug verfügbar.`); } - parts.push('Automatisch aus Zefix importiert. Manuelle Recherche für Stiftungszweck, Website, Kontaktdaten und Förderprioritäten erforderlich.'); + parts.push( + 'Automatisch aus Zefix importiert. Manuelle Recherche für Stiftungszweck, Website, Kontaktdaten und Förderprioritäten erforderlich.', + ); return parts.join(' '); } @@ -158,10 +156,7 @@ function generateNameOnlyResearchNotes( // DB UPSERT // ============================================================================ -async function upsertEntry( - sql: SqlClient, - entry: ZefixEntry, -): Promise<{ success: boolean }> { +async function upsertEntry(sql: SqlClient, entry: ZefixEntry): Promise<{ success: boolean }> { const slug = toSlug(entry.name); const nameLower = entry.name.toLowerCase(); // Zefix has no purpose text — classify from name only @@ -170,8 +165,11 @@ async function upsertEntry( const applicationMethod = 'unknown'; const { fitScore } = computeFitScore({ - themes, canton: '', city: entry.city || '', - applicationMethod, isFunder, + themes, + canton: '', + city: entry.city || '', + applicationMethod, + isFunder, }); const researchDepth: ResearchDepth = 'rapid'; const fitDisplay = fitScoreToDisplay(fitScore, researchDepth === 'rapid'); @@ -252,7 +250,7 @@ async function upsertEntry( async function main() { const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); - const limitArg = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '0', 10); + const limitArg = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' Zefix Ingest — Filter + cross-ref + upsert to DB'); @@ -271,13 +269,15 @@ async function main() { console.log(` Entries: ${register.foundations.length}`); // 2. Filter: active only - const active = register.foundations.filter(e => - !e.status || e.status === 'EXISTIEREND' || e.status === '' + const active = register.foundations.filter( + (e) => !e.status || e.status === 'EXISTIEREND' || e.status === '', + ); + console.log( + ` Active: ${active.length} (filtered ${register.foundations.length - active.length} inactive)`, ); - console.log(` Active: ${active.length} (filtered ${register.foundations.length - active.length} inactive)`); // 3. Filter: exclude pension/non-charitable - const charitable = active.filter(e => !isExcluded(e.name)); + const charitable = active.filter((e) => !isExcluded(e.name)); const excluded = active.length - charitable.length; console.log(` Charitable: ${charitable.length} (excluded ${excluded} pension/non-charitable)`); @@ -289,7 +289,7 @@ async function main() { const existingUids = new Set( existingRows .map((r) => r.uid) - .filter((uid): uid is string => !!uid && uid !== 'CHE-XXX.XXX.XXX') + .filter((uid): uid is string => !!uid && uid !== 'CHE-XXX.XXX.XXX'), ); console.log(` Existing in DB: ${existingSlugs.size} slugs, ${existingUids.size} UIDs`); @@ -320,11 +320,20 @@ async function main() { const slug = toSlug(entry.name); // Skip if slug already exists - if (existingSlugs.has(slug)) { skippedBySlug++; continue; } + if (existingSlugs.has(slug)) { + skippedBySlug++; + continue; + } // Skip if UID already exists - if (entry.uid && existingUids.has(entry.uid)) { skippedByUid++; continue; } + if (entry.uid && existingUids.has(entry.uid)) { + skippedByUid++; + continue; + } // Skip if normalized name matches (catches case/hyphen/suffix variations) - if (existingNormNames.has(normalizeName(entry.name))) { skippedByName++; continue; } + if (existingNormNames.has(normalizeName(entry.name))) { + skippedByName++; + continue; + } // Skip duplicates within this batch if (slugsSeen.has(slug)) continue; diff --git a/scripts/zhaw-ingest.ts b/scripts/zhaw-ingest.ts index 442ed9d0..bf1225b1 100644 --- a/scripts/zhaw-ingest.ts +++ b/scripts/zhaw-ingest.ts @@ -134,7 +134,8 @@ function generateResearchNotes( if (purposeLower.includes('förderung')) activities.push('Förderung'); if (purposeLower.includes('forschung')) activities.push('Forschung'); if (purposeLower.includes('unterstützung')) activities.push('Unterstützung'); - if (purposeLower.includes('ausbildung') || purposeLower.includes('bildung')) activities.push('Bildung'); + if (purposeLower.includes('ausbildung') || purposeLower.includes('bildung')) + activities.push('Bildung'); if (purposeLower.includes('projekt')) activities.push('Projektarbeit'); if (purposeLower.includes('stipend')) activities.push('Stipendien'); if (purposeLower.includes('sozial')) activities.push('Sozialarbeit'); @@ -157,7 +158,9 @@ function generateResearchNotes( // Themes if (themes.length > 0) { - parts.push(`Thematische Anknüpfungspunkte: ${themes.map(t => THEME_LABELS[t] || t).join(', ')}.`); + parts.push( + `Thematische Anknüpfungspunkte: ${themes.map((t) => THEME_LABELS[t] || t).join(', ')}.`, + ); } else { parts.push('Keine direkten thematischen Anknüpfungspunkte für Revamp-IT erkannt.'); } @@ -186,7 +189,9 @@ function generateResearchNotes( }; if (strategies[type]) parts.push(strategies[type]); - parts.push('Aus ZHAW-Verzeichnis Fonds und Stiftungen (Kanton Zürich) importiert. Kontaktdaten aus ZHAW, ergänzende Recherche empfohlen.'); + parts.push( + 'Aus ZHAW-Verzeichnis Fonds und Stiftungen (Kanton Zürich) importiert. Kontaktdaten aus ZHAW, ergänzende Recherche empfohlen.', + ); return parts.join(' '); } @@ -205,8 +210,11 @@ function generateDraft(zhaw: ZhawFoundation): object { const email = extractEmail(zhaw); const { fitScore } = computeFitScore({ - themes, canton: 'ZH', city: zhaw.city, - applicationMethod, isFunder, + themes, + canton: 'ZH', + city: zhaw.city, + applicationMethod, + isFunder, }); const suggestedFit: 1 | 2 | 3 = fitScore >= 7 ? 3 : fitScore >= 4 ? 2 : 1; @@ -282,7 +290,10 @@ function isZefixUrl(url: string): boolean { return url.includes('zefix.ch') || url.includes('uid.admin.ch'); } -function enrichExistingDraft(draftPath: string, zhaw: ZhawFoundation): { enriched: boolean; fields: string[] } { +function enrichExistingDraft( + draftPath: string, + zhaw: ZhawFoundation, +): { enriched: boolean; fields: string[] } { const raw = JSON.parse(fs.readFileSync(draftPath, 'utf-8')); const fields: string[] = []; @@ -348,7 +359,7 @@ function enrichExistingDraft(draftPath: string, zhaw: ZhawFoundation): { enriche async function main() { const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); - const limitArg = parseInt(args.find(a => a.startsWith('--limit='))?.split('=')[1] || '0', 10); + const limitArg = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0', 10); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(' ZHAW Ingest — Import Zürich cantonal foundations'); diff --git a/src/app/(platform)/layout.tsx b/src/app/(platform)/layout.tsx index 024107d3..6b1a0aed 100644 --- a/src/app/(platform)/layout.tsx +++ b/src/app/(platform)/layout.tsx @@ -20,7 +20,9 @@ export default function PlatformLayout({ children }: { children: React.ReactNode {PLATFORM_BRAND.name} - {PLATFORM_BRAND.tagline} + + {PLATFORM_BRAND.tagline} +
{ const locale = (await getLocale()) as PlatformLocale; const meta = PLATFORM_CONTENT[locale].meta; - return { title: meta.title, description: meta.description, openGraph: { title: meta.title, description: meta.description } }; + return { + title: meta.title, + description: meta.description, + openGraph: { title: meta.title, description: meta.description }, + }; } export default async function PlattformPage() { diff --git a/src/app/(share)/gesuch/share/[token]/page.tsx b/src/app/(share)/gesuch/share/[token]/page.tsx index 66031dae..4fb21b56 100644 --- a/src/app/(share)/gesuch/share/[token]/page.tsx +++ b/src/app/(share)/gesuch/share/[token]/page.tsx @@ -103,7 +103,8 @@ export default async function GesuchSharePage({ params, searchParams }: Props) { if (!gesuch.ready) notFound(); // Merge any saved overrides (same pipeline as the full gesuch page) - const { loadGesuchOverrides, applyGesuchOverrides } = await import('@/lib/domain/apply-overrides'); + const { loadGesuchOverrides, applyGesuchOverrides } = + await import('@/lib/domain/apply-overrides'); const overrides = await loadGesuchOverrides(slug, selectedSchwerpunkt); const merged = applyGesuchOverrides(gesuch, overrides); diff --git a/src/app/(tenant)/DashboardClient.tsx b/src/app/(tenant)/DashboardClient.tsx index 96b68a59..45f067b7 100644 --- a/src/app/(tenant)/DashboardClient.tsx +++ b/src/app/(tenant)/DashboardClient.tsx @@ -19,13 +19,7 @@ import StoryBridge from '@/components/layout/StoryBridge'; import { STORY_BRIDGES } from '@/lib/config/story-bridges'; export default function DashboardClient() { - const { - selectedYear, - totals, - monthCount, - monthlyAvg, - selfFinancingRate, - } = useFinancialData(); + const { selectedYear, totals, monthCount, monthlyAvg, selfFinancingRate } = useFinancialData(); const inspector = useNumberInspector(); @@ -33,9 +27,8 @@ export default function DashboardClient() { const co2Avoided = estimateCO2Avoided(deviceCount); const prevYearData = useFinancialData(selectedYear - 1); - const growth = prevYearData.totals.total > 0 - ? calcGrowth(prevYearData.totals.total, totals.total) - : 0; + const growth = + prevYearData.totals.total > 0 ? calcGrowth(prevYearData.totals.total, totals.total) : 0; return (
@@ -58,11 +51,11 @@ export default function DashboardClient() { trend={growth !== 0 ? { value: growth, label: 'vs. Vorjahr' } : undefined} sourceType="live" onClick={() => - inspector.inspect(metricToInspectorData( - NumberSources.financial_total_2025, - formatCHF(totals.total), - { year: selectedYear }, - )) + inspector.inspect( + metricToInspectorData(NumberSources.financial_total_2025, formatCHF(totals.total), { + year: selectedYear, + }), + ) } /> - inspector.inspect(metricToInspectorData( - NumberSources.financial_monthly_avg_2025, - formatCHF(monthlyAvg), - { year: selectedYear, formula: `${formatCHF(totals.total)} / ${monthCount} Monate` }, - )) + inspector.inspect( + metricToInspectorData( + NumberSources.financial_monthly_avg_2025, + formatCHF(monthlyAvg), + { + year: selectedYear, + formula: `${formatCHF(totals.total)} / ${monthCount} Monate`, + }, + ), + ) } /> - inspector.inspect(metricToInspectorData( - NumberSources.financial_self_financing_2025, - formatPercent(selfFinancingRate), - { - year: selectedYear, - formula: `(${formatCHF(totals.warenverkauf)} + ${formatCHF(totals.dienstleistungen)}) / ${formatCHF(totals.total)}`, - }, - )) + inspector.inspect( + metricToInspectorData( + NumberSources.financial_self_financing_2025, + formatPercent(selfFinancingRate), + { + year: selectedYear, + formula: `(${formatCHF(totals.warenverkauf)} + ${formatCHF(totals.dienstleistungen)}) / ${formatCHF(totals.total)}`, + }, + ), + ) } /> - inspector.inspect(metricToInspectorData( - NumberSources.co2_total_2025, - `~${co2Avoided} Tonnen`, - { year: selectedYear, formula: `${formatCHF(totals.warenverkauf)} / CHF ${AVG_DEVICE_PRICE} pro Gerät × ${CO2_PER_LAPTOP} kg CO₂` }, - )) + inspector.inspect( + metricToInspectorData(NumberSources.co2_total_2025, `~${co2Avoided} Tonnen`, { + year: selectedYear, + formula: `${formatCHF(totals.warenverkauf)} / CHF ${AVG_DEVICE_PRICE} pro Gerät × ${CO2_PER_LAPTOP} kg CO₂`, + }), + ) } /> @@ -117,7 +118,10 @@ export default function DashboardClient() { href={link.href} className="block transition-all hover:no-underline" > - + {link.icon}
{link.title} diff --git a/src/app/(tenant)/data.ts b/src/app/(tenant)/data.ts index 2f7ba6cc..ff1cc5d9 100644 --- a/src/app/(tenant)/data.ts +++ b/src/app/(tenant)/data.ts @@ -1,8 +1,18 @@ // Dashboard quicklinks — SSOT for dashboard navigation shortcuts export const DASHBOARD_QUICKLINKS = [ - { href: '/strategie', icon: '🧭', title: 'Strategie & Vision', desc: 'Mission, Werte, Community Tech Space' }, + { + href: '/strategie', + icon: '🧭', + title: 'Strategie & Vision', + desc: 'Mission, Werte, Community Tech Space', + }, { href: '/fundraising', icon: '🎯', title: 'Fundraising', desc: 'Budget, Pipeline, Pakete' }, - { href: '/fundraising/stiftungen', icon: '🏛️', title: 'Stiftungen', desc: 'Alle Förderstiftungen mit Deadlines' }, + { + href: '/fundraising/stiftungen', + icon: '🏛️', + title: 'Stiftungen', + desc: 'Alle Förderstiftungen mit Deadlines', + }, { href: '/wirkung', icon: '🌱', title: 'Wirkung', desc: 'CO₂, Geräte, Menschen' }, { href: '/finanzen', icon: '💰', title: 'Finanzen', desc: 'Einnahmen, Ausgaben, 8-Jahres-Trend' }, { href: '/preismodell', icon: '🏷️', title: 'Preismodell', desc: 'Solidarisches 4-Stufen-Modell' }, diff --git a/src/app/(tenant)/dokumente/DocumentsClient.tsx b/src/app/(tenant)/dokumente/DocumentsClient.tsx index 4630c270..2f0a0715 100644 --- a/src/app/(tenant)/dokumente/DocumentsClient.tsx +++ b/src/app/(tenant)/dokumente/DocumentsClient.tsx @@ -50,8 +50,12 @@ function DocumentSection({
{docs.length === 0 ? ( - -

Keine {type} gefunden für „{query}“

+ +

+ Keine {type} gefunden für „{query}“ +

) : (
@@ -82,7 +86,7 @@ export default function DocumentsClient({ documents, stats }: DocumentsClientPro (doc) => doc.title.toLowerCase().includes(query) || doc.description.toLowerCase().includes(query) || - doc.badge?.toLowerCase().includes(query) + doc.badge?.toLowerCase().includes(query), ); }; @@ -95,8 +99,8 @@ export default function DocumentsClient({ documents, stats }: DocumentsClientPro return (

- PDF-Gesuche öffnen im Browser.{' '} - CSV/Excel werden direkt heruntergeladen. + PDF-Gesuche öffnen im Browser. CSV/Excel werden direkt + heruntergeladen.

{/* Tabs */} @@ -172,7 +176,15 @@ export default function DocumentsClient({ documents, stats }: DocumentsClientPro /> Original-Datenquellen aus Kivitendo (anonymisiert). Diese Dateien bilden die Grundlage für alle Finanzdaten auf dieser Seite.Empfohlen: Lesen Sie zuerst das README!} + subtitle={ + <> + Original-Datenquellen aus Kivitendo (anonymisiert). Diese Dateien bilden die + Grundlage für alle Finanzdaten auf dieser Seite. + + Empfohlen: Lesen Sie zuerst das README! + + + } docs={filteredQuellen} query={searchQuery} type="Quelldateien" diff --git a/src/app/(tenant)/finanzen/FinanzenClient.tsx b/src/app/(tenant)/finanzen/FinanzenClient.tsx index 0333d3e8..c3e3c76f 100644 --- a/src/app/(tenant)/finanzen/FinanzenClient.tsx +++ b/src/app/(tenant)/finanzen/FinanzenClient.tsx @@ -17,13 +17,7 @@ import ShareButton from '@/components/ui/ShareButton'; import { useFinancialData } from '@/hooks/useFinancialData'; import { useNumberInspector } from '@/hooks/useNumberInspector'; import { formatCHF, calcGrowth } from '@/lib/utils/format'; -import { - COMPLETE_YEARS, - CURRENT_YEAR_DATA, - PEAK_REVENUE, - PEAK_YEAR, - DATA_QUALITY, -} from './data'; +import { COMPLETE_YEARS, CURRENT_YEAR_DATA, PEAK_REVENUE, PEAK_YEAR, DATA_QUALITY } from './data'; import { FINANCIAL_YEAR_RANGE } from '@/lib/config/financial-constants'; import OverviewTab from './tabs/OverviewTab'; import MonthlyTab from './tabs/MonthlyTab'; @@ -37,9 +31,8 @@ export default function FinanzenClient() { const financialData = useFinancialData(); const inspector = useNumberInspector(); const prevYear = useFinancialData(financialData.selectedYear - 1); - const growth = prevYear.totals.total > 0 - ? calcGrowth(prevYear.totals.total, financialData.totals.total) - : 0; + const growth = + prevYear.totals.total > 0 ? calcGrowth(prevYear.totals.total, financialData.totals.total) : 0; return (
@@ -64,26 +57,35 @@ export default function FinanzenClient() {

Warum jetzt? Die Ausgangslage ehrlich

- Unsere Einnahmen sind von {formatCHF(PEAK_REVENUE)} ({PEAK_YEAR}) auf {formatCHF(CURRENT_YEAR_DATA.revenue)} ({CURRENT_YEAR_DATA.year}) gefallen — ein Rückgang von über 50%. - Der Haupttreiber: Verlust von B2B-Hosting-Kunden (Dienstleistungen von {formatCHF(Math.max(...COMPLETE_YEARS.map(y => y.revenueDetail.dienstleistungen)))} auf {formatCHF(CURRENT_YEAR_DATA.revenueDetail.dienstleistungen)}). + Unsere Einnahmen sind von {formatCHF(PEAK_REVENUE)} ({PEAK_YEAR}) auf{' '} + {formatCHF(CURRENT_YEAR_DATA.revenue)} ({CURRENT_YEAR_DATA.year}) + gefallen — ein Rückgang von über 50%. Der Haupttreiber: Verlust von B2B-Hosting-Kunden + (Dienstleistungen von{' '} + {formatCHF(Math.max(...COMPLETE_YEARS.map((y) => y.revenueDetail.dienstleistungen)))}{' '} + auf {formatCHF(CURRENT_YEAR_DATA.revenueDetail.dienstleistungen)}).

- Das aktuelle Modell — abhängig von wenigen grossen Einzelkunden — ist fragil. - Stiftungsgelder sind keine Wachstumsinvestition, sondern ermöglichen die Diversifizierung, - die Revamp-IT zum langfristigen Überleben braucht. + Das aktuelle Modell — abhängig von wenigen grossen Einzelkunden — ist{' '} + fragil. Stiftungsgelder sind keine Wachstumsinvestition, sondern + ermöglichen die Diversifizierung, die Revamp-IT zum langfristigen + Überleben braucht.

- Alle Zahlen aus Kivitendo Buchhaltung. Vollständige P&L nur für {DATA_QUALITY.completeRange} verfügbar. - Seit 2024 wurden keine Aufwände verbucht. + Alle Zahlen aus Kivitendo Buchhaltung. Vollständige P&L nur für{' '} + {DATA_QUALITY.completeRange} verfügbar. Seit 2024 wurden keine Aufwände verbucht.

- Diese Seite zeigt: Einnahmen & Ausgaben (woher kommt das Geld?)
- Impact ansehen: Wirkungsseite - {' '}zeigt, was wir mit dem Geld bewirken. + Diese Seite zeigt: Einnahmen & Ausgaben (woher kommt das Geld?) +
+ Impact ansehen:{' '} + + Wirkungsseite + {' '} + zeigt, was wir mit dem Geld bewirken.

diff --git a/src/app/(tenant)/finanzen/__tests__/data.test.ts b/src/app/(tenant)/finanzen/__tests__/data.test.ts index 6bdb3287..1591a4dd 100644 --- a/src/app/(tenant)/finanzen/__tests__/data.test.ts +++ b/src/app/(tenant)/finanzen/__tests__/data.test.ts @@ -53,7 +53,7 @@ describe('ANNUAL_PL', () => { }); it('all years are distinct', () => { - const years = ANNUAL_PL.map(y => y.year); + const years = ANNUAL_PL.map((y) => y.year); expect(new Set(years).size).toBe(years.length); }); }); @@ -64,7 +64,7 @@ describe('ANNUAL_PL', () => { describe('COMPLETE_YEARS', () => { it('is a subset of ANNUAL_PL', () => { - const allYears = new Set(ANNUAL_PL.map(y => y.year)); + const allYears = new Set(ANNUAL_PL.map((y) => y.year)); for (const yr of COMPLETE_YEARS) { expect(allYears.has(yr.year)).toBe(true); } @@ -95,7 +95,7 @@ describe('CURRENT_YEAR_DATA', () => { }); it('has year equal to the maximum year in ANNUAL_PL', () => { - const maxYear = Math.max(...ANNUAL_PL.map(y => y.year)); + const maxYear = Math.max(...ANNUAL_PL.map((y) => y.year)); expect(CURRENT_YEAR_DATA.year).toBe(maxYear); }); @@ -110,12 +110,12 @@ describe('CURRENT_YEAR_DATA', () => { describe('PEAK_REVENUE and PEAK_YEAR', () => { it('PEAK_REVENUE is the maximum revenue across all years', () => { - const max = Math.max(...ANNUAL_PL.map(y => y.revenue)); + const max = Math.max(...ANNUAL_PL.map((y) => y.revenue)); expect(PEAK_REVENUE).toBe(max); }); it('PEAK_YEAR is the year with PEAK_REVENUE', () => { - const peakEntry = ANNUAL_PL.find(y => y.revenue === PEAK_REVENUE); + const peakEntry = ANNUAL_PL.find((y) => y.revenue === PEAK_REVENUE); expect(peakEntry).toBeDefined(); expect(PEAK_YEAR).toBe(peakEntry!.year); }); @@ -184,8 +184,8 @@ describe('AVG_REVENUE', () => { }); it('is within the range of min and max annual revenue', () => { - const min = Math.min(...ANNUAL_PL.map(y => y.revenue)); - const max = Math.max(...ANNUAL_PL.map(y => y.revenue)); + const min = Math.min(...ANNUAL_PL.map((y) => y.revenue)); + const max = Math.max(...ANNUAL_PL.map((y) => y.revenue)); expect(AVG_REVENUE).toBeGreaterThanOrEqual(min); expect(AVG_REVENUE).toBeLessThanOrEqual(max); }); diff --git a/src/app/(tenant)/finanzen/components/CostStructureCard.tsx b/src/app/(tenant)/finanzen/components/CostStructureCard.tsx index 5d97b266..c1e94733 100644 --- a/src/app/(tenant)/finanzen/components/CostStructureCard.tsx +++ b/src/app/(tenant)/finanzen/components/CostStructureCard.tsx @@ -70,19 +70,17 @@ export function CostStructureCard({ entry }: { entry: AnnualPLEntry }) { Ergebnis {entry.year}:{' '} = 0 ? 'text-success' : 'text-danger'}> {formatCHF(entry.result)} - - {' '}bei {formatCHF(entry.revenue)} Einnahmen. + {' '} + bei {formatCHF(entry.revenue)} Einnahmen. {entry.year === 2023 && ( - Miete macht {((detail.miete / total) * 100).toFixed(0)}% der Kosten aus — - der grösste Kostentreiber. + Miete macht {((detail.miete / total) * 100).toFixed(0)}% der Kosten aus — der grösste + Kostentreiber. )}
- {entry.note && ( -

⚠ {entry.note}

- )} + {entry.note &&

⚠ {entry.note}

} ); } diff --git a/src/app/(tenant)/finanzen/components/DataQualityBanner.tsx b/src/app/(tenant)/finanzen/components/DataQualityBanner.tsx index 4f0234d5..97db10cb 100644 --- a/src/app/(tenant)/finanzen/components/DataQualityBanner.tsx +++ b/src/app/(tenant)/finanzen/components/DataQualityBanner.tsx @@ -13,7 +13,8 @@ export function DataQualityBanner({ className = 'mb-6' }: DataQualityBannerProps

Wichtig: Eingeschränkte Datenverfügbarkeit

Vollständige P&L (Einnahmen + Aufwände): {DATA_QUALITY.completeRange}.{' '} - Nur Einnahmen: {DATA_QUALITY.incompleteRange} — Aufwände wurden im Buchhaltungssystem nicht verbucht. + Nur Einnahmen: {DATA_QUALITY.incompleteRange} — Aufwände wurden im + Buchhaltungssystem nicht verbucht.

Quelle: {DATA_QUALITY.source}. {DATA_QUALITY.caveat} diff --git a/src/app/(tenant)/finanzen/components/ProfitLossTable.tsx b/src/app/(tenant)/finanzen/components/ProfitLossTable.tsx index c79227c3..d313216b 100644 --- a/src/app/(tenant)/finanzen/components/ProfitLossTable.tsx +++ b/src/app/(tenant)/finanzen/components/ProfitLossTable.tsx @@ -20,19 +20,29 @@ export function ProfitLossTable({ data }: { data: AnnualPLEntry[] }) { - - - - - - + + + + + + {data.map((row) => { - const margin = row.isComplete && row.revenue > 0 - ? row.result / row.revenue - : null; + const margin = row.isComplete && row.revenue > 0 ? row.result / row.revenue : null; return ( {row.year} {row.note && ( - * + + * + )} -
JahrEinnahmenAufwandErgebnisMargeStatus + Jahr + + Einnahmen + + Aufwand + + Ergebnis + + Marge + + Status +
{formatCHF(row.revenue)} - {row.isComplete ? formatCHF(row.expenses) : ( + {row.isComplete ? ( + formatCHF(row.expenses) + ) : ( k.A. )} 0 ? 'text-success' : 'text-danger' - }`}> - {row.isComplete ? formatCHF(row.result) : ( + 0 + ? 'text-success' + : 'text-danger' + }`} + > + {row.isComplete ? ( + formatCHF(row.result) + ) : ( )} {margin !== null ? ( = 0 ? 'text-success' : 'text-danger'}> - {margin >= 0 ? '+' : ''}{formatPercent(margin)} + {margin >= 0 ? '+' : ''} + {formatPercent(margin)} ) : ( @@ -114,7 +136,9 @@ export function ProfitLossTable({ data }: { data: AnnualPLEntry[] }) { {row.isComplete && (
Ergebnis -
= 0 ? 'text-success' : 'text-danger'}`}> +
= 0 ? 'text-success' : 'text-danger'}`} + > {formatCHF(row.result)}
@@ -127,7 +151,11 @@ export function ProfitLossTable({ data }: { data: AnnualPLEntry[] }) { {/* Footnotes */} {data.some((d) => d.note) && (

- * {data.filter((d) => d.note).map((d) => `${d.year}: ${d.note}`).join(' | ')} + *{' '} + {data + .filter((d) => d.note) + .map((d) => `${d.year}: ${d.note}`) + .join(' | ')}

)} diff --git a/src/app/(tenant)/finanzen/components/YearComparison.tsx b/src/app/(tenant)/finanzen/components/YearComparison.tsx index b165d4ee..244f81e5 100644 --- a/src/app/(tenant)/finanzen/components/YearComparison.tsx +++ b/src/app/(tenant)/finanzen/components/YearComparison.tsx @@ -33,7 +33,11 @@ export function YearComparison({ const rows = [ { label: 'Gesamteinnahmen', current: totals.total, previous: prevTotals.total }, { label: 'Warenverkauf', current: totals.warenverkauf, previous: prevTotals.warenverkauf }, - { label: 'Dienstleistungen', current: totals.dienstleistungen, previous: prevTotals.dienstleistungen }, + { + label: 'Dienstleistungen', + current: totals.dienstleistungen, + previous: prevTotals.dienstleistungen, + }, { label: 'Integration', current: totals.integration, previous: prevTotals.integration }, { label: 'Spenden + Aufstockung', current: donations, previous: prevDonations }, ]; @@ -41,20 +45,28 @@ export function YearComparison({ return ( - Jahresvergleich {selectedYear - 1} vs. {selectedYear} + + Jahresvergleich {selectedYear - 1} vs. {selectedYear} +
{rows.map((row) => { const change = row.previous > 0 ? calcGrowth(row.previous, row.current) : 0; return ( -
+
{row.label}
{formatCHF(row.previous)} {formatCHF(row.current)} {row.previous > 0 && ( - 0.05 ? 'success' : change < -0.05 ? 'danger' : 'default'}> - {change > 0 ? '+' : ''}{formatPercent(change)} + 0.05 ? 'success' : change < -0.05 ? 'danger' : 'default'} + > + {change > 0 ? '+' : ''} + {formatPercent(change)} )}
diff --git a/src/app/(tenant)/finanzen/data.ts b/src/app/(tenant)/finanzen/data.ts index 1d3e9f95..1418fc3b 100644 --- a/src/app/(tenant)/finanzen/data.ts +++ b/src/app/(tenant)/finanzen/data.ts @@ -30,15 +30,40 @@ export const REVENUE_CATEGORIES = [ // --------------------------------------------------------------------------- export const EXPENSE_CATEGORIES = [ - { key: 'miete' as const, label: 'Miete & Nebenkosten', codes: '6000, 6400, 6460', color: CHART_COLORS.red }, + { + key: 'miete' as const, + label: 'Miete & Nebenkosten', + codes: '6000, 6400, 6460', + color: CHART_COLORS.red, + }, { key: 'personal' as const, label: 'Personal', codes: '5000–5830', color: CHART_COLORS.blue }, - { key: 'material' as const, label: 'Material & Beschaffung', codes: '4000, 4400, 4710', color: CHART_COLORS.amber }, - { key: 'it' as const, label: 'IT & Kommunikation', codes: '6510, 6511', color: CHART_COLORS.purple }, - { key: 'abschreibungen' as const, label: 'Abschreibungen', codes: '6800', color: CHART_COLORS.concrete }, - { key: 'uebrig' as const, label: 'Übriger Betrieb', codes: '6100–6900, 8xxx', color: CHART_COLORS.silver }, + { + key: 'material' as const, + label: 'Material & Beschaffung', + codes: '4000, 4400, 4710', + color: CHART_COLORS.amber, + }, + { + key: 'it' as const, + label: 'IT & Kommunikation', + codes: '6510, 6511', + color: CHART_COLORS.purple, + }, + { + key: 'abschreibungen' as const, + label: 'Abschreibungen', + codes: '6800', + color: CHART_COLORS.concrete, + }, + { + key: 'uebrig' as const, + label: 'Übriger Betrieb', + codes: '6100–6900, 8xxx', + color: CHART_COLORS.silver, + }, ] as const; -type ExpenseCategoryKey = typeof EXPENSE_CATEGORIES[number]['key']; +type ExpenseCategoryKey = (typeof EXPENSE_CATEGORIES)[number]['key']; // --------------------------------------------------------------------------- // Annual P&L data (Erfolgsrechnung 2018-2025) @@ -64,43 +89,152 @@ export interface AnnualPLEntry { // Expense categories aggregated from individual Kontenplan accounts export const ANNUAL_PL: AnnualPLEntry[] = [ { - year: 2018, revenue: 107135, expenses: 83066, result: 24069, isComplete: true, - revenueDetail: { warenverkauf: 23689, dienstleistungen: 57472, integration: 5500, spenden: 20494 }, - expenseDetail: { miete: 30000, personal: 39999, material: 5275, it: 4173, abschreibungen: 0, uebrig: 3620 }, + year: 2018, + revenue: 107135, + expenses: 83066, + result: 24069, + isComplete: true, + revenueDetail: { + warenverkauf: 23689, + dienstleistungen: 57472, + integration: 5500, + spenden: 20494, + }, + expenseDetail: { + miete: 30000, + personal: 39999, + material: 5275, + it: 4173, + abschreibungen: 0, + uebrig: 3620, + }, }, { - year: 2019, revenue: 79842, expenses: 75039, result: 4802, isComplete: true, + year: 2019, + revenue: 79842, + expenses: 75039, + result: 4802, + isComplete: true, note: 'Buchungsanomalien: Löhne=0, Miete=2.5k, 6700=55.6k (Umstrukturierung)', - revenueDetail: { warenverkauf: 30925, dienstleistungen: 67137, integration: 9500, spenden: 4675 }, - expenseDetail: { miete: 2500, personal: 3000, material: 117, it: 78, abschreibungen: 500, uebrig: 68845 }, + revenueDetail: { + warenverkauf: 30925, + dienstleistungen: 67137, + integration: 9500, + spenden: 4675, + }, + expenseDetail: { + miete: 2500, + personal: 3000, + material: 117, + it: 78, + abschreibungen: 500, + uebrig: 68845, + }, }, { - year: 2020, revenue: 93715, expenses: 95785, result: -2070, isComplete: true, - revenueDetail: { warenverkauf: 17815, dienstleistungen: 67592, integration: 6000, spenden: 2324 }, - expenseDetail: { miete: 34970, personal: 30344, material: 2805, it: 7767, abschreibungen: 1245, uebrig: 18654 }, + year: 2020, + revenue: 93715, + expenses: 95785, + result: -2070, + isComplete: true, + revenueDetail: { + warenverkauf: 17815, + dienstleistungen: 67592, + integration: 6000, + spenden: 2324, + }, + expenseDetail: { + miete: 34970, + personal: 30344, + material: 2805, + it: 7767, + abschreibungen: 1245, + uebrig: 18654, + }, }, { - year: 2021, revenue: 140191, expenses: 142870, result: -2679, isComplete: true, - revenueDetail: { warenverkauf: 48221, dienstleistungen: 69020, integration: 9000, spenden: 15832 }, - expenseDetail: { miete: 79465, personal: 29328, material: 10667, it: 5770, abschreibungen: 0, uebrig: 17640 }, + year: 2021, + revenue: 140191, + expenses: 142870, + result: -2679, + isComplete: true, + revenueDetail: { + warenverkauf: 48221, + dienstleistungen: 69020, + integration: 9000, + spenden: 15832, + }, + expenseDetail: { + miete: 79465, + personal: 29328, + material: 10667, + it: 5770, + abschreibungen: 0, + uebrig: 17640, + }, }, { - year: 2022, revenue: 129920, expenses: 130413, result: -493, isComplete: true, - revenueDetail: { warenverkauf: 35441, dienstleistungen: 80080, integration: 3500, spenden: 10867 }, - expenseDetail: { miete: 79990, personal: 32109, material: 8958, it: 5994, abschreibungen: 0, uebrig: 3363 }, + year: 2022, + revenue: 129920, + expenses: 130413, + result: -493, + isComplete: true, + revenueDetail: { + warenverkauf: 35441, + dienstleistungen: 80080, + integration: 3500, + spenden: 10867, + }, + expenseDetail: { + miete: 79990, + personal: 32109, + material: 8958, + it: 5994, + abschreibungen: 0, + uebrig: 3363, + }, }, { - year: 2023, revenue: 134450, expenses: 159667, result: -25217, isComplete: true, - revenueDetail: { warenverkauf: 36997, dienstleistungen: 75342, integration: 10500, spenden: 10192 }, - expenseDetail: { miete: 80280, personal: 48075, material: 8857, it: 8583, abschreibungen: 10000, uebrig: 3872 }, + year: 2023, + revenue: 134450, + expenses: 159667, + result: -25217, + isComplete: true, + revenueDetail: { + warenverkauf: 36997, + dienstleistungen: 75342, + integration: 10500, + spenden: 10192, + }, + expenseDetail: { + miete: 80280, + personal: 48075, + material: 8857, + it: 8583, + abschreibungen: 10000, + uebrig: 3872, + }, }, { - year: 2024, revenue: 119274, expenses: 131, result: 119143, isComplete: false, + year: 2024, + revenue: 119274, + expenses: 131, + result: 119143, + isComplete: false, note: 'Aufwände nicht verbucht — nur Einnahmen zuverlässig', - revenueDetail: { warenverkauf: 23135, dienstleistungen: 84974, integration: 4500, spenden: -209 }, + revenueDetail: { + warenverkauf: 23135, + dienstleistungen: 84974, + integration: 4500, + spenden: -209, + }, }, { - year: 2025, revenue: 60402, expenses: 1397, result: 59005, isComplete: false, + year: 2025, + revenue: 60402, + expenses: 1397, + result: 59005, + isComplete: false, note: 'Aufwände nicht verbucht — nur Einnahmen zuverlässig', revenueDetail: { warenverkauf: 22086, dienstleistungen: 27573, integration: 0, spenden: 762 }, }, @@ -110,15 +244,18 @@ export const ANNUAL_PL: AnnualPLEntry[] = [ // Derived constants // --------------------------------------------------------------------------- -export const COMPLETE_YEARS = ANNUAL_PL.filter(y => y.isComplete); -export const PEAK_REVENUE = Math.max(...ANNUAL_PL.map(y => y.revenue)); -export const PEAK_YEAR = ANNUAL_PL.find(y => y.revenue === PEAK_REVENUE)!.year; +export const COMPLETE_YEARS = ANNUAL_PL.filter((y) => y.isComplete); +export const PEAK_REVENUE = Math.max(...ANNUAL_PL.map((y) => y.revenue)); +export const PEAK_YEAR = ANNUAL_PL.find((y) => y.revenue === PEAK_REVENUE)!.year; export const LATEST_COMPLETE = COMPLETE_YEARS[COMPLETE_YEARS.length - 1]; /** Most recent entry in ANNUAL_PL — may be partial/in-progress year */ export const CURRENT_YEAR_DATA = ANNUAL_PL[ANNUAL_PL.length - 1]; export const CUMULATIVE_RESULT = COMPLETE_YEARS.reduce((sum, y) => sum + y.result, 0); /** Total device-sale revenue across all tracked years — SSOT for ~1600+ device count estimate */ -export const CUMULATIVE_WARENVERKAUF = ANNUAL_PL.reduce((sum, y) => sum + y.revenueDetail.warenverkauf, 0); +export const CUMULATIVE_WARENVERKAUF = ANNUAL_PL.reduce( + (sum, y) => sum + y.revenueDetail.warenverkauf, + 0, +); export const AVG_REVENUE = Math.round( COMPLETE_YEARS.reduce((sum, y) => sum + y.revenue, 0) / COMPLETE_YEARS.length, @@ -134,7 +271,9 @@ export const DATA_QUALITY = { completeRange: '2018–2023', incompleteRange: '2024–2025', lastCompleteYear: 2023, - caveat: 'Seit 2024 wurden keine Aufwände verbucht. Das Geschäftsergebnis 2024–2025 ist nicht berechenbar. Nur Einnahmen sind zuverlässig.', + caveat: + 'Seit 2024 wurden keine Aufwände verbucht. Das Geschäftsergebnis 2024–2025 ist nicht berechenbar. Nur Einnahmen sind zuverlässig.', anomalousYear: 2019, - anomalyNote: '2019: Grosse Umbuchungen (6700=55.6k, 3805=-34.2k). Aufwandstruktur nicht aussagekräftig.', + anomalyNote: + '2019: Grosse Umbuchungen (6700=55.6k, 3805=-34.2k). Aufwandstruktur nicht aussagekräftig.', } as const; diff --git a/src/app/(tenant)/finanzen/tabs/MonthlyTab.tsx b/src/app/(tenant)/finanzen/tabs/MonthlyTab.tsx index bb761551..e9116f58 100644 --- a/src/app/(tenant)/finanzen/tabs/MonthlyTab.tsx +++ b/src/app/(tenant)/finanzen/tabs/MonthlyTab.tsx @@ -4,18 +4,10 @@ import MetricGrid from '@/components/metrics/MetricGrid'; import Card, { CardHeader, CardTitle } from '@/components/ui/Card'; import { ChartLoadingSkeleton } from '@/components/charts/ChartWrapper'; import YearSelector from '@/components/ui/YearSelector'; -import { - formatCHF, - formatPercent, - formatMonthShort, -} from '@/lib/utils/format'; +import { formatCHF, formatPercent, formatMonthShort } from '@/lib/utils/format'; import { NumberSources, metricToInspectorData } from '@/lib/config/metrics'; import { REVENUE_CATEGORIES } from '../data'; -import { - InsightCard, - YearComparison, - MonthlyBreakdownTable, -} from '../components'; +import { InsightCard, YearComparison, MonthlyBreakdownTable } from '../components'; import type { InspectorHandle } from '@/app/(tenant)/fundraising/sections/Inspectable'; import type { useFinancialData } from '@/hooks/useFinancialData'; @@ -71,11 +63,11 @@ export default function MonthlyTab({ data, prevTotals, growth, inspector }: Mont trend={growth !== 0 ? { value: growth, label: `vs. ${selectedYear - 1}` } : undefined} sourceType="live" onClick={() => - inspector.inspect(metricToInspectorData( - NumberSources.financial_total_2025, - formatCHF(totals.total), - { year: selectedYear }, - )) + inspector.inspect( + metricToInspectorData(NumberSources.financial_total_2025, formatCHF(totals.total), { + year: selectedYear, + }), + ) } /> - inspector.inspect(metricToInspectorData( - NumberSources.financial_earned_2025, - formatCHF(earned), - { + inspector.inspect( + metricToInspectorData(NumberSources.financial_earned_2025, formatCHF(earned), { year: selectedYear, formula: `${formatCHF(totals.warenverkauf)} + ${formatCHF(totals.dienstleistungen)} + ${formatCHF(totals.integration)}`, - }, - )) + }), + ) } /> 0 ? donations / totals.total : 0)} vom Total`} sourceType="live" onClick={() => - inspector.inspect(metricToInspectorData( - NumberSources.financial_donations_2025, - formatCHF(donations), - { + inspector.inspect( + metricToInspectorData(NumberSources.financial_donations_2025, formatCHF(donations), { year: selectedYear, formula: `${formatCHF(totals.spenden)} + ${formatCHF(totals.aufstockung)}`, - }, - )) + }), + ) } /> - inspector.inspect(metricToInspectorData( - NumberSources.financial_self_financing_2025, - formatPercent(selfFinancingRate), - { - year: selectedYear, - formula: `(${formatCHF(totals.warenverkauf)} + ${formatCHF(totals.dienstleistungen)}) / ${formatCHF(totals.total)}`, - }, - )) + inspector.inspect( + metricToInspectorData( + NumberSources.financial_self_financing_2025, + formatPercent(selfFinancingRate), + { + year: selectedYear, + formula: `(${formatCHF(totals.warenverkauf)} + ${formatCHF(totals.dienstleistungen)}) / ${formatCHF(totals.total)}`, + }, + ), + ) } /> @@ -161,11 +151,16 @@ export default function MonthlyTab({ data, prevTotals, growth, inspector }: Mont subtitle="pro aktivem Monat" sourceType="derived" onClick={() => - inspector.inspect(metricToInspectorData( - NumberSources.financial_monthly_avg_2025, - formatCHF(monthlyAvg), - { year: selectedYear, formula: `${formatCHF(totals.total)} / ${monthCount} Monate` }, - )) + inspector.inspect( + metricToInspectorData( + NumberSources.financial_monthly_avg_2025, + formatCHF(monthlyAvg), + { + year: selectedYear, + formula: `${formatCHF(totals.total)} / ${monthCount} Monate`, + }, + ), + ) } /> @@ -207,7 +202,9 @@ export default function MonthlyTab({ data, prevTotals, growth, inspector }: Mont
= 0.6 ? 'success' : 'warning'} - title={selfFinancingRate >= 0.6 ? 'Eigenfinanzierung stabil' : 'Hohe Spendenabhängigkeit'} + title={ + selfFinancingRate >= 0.6 ? 'Eigenfinanzierung stabil' : 'Hohe Spendenabhängigkeit' + } text={ selfFinancingRate >= 0.6 ? `Mit ${formatPercent(selfFinancingRate)} Eigenfinanzierung ist die Organisation gut aufgestellt.` @@ -245,7 +242,8 @@ export default function MonthlyTab({ data, prevTotals, growth, inspector }: Mont

Datenquelle

Kivitendo Buchhaltung (Export: revamp-Einnahmen-{selectedYear}.xlsx)

- Konten: 3100 Warenverkauf, 3400 Dienstleistungen, 3450 Integration, 3500 Spenden, 3510 Aufstockung + Konten: 3100 Warenverkauf, 3400 Dienstleistungen, 3450 Integration, 3500 Spenden, 3510 + Aufstockung

diff --git a/src/app/(tenant)/finanzen/tabs/OverviewTab.tsx b/src/app/(tenant)/finanzen/tabs/OverviewTab.tsx index 50f84a00..37a22c78 100644 --- a/src/app/(tenant)/finanzen/tabs/OverviewTab.tsx +++ b/src/app/(tenant)/finanzen/tabs/OverviewTab.tsx @@ -50,10 +50,7 @@ export default function OverviewTab({ inspector }: { inspector: InspectorHandle value={formatCHF(CURRENT_YEAR_DATA.revenue)} subtitle="Aktuellstes Jahr" trend={{ - value: calcGrowth( - ANNUAL_PL[ANNUAL_PL.length - 2].revenue, - CURRENT_YEAR_DATA.revenue, - ), + value: calcGrowth(ANNUAL_PL[ANNUAL_PL.length - 2].revenue, CURRENT_YEAR_DATA.revenue), label: `vs. ${ANNUAL_PL[ANNUAL_PL.length - 2].year}`, }} sourceType="live" @@ -95,7 +92,8 @@ export default function OverviewTab({ inspector }: { inspector: InspectorHandle sourceType: 'derived', source: DATA_QUALITY.source, formula: `SUM(Umsatz ${DATA_QUALITY.completeRange}) / ${COMPLETE_YEARS.length}`, - description: 'Durchschnittlicher Jahresumsatz über alle vollständigen Geschäftsjahre.', + description: + 'Durchschnittlicher Jahresumsatz über alle vollständigen Geschäftsjahre.', }) } /> @@ -118,13 +116,18 @@ export default function OverviewTab({ inspector }: { inspector: InspectorHandle Analyse {(() => { - const lossYears = COMPLETE_YEARS.filter(y => y.result < 0); - const lossYearRange = lossYears.length > 0 - ? `${lossYears[0].year}–${lossYears[lossYears.length - 1].year}` - : ''; + const lossYears = COMPLETE_YEARS.filter((y) => y.result < 0); + const lossYearRange = + lossYears.length > 0 + ? `${lossYears[0].year}–${lossYears[lossYears.length - 1].year}` + : ''; const rentFirst = ANNUAL_PL[0].expenseDetail?.miete ?? 0; - const rentPeak = Math.max(...COMPLETE_YEARS.filter(y => y.expenseDetail?.miete).map(y => y.expenseDetail!.miete)); - const profitYear = COMPLETE_YEARS.find(y => y.result > 0); + const rentPeak = Math.max( + ...COMPLETE_YEARS.filter((y) => y.expenseDetail?.miete).map( + (y) => y.expenseDetail!.miete, + ), + ); + const profitYear = COMPLETE_YEARS.find((y) => y.result > 0); return (
@@ -161,11 +164,10 @@ export default function OverviewTab({ inspector }: { inspector: InspectorHandle

Datenquelle

{DATA_QUALITY.source}

- System: {DATA_QUALITY.sourceSystem} | Vollständig: {DATA_QUALITY.completeRange} | Unvollständig: {DATA_QUALITY.incompleteRange} -

-

- {DATA_QUALITY.caveat} + System: {DATA_QUALITY.sourceSystem} | Vollständig: {DATA_QUALITY.completeRange} | + Unvollständig: {DATA_QUALITY.incompleteRange}

+

{DATA_QUALITY.caveat}

diff --git a/src/app/(tenant)/fundraising/FundraisingClient.tsx b/src/app/(tenant)/fundraising/FundraisingClient.tsx index c08270fd..f1de85ba 100644 --- a/src/app/(tenant)/fundraising/FundraisingClient.tsx +++ b/src/app/(tenant)/fundraising/FundraisingClient.tsx @@ -9,7 +9,12 @@ import { NumberSources, metricToInspectorData } from '@/lib/config/metrics'; import { ORG_PROFILE } from '@/lib/config/org-profile'; import StoryBridge from '@/components/layout/StoryBridge'; import { STORY_BRIDGES } from '@/lib/config/story-bridges'; -import { QUICK_ACTIONS, HERO_STATS, SPACE_TOTAL_WITH_CIRCULATION, PROJECT_YEAR_RANGE } from './data'; +import { + QUICK_ACTIONS, + HERO_STATS, + SPACE_TOTAL_WITH_CIRCULATION, + PROJECT_YEAR_RANGE, +} from './data'; import Inspectable from './sections/Inspectable'; import WhyWeNeedFunding from './sections/WhyWeNeedFunding'; import TwoAsks from './sections/TwoAsks'; @@ -32,7 +37,13 @@ export default function FundraisingClient({ foundations }: { foundations: Founda const inspectSpace = NumberSources.space_total ? metricToInspectorData(NumberSources.space_total, `${SPACE_TOTAL_WITH_CIRCULATION} m²`) - : { label: 'Gesamtfläche', value: `${SPACE_TOTAL_WITH_CIRCULATION} m²`, sourceType: 'derived' as const, source: 'SPACE_PLAN', confidence: 'Hoch' }; + : { + label: 'Gesamtfläche', + value: `${SPACE_TOTAL_WITH_CIRCULATION} m²`, + sourceType: 'derived' as const, + source: 'SPACE_PLAN', + confidence: 'Hoch', + }; return ( <> @@ -51,7 +62,9 @@ export default function FundraisingClient({ foundations }: { foundations: Founda className="flex items-center justify-between rounded-lg border border-border-default bg-surface-base px-5 py-4 text-sm font-semibold text-text-primary transition-colors hover:bg-surface-raised" > {label} - + ))}
@@ -67,29 +80,37 @@ export default function FundraisingClient({ foundations }: { foundations: Founda “Alte Computer. Neue Chancen. Bessere Zukunft.”

- Seit {ORG_PROFILE.founded} verbinden wir Kreislaufwirtschaft, Arbeitsintegration und Tech-Bildung unter - einem Dach. Auf{' '} + Seit {ORG_PROFILE.founded} verbinden wir Kreislaufwirtschaft, Arbeitsintegration und + Tech-Bildung unter einem Dach. Auf{' '} {SPACE_TOTAL_WITH_CIRCULATION} m² - - {' '}bauen wir Werkstatt, Makerspace, AI Lab, Event-/Kulturraum - und Museum — ein Ort für nachhaltige Technologie, souveräne KI und Community. + {' '} + bauen wir Werkstatt, Makerspace, AI Lab, Event-/Kulturraum und Museum — ein Ort für + nachhaltige Technologie, souveräne KI und Community.

{HERO_STATS.map((item) => ( -
-
{item.label}
+
+
+ {item.label} +
{item.value}
{item.sub}
))}
- + Vollständige Vision & Strategie →
diff --git a/src/app/(tenant)/fundraising/applications/[id]/page.tsx b/src/app/(tenant)/fundraising/applications/[id]/page.tsx index 0a589b27..2297fa1d 100644 --- a/src/app/(tenant)/fundraising/applications/[id]/page.tsx +++ b/src/app/(tenant)/fundraising/applications/[id]/page.tsx @@ -13,19 +13,31 @@ import Card from '@/components/ui/Card'; import { LoadingState } from '@/components/ui/LoadingState'; import { ErrorAlert } from '@/components/ui/ErrorAlert'; import { Button } from '@/components/ui/Button'; -import { APPLICATION_STATUSES, getStatusConfig, isTerminalStatus, type ApplicationStatusId } from '@/lib/config/application-statuses'; +import { + APPLICATION_STATUSES, + getStatusConfig, + isTerminalStatus, + type ApplicationStatusId, +} from '@/lib/config/application-statuses'; import { PRIORITY_CONFIG } from '@/lib/config/foundations'; import { useApplicationForm } from '@/hooks/useApplicationForm'; import ActivityTimeline from '@/components/ui/ActivityTimeline'; import DeleteConfirmBlock from '@/components/fundraising/DeleteConfirmBlock'; -import { ApplicationDateFields, ApplicationOutcomeFields } from '@/components/fundraising/ApplicationFormSections'; +import { + ApplicationDateFields, + ApplicationOutcomeFields, +} from '@/components/fundraising/ApplicationFormSections'; import ApplicationFoundationCard from '@/components/fundraising/ApplicationFoundationCard'; interface ApplicationDetailProps { params: Promise<{ id: string }>; } -import { FORM_INPUT_CLASS as inputClass, FORM_LABEL_CLASS as labelClass, FORM_GRID_2COL_CLASS } from '@/lib/utils/form-classes'; +import { + FORM_INPUT_CLASS as inputClass, + FORM_LABEL_CLASS as labelClass, + FORM_GRID_2COL_CLASS, +} from '@/lib/utils/form-classes'; export default function ApplicationDetailPage({ params }: ApplicationDetailProps) { const { id } = use(params); @@ -75,7 +87,6 @@ export default function ApplicationDetailPage({ params }: ApplicationDetailProps return (
- {/* Header */}
@@ -85,9 +96,7 @@ export default function ApplicationDetailPage({ params }: ApplicationDetailProps > ← Pipeline -

- {foundation?.name ?? 'Unbekannte Stiftung'} -

+

{foundation?.name ?? 'Unbekannte Stiftung'}

{foundation && (
- updateField('status', e.target.value as ApplicationStatusId)} + className={inputClass} + > {APPLICATION_STATUSES.map((s) => ( - + ))}
- updateField('priorityLevel', e.target.value)} + className={inputClass} + > {Object.entries(PRIORITY_CONFIG).map(([value, cfg]) => ( - + ))}
@@ -246,12 +267,7 @@ export default function ApplicationDetailPage({ params }: ApplicationDetailProps /> {/* Save */} -
diff --git a/src/app/(tenant)/fundraising/applications/error.tsx b/src/app/(tenant)/fundraising/applications/error.tsx index 0fb7ee1a..ff5ede5b 100644 --- a/src/app/(tenant)/fundraising/applications/error.tsx +++ b/src/app/(tenant)/fundraising/applications/error.tsx @@ -10,9 +10,7 @@ export default function ApplicationsError({ }) { return (
-

- Pipeline konnte nicht geladen werden -

+

Pipeline konnte nicht geladen werden

Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut oder laden Sie die Seite neu.

diff --git a/src/app/(tenant)/fundraising/applications/page.tsx b/src/app/(tenant)/fundraising/applications/page.tsx index de224417..bf085deb 100644 --- a/src/app/(tenant)/fundraising/applications/page.tsx +++ b/src/app/(tenant)/fundraising/applications/page.tsx @@ -21,10 +21,7 @@ export default async function ApplicationsPage() { return (
- +
); diff --git a/src/app/(tenant)/fundraising/bildung/sections/BPLRolesSection.tsx b/src/app/(tenant)/fundraising/bildung/sections/BPLRolesSection.tsx index 8b64afbe..5c86f896 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/BPLRolesSection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/BPLRolesSection.tsx @@ -5,7 +5,9 @@ import { BILDUNGSPROGRAMMLEITER, MULTIPLICATION_EFFECT } from '@/lib/config/team export default function BPLRolesSection() { return (
-

Zwei Bildungsprogrammleiter:innen — Zwei Fokusgebiete

+

+ Zwei Bildungsprogrammleiter:innen — Zwei Fokusgebiete +

{BILDUNGSPROGRAMMLEITER.map((bpl) => ( @@ -28,7 +30,9 @@ export default function BPLRolesSection() {

Fachgebiete:

{bpl.fachgebiete?.map((fg) => ( - {fg} + + {fg} + ))}
@@ -36,12 +40,22 @@ export default function BPLRolesSection() {

Multiplikationseffekt:

{bpl.name.includes('Hardware') ? (
-

{MULTIPLICATION_EFFECT.hardware_bpl.direct_training} Techniker/Jahr direkt trainiert

+

+ + {MULTIPLICATION_EFFECT.hardware_bpl.direct_training} Techniker/Jahr + {' '} + direkt trainiert +

Strukturierte Refurbishment-Ausbildung mit Curricula und Qualitätssicherung

) : (
-

{MULTIPLICATION_EFFECT.software_bpl.direct_training} Menschen/Jahr direkt trainiert

+

+ + {MULTIPLICATION_EFFECT.software_bpl.direct_training} Menschen/Jahr + {' '} + direkt trainiert +

AI Literacy, Coding, Open-Source-Workshops für verschiedene Niveaus

)} diff --git a/src/app/(tenant)/fundraising/bildung/sections/BildungBudgetSection.tsx b/src/app/(tenant)/fundraising/bildung/sections/BildungBudgetSection.tsx index d01b8e4c..d2067a81 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/BildungBudgetSection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/BildungBudgetSection.tsx @@ -27,7 +27,9 @@ export default function BildungBudgetSection() {
Total pro Jahr - CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)} + + CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)} +
@@ -38,7 +40,12 @@ export default function BildungBudgetSection() {
Investition pro direkt Trainierter
- CHF {formatNumber(Math.round(BPL_TOTAL_COST_PER_YEAR / MULTIPLICATION_EFFECT.combined.direct_training))} + CHF{' '} + {formatNumber( + Math.round( + BPL_TOTAL_COST_PER_YEAR / MULTIPLICATION_EFFECT.combined.direct_training, + ), + )}
pro direkt trainierter Person/Jahr
@@ -52,7 +59,9 @@ export default function BildungBudgetSection() {
Finanzierungsziel
3 Jahre
-
CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)}/Jahr bis Selbsttragung
+
+ CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)}/Jahr bis Selbsttragung +
diff --git a/src/app/(tenant)/fundraising/bildung/sections/BildungCTASection.tsx b/src/app/(tenant)/fundraising/bildung/sections/BildungCTASection.tsx index 987697ea..e9f48174 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/BildungCTASection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/BildungCTASection.tsx @@ -9,8 +9,9 @@ export default function BildungCTASection() {

Unterstütze das Bildungsprogramm

- Mit CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)}/Jahr über 3 Jahre (degressiv finanziert) schaffen wir ein selbsttragendes Train-the-Trainer-Programm, - das {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr erreicht. + Mit CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)}/Jahr über 3 Jahre{' '} + (degressiv finanziert) schaffen wir ein selbsttragendes Train-the-Trainer-Programm, das{' '} + {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr erreicht.

Das Problem: Soziale Reichweite ist begrenzt
- +

Aktueller Engpass

- {TEAM_MEMBERS.length} Personen im Team (Leitung, Techniker, Betrieb) — aber nur 3 in der Leitung (Vero, Dani, Andreas), keine dedizierte Bildungskapazität + {TEAM_MEMBERS.length} Personen im Team (Leitung, Techniker, + Betrieb) — aber nur 3 in der Leitung (Vero, Dani, Andreas), keine dedizierte + Bildungskapazität

- Aktuelle Reichweite: ~5 Menschen/Jahr direkt trainiert (Schätzung, nicht systematisch erfasst) + Aktuelle Reichweite: ~5 Menschen/Jahr direkt trainiert (Schätzung, + nicht systematisch erfasst) +

+

+ Was fehlt:

-

Was fehlt:

  • Dedizierte Bildungsprogrammleiter:innen für systematisches Training
  • -
  • Train-the-Trainer Ansatz: Trainer:innen ausbilden statt nur direkt trainieren
  • +
  • + Train-the-Trainer Ansatz: Trainer:innen ausbilden statt nur direkt trainieren +
  • Strukturierte Curricula für Hardware- und Software-/AI-Bildung
  • -
  • Skalierbare Kapazität: Von informellem Training zu {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr
  • +
  • + Skalierbare Kapazität: Von informellem Training zu {PEOPLE_REACHED_PER_YEAR}{' '} + Menschen/Jahr +
diff --git a/src/app/(tenant)/fundraising/bildung/sections/FinanzierungsstrategieSection.tsx b/src/app/(tenant)/fundraising/bildung/sections/FinanzierungsstrategieSection.tsx index c2718f3a..d18d5a88 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/FinanzierungsstrategieSection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/FinanzierungsstrategieSection.tsx @@ -12,44 +12,65 @@ export default function FinanzierungsstrategieSection() {

Finanzierungsstrategie: 3 Jahre bis Selbsttragung

- Wir suchen Stiftungsfinanzierung über 3 Jahre (CHF {formatNumber(BPL_TOTAL_COST_PER_YEAR)}/Jahr, degressiv), - um die Bildungsprogrammleiter:innen-Stellen zu finanzieren, während wir parallel Einnahmequellen aufbauen. + Wir suchen Stiftungsfinanzierung über 3 Jahre (CHF{' '} + {formatNumber(BPL_TOTAL_COST_PER_YEAR)}/Jahr, degressiv), um die + Bildungsprogrammleiter:innen-Stellen zu finanzieren, während wir parallel Einnahmequellen + aufbauen.

- Jahr 1 + + Jahr 1 +

Aufbau & Pilotphase

  • Hardware-BPL eingestellt, Curricula entwickelt, erste Trainings
  • Erste trainierte Techniker werden aktiv (2-3 Trainer:innen)
  • -
  • Finanzierung: 100% Stiftungsgelder (1× BPL: CHF {formatNumber(hardware_bpl_cost)} inkl. Sozialabgaben)
  • -
  • Einnahmen: Workshop-Fees beginnen (CHF 5-10k, nicht ausreichend für Selbsttragung)
  • +
  • + Finanzierung: 100% Stiftungsgelder (1× BPL: CHF {formatNumber(hardware_bpl_cost)}{' '} + inkl. Sozialabgaben) +
  • +
  • + Einnahmen: Workshop-Fees beginnen (CHF 5-10k, nicht ausreichend für Selbsttragung) +
- Jahr 2 + + Jahr 2 +

Skalierung & zweite:r BPL

  • Software/AI-BPL eingestellt, beide Programme laufen parallel
  • 5 Hardware-Techniker + 3 AI-Trainer gleichzeitig aktiv
  • -
  • Finanzierung: degressiv (Stiftungsgelder sinken, Eigenmittel durch Kurseinnahmen steigen)
  • +
  • + Finanzierung: degressiv (Stiftungsgelder sinken, Eigenmittel durch Kurseinnahmen + steigen) +
  • Einnahmen: Corporate Trainings, Workshop-Fees (CHF 30-40k/Jahr)
- Jahr 3 (Ziel) + + Jahr 3 (Ziel) +

Selbsttragung erreicht

    -
  • Train-the-Trainer voll etabliert, {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr erreicht
  • +
  • + Train-the-Trainer voll etabliert, {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr erreicht +
  • Trainer:innen trainieren ohne unsere direkte Beteiligung
  • -
  • Finanzierung: 50% Stiftungsgelder, 50% Eigenmittel (Workshop-Fees, Corporate Training)
  • +
  • + Finanzierung: 50% Stiftungsgelder, 50% Eigenmittel (Workshop-Fees, Corporate + Training) +
  • Einnahmen: Corporate Trainings, Workshops, Zuschüsse (CHF 80-100k/Jahr)
diff --git a/src/app/(tenant)/fundraising/bildung/sections/HubBildungSynergySection.tsx b/src/app/(tenant)/fundraising/bildung/sections/HubBildungSynergySection.tsx index 649d1adf..4bcdb1af 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/HubBildungSynergySection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/HubBildungSynergySection.tsx @@ -10,17 +10,20 @@ export default function HubBildungSynergySection() {

❌ Bildung ohne Hub = Begrenzte Kapazität

- Ohne grösseren Raum fehlen Werkstattplätze, Schulungsräume und Equipment für mehr Teilnehmende. - Trainings bleiben theoretisch, weil die Praxis-Infrastruktur fehlt. + Ohne grösseren Raum fehlen Werkstattplätze, Schulungsräume und Equipment für mehr + Teilnehmende. Trainings bleiben theoretisch, weil die Praxis-Infrastruktur fehlt.

Limitiert durch Raumkapazität
-

✅ Hub + Bildung = Exponentielles Wachstum

+

+ ✅ Hub + Bildung = Exponentielles Wachstum +

Hub bietet Infrastruktur (Werkstatt, Schulungsräume, Equipment). - Bildungsprogrammleiter:innen bieten Know-how-Multiplikation (Train-the-Trainer). + Bildungsprogrammleiter:innen bieten Know-how-Multiplikation{' '} + (Train-the-Trainer).

diff --git a/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerMechanismSection.tsx b/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerMechanismSection.tsx index 782bce6f..550d7b62 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerMechanismSection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerMechanismSection.tsx @@ -14,55 +14,75 @@ export default function TrainTheTrainerMechanismSection() {

- Transparenz-Hinweis: Train-the-Trainer ist ein etabliertes Konzept in Bildung und Capacity Building - (z.B. WHO, viele NGOs nutzen diesen Ansatz). + Transparenz-Hinweis: Train-the-Trainer ist ein etabliertes Konzept in + Bildung und Capacity Building (z.B. WHO, viele NGOs nutzen diesen Ansatz).

- Die spezifischen Zahlen unten ({BPL_HARDWARE_PER_YEAR_DISPLAY} Techniker/Jahr, etc.) sind Projektionen - basierend auf unserer informellen Erfahrung und Schätzungen. Nicht empirisch gemessen. + Die spezifischen Zahlen unten ({BPL_HARDWARE_PER_YEAR_DISPLAY} Techniker/Jahr, etc.) + sind Projektionen + basierend auf unserer informellen Erfahrung und Schätzungen.{' '} + Nicht empirisch gemessen. Wir werden diese ab 2026 systematisch erfassen (Data-Strategie).

-
1
+
+ 1 +
-

Bildungsprogrammleiter:innen trainieren Trainer:innen

+

+ Bildungsprogrammleiter:innen trainieren Trainer:innen +

- Hardware-BPL bildet {BPL_HARDWARE_PER_YEAR_DISPLAY} Techniker/Jahr aus. Software/AI-BPL trainiert {BPL_SOFTWARE_PER_YEAR_DISPLAY} Entwickler/Jahr. - Total: {BPL_DIRECT_TRAINED_PER_YEAR_DISPLAY} Menschen/Jahr direkt (vs. 5 heute). + Hardware-BPL bildet {BPL_HARDWARE_PER_YEAR_DISPLAY} Techniker/Jahr{' '} + aus. Software/AI-BPL trainiert{' '} + {BPL_SOFTWARE_PER_YEAR_DISPLAY} Entwickler/Jahr. Total:{' '} + {BPL_DIRECT_TRAINED_PER_YEAR_DISPLAY} Menschen/Jahr direkt (vs. 5 + heute).

- Beispiel Hardware: Ein:e Teilnehmer:in lernt nicht nur Laptop-Reparatur, sondern auch, - wie man dieses Wissen weitervermittelt (Didaktik, Curricula, Praxisanleitung). + Beispiel Hardware: Ein:e Teilnehmer:in lernt nicht nur + Laptop-Reparatur, sondern auch, wie man dieses Wissen weitervermittelt (Didaktik, + Curricula, Praxisanleitung).
-
2
+
+ 2 +

Trainer:innen trainieren andere

- Hardware: 5 gleichzeitig aktive Techniker → je 10 Menschen/Jahr = 50 indirekt. - Software/AI: 3 AI-Literacy-Trainer → je ~13 Menschen/Jahr = 40 indirekt. + Hardware: 5 gleichzeitig aktive Techniker → je 10 Menschen/Jahr ={' '} + 50 indirekt. Software/AI: 3 AI-Literacy-Trainer → + je ~13 Menschen/Jahr = 40 indirekt.

- Beispiel Software: Ein:e trainierte Entwickler:in gibt AI-Literacy-Workshops in Asylorganisationen, - Bibliotheken oder Schulen → erreicht 40+ Menschen/Jahr ohne dass wir direkt involviert sind. + Beispiel Software: Ein:e trainierte Entwickler:in gibt + AI-Literacy-Workshops in Asylorganisationen, Bibliotheken oder Schulen → erreicht + 40+ Menschen/Jahr ohne dass wir direkt involviert sind.
-
3
+
+ 3 +

Community-Effekte & Workshops

- Zusätzlich zu direktem und indirektem Training: Workshops, Events, Repair Cafés im Hub erreichen weitere 50-80 Menschen/Jahr. + Zusätzlich zu direktem und indirektem Training:{' '} + Workshops, Events, Repair Cafés im Hub erreichen weitere 50-80 + Menschen/Jahr.

- Gesamt-Reichweite (konservativ): {BPL_DIRECT_TRAINED_PER_YEAR_DISPLAY} (direkt trainiert) + 20-40 (Workshop-Teilnehmer) = {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr. + Gesamt-Reichweite (konservativ):{' '} + {BPL_DIRECT_TRAINED_PER_YEAR_DISPLAY} (direkt trainiert) + 20-40 + (Workshop-Teilnehmer) = {PEOPLE_REACHED_PER_YEAR} Menschen/Jahr.
diff --git a/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerVisionSection.tsx b/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerVisionSection.tsx index 5d72643c..55920611 100644 --- a/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerVisionSection.tsx +++ b/src/app/(tenant)/fundraising/bildung/sections/TrainTheTrainerVisionSection.tsx @@ -1,5 +1,8 @@ import { BILDUNGSPROGRAMMLEITER } from '@/lib/config/team'; -import { BPL_DIRECT_TRAINED_PER_YEAR_DISPLAY, PEOPLE_REACHED_PER_YEAR } from '@/lib/config/projections'; +import { + BPL_DIRECT_TRAINED_PER_YEAR_DISPLAY, + PEOPLE_REACHED_PER_YEAR, +} from '@/lib/config/projections'; const HERO_STATS = [ { value: `${BILDUNGSPROGRAMMLEITER.length} VZÄ`, label: 'Bildungsprogrammleiter:innen' }, @@ -19,12 +22,16 @@ export default function TrainTheTrainerVisionSection() {

- Unsere {BILDUNGSPROGRAMMLEITER.length}× Bildungsprogrammleiter:innen bilden Techniker und Entwickler aus - und führen Workshops durch. Strukturiertes Training statt informellem Wissenstransfer. + Unsere {BILDUNGSPROGRAMMLEITER.length}× Bildungsprogrammleiter:innen{' '} + bilden Techniker und Entwickler aus und führen Workshops durch. Strukturiertes Training + statt informellem Wissenstransfer.

{HERO_STATS.map((stat) => ( -
+
{stat.value}
{stat.label}
diff --git a/src/app/(tenant)/fundraising/data.ts b/src/app/(tenant)/fundraising/data.ts index 30156153..663da70b 100644 --- a/src/app/(tenant)/fundraising/data.ts +++ b/src/app/(tenant)/fundraising/data.ts @@ -2,7 +2,12 @@ import { CORE_FACTS, SOCIAL_DISPLAY } from '@/lib/config/stories'; import { getScenario, getLineItemsForScenario } from '@/lib/domain/budget-calculations'; import { fitScoreToDisplay } from '@/lib/domain/fit-scoring'; import { ORG_PROFILE } from '@/lib/config/org-profile'; -import { SPACE_SUMMARY, HUB_SPACE_AREAS, STORAGE_AREA, LOADING_AREA } from '@/lib/config/hub-space-plan'; +import { + SPACE_SUMMARY, + HUB_SPACE_AREAS, + STORAGE_AREA, + LOADING_AREA, +} from '@/lib/config/hub-space-plan'; import type { BudgetLineItem } from '@/lib/schemas/budget'; import type { Foundation, FoundationStatus } from '@/lib/schemas/foundation'; import { MS_PER_DAY } from '@/lib/utils/time'; @@ -64,16 +69,27 @@ if (!MODERATE_SCENARIO) { const MODERATE_LINE_ITEMS = getLineItemsForScenario('moderate'); -export const BUDGET_EINMALIG: BudgetLineItem[] = MODERATE_LINE_ITEMS.filter((m) => m.type === 'einmalig'); -export const BUDGET_JAEHRLICH: BudgetLineItem[] = MODERATE_LINE_ITEMS.filter((m) => m.type === 'jaehrlich'); +export const BUDGET_EINMALIG: BudgetLineItem[] = MODERATE_LINE_ITEMS.filter( + (m) => m.type === 'einmalig', +); +export const BUDGET_JAEHRLICH: BudgetLineItem[] = MODERATE_LINE_ITEMS.filter( + (m) => m.type === 'jaehrlich', +); export const BUDGET_EINMALIG_TOTAL = BUDGET_EINMALIG.reduce((sum, m) => sum + m.amount, 0); export const BUDGET_JAEHRLICH_TOTAL = BUDGET_JAEHRLICH.reduce((sum, m) => sum + m.amount, 0); export const BUDGET_SUMMARY = { total: BUDGET_EINMALIG_TOTAL + BUDGET_JAEHRLICH_TOTAL, eigenleistung: MODERATE_SCENARIO.threeYearModel.year1.eigenleistung, - foerderbedarf: (BUDGET_EINMALIG_TOTAL + BUDGET_JAEHRLICH_TOTAL) - MODERATE_SCENARIO.threeYearModel.year1.eigenleistung, - selfFinancingPct: Math.round((MODERATE_SCENARIO.threeYearModel.year1.eigenleistung / (BUDGET_EINMALIG_TOTAL + BUDGET_JAEHRLICH_TOTAL)) * 100), + foerderbedarf: + BUDGET_EINMALIG_TOTAL + + BUDGET_JAEHRLICH_TOTAL - + MODERATE_SCENARIO.threeYearModel.year1.eigenleistung, + selfFinancingPct: Math.round( + (MODERATE_SCENARIO.threeYearModel.year1.eigenleistung / + (BUDGET_EINMALIG_TOTAL + BUDGET_JAEHRLICH_TOTAL)) * + 100, + ), }; // -- 3-Year Budget Summary (the REAL numbers foundations see) ------------------- @@ -94,19 +110,53 @@ export const QUICK_ACTIONS = [ /** Built at render time — foundation count comes from the DB read layer, not a module-scope import. */ export function buildResources(foundationCount: number) { return [ - { href: '/api/documents/pitch-deck', label: 'Pitch Deck (PDF)', description: '8-Folien-Präsentation für Stiftungen', external: true }, - { href: '/api/documents/impact-report', label: 'Wirkungsbericht (PDF)', description: 'Jährlicher Impact-Report, 2 Seiten', external: true }, - { href: '/wirkung', label: 'Impact-Zahlen', description: 'Interaktive Wirkungsseite mit Quellen', external: false }, - { href: '/fundraising/stiftungen', label: 'Stiftungen', description: `${foundationCount} Förderer mit Fit-Score`, external: false }, - { href: '/finanzen', label: 'Finanzdaten', description: 'Kivitendo-Daten visualisiert', external: false }, - { href: '/dokumente', label: 'Alle Dokumente', description: 'Gesuche, Vorlagen, Exporte', external: false }, + { + href: '/api/documents/pitch-deck', + label: 'Pitch Deck (PDF)', + description: '8-Folien-Präsentation für Stiftungen', + external: true, + }, + { + href: '/api/documents/impact-report', + label: 'Wirkungsbericht (PDF)', + description: 'Jährlicher Impact-Report, 2 Seiten', + external: true, + }, + { + href: '/wirkung', + label: 'Impact-Zahlen', + description: 'Interaktive Wirkungsseite mit Quellen', + external: false, + }, + { + href: '/fundraising/stiftungen', + label: 'Stiftungen', + description: `${foundationCount} Förderer mit Fit-Score`, + external: false, + }, + { + href: '/finanzen', + label: 'Finanzdaten', + description: 'Kivitendo-Daten visualisiert', + external: false, + }, + { + href: '/dokumente', + label: 'Alle Dokumente', + description: 'Gesuche, Vorlagen, Exporte', + external: false, + }, ] as const; } // -- HERO_STATS — derived from CORE_FACTS (SSOT) ----------------------------- export const HERO_STATS = [ - { label: 'Ökologie', value: `${CORE_FACTS.metrics.environmental.co2_per_laptop} kg`, sub: 'CO2 pro Laptop gespart' }, + { + label: 'Ökologie', + value: `${CORE_FACTS.metrics.environmental.co2_per_laptop} kg`, + sub: 'CO2 pro Laptop gespart', + }, { label: 'Soziales', value: SOCIAL_DISPLAY.practitioners_total, sub: 'Menschen begleitet' }, { label: 'Bildung', value: `${ORG_PROFILE.yearsActive}+`, sub: 'Jahre Erfahrung' }, ] as const; @@ -116,29 +166,53 @@ export const HERO_STATS = [ // Excludes circulation (corridors, bathrooms) — shown separately as note. const SPACE_AREA_MAP: Record = { - 'Shop & Customer Area': { area: 'Shop & Empfang', description: 'Verkauf, Geräte-Annahme, Beratung, Wartebereich' }, - 'Refurbishment Workshop': { area: 'Werkstatt & Reparatur', description: '6-8 Arbeitsplätze, Test & Datenlöschung, QA, Ersatzteile' }, - 'Office & Meeting Spaces': { area: 'Büro & Besprechung', description: '5 Arbeitsplätze (Kern + BPL), Sitzungszimmer' }, - 'Makerspace & Hackerspace': { area: 'Makerspace & Hackerspace', description: '3D-Drucker, Lötstationen, Laser Cutter, Tool Library' }, - 'AI Lab (Server Room)': { area: 'AI Lab / Serverraum', description: 'GPU-Cluster, klimatisiert, Netzwerk-Infrastruktur' }, - 'Training & Course Room': { area: 'Schulungsraum', description: '20 Plätze, Beamer, Übungsrechner, Corporate Training' }, - 'Event Space + Community Café (MULTI-PURPOSE)': { area: 'Event-/Kulturraum & Café', description: 'Tags: Café. Abends: Konzerte, Talks, Filmabende. Repair Café 2×/Monat' }, + 'Shop & Customer Area': { + area: 'Shop & Empfang', + description: 'Verkauf, Geräte-Annahme, Beratung, Wartebereich', + }, + 'Refurbishment Workshop': { + area: 'Werkstatt & Reparatur', + description: '6-8 Arbeitsplätze, Test & Datenlöschung, QA, Ersatzteile', + }, + 'Office & Meeting Spaces': { + area: 'Büro & Besprechung', + description: '5 Arbeitsplätze (Kern + BPL), Sitzungszimmer', + }, + 'Makerspace & Hackerspace': { + area: 'Makerspace & Hackerspace', + description: '3D-Drucker, Lötstationen, Laser Cutter, Tool Library', + }, + 'AI Lab (Server Room)': { + area: 'AI Lab / Serverraum', + description: 'GPU-Cluster, klimatisiert, Netzwerk-Infrastruktur', + }, + 'Training & Course Room': { + area: 'Schulungsraum', + description: '20 Plätze, Beamer, Übungsrechner, Corporate Training', + }, + 'Event Space + Community Café (MULTI-PURPOSE)': { + area: 'Event-/Kulturraum & Café', + description: 'Tags: Café. Abends: Konzerte, Talks, Filmabende. Repair Café 2×/Monat', + }, }; // Storage & Logistics + Loading & Delivery Zone → combined as one display area -const STORAGE_COMBINED_SQM = (STORAGE_AREA?.sqm_recommended ?? 0) + (LOADING_AREA?.sqm_recommended ?? 0); +const STORAGE_COMBINED_SQM = + (STORAGE_AREA?.sqm_recommended ?? 0) + (LOADING_AREA?.sqm_recommended ?? 0); export const SPACE_PLAN = [ // Combined storage area first (as it was in the original layout) - { area: 'Lager & Logistik', sqm: STORAGE_COMBINED_SQM, description: 'Eingang/Triage, Ersatzteile, Fertigware, Laderampe, Recycling-Staging' }, + { + area: 'Lager & Logistik', + sqm: STORAGE_COMBINED_SQM, + description: 'Eingang/Triage, Ersatzteile, Fertigware, Laderampe, Recycling-Staging', + }, // Individual areas from SSOT - ...HUB_SPACE_AREAS - .filter(a => a.name in SPACE_AREA_MAP) - .map(a => ({ - area: SPACE_AREA_MAP[a.name].area, - sqm: a.sqm_recommended, - description: SPACE_AREA_MAP[a.name].description, - })), + ...HUB_SPACE_AREAS.filter((a) => a.name in SPACE_AREA_MAP).map((a) => ({ + area: SPACE_AREA_MAP[a.name].area, + sqm: a.sqm_recommended, + description: SPACE_AREA_MAP[a.name].description, + })), ]; export const SPACE_PLAN_TOTAL = SPACE_SUMMARY.total_usable; // ~590m² — derived from hub-space-plan.ts (excludes circulation) @@ -163,8 +237,8 @@ const Y1_EIGEN = MODERATE_SCENARIO.threeYearModel.year1.eigenleistung; // modelling progressive self-sufficiency. This is the standard Swiss // degressive funding model for 3-year foundation grants. const DEGRESSIVE_CONFIG = { - year2: { stiftungenPct: 0.745, eigenGrowth: 40_000 }, // 74.5% of Y1 stiftungen; eigen: 100k + 40k = 140k - year3: { stiftungenPct: 0.478, eigenGrowth: 95_000 }, // 47.8% of Y1 stiftungen; eigen: 100k + 95k = 195k + year2: { stiftungenPct: 0.745, eigenGrowth: 40_000 }, // 74.5% of Y1 stiftungen; eigen: 100k + 40k = 140k + year3: { stiftungenPct: 0.478, eigenGrowth: 95_000 }, // 47.8% of Y1 stiftungen; eigen: 100k + 95k = 195k } as const; export const THREE_YEAR_MODEL = [ @@ -181,7 +255,10 @@ export const THREE_YEAR_MODEL = [ einmalig: 0, stiftungen: Math.round(Y1_STIFTUNGEN * DEGRESSIVE_CONFIG.year2.stiftungenPct), eigen: Y1_EIGEN + DEGRESSIVE_CONFIG.year2.eigenGrowth, - total: Math.round(Y1_STIFTUNGEN * DEGRESSIVE_CONFIG.year2.stiftungenPct) + Y1_EIGEN + DEGRESSIVE_CONFIG.year2.eigenGrowth, + total: + Math.round(Y1_STIFTUNGEN * DEGRESSIVE_CONFIG.year2.stiftungenPct) + + Y1_EIGEN + + DEGRESSIVE_CONFIG.year2.eigenGrowth, label: 'Wachstum' as const, }, { @@ -189,13 +266,19 @@ export const THREE_YEAR_MODEL = [ einmalig: 0, stiftungen: Math.round(Y1_STIFTUNGEN * DEGRESSIVE_CONFIG.year3.stiftungenPct), eigen: Y1_EIGEN + DEGRESSIVE_CONFIG.year3.eigenGrowth, - total: Math.round(Y1_STIFTUNGEN * DEGRESSIVE_CONFIG.year3.stiftungenPct) + Y1_EIGEN + DEGRESSIVE_CONFIG.year3.eigenGrowth, + total: + Math.round(Y1_STIFTUNGEN * DEGRESSIVE_CONFIG.year3.stiftungenPct) + + Y1_EIGEN + + DEGRESSIVE_CONFIG.year3.eigenGrowth, label: 'Verselbständigung' as const, }, ]; // Derived 3-year totals — unambiguous headline numbers -export const STIFTUNGEN_3Y_TOTAL = THREE_YEAR_MODEL.reduce((sum, y) => sum + y.stiftungen + y.einmalig, 0); +export const STIFTUNGEN_3Y_TOTAL = THREE_YEAR_MODEL.reduce( + (sum, y) => sum + y.stiftungen + y.einmalig, + 0, +); export const EIGEN_3Y_TOTAL = THREE_YEAR_MODEL.reduce((sum, y) => sum + y.eigen, 0); export const PROJECT_3Y_TOTAL = THREE_YEAR_MODEL.reduce((sum, y) => sum + y.total, 0); const STIFTUNGEN_Y1 = THREE_YEAR_MODEL[0].stiftungen + THREE_YEAR_MODEL[0].einmalig; @@ -206,22 +289,22 @@ export const REDUCTION_PCT = Math.round((1 - STIFTUNGEN_Y3 / STIFTUNGEN_Y1) * 10 // Source: Erfolgsrechnung per account (Kivitendo rp.pl → generate_income_statement) export const FINANCIAL_CONTEXT = { // Dienstleistungen (3400) — the category that crashed - services_2022: 80080, // Erfolgsrechnung 3400 (2022): 80'080.10 - services_2023: 75342, // Erfolgsrechnung 3400 (2023): 75'341.70 - services_2024: 84974, // Erfolgsrechnung 3400 (2024): 84'973.55 - services_2025: 27573, // Erfolgsrechnung 3400 (2025): 27'572.97 — FULL YEAR + services_2022: 80080, // Erfolgsrechnung 3400 (2022): 80'080.10 + services_2023: 75342, // Erfolgsrechnung 3400 (2023): 75'341.70 + services_2024: 84974, // Erfolgsrechnung 3400 (2024): 84'973.55 + services_2025: 27573, // Erfolgsrechnung 3400 (2025): 27'572.97 — FULL YEAR services_avg_2022_23: 77711, // (80080 + 75342) / 2 - decline_pct: 65, // (77711 - 27573) / 77711 × 100 ≈ 64.5% → round to 65% + decline_pct: 65, // (77711 - 27573) / 77711 × 100 ≈ 64.5% → round to 65% // Warenverkauf (3100) - warenverkauf_2022: 35441, // Erfolgsrechnung 3100 (2022): 35'441.03 - warenverkauf_2023: 36997, // Erfolgsrechnung 3100 (2023): 36'997.19 - warenverkauf_2024: 23135, // Erfolgsrechnung 3100 (2024): 23'135.36 - warenverkauf_2025: 22086, // Erfolgsrechnung 3100 (2025): 22'086.27 + warenverkauf_2022: 35441, // Erfolgsrechnung 3100 (2022): 35'441.03 + warenverkauf_2023: 36997, // Erfolgsrechnung 3100 (2023): 36'997.19 + warenverkauf_2024: 23135, // Erfolgsrechnung 3100 (2024): 23'135.36 + warenverkauf_2025: 22086, // Erfolgsrechnung 3100 (2025): 22'086.27 // Totals (from Erfolgsrechnung — includes ALL revenue accounts) - total_2022: 129920, // Erfolgsrechnung 2022: 129'919.96 - total_2023: 134450, // Erfolgsrechnung 2023: 134'450.11 - total_2024: 119274, // Erfolgsrechnung 2024: 119'274.13 - total_2025: 60402, // Erfolgsrechnung 2025: 60'401.93 — FULL YEAR + total_2022: 129920, // Erfolgsrechnung 2022: 129'919.96 + total_2023: 134450, // Erfolgsrechnung 2023: 134'450.11 + total_2024: 119274, // Erfolgsrechnung 2024: 119'274.13 + total_2025: 60402, // Erfolgsrechnung 2025: 60'401.93 — FULL YEAR } as const; // Display helpers for services revenue drop narrative @@ -250,7 +333,12 @@ export const COST_STRUCTURE_2023 = { result: -25217, categories: [ { label: 'Miete', amount: 76934, color: 'bg-danger', pctOfExpenses: 48.2 }, - { label: 'Personal (Löhne, Sozial, Bildung)', amount: 48075, color: 'bg-primary', pctOfExpenses: 30.1 }, + { + label: 'Personal (Löhne, Sozial, Bildung)', + amount: 48075, + color: 'bg-primary', + pctOfExpenses: 30.1, + }, { label: 'Abschreibungen', amount: 10000, color: 'bg-grey-medium', pctOfExpenses: 6.3 }, { label: 'IT & Kommunikation', amount: 8584, color: 'bg-chart-5', pctOfExpenses: 5.4 }, { label: 'Material & Dienstleistungen', amount: 8857, color: 'bg-amber', pctOfExpenses: 5.5 }, @@ -262,11 +350,11 @@ export const COST_STRUCTURE_2023 = { // -- Track Record (verified from Kivitendo operational data) ------------------ export const TRACK_RECORD = { yearsActive: new Date().getFullYear() - CORE_FACTS.organization.founded, - totalInvoices: 2777, // rechnungen_ar_invoices.csv - totalCustomers: 5803, // kunden_customers.csv - productsInCatalog: 4134, // artikel_products.csv - deliveryNotes: 1012, // lieferscheine_delivery_notes.csv - quoteConversion: 92.6, // angebote_quotes.csv (162/175 converted) + totalInvoices: 2777, // rechnungen_ar_invoices.csv + totalCustomers: 5803, // kunden_customers.csv + productsInCatalog: 4134, // artikel_products.csv + deliveryNotes: 1012, // lieferscheine_delivery_notes.csv + quoteConversion: 92.6, // angebote_quotes.csv (162/175 converted) source: 'Kivitendo ERP, verifiziert 11.02.2026', } as const; @@ -276,22 +364,62 @@ export const TRACK_RECORD = { // Year 3 projections assume Hub exists with 650 m², new programs, and diversified income. export const REVENUE_STREAMS = [ - { source: 'Geräteverkauf', current: 22000, year3: 45000, - rationale: 'Kivitendo 3100: CHF 22k (2025), 23k (2024), 37k (2023). Grösserer Shop im Hub mit mehr Laufkundschaft. Ziel: Rückkehr auf 2023-Niveau + 20%.' }, - { source: 'Dienstleistungen & IT-Services', current: 28000, year3: 55000, - rationale: 'Kivitendo 3400: Einbruch von 80k (2022) auf 28k (2025) durch Verlust von B2B-Hosting-Kunden. Diversifizierung: KMU-Support, Reparaturen, IT-Beratung statt Grosskundenabhängigkeit.' }, - { source: 'Corporate Placements', current: 0, year3: 30000, - rationale: 'Neu. 2-3 Firmen-Platzierungen à CHF 10-15k/Jahr. Modell: Outplacement/Reintegration für Unternehmen.' }, - { source: 'Kursgebühren & Workshops', current: 0, year3: 20000, - rationale: 'Neu. Schulungsraum (40 m², 15-20 Plätze) ermöglicht 2-3 Kurse/Monat à CHF 50-100. Hardware-Repair, Linux, Digital Literacy.' }, - { source: 'Integration (RAV/IV)', current: 0, year3: 15000, - rationale: 'Kivitendo 3450: CHF 10.5k (2023), 4.5k (2024), 0 (2025). Hub bietet 5-8 statt 2-3 Plätze. RAV/IV-Beiträge steigen proportional.' }, - { source: 'Repair Café & Raumvermietung', current: 0, year3: 15000, - rationale: 'Neu. Event-/Kulturraum 80 m²: monatliches Repair Café + Raumvermietung für externe Veranstaltungen.' }, - { source: 'AI Hosting & Digitale Souveränität', current: 0, year3: 10000, - rationale: 'Neu. GPU-Cluster für souveränes KI-Hosting. Start mit 2-3 Schweizer KMU-Pilotkunden über bestehendes Netzwerk.' }, - { source: 'Spenden & Mitgliederbeiträge', current: 800, year3: 5000, - rationale: 'Kivitendo 3500: CHF 762 (2025), 10k (2023). Community-Hub mit Events und Sichtbarkeit soll Spendenbereitschaft auf 2023-Niveau heben.' }, + { + source: 'Geräteverkauf', + current: 22000, + year3: 45000, + rationale: + 'Kivitendo 3100: CHF 22k (2025), 23k (2024), 37k (2023). Grösserer Shop im Hub mit mehr Laufkundschaft. Ziel: Rückkehr auf 2023-Niveau + 20%.', + }, + { + source: 'Dienstleistungen & IT-Services', + current: 28000, + year3: 55000, + rationale: + 'Kivitendo 3400: Einbruch von 80k (2022) auf 28k (2025) durch Verlust von B2B-Hosting-Kunden. Diversifizierung: KMU-Support, Reparaturen, IT-Beratung statt Grosskundenabhängigkeit.', + }, + { + source: 'Corporate Placements', + current: 0, + year3: 30000, + rationale: + 'Neu. 2-3 Firmen-Platzierungen à CHF 10-15k/Jahr. Modell: Outplacement/Reintegration für Unternehmen.', + }, + { + source: 'Kursgebühren & Workshops', + current: 0, + year3: 20000, + rationale: + 'Neu. Schulungsraum (40 m², 15-20 Plätze) ermöglicht 2-3 Kurse/Monat à CHF 50-100. Hardware-Repair, Linux, Digital Literacy.', + }, + { + source: 'Integration (RAV/IV)', + current: 0, + year3: 15000, + rationale: + 'Kivitendo 3450: CHF 10.5k (2023), 4.5k (2024), 0 (2025). Hub bietet 5-8 statt 2-3 Plätze. RAV/IV-Beiträge steigen proportional.', + }, + { + source: 'Repair Café & Raumvermietung', + current: 0, + year3: 15000, + rationale: + 'Neu. Event-/Kulturraum 80 m²: monatliches Repair Café + Raumvermietung für externe Veranstaltungen.', + }, + { + source: 'AI Hosting & Digitale Souveränität', + current: 0, + year3: 10000, + rationale: + 'Neu. GPU-Cluster für souveränes KI-Hosting. Start mit 2-3 Schweizer KMU-Pilotkunden über bestehendes Netzwerk.', + }, + { + source: 'Spenden & Mitgliederbeiträge', + current: 800, + year3: 5000, + rationale: + 'Kivitendo 3500: CHF 762 (2025), 10k (2023). Community-Hub mit Events und Sichtbarkeit soll Spendenbereitschaft auf 2023-Niveau heben.', + }, ] as const; export const REVENUE_CURRENT_TOTAL = REVENUE_STREAMS.reduce((sum, r) => sum + r.current, 0); @@ -299,9 +427,21 @@ export const REVENUE_YEAR3_TOTAL = REVENUE_STREAMS.reduce((sum, r) => sum + r.ye // Display config for 3-year model cards (color scheme + description per year) export const THREE_YEAR_DISPLAY = [ - { border: 'border-primary/20', text: 'text-primary', description: 'Aufbau: Hub-Einrichtung + Team-Rekrutierung' }, - { border: 'border-pillar-vision/20', text: 'text-pillar-vision', description: 'Wachstum: Revenue steigt, Stiftungen sinken' }, - { border: 'border-success/20', text: 'text-success', description: `Verselbständigung: Revenue ${formatCHF(REVENUE_YEAR3_TOTAL)}, Operations zunehmend selbsttragend` }, + { + border: 'border-primary/20', + text: 'text-primary', + description: 'Aufbau: Hub-Einrichtung + Team-Rekrutierung', + }, + { + border: 'border-pillar-vision/20', + text: 'text-pillar-vision', + description: 'Wachstum: Revenue steigt, Stiftungen sinken', + }, + { + border: 'border-success/20', + text: 'text-success', + description: `Verselbständigung: Revenue ${formatCHF(REVENUE_YEAR3_TOTAL)}, Operations zunehmend selbsttragend`, + }, ] as const; export const NEXT_STEPS = [ diff --git a/src/app/(tenant)/fundraising/error.tsx b/src/app/(tenant)/fundraising/error.tsx index 59c447b0..1a72abb5 100644 --- a/src/app/(tenant)/fundraising/error.tsx +++ b/src/app/(tenant)/fundraising/error.tsx @@ -10,9 +10,7 @@ export default function FundraisingError({ }) { return (
-

- Fundraising konnte nicht geladen werden -

+

Fundraising konnte nicht geladen werden

Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut oder laden Sie die Seite neu.

diff --git a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/dokument/page.tsx b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/dokument/page.tsx index bad0193c..fb500dc2 100644 --- a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/dokument/page.tsx +++ b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/dokument/page.tsx @@ -52,11 +52,13 @@ export default async function SchwerpunktGesuchDokumentPage({ params }: Props) { {/* VORLAGE banner + print bar */}
- Schwerpunkt: {sp.label} | - Platzhalterfelder vor dem Versand ersetzen. + Schwerpunkt: {sp.label} | Platzhalterfelder vor dem Versand ersetzen. - + Interaktive Seite diff --git a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/page.tsx b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/page.tsx index 14c582fa..7ea624cb 100644 --- a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/page.tsx +++ b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/[type]/page.tsx @@ -55,8 +55,11 @@ export default async function SchwerpunktGesuchPage({ params }: Props) {
{/* VORLAGE banner */} - Schwerpunkt: {sp.label} | - Felder wie [Name der Stiftung] vor dem Versand ersetzen. + Schwerpunkt: {sp.label} | Felder wie{' '} + + [Name der Stiftung] + {' '} + vor dem Versand ersetzen. - + {/* Navigation links */}
diff --git a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/dokument/page.tsx b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/dokument/page.tsx index 23dbbbb8..5e2e43b6 100644 --- a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/dokument/page.tsx +++ b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/dokument/page.tsx @@ -3,7 +3,11 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { ORG_PROFILE } from '@/lib/config/org-profile'; import { resolveTypeLabel } from '@/lib/config/foundations'; -import { TEMPLATE_TYPES, TEMPLATE_LABELS, getTemplateFoundation } from '@/lib/config/gesuch-templates'; +import { + TEMPLATE_TYPES, + TEMPLATE_LABELS, + getTemplateFoundation, +} from '@/lib/config/gesuch-templates'; import { composeGesuchDokument } from '@/lib/domain/gesuch-composer'; import { AnschreibenSection, @@ -60,10 +64,14 @@ export default async function GesuchVorlageDokumentPage({ params }: Props) { {/* VORLAGE banner + print bar */}
- Platzhalterfelder wie [Name der Stiftung] vor dem Versand ersetzen. + Platzhalterfelder wie [Name der Stiftung] vor dem + Versand ersetzen. - + Interaktive Seite diff --git a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/page.tsx b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/page.tsx index b9c54006..8f3c50c6 100644 --- a/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/page.tsx +++ b/src/app/(tenant)/fundraising/gesuch-vorlagen/[vorlage]/page.tsx @@ -5,7 +5,11 @@ import { Button } from '@/components/ui/Button'; import { ORG_PROFILE } from '@/lib/config/org-profile'; import { DEFAULT_THEME_COLOR } from '@/lib/config/chart-colors'; import { THEMES, resolveTypeLabel } from '@/lib/config/foundations'; -import { TEMPLATE_TYPES, TEMPLATE_LABELS, getTemplateFoundation } from '@/lib/config/gesuch-templates'; +import { + TEMPLATE_TYPES, + TEMPLATE_LABELS, + getTemplateFoundation, +} from '@/lib/config/gesuch-templates'; import { composeGesuch } from '@/lib/domain/gesuch-composer'; import { GesuchHeroSection, @@ -70,8 +74,9 @@ export default async function GesuchVorlagePage({ params }: Props) {
{/* VORLAGE banner */} - Dies ist eine generische Vorlage. Felder wie [Name der Stiftung] müssen - vor dem Versand durch die tatsächlichen Angaben ersetzt werden. + Dies ist eine generische Vorlage. Felder wie{' '} + [Name der Stiftung] müssen vor dem Versand durch die + tatsächlichen Angaben ersetzt werden. - + {/* Navigation links */}
diff --git a/src/app/(tenant)/fundraising/gesuch-vorlagen/page.tsx b/src/app/(tenant)/fundraising/gesuch-vorlagen/page.tsx index 2cf96b06..113adaef 100644 --- a/src/app/(tenant)/fundraising/gesuch-vorlagen/page.tsx +++ b/src/app/(tenant)/fundraising/gesuch-vorlagen/page.tsx @@ -4,15 +4,25 @@ import Card from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { ORG_PROFILE } from '@/lib/config/org-profile'; import { TYPE_LABELS } from '@/lib/config/foundations'; -import { TYPE_TEMPLATE_KEYS, TEMPLATE_LABELS, SCHWERPUNKT_TEMPLATE_TYPES } from '@/lib/config/gesuch-templates'; +import { + TYPE_TEMPLATE_KEYS, + TEMPLATE_LABELS, + SCHWERPUNKT_TEMPLATE_TYPES, +} from '@/lib/config/gesuch-templates'; import { SCHWERPUNKTE, SCHWERPUNKT_IDS } from '@/lib/config/schwerpunkte'; export const metadata: Metadata = { title: `Gesuch-Vorlagen — ${ORG_PROFILE.name}`, - description: 'Gesuch-Vorlagen nach Schwerpunkt und Stiftungstyp — themenspezifische und typbasierte Referenzvorlagen', + description: + 'Gesuch-Vorlagen nach Schwerpunkt und Stiftungstyp — themenspezifische und typbasierte Referenzvorlagen', }; -function TemplateCard({ slug, title, description, subtitle }: { +function TemplateCard({ + slug, + title, + description, + subtitle, +}: { slug: string; title: string; description: string; @@ -24,9 +34,7 @@ function TemplateCard({ slug, title, description, subtitle }: {

{title}

{description}

- {subtitle && ( -

{subtitle}

- )} + {subtitle &&

{subtitle}

}
@@ -62,9 +73,9 @@ export default function GesuchVorlagenPage() {

Nach Schwerpunkt

- Wählen Sie den thematischen Schwerpunkt, der zur Stiftung passt. - Gleiches Budget, unterschiedliche Framing — die Stiftung sieht genau die Aktivitäten, - die ihrem Förderzweck entsprechen. + Wählen Sie den thematischen Schwerpunkt, der zur Stiftung passt. Gleiches Budget, + unterschiedliche Framing — die Stiftung sieht genau die Aktivitäten, die ihrem Förderzweck + entsprechen.

{SCHWERPUNKT_IDS.map((id) => { @@ -114,22 +125,18 @@ export default function GesuchVorlagenPage() {

Universelle Vorlage

- Verwenden Sie diese Vorlage, wenn Sie den Fokus der Stiftung noch nicht kennen. - Sie enthält das gesamte {ORG_PROFILE.name}-Profil mit allen Schwerpunkten. + Verwenden Sie diese Vorlage, wenn Sie den Fokus der Stiftung noch nicht kennen. Sie + enthält das gesamte {ORG_PROFILE.name}-Profil mit allen Schwerpunkten.

- +
{/* Section 3: Nach Stiftungstyp (legacy) */}

Nach Stiftungstyp

- Klassische Vorlagen nur nach Typ — wenn Sie den Schwerpunkt noch nicht kennen, - aber den Stiftungstyp schon. Inkl. D (Corporate) und Netzwerk. + Klassische Vorlagen nur nach Typ — wenn Sie den Schwerpunkt noch nicht kennen, aber den + Stiftungstyp schon. Inkl. D (Corporate) und Netzwerk.

{TYPE_TEMPLATE_KEYS.map((type) => { diff --git a/src/app/(tenant)/fundraising/gesuche/page.tsx b/src/app/(tenant)/fundraising/gesuche/page.tsx index 3356e6e1..aa24e603 100644 --- a/src/app/(tenant)/fundraising/gesuche/page.tsx +++ b/src/app/(tenant)/fundraising/gesuche/page.tsx @@ -27,7 +27,6 @@ interface GesuchOverviewRow { applicationStatus: ApplicationStatusId | null; } - export default function MeineGesuchePage() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -64,10 +63,24 @@ export default function MeineGesuchePage() { - - - - + + + + @@ -78,7 +91,10 @@ export default function MeineGesuchePage() { : null; return ( - +
StiftungStatusFelderZuletzt + Stiftung + + Status + + Felder + + Zuletzt +
{statusConfig ? ( - + {statusConfig.label} ) : ( diff --git a/src/app/(tenant)/fundraising/hub/BudgetSection.tsx b/src/app/(tenant)/fundraising/hub/BudgetSection.tsx index d55e25f3..47c7537e 100644 --- a/src/app/(tenant)/fundraising/hub/BudgetSection.tsx +++ b/src/app/(tenant)/fundraising/hub/BudgetSection.tsx @@ -57,8 +57,9 @@ export default function BudgetSection() {

Jahr 1 Total

- CHF {formatNumber( - scenario.threeYearModel.year1.einmalig + scenario.threeYearModel.year1.jaehrlich + CHF{' '} + {formatNumber( + scenario.threeYearModel.year1.einmalig + scenario.threeYearModel.year1.jaehrlich, )}

@@ -69,9 +70,7 @@ export default function BudgetSection() {
{/* Line Items (2/3 width) */}
-

- Budget-Details (klicken für Quellen) -

+

Budget-Details (klicken für Quellen)

diff --git a/src/app/(tenant)/fundraising/hub/page.tsx b/src/app/(tenant)/fundraising/hub/page.tsx index 505600fc..a76becf2 100644 --- a/src/app/(tenant)/fundraising/hub/page.tsx +++ b/src/app/(tenant)/fundraising/hub/page.tsx @@ -13,7 +13,8 @@ import CultureSection from './sections/CultureSection'; export const metadata: Metadata = { title: 'Revamp Hub — Community Tech Space', - description: 'Werkstatt, Kultur, Innovation: Wo Nachhaltigkeit, Technologie und Gemeinschaft zusammenkommen', + description: + 'Werkstatt, Kultur, Innovation: Wo Nachhaltigkeit, Technologie und Gemeinschaft zusammenkommen', }; export default function HubPage() { @@ -28,16 +29,20 @@ export default function HubPage() { {/* Vision */}
-

Der Revamp Hub: Prozesse professionalisieren, Menschen befähigen

+

+ Der Revamp Hub: Prozesse professionalisieren, Menschen befähigen +

- Das Kernproblem heute: Nicht fehlende Fläche, sondern fehlende Organisation. - Wir haben zu viel Inventar in 2 Lagern, aber keine effizienten Verkaufs- und Refurbishment-Prozesse. - 4 Reparaturtische vorhanden, meist nur 1-2 in Nutzung. Unstrukturierte Workflows. + Das Kernproblem heute: Nicht fehlende Fläche, sondern fehlende + Organisation. Wir haben zu viel Inventar in 2 Lagern, aber keine effizienten Verkaufs- + und Refurbishment-Prozesse. 4 Reparaturtische vorhanden, meist nur 1-2 in Nutzung. + Unstrukturierte Workflows.

- Die Lösung: {HUB_SPACE_DISPLAY} effizienter, gut organisierter Raum. Nicht endlos Platz — - sondern strukturierte Prozesse mit 2 Bildungsprogrammleitern + sozialpädagogischer Begleitung (Veronica). - Plus: Tech-Bildung, AI Lab, Makerspace — damit Menschen nicht nur konsumieren, sondern lernen und gestalten. + Die Lösung: {HUB_SPACE_DISPLAY} effizienter, gut organisierter Raum. + Nicht endlos Platz — sondern strukturierte Prozesse mit 2 + Bildungsprogrammleitern + sozialpädagogischer Begleitung (Veronica). Plus: Tech-Bildung, + AI Lab, Makerspace — damit Menschen nicht nur konsumieren, sondern lernen und gestalten.

@@ -55,8 +60,9 @@ export default function HubPage() {

🎨 Hub visualisieren: AI-Bilder generieren

- Erstelle professionelle Visualisierungen der verschiedenen Hub-Bereiche mit AI-Bildgenerierung. - Wähle einen Raum, kopiere den Prompt, und generiere in Midjourney, DALL-E 3 oder Stable Diffusion. + Erstelle professionelle Visualisierungen der verschiedenen Hub-Bereiche mit + AI-Bildgenerierung. Wähle einen Raum, kopiere den Prompt, und generiere in Midjourney, + DALL-E 3 oder Stable Diffusion.

@@ -65,11 +71,24 @@ export default function HubPage() {
Der Revamp Hub ist mehr als ein Gebäude — es ist eine Plattform für digitale Teilhabe, kulturelle Transformation und nachhaltige Innovation. Mit deiner Unterstützung können wir zeigen: Elektroschrott ist nicht Müll — es ist Potenzial.} + description={ + <> + Der Revamp Hub ist mehr als ein Gebäude — es ist eine Plattform für{' '} + + digitale Teilhabe, kulturelle Transformation und nachhaltige Innovation + + . Mit deiner Unterstützung können wir zeigen: Elektroschrott ist nicht Müll — es ist + Potenzial. + + } variant="light" links={[ { href: '/fundraising', label: '📊 Fundraising-Übersicht' }, - { href: '/fundraising/stiftungen', label: '🏛️ Passende Stiftungen finden', variant: 'secondary' }, + { + href: '/fundraising/stiftungen', + label: '🏛️ Passende Stiftungen finden', + variant: 'secondary', + }, { href: '/revamp-2030', label: '🚀 Gesamtstrategie 2030', variant: 'secondary' }, ]} /> diff --git a/src/app/(tenant)/fundraising/hub/sections/CoreBusinessSection.tsx b/src/app/(tenant)/fundraising/hub/sections/CoreBusinessSection.tsx index 116a342a..43494ddc 100644 --- a/src/app/(tenant)/fundraising/hub/sections/CoreBusinessSection.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/CoreBusinessSection.tsx @@ -9,11 +9,19 @@ export default function CoreBusinessSection() {

🏪 Das Kerngeschäft: Shop & Refurbishment

- Wo alles beginnt: Kunden bringen Geräte, wir reparieren & verkaufen sie. Das ist unser Fundament. + Wo alles beginnt: Kunden bringen Geräte, wir reparieren & verkaufen sie. Das ist unser + Fundament.

- +

Erste Anlaufstelle für Kunden: Geräte kaufen, zur Reparatur bringen, beraten lassen. Heute: Kein dedizierter Verkaufsraum. Neu: Professioneller Shop mit Ausstellungsfläche. @@ -37,48 +45,83 @@ export default function CoreBusinessSection() {

- + - +

- Das Herzstück: Hier entstehen refurbishte Geräte. Heute: 4 Tische, chaotisch, nur 1-2 in Nutzung. - Neu: Strukturierte Prozesse (Triage → Data Wipe → Repair → Test → QA), nicht endlos Platz. + Das Herzstück: Hier entstehen refurbishte Geräte. Heute: 4 Tische, + chaotisch, nur 1-2 in Nutzung. + Neu: Strukturierte Prozesse (Triage → Data Wipe → Repair → Test → QA), + nicht endlos Platz.

Flächen & Zonen (ENTWURF):

    -
  • 70 m² Haupt-Werkstatt (6-8× Reparaturtische)
  • -
  • 30 m² Test & Data Wipe (10× Plätze parallel)
  • -
  • 30 m² Quality Assurance & Verpackung
  • -
  • 20 m² Ersatzteile & Werkzeug-Lager
  • +
  • + • 70 m² Haupt-Werkstatt (6-8× Reparaturtische) +
  • +
  • + • 30 m² Test & Data Wipe (10× Plätze parallel) +
  • +
  • + • 30 m² Quality Assurance & Verpackung +
  • +
  • + • 20 m² Ersatzteile & Werkzeug-Lager +

Arbeitsplätze & Kapazität:

    -
  • 6-8× Reparaturtische (kompakt, effizient)
  • -
  • 10× Test/Data-Wipe-Plätze (parallel)
  • -
  • Personal: 2-4 Techniker gleichzeitig
  • -
  • Kapazität: ~40 Geräte/Monat (durch bessere Prozesse + strukturiertes Team)
  • +
  • + • 6-8× Reparaturtische (kompakt, effizient) +
  • +
  • + • 10× Test/Data-Wipe-Plätze (parallel) +
  • +
  • + • Personal: 2-4 Techniker gleichzeitig +
  • +
  • + • Kapazität: ~40 Geräte/Monat (durch bessere Prozesse + + strukturiertes Team) +
-

Effizienz durch Prozesse, nicht durch Platz

+

+ Effizienz durch Prozesse, nicht durch Platz +

- Nicht 600m² Werkstatt, sondern klare Prozesse mit 2× Bildungsprogrammleitern. - Sie organisieren Workflows, bilden Techniker aus, koordinieren Freiwillige & Reintegrations-Teilnehmer. - Plus: Sozialpädagogische Begleitung (Veronica) für nachhaltige Arbeitsintegration. + Nicht 600m² Werkstatt, sondern klare Prozesse mit 2× + Bildungsprogrammleitern. Sie organisieren Workflows, bilden Techniker aus, + koordinieren Freiwillige & Reintegrations-Teilnehmer. Plus: Sozialpädagogische + Begleitung (Veronica) für nachhaltige Arbeitsintegration.

- +
diff --git a/src/app/(tenant)/fundraising/hub/sections/CultureSection.tsx b/src/app/(tenant)/fundraising/hub/sections/CultureSection.tsx index c8d04b21..990f4b3f 100644 --- a/src/app/(tenant)/fundraising/hub/sections/CultureSection.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/CultureSection.tsx @@ -9,56 +9,95 @@ export default function CultureSection() {

🎨 Kultur, Kunst & Gemeinschaft

- Warum Kultur? Weil Menschen nicht zu einer „Werkstatt“ kommen — aber zu einem Konzert, einer Ausstellung, einem Filmabend. - Kultur ist der Einstiegspunkt. Und dann sehen sie: Alte Synths machen Musik. Computer sind Geschichte. Reparatur ist Kultur, nicht Pflicht. + Warum Kultur? Weil Menschen nicht zu einer „Werkstatt“ kommen — + aber zu einem Konzert, einer Ausstellung, einem Filmabend. Kultur ist der Einstiegspunkt. + Und dann sehen sie: Alte Synths machen Musik. Computer sind Geschichte. Reparatur ist + Kultur, nicht Pflicht.

- +

- Von Commodore 64 bis zur ersten Cray: Computergeschichte als lebendiges Archiv. - Zeigt, dass alte Hardware wertvoll ist — nicht Müll. + Von Commodore 64 bis zur ersten Cray: Computergeschichte als lebendiges Archiv. Zeigt, + dass alte Hardware wertvoll ist — nicht Müll.

Ausstellungsflächen & Inhalte

    -
  • 40 m² Permanent-Ausstellung (Commodore, Amiga, NeXT, Cray)
  • -
  • 20 m² Wechselausstellungen (E-Waste-Kunst, Tech-Fotografie)
  • +
  • + • 40 m² Permanent-Ausstellung (Commodore, Amiga, NeXT, Cray) +
  • +
  • + • 20 m² Wechselausstellungen (E-Waste-Kunst, Tech-Fotografie) +
  • • Führungen für Schulklassen (CHF 200/Klasse, 2×/Monat)
  • • Depot für historische Hardware (Schenkungen)
  • • Tech-Nostalgie-Events: „Deine erste Konsole?“ (4×/Jahr)
- +
- +

- Vintage-Synths restaurieren, Circuit-Bending lernen, Live-Konzerte veranstalten. - Musik als Zugang zu Technologie. + Vintage-Synths restaurieren, Circuit-Bending lernen, Live-Konzerte veranstalten. Musik + als Zugang zu Technologie.

Arbeitsplätze & Ausstattung

    -
  • 6× Synth-Restaurierungs-Arbeitsplätze (Lötkolben, Oszilloskop)
  • -
  • 10-15× Vintage-Synths (Roland, Korg, Moog aus 70er/80er)
  • -
  • Modular-Synthese-Setup für Workshops
  • -
  • 20m² Performance-Bereich für Konzerte (20-30 Besucher)
  • +
  • + • 6× Synth-Restaurierungs-Arbeitsplätze (Lötkolben, Oszilloskop) +
  • +
  • + • 10-15× Vintage-Synths (Roland, Korg, Moog aus 70er/80er) +
  • +
  • + • Modular-Synthese-Setup für Workshops +
  • +
  • + • 20m² Performance-Bereich für Konzerte (20-30 Besucher) +
  • • Circuit-Bending-Workshops (Spielzeug-Elektronik zu Instrumenten)
- +
- +

Künstler:innen schaffen aus Platinen, Gehäusen, Komponenten Skulpturen & Installationen. Zeigt: Schrott = Ressource. @@ -66,28 +105,42 @@ export default function CultureSection() {

Arbeitsplätze & Programm

    -
  • 3× Resident Artists-Atelierplätze (je 10-12m², 3-6 Monate)
  • +
  • + • 3× Resident Artists-Atelierplätze (je 10-12m², 3-6 Monate) +
  • • Werkzeuge für E-Waste-Verarbeitung (Sägen, Kleber, Löten)
  • • Wechselausstellungen im Hub (4×/Jahr)
  • • E-Waste-Art-Workshops für Schulen (2×/Monat)
  • • Verkauf: 50% Künstler, 50% Hub (Revenue-Modell)
- +
- +

- Flexibler Raum für 50-80 Personen. Tags: Workshops, Co-Working. Abends: Konzerte, Film, Talks. + Flexibler Raum für 50-80 Personen. Tags: Workshops, Co-Working. Abends: Konzerte, Film, + Talks.

Ausstattung & Nutzung

    -
  • Bestuhlung für 50-80 Personen (flexibel)
  • +
  • + • Bestuhlung für 50-80 Personen (flexibel) +
  • • Beamer, Sound-System, Bühne (kleinere Konzerte)
  • • Film Nights (1×/Monat, Dokumentarfilme zu Tech & Gesellschaft)
  • • Tech-Talks & Panels (Nachhaltigkeit, KI, Open Source)
  • @@ -95,53 +148,80 @@ export default function CultureSection() {
  • • Corporate Events & Team-Buildings (Revenue)
- +
- +

- Fair-Trade-Café tags, Community-Kitchen abends. Niederschwelliger Treffpunkt — keine Konsumpflicht. + Fair-Trade-Café tags, Community-Kitchen abends. Niederschwelliger Treffpunkt — keine + Konsumpflicht.

Ausstattung & Angebot

    -
  • 15-20 Sitzplätze (Café-Bereich)
  • -
  • 30 Plätze beim Community-Dinner (1×/Woche)
  • +
  • + • 15-20 Sitzplätze (Café-Bereich) +
  • +
  • + • 30 Plätze beim Community-Dinner (1×/Woche) +
  • • Fair-Trade-Kaffee & Snacks (kostendeckend, keine Gewinnmarge)
  • • Zero-Waste-Prinzip: Kompostierung, Mehrweg, lokal
  • • Tech-Support-Sprechstunde (Di + Do 14-17 Uhr, kostenlos)
- +
- +

- Öffentliche Reparatur-Events: Bring dein kaputtes Gerät, wir helfen dir es zu reparieren. - Lernen durch Tun. + Öffentliche Reparatur-Events: Bring dein kaputtes Gerät, wir helfen dir es zu + reparieren. Lernen durch Tun.

Arbeitsplätze & Format

    -
  • 6× Reparatur-Arbeitsplätze (Werkzeuge & Material vor Ort)
  • +
  • + • 6× Reparatur-Arbeitsplätze (Werkzeuge & Material vor Ort) +
  • • Jeden 1. & 3. Samstag im Monat (10-16 Uhr)
  • • Freiwillige Techniker helfen bei Reparatur
  • • Spende nach eigenem Ermessen (kostenlos für KulturLegi)
  • • Lernen durch Tun: Du reparierst, wir unterstützen
- +
diff --git a/src/app/(tenant)/fundraising/hub/sections/HubCardFooter.tsx b/src/app/(tenant)/fundraising/hub/sections/HubCardFooter.tsx index 5c6eeab7..38a81125 100644 --- a/src/app/(tenant)/fundraising/hub/sections/HubCardFooter.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/HubCardFooter.tsx @@ -10,7 +10,9 @@ interface HubCardFooterProps { export default function HubCardFooter({ items, className }: HubCardFooterProps) { return ( -
+
{items.map((item, i) => ( {item.label}: {item.value} diff --git a/src/app/(tenant)/fundraising/hub/sections/HubCardHeader.tsx b/src/app/(tenant)/fundraising/hub/sections/HubCardHeader.tsx index fa23fa8d..719d7a34 100644 --- a/src/app/(tenant)/fundraising/hub/sections/HubCardHeader.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/HubCardHeader.tsx @@ -23,15 +23,15 @@ export default function HubCardHeader({ return (
- +

{title}

{subtitle}

- {badgeColor && badgeText && ( - {badgeText} - )} + {badgeColor && badgeText && {badgeText}}
); } diff --git a/src/app/(tenant)/fundraising/hub/sections/InnovationSection.tsx b/src/app/(tenant)/fundraising/hub/sections/InnovationSection.tsx index 8b12236b..df29a03b 100644 --- a/src/app/(tenant)/fundraising/hub/sections/InnovationSection.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/InnovationSection.tsx @@ -10,77 +10,132 @@ export default function InnovationSection() {

🚀 Innovation, Makerspace & Bildung

- Nächste Stufe: Nicht nur reparieren, sondern experimentieren, prototypen, lernen. - Makerspace, Robotik, Schulungen. Hier entsteht Know-how. + Nächste Stufe: Nicht nur reparieren, sondern{' '} + experimentieren, prototypen, lernen. Makerspace, Robotik, Schulungen. Hier + entsteht Know-how.

- +

Offene Werkstatt: 3D-Drucker, Laser-Cutter, Lötarbeitsplätze. Vom Prototyp zum Produkt.

Arbeitsplätze & Ausstattung

    -
  • 12× Werkbänke (je 6m²) für Elektronik-Projekte
  • -
  • 6× Lötarbeitsplätze mit Absaugung
  • -
  • 4× 3D-Drucker (FDM & Resin)
  • -
  • 1× Laser-Cutter & CNC-Fräse
  • +
  • + • 12× Werkbänke (je 6m²) für Elektronik-Projekte +
  • +
  • + • 6× Lötarbeitsplätze mit Absaugung +
  • +
  • + • 4× 3D-Drucker (FDM & Resin) +
  • +
  • + • 1× Laser-Cutter & CNC-Fräse +
  • • Tool Library: Werkzeugverleih (CHF 20/Tag)
- +
- +

- Robotik-Kits für Schulen, Arduino-Workshops, autonome Roboter bauen. MINT-Bildung hands-on. + Robotik-Kits für Schulen, Arduino-Workshops, autonome Roboter bauen. MINT-Bildung + hands-on.

Arbeitsplätze & Ausstattung

    -
  • 10× Robotik-Arbeitsplätze (je 6m²)
  • -
  • 30× Arduino/Raspberry Pi Kits (Leihgabe an Schulen)
  • -
  • 8× Roboter-Chassis für autonome Projekte
  • +
  • + • 10× Robotik-Arbeitsplätze (je 6m²) +
  • +
  • + • 30× Arduino/Raspberry Pi Kits (Leihgabe an Schulen) +
  • +
  • + • 8× Roboter-Chassis für autonome Projekte +
  • • Sensoren, Motoren, Mikrocontroller (Lager)
  • • Testfläche für autonome Navigation (20m²)
- +
- +

- Strukturierte Bildung: Linux-Kurse, AI Literacy, Programmieren lernen. Digital Literacy für alle. + Strukturierte Bildung: Linux-Kurse, AI Literacy, Programmieren lernen. Digital Literacy + für alle.

Arbeitsplätze & Ausstattung

    -
  • 20× Kursarbeitsplätze (Laptops, Monitore)
  • +
  • + • 20× Kursarbeitsplätze (Laptops, Monitore) +
  • • Whiteboard, Beamer, Präsentations-Setup
  • • Flexible Tische (Gruppen- oder Einzelarbeit)
  • • Netzwerk-Infrastruktur (Gigabit, VPN-Zugang)
- +
- +

- Vision: Nicht Cloud-abhängig. Eigene GPU-Infrastruktur für AI-Modelle trainieren, hosten, nutzen. - GPUs können gekauft oder gespendet werden — verschiedene Setups möglich, je nach Budget & Spenden. + Vision: Nicht Cloud-abhängig. Eigene GPU-Infrastruktur für AI-Modelle + trainieren, hosten, nutzen. GPUs können gekauft oder gespendet werden —{' '} + verschiedene Setups möglich, je nach Budget & Spenden.

@@ -88,47 +143,76 @@ export default function InnovationSection() {
-

Setup A — Starter (CHF 15'000-20'000)

+

+ Setup A — Starter (CHF 15'000-20'000) +

  • • 2-4× Consumer GPUs (NVIDIA RTX 3090/4090, gebraucht oder gespendet)
  • • 1× Server-Rack mit Basis-Kühlung
  • -
  • Wirkung: AI Literacy Workshops (20-30 Teilnehmer/Monat), kleine Modelle fine-tunen
  • +
  • + • Wirkung: AI Literacy Workshops (20-30 Teilnehmer/Monat), + kleine Modelle fine-tunen +
-

Setup B — Professional (CHF 40'000-60'000)

+

+ Setup B — Professional (CHF 40'000-60'000) +

    -
  • • 4-6× Professional GPUs (NVIDIA A40, teilweise gespendet von Unternehmen)
  • +
  • + • 4-6× Professional GPUs (NVIDIA A40, teilweise gespendet von Unternehmen) +
  • • 2× Server-Racks mit professioneller Kühlung
  • -
  • Wirkung: AI-gestützte Hardware-Diagnostik, AI Hosting für NGOs/KMUs, grössere Modelle trainieren
  • +
  • + • Wirkung: AI-gestützte Hardware-Diagnostik, AI Hosting für + NGOs/KMUs, grössere Modelle trainieren +
-

Setup C — Enterprise (CHF 100'000-150'000)

+

+ Setup C — Enterprise (CHF 100'000-150'000) +

  • • 8-12× NVIDIA A100 GPUs (Corporate-Spenden + Teilkauf, je CHF 10-15k)
  • • ODER 4-6× NVIDIA H100 GPUs (falls verfügbar, je CHF 25-35k)
  • • 2-3× Server-Racks mit Klimatisierung & Redundanz
  • -
  • Wirkung: Alle oben + Open-Source-Modelle hosten, AI-Forschung für NGO-Sektor, Data Sovereignty für sensible Daten
  • +
  • + • Wirkung: Alle oben + Open-Source-Modelle hosten, AI-Forschung + für NGO-Sektor, Data Sovereignty für sensible Daten +
- Wichtig: Wir starten nicht mit Setup C. Wir bauen modular auf, basierend auf Spenden + verfügbarem Budget. - Jedes Setup ist nützlich — auch Setup A ermöglicht AI Literacy Workshops und kleine Modelle. -

- Realität: Enterprise GPUs sind teuer (A100: CHF 10-15k, H100: CHF 25-35k pro Stück). - Setup C erfordert massive Corporate-Spenden oder schrittweisen Aufbau über mehrere Jahre. + Wichtig: Wir starten nicht mit Setup C. Wir bauen modular auf, + basierend auf Spenden + verfügbarem Budget. Jedes Setup ist nützlich — auch Setup A + ermöglicht AI Literacy Workshops und kleine Modelle. +
+
+ Realität: Enterprise GPUs sind teuer (A100: CHF 10-15k, H100: CHF + 25-35k pro Stück). Setup C erfordert massive Corporate-Spenden oder schrittweisen Aufbau + über mehrere Jahre.
- +
diff --git a/src/app/(tenant)/fundraising/hub/sections/OperationsSection.tsx b/src/app/(tenant)/fundraising/hub/sections/OperationsSection.tsx index 262cf3d0..43d178c1 100644 --- a/src/app/(tenant)/fundraising/hub/sections/OperationsSection.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/OperationsSection.tsx @@ -14,11 +14,14 @@ export default function OperationsSection() {
- +

Offices & Sozialräume

- {OFFICE_AREA.sqm_recommended} m² für Geschäftsleitung, Koordination, Meetings, Pausenraum, Sozialräume. + {OFFICE_AREA.sqm_recommended} m² für Geschäftsleitung, Koordination, + Meetings, Pausenraum, Sozialräume.

  • • 5× Büroarbeitsplätze (Kernteam + 2× BPL)
  • @@ -26,19 +29,24 @@ export default function OperationsSection() {
  • • Pausenraum & Küche
  • • Sanitäranlagen
- +
- +

Lager & Logistik

- {STORAGE_AREA.sqm_recommended} m² für Eingang/Triage, Ersatzteile, Fertigware, Recycling-Staging. + {STORAGE_AREA.sqm_recommended} m² für Eingang/Triage, Ersatzteile, + Fertigware, Recycling-Staging.

  • • 60 m² Eingang & Triage (Geräte-Annahme)
  • @@ -46,19 +54,24 @@ export default function OperationsSection() {
  • • 30 m² Recycling-Staging (Elektroschrott)
  • • 20 m² Versand & Verpackung
- +
- +

Lade- & Anlieferzone

- {LOADING_AREA.sqm_recommended} m² für Wareneingang, LKW-Zufahrt, Palette-Handling. + {LOADING_AREA.sqm_recommended} m² für Wareneingang, LKW-Zufahrt, + Palette-Handling.

  • • Rampe für LKW-Anlieferung
  • @@ -66,10 +79,12 @@ export default function OperationsSection() {
  • • Temporäre Lagerung (24-48h)
  • • Recycling-Abholung
- +
diff --git a/src/app/(tenant)/fundraising/hub/sections/SpacePlanningSection.tsx b/src/app/(tenant)/fundraising/hub/sections/SpacePlanningSection.tsx index 932766c5..893281df 100644 --- a/src/app/(tenant)/fundraising/hub/sections/SpacePlanningSection.tsx +++ b/src/app/(tenant)/fundraising/hub/sections/SpacePlanningSection.tsx @@ -13,7 +13,8 @@ export default function SpacePlanningSection() {

📐 Raumplanung: Wie gross? Warum? Was kostet es?

- Detaillierte Begründung für jede Fläche — basierend auf Industrie-Standards, Zürich-Marktforschung und Multi-Purpose-Strategie. + Detaillierte Begründung für jede Fläche — basierend auf Industrie-Standards, + Zürich-Marktforschung und Multi-Purpose-Strategie.

{/* Total Space Summary */} @@ -21,7 +22,9 @@ export default function SpacePlanningSection() {

Gesamt-Raumkonzept

-

{SPACE_SUMMARY.total_with_circulation} m²

+

+ {SPACE_SUMMARY.total_with_circulation} m² +

Realistisch
@@ -29,17 +32,23 @@ export default function SpacePlanningSection() {

Kerngeschäft

-

{SPACE_SUMMARY.by_category.core_business.total_sqm} m²

+

+ {SPACE_SUMMARY.by_category.core_business.total_sqm} m² +

Shop + Werkstatt + Lager

Innovation & Bildung

-

{SPACE_SUMMARY.by_category.innovation.total_sqm} m²

+

+ {SPACE_SUMMARY.by_category.innovation.total_sqm} m² +

Makerspace + AI Lab + Schulung

Kultur & Community

-

{SPACE_SUMMARY.by_category.culture_community.total_sqm} m²

+

+ {SPACE_SUMMARY.by_category.culture_community.total_sqm} m² +

Events + Café + Repair Café

@@ -47,10 +56,13 @@ export default function SpacePlanningSection() {

Warum diese Grösse?

- Basiert auf Industrie-Standards: Auto-Werkstatt mit 2 Hebebühnen = 140-185m² (Baseline für Werkstatt-Dimensionierung). - Professionelle Reparatur-Arbeitsplätze benötigen 12-18m² (Werkbank + Lagerfläche + Bewegungsraum). - Workshop-Räume optimal bei 2,4-3,6m Deckenhöhe (Belüftung + Beleuchtung). Unsere Planung: - ~{SPACE_SUMMARY.total_with_circulation}m² effizient genutzter Raum, nicht 1000m² ineffizient. + Basiert auf Industrie-Standards: Auto-Werkstatt mit 2 Hebebühnen = 140-185m² (Baseline + für Werkstatt-Dimensionierung). Professionelle Reparatur-Arbeitsplätze benötigen{' '} + 12-18m² (Werkbank + Lagerfläche + Bewegungsraum). Workshop-Räume + optimal bei 2,4-3,6m Deckenhöhe (Belüftung + Beleuchtung). Unsere + Planung: + ~{SPACE_SUMMARY.total_with_circulation}m² effizient genutzter Raum, + nicht 1000m² ineffizient.

@@ -59,7 +71,9 @@ export default function SpacePlanningSection() {
-

Zürich Marktforschung: Was kostet {SPACE_SUMMARY.total_with_circulation}m²?

+

+ Zürich Marktforschung: Was kostet {SPACE_SUMMARY.total_with_circulation}m²? +

Marktdaten 2026
@@ -68,18 +82,24 @@ export default function SpacePlanningSection() {

Agglomeration (empfohlen)

- {ZURICH_MARKET_DATA.agglomeration.commercial_space_per_year} pro m²/Jahr + {ZURICH_MARKET_DATA.agglomeration.commercial_space_per_year} pro + m²/Jahr

Standorte: {ZURICH_MARKET_DATA.agglomeration.locations.join(', ')}

-

Für {SPACE_SUMMARY.total_with_circulation}m²:

+

+ Für {SPACE_SUMMARY.total_with_circulation}m²: +

- CHF {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.agglomeration.min)} - {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.agglomeration.max)} /Jahr + CHF {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.agglomeration.min)} -{' '} + {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.agglomeration.max)} /Jahr

- (Realistisch: CHF {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.agglomeration.realistic)} für gute Lage) + (Realistisch: CHF{' '} + {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.agglomeration.realistic)} für + gute Lage)

@@ -89,13 +109,14 @@ export default function SpacePlanningSection() {

{ZURICH_MARKET_DATA.city_zurich.office_space_per_year} pro m²/Jahr

-

- Zentrale Lagen (Kreis 4/5, Zürich West) -

+

Zentrale Lagen (Kreis 4/5, Zürich West)

-

Für {SPACE_SUMMARY.total_with_circulation}m²:

+

+ Für {SPACE_SUMMARY.total_with_circulation}m²: +

- CHF {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.city_zurich.min)} - {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.city_zurich.max)} /Jahr + CHF {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.city_zurich.min)} -{' '} + {formatNumber(ZURICH_MARKET_DATA.estimate_for_600m2.city_zurich.max)} /Jahr

({ZURICH_MARKET_DATA.estimate_for_600m2.city_zurich.note}) @@ -107,9 +128,11 @@ export default function SpacePlanningSection() {

Quelle & Methodik:

- Marktforschung basiert auf Homegate.ch, ImmoScout24, Comparis (Februar 2026). Kategorisierung: - Agglomeration ({ZURICH_MARKET_DATA.agglomeration.locations.join(', ')}) vs. Stadt Zürich (Zentrale Lagen). - Für detaillierte Quellenangaben siehe Methodik-Seite. + Marktforschung basiert auf Homegate.ch, ImmoScout24, Comparis (Februar 2026). + Kategorisierung: + Agglomeration ({ZURICH_MARKET_DATA.agglomeration.locations.join(', ')} + ) vs. Stadt Zürich (Zentrale Lagen). Für detaillierte Quellenangaben + siehe Methodik-Seite.

@@ -118,13 +141,16 @@ export default function SpacePlanningSection() {
-

Multi-Purpose-Strategie: Effizienz durch Zeit-Sharing

+

+ Multi-Purpose-Strategie: Effizienz durch Zeit-Sharing +

Platzsparend

- Nicht jede Funktion braucht einen eigenen Raum 24/7. Durch intelligente Zeit-Nutzung sparen wir {MULTI_PURPOSE_STRATEGY.event_space_cafe.sqm_saved}m². + Nicht jede Funktion braucht einen eigenen Raum 24/7. Durch intelligente Zeit-Nutzung + sparen wir {MULTI_PURPOSE_STRATEGY.event_space_cafe.sqm_saved}m².

@@ -132,29 +158,46 @@ export default function SpacePlanningSection() {

Werktag (9-17 Uhr)

-

{MULTI_PURPOSE_STRATEGY.event_space_cafe.time_sharing.weekday_day}

+

+ {MULTI_PURPOSE_STRATEGY.event_space_cafe.time_sharing.weekday_day} +

Abends (18-22 Uhr)

-

{MULTI_PURPOSE_STRATEGY.event_space_cafe.time_sharing.weekday_evening}

+

+ {MULTI_PURPOSE_STRATEGY.event_space_cafe.time_sharing.weekday_evening} +

1./3. Samstag

-

{MULTI_PURPOSE_STRATEGY.event_space_cafe.time_sharing.saturday_1st_3rd}

+

+ {MULTI_PURPOSE_STRATEGY.event_space_cafe.time_sharing.saturday_1st_3rd} +

- Ersparnis: Statt 140m² (60m² Café + 80m² Event-Raum separat) nutzen wir 100m² kombiniert. - Das spart {MULTI_PURPOSE_STRATEGY.event_space_cafe.sqm_saved}m² und damit CHF {formatNumber(MULTI_PURPOSE_STRATEGY.event_space_cafe.sqm_saved * 200)}/Jahr Miete. + Ersparnis: Statt 140m² (60m² Café + 80m² Event-Raum separat) nutzen wir + 100m² kombiniert. Das spart{' '} + {MULTI_PURPOSE_STRATEGY.event_space_cafe.sqm_saved}m² und damit CHF{' '} + {formatNumber(MULTI_PURPOSE_STRATEGY.event_space_cafe.sqm_saved * 200)}/Jahr Miete.

Weitere Multi-Purpose-Konzepte

    -
  • Schulungsraum + Repair Café: Werktags Kurse, 1./3. Samstag öffentliche Reparaturen
  • -
  • Makerspace + Synth-Labor: Geteilte Werkbänke, unterschiedliche Nutzungszeiten
  • -
  • AI Lab + Schulungsraum: GPUs für Training (nachts) + Workshops (tags)
  • +
  • + • Schulungsraum + Repair Café: Werktags Kurse, 1./3. Samstag + öffentliche Reparaturen +
  • +
  • + • Makerspace + Synth-Labor: Geteilte Werkbänke, unterschiedliche + Nutzungszeiten +
  • +
  • + • AI Lab + Schulungsraum: GPUs für Training (nachts) + Workshops + (tags) +
diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/page.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/page.tsx index acf2cd40..d296766a 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/page.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/page.tsx @@ -25,9 +25,10 @@ export default async function PipelineMethodikPage() { subtitle="Wie wir aus tausenden Schweizer Stiftungen die richtigen finden" />

- Die Schweiz hat über {SWISS_FOUNDATIONS_DISPLAY} eingetragene Stiftungen. Wir können nicht alle recherchieren — - das wäre Jahre an Arbeit. Stattdessen nutzen wir einen mehrstufigen Trichter: jede Stufe ist - günstiger als die nächste und eliminiert Stiftungen, die eine klare Frage nicht bestehen. + Die Schweiz hat über {SWISS_FOUNDATIONS_DISPLAY} eingetragene Stiftungen. Wir können nicht + alle recherchieren — das wäre Jahre an Arbeit. Stattdessen nutzen wir einen mehrstufigen + Trichter: jede Stufe ist günstiger als die nächste und eliminiert Stiftungen, die eine klare + Frage nicht bestehen.

diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/sections/DarkPoolSection.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/sections/DarkPoolSection.tsx index ee823f75..68ada1b2 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/sections/DarkPoolSection.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/sections/DarkPoolSection.tsx @@ -12,19 +12,20 @@ export default function DarkPoolSection({ s }: Props) {

Die verbleibende Lücke

- Von den {formatNumber(s.total)} Stiftungen in unserer Datenbank - haben {formatNumber(s.total - s.withPurpose)} keinen bekannten Stiftungszweck — sie stammen - ausschliesslich aus dem Handelsregister (Zefix) und bestehen nur aus einem Namen und einer UID. + Von den {formatNumber(s.total)} Stiftungen in unserer Datenbank haben{' '} + {formatNumber(s.total - s.withPurpose)} keinen bekannten Stiftungszweck — sie stammen + ausschliesslich aus dem Handelsregister (Zefix) und bestehen nur aus einem Namen und einer + UID.

- Für diese Stiftungen gibt es keine günstige Erstbewertung. Der nächste Schritt ist - die automatische Website-Erkennung: Über DNS-Abfragen und Namens-Heuristiken - wird geprüft, ob eine Stiftung eine eigene Website hat. Falls ja, wird diese - gescrapt und der Stiftungszweck daraus extrahiert — danach greift der reguläre Trichter. + Für diese Stiftungen gibt es keine günstige Erstbewertung. Der nächste Schritt ist die + automatische Website-Erkennung: Über DNS-Abfragen und Namens-Heuristiken wird geprüft, ob + eine Stiftung eine eigene Website hat. Falls ja, wird diese gescrapt und der + Stiftungszweck daraus extrahiert — danach greift der reguläre Trichter.

- Bisherige Website-Erkennung: ~555 Websites bei ~16'000 geprüften Stiftungen gefunden (3.4% Trefferquote). - Die meisten Zefix-Stiftungen haben keine eigene Website. + Bisherige Website-Erkennung: ~555 Websites bei ~16'000 geprüften Stiftungen gefunden + (3.4% Trefferquote). Die meisten Zefix-Stiftungen haben keine eigene Website.
diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/sections/DataSourcesSection.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/sections/DataSourcesSection.tsx index 34dfdfc4..ced991d6 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/sections/DataSourcesSection.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/sections/DataSourcesSection.tsx @@ -9,19 +9,23 @@ export default function DataSourcesSection() {

Zefix (Handelsregister)

- Offizielles Schweizer Handelsregister. Enthält alle eingetragenen Stiftungen - mit Name, UID und Sitz. Keine Informationen über Stiftungszweck oder Tätigkeit. + Offizielles Schweizer Handelsregister. Enthält alle eingetragenen Stiftungen mit Name, + UID und Sitz. Keine Informationen über Stiftungszweck oder Tätigkeit. +

+

+ {SWISS_FOUNDATIONS_DISPLAY} Stiftungen. Quelle: zefix.ch

-

{SWISS_FOUNDATIONS_DISPLAY} Stiftungen. Quelle: zefix.ch

ESA (Eidg. Stiftungsaufsicht)

- Bundesaufsicht über Stiftungen. Enthält den offiziellen Stiftungszweck — - die rechtliche Zweckbeschreibung, die bei der Gründung festgelegt wurde. - Dies ist unser wichtigstes Signal für die Erstbewertung. + Bundesaufsicht über Stiftungen. Enthält den offiziellen Stiftungszweck — die rechtliche + Zweckbeschreibung, die bei der Gründung festgelegt wurde. Dies ist unser wichtigstes + Signal für die Erstbewertung. +

+

+ ~5'400 Stiftungen mit Zwecktext. Quelle: esa.admin.ch

-

~5'400 Stiftungen mit Zwecktext. Quelle: esa.admin.ch

diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/sections/FunnelSection.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/sections/FunnelSection.tsx index b17cf681..3c5c6852 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/sections/FunnelSection.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/sections/FunnelSection.tsx @@ -6,7 +6,17 @@ interface Props { s: FunnelStats; } -function FunnelStage({ number, title, question, count, countLabel, method, cost, color, detail }: { +function FunnelStage({ + number, + title, + question, + count, + countLabel, + method, + cost, + color, + detail, +}: { number: number; title: string; question: string; @@ -28,9 +38,7 @@ function FunnelStage({ number, title, question, count, countLabel, method, cost,

{title}

«{question}»

- {detail && ( -

{detail}

- )} + {detail &&

{detail}

}
Methode: {method} Kosten: {cost} diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/sections/PrinciplesSection.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/sections/PrinciplesSection.tsx index a4240c9d..3b57d7a9 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/sections/PrinciplesSection.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/sections/PrinciplesSection.tsx @@ -8,33 +8,33 @@ export default function PrinciplesSection() {

Günstigstes Signal zuerst

- Jede Stufe ist billiger als die nächste. Keyword-Matching kostet nichts, - KI-Analyse kostet wenig, Tiefenrecherche kostet am meisten. Wir eliminieren - so viel wie möglich, bevor wir Ressourcen investieren. + Jede Stufe ist billiger als die nächste. Keyword-Matching kostet nichts, KI-Analyse + kostet wenig, Tiefenrecherche kostet am meisten. Wir eliminieren so viel wie möglich, + bevor wir Ressourcen investieren.

Nie verschlechtern

- Neue Daten können einen Fit-Score nur verbessern, nie verschlechtern. - Prioritäten können nur steigen, nie sinken. Bereits recherchierte Stiftungen - werden nie auf eine niedrigere Stufe zurückgesetzt. + Neue Daten können einen Fit-Score nur verbessern, nie verschlechtern. Prioritäten können + nur steigen, nie sinken. Bereits recherchierte Stiftungen werden nie auf eine niedrigere + Stufe zurückgesetzt.

Inspizierbar

- Jeder Score und jede Stufe ist auf der Stiftungs-Detailseite einsehbar. - Welche Checks bestanden haben, welche Felder fehlen, warum die Priorität - so ist wie sie ist. Keine Black Boxes. + Jeder Score und jede Stufe ist auf der Stiftungs-Detailseite einsehbar. Welche Checks + bestanden haben, welche Felder fehlen, warum die Priorität so ist wie sie ist. Keine + Black Boxes.

Wiederholbar

- Der gesamte Trichter ist idempotent — er kann jederzeit erneut ausgeführt werden, - ohne bestehende Daten zu beschädigen. Neue Register-Daten werden automatisch - in den Trichter aufgenommen. + Der gesamte Trichter ist idempotent — er kann jederzeit erneut ausgeführt werden, ohne + bestehende Daten zu beschädigen. Neue Register-Daten werden automatisch in den Trichter + aufgenommen.

diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/sections/StageDescriptionsSection.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/sections/StageDescriptionsSection.tsx index 6917c2a8..8736b869 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/sections/StageDescriptionsSection.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/sections/StageDescriptionsSection.tsx @@ -10,8 +10,8 @@ export default function StageDescriptionsSection() {

Stufe 1-2: Register-Import

Automatischer Import aller Stiftungen aus dem Zefix-Handelsregister und dem - ESA-Stiftungsverzeichnis. Jede Stiftung bekommt einen Datenbankeintrag mit - Name, UID und — falls von ESA vorhanden — dem offiziellen Stiftungszweck. + ESA-Stiftungsverzeichnis. Jede Stiftung bekommt einen Datenbankeintrag mit Name, UID und — + falls von ESA vorhanden — dem offiziellen Stiftungszweck.

Vollständig automatisiert. Wird periodisch aktualisiert. @@ -23,13 +23,13 @@ export default function StageDescriptionsSection() {

Der offizielle Stiftungszweck wird gegen unsere Themenbereiche abgeglichen: Arbeitsintegration, Kreislaufwirtschaft, digitale Bildung, soziale Integration, - Jugendförderung, Klimaschutz, Region Zürich. Parallel wird erkannt, ob die - Stiftung eine Förderstiftung (vergibt Gelder) oder eine operative Stiftung - (betreibt eigene Einrichtungen) ist. + Jugendförderung, Klimaschutz, Region Zürich. Parallel wird erkannt, ob die Stiftung eine + Förderstiftung (vergibt Gelder) oder eine operative Stiftung (betreibt eigene + Einrichtungen) ist.

- Keyword-Matching auf dem Stiftungszweck. Keine KI-Kosten. - Eliminiert Stiftungen ohne thematische Überschneidung (z.B. Medizinforschung, Kunst, Oper). + Keyword-Matching auf dem Stiftungszweck. Keine KI-Kosten. Eliminiert Stiftungen ohne + thematische Überschneidung (z.B. Medizinforschung, Kunst, Oper).

@@ -37,9 +37,9 @@ export default function StageDescriptionsSection() {

Stufe 3-4: Fit-Bewertung und Tiefenrecherche

Stiftungen mit thematischem Signal werden vertieft analysiert: Passt der Stiftungszweck - wirklich zu unserer Mission? Dies geschieht durch KI-gestützte Analyse des Zwecktexts - oder — bei vorhandener Website — durch Scraping und Auswertung der Stiftungs-Website. - Dabei werden Kontaktdaten, Förderbereiche, Fristen und Bewerbungswege extrahiert. + wirklich zu unserer Mission? Dies geschieht durch KI-gestützte Analyse des Zwecktexts oder + — bei vorhandener Website — durch Scraping und Auswertung der Stiftungs-Website. Dabei + werden Kontaktdaten, Förderbereiche, Fristen und Bewerbungswege extrahiert.

KI-gestützt (LLM-Analyse). Moderate Kosten. Nur für Stiftungen mit positivem Screening. @@ -49,13 +49,16 @@ export default function StageDescriptionsSection() {

Stufe 4-5: Prioritäts-Gate

- Die Kombination aus Fit-Score und Bereitschafts-Score ergibt die Priorität. - Nur Stiftungen mit Priorität P1-P3 bekommen eine generierte Gesuch-Seite. - P4-Stiftungen bleiben in der Datenbank für spätere Beziehungspflege, - erhalten aber kein Gesuch. + Die Kombination aus Fit-Score und Bereitschafts-Score ergibt die Priorität. Nur Stiftungen + mit Priorität P1-P3 bekommen eine generierte Gesuch-Seite. P4-Stiftungen bleiben in der + Datenbank für spätere Beziehungspflege, erhalten aber kein Gesuch.

- Algorithmisch berechnet. Details: Scoring-Methodik. + Algorithmisch berechnet. Details:{' '} + + Scoring-Methodik + + .

diff --git a/src/app/(tenant)/fundraising/pipeline-methodik/sections/TierDistributionSection.tsx b/src/app/(tenant)/fundraising/pipeline-methodik/sections/TierDistributionSection.tsx index 2690e7dd..4483037c 100644 --- a/src/app/(tenant)/fundraising/pipeline-methodik/sections/TierDistributionSection.tsx +++ b/src/app/(tenant)/fundraising/pipeline-methodik/sections/TierDistributionSection.tsx @@ -1,6 +1,11 @@ import Card from '@/components/ui/Card'; import Badge from '@/components/ui/Badge'; -import { TIER_LABELS, TIER_COLORS, TIER_CAPABILITIES, QUALITY_TIERS } from '@/lib/domain/foundation-helpers'; +import { + TIER_LABELS, + TIER_COLORS, + TIER_CAPABILITIES, + QUALITY_TIERS, +} from '@/lib/domain/foundation-helpers'; import { formatNumber } from '@/lib/utils/format'; import type { FunnelStats } from '@/lib/domain/pipeline-stats'; @@ -13,16 +18,19 @@ export default function TierDistributionSection({ s }: Props) {

Aktuelle Verteilung nach Qualitätsstufe

- Jede Stiftung wird anhand ihrer Datenvollständigkeit einer von fünf Qualitätsstufen zugeordnet. - Die Stufe bestimmt, was mit der Stiftung möglich ist. + Jede Stiftung wird anhand ihrer Datenvollständigkeit einer von fünf Qualitätsstufen + zugeordnet. Die Stufe bestimmt, was mit der Stiftung möglich ist.

- {[...QUALITY_TIERS].reverse().map(tier => { + {[...QUALITY_TIERS].reverse().map((tier) => { const count = s.tierCounts[tier]; const pct = s.total > 0 ? ((count / s.total) * 100).toFixed(1) : '0'; return ( -
+
{TIER_LABELS[tier]} diff --git a/src/app/(tenant)/fundraising/scoring-methodik/page.tsx b/src/app/(tenant)/fundraising/scoring-methodik/page.tsx index 036958a0..8c2ffec4 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/page.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; import PageHeader from '@/components/layout/PageHeader'; -import { computePriorityDistribution, computeReadinessDistribution } from '@/lib/domain/scoring-stats'; +import { + computePriorityDistribution, + computeReadinessDistribution, +} from '@/lib/domain/scoring-stats'; import { getAllFoundations } from '@/lib/db/foundations-repo'; import ScoringArchitectureSection from './sections/ScoringArchitectureSection'; import FitScoreSection from './sections/FitScoreSection'; @@ -26,9 +29,9 @@ export default async function ScoringMethodikPage() { subtitle="Algorithmische Bewertung von Stiftungen — drei Ebenen, komplett inspizierbar" />

- Jeder Score wird algorithmisch aus den vorhandenen Stiftungsdaten berechnet. - Keine manuellen Noten, keine Black Boxes. Alle Gewichte und Schwellenwerte - sind konfigurierbar und hier vollständig dokumentiert. + Jeder Score wird algorithmisch aus den vorhandenen Stiftungsdaten berechnet. Keine manuellen + Noten, keine Black Boxes. Alle Gewichte und Schwellenwerte sind konfigurierbar und hier + vollständig dokumentiert.

diff --git a/src/app/(tenant)/fundraising/scoring-methodik/sections/DesignPrinciplesSection.tsx b/src/app/(tenant)/fundraising/scoring-methodik/sections/DesignPrinciplesSection.tsx index 42fe7252..6e8f7a1e 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/sections/DesignPrinciplesSection.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/sections/DesignPrinciplesSection.tsx @@ -8,8 +8,8 @@ export default function DesignPrinciplesSection() {

Fit als Gate, nicht als Summand

- Eine Stiftung mit perfekten Daten aber schlechtem Fit (z.B. nur Medizinforschung) - hat Priorität 0. Gute Daten kompensieren keinen fundamentalen Mismatch. + Eine Stiftung mit perfekten Daten aber schlechtem Fit (z.B. nur Medizinforschung) hat + Priorität 0. Gute Daten kompensieren keinen fundamentalen Mismatch.

@@ -22,15 +22,15 @@ export default function DesignPrinciplesSection() {

Inspizierbar

- Jede Stiftungs-Detailseite zeigt den Score-Aufschlüsselung: - welche Checks bestanden haben und welche fehlen. Keine Black Box. + Jede Stiftungs-Detailseite zeigt den Score-Aufschlüsselung: welche Checks bestanden + haben und welche fehlen. Keine Black Box.

Konfigurierbar

- Alle Gewichte, Schwellenwerte und Abzüge leben in einer einzigen - Konfigurationsdatei. Pro Organisation anpassbar. + Alle Gewichte, Schwellenwerte und Abzüge leben in einer einzigen Konfigurationsdatei. + Pro Organisation anpassbar.

diff --git a/src/app/(tenant)/fundraising/scoring-methodik/sections/FitScoreSection.tsx b/src/app/(tenant)/fundraising/scoring-methodik/sections/FitScoreSection.tsx index 374219bd..da9bb5e8 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/sections/FitScoreSection.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/sections/FitScoreSection.tsx @@ -6,23 +6,31 @@ export default function FitScoreSection() {

Ebene 1: Fit-Score

- Der Fit-Score bewertet die inhaltliche Übereinstimmung zwischen Stiftungszweck - und unserer Mission auf drei Dimensionen: + Der Fit-Score bewertet die inhaltliche Übereinstimmung zwischen Stiftungszweck und unserer + Mission auf drei Dimensionen:

- - - + + + - + @@ -38,17 +46,19 @@ export default function FitScoreSection() {
DimensionBereichBeschreibung + Dimension + + Bereich + + Beschreibung +
Thematisch 0-4Gewichtete Übereinstimmung der Förderthemen mit unseren Schwerpunkten + Gewichtete Übereinstimmung der Förderthemen mit unseren Schwerpunkten +
Geografisch
- Dimensionsfloors: Wenn der geografische Score unter 1 liegt - (z.B. Stiftung fördert nur in Mexiko), wird der Gesamt-Fit auf maximal 3 - begrenzt. Bei thematischem Score 0 auf maximal 2. Dies verhindert, dass - ein guter Zugang einen fundamentalen Mismatch kompensiert. + Dimensionsfloors: Wenn der geografische Score unter 1 liegt (z.B. + Stiftung fördert nur in Mexiko), wird der Gesamt-Fit auf maximal 3 begrenzt. Bei + thematischem Score 0 auf maximal 2. Dies verhindert, dass ein guter Zugang einen + fundamentalen Mismatch kompensiert.

- Anzeige: 0-3 Sterne (≥7 → 3 Sterne, ≥4 → 2 Sterne, ≥1 → 1 Stern, 0 → 0 Sterne). Stiftungen unter Tier «Profiliert» → 0 Sterne (ungenügende Datengrundlage). + Anzeige: 0-3 Sterne (≥7 → 3 Sterne, ≥4 → 2 Sterne, ≥1 → 1 Stern, 0 → 0 Sterne). Stiftungen + unter Tier «Profiliert» → 0 Sterne (ungenügende Datengrundlage).

- Vertrauensgate: Stiftungen mit Bereitschafts-Tier unter «Profiliert» zeigen keine - Sterne-Bewertung, da die Datengrundlage für eine belastbare Einschätzung nicht ausreicht. + Vertrauensgate: Stiftungen mit Bereitschafts-Tier unter «Profiliert» + zeigen keine Sterne-Bewertung, da die Datengrundlage für eine belastbare Einschätzung + nicht ausreicht.
diff --git a/src/app/(tenant)/fundraising/scoring-methodik/sections/GesuchGateSection.tsx b/src/app/(tenant)/fundraising/scoring-methodik/sections/GesuchGateSection.tsx index 6f61163e..b6a89881 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/sections/GesuchGateSection.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/sections/GesuchGateSection.tsx @@ -12,7 +12,7 @@ export default function GesuchGateSection({ foundations }: Props) { const total = foundations.length; const gesuchCount = foundations.filter(hasGesuchPage).length; const recherchiertMinScore = READINESS_ENGINE.display.thresholds.find( - (t) => TIER_FROM_LEVEL[t.level] === 'recherchiert' + (t) => TIER_FROM_LEVEL[t.level] === 'recherchiert', )?.minScore; const p3MinScore = PRIORITY_FORMULA.display[PRIORITY_FORMULA.display.length - 1].minScore; @@ -24,13 +24,17 @@ export default function GesuchGateSection({ foundations }: Props) { Eine Gesuch-Seite wird nur generiert, wenn beide Bedingungen erfüllt sind:

    -
  1. Bereitschafts-Stufe ≥ Recherchiert (Score ≥{recherchiertMinScore})
  2. -
  3. Prioritätsstufe P1, P2 oder P3 (Score ≥{p3MinScore})
  4. +
  5. + Bereitschafts-Stufe ≥ Recherchiert (Score ≥{recherchiertMinScore}) +
  6. +
  7. + Prioritätsstufe P1, P2 oder P3 (Score ≥{p3MinScore}) +

- P3-Stiftungen («Beobachten — bei passendem Timing bewerben») bekommen ein - vorbereitetes Gesuch, damit wir bei gutem Timing sofort einreichen können. - P4-Stiftungen bekommen kein Gesuch, da die strategische Priorität zu niedrig ist. + P3-Stiftungen («Beobachten — bei passendem Timing bewerben») bekommen ein vorbereitetes + Gesuch, damit wir bei gutem Timing sofort einreichen können. P4-Stiftungen bekommen kein + Gesuch, da die strategische Priorität zu niedrig ist.

Aktuell: {gesuchCount} von {total} Stiftungen haben eine Gesuch-Seite. diff --git a/src/app/(tenant)/fundraising/scoring-methodik/sections/PrioritySection.tsx b/src/app/(tenant)/fundraising/scoring-methodik/sections/PrioritySection.tsx index 59d8050f..593f6526 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/sections/PrioritySection.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/sections/PrioritySection.tsx @@ -10,26 +10,29 @@ interface Props { } export default function PrioritySection({ priorityCounts, priorityAvg, total }: Props) { - return (

Ebene 3: Prioritäts-Score

- Die Priorität kombiniert Fit und Bereitschaft zu einer handlungsorientierten - Empfehlung. Fit wirkt als Multiplikator (Gate), nicht als Summand — - eine Stiftung ohne thematischen Fit hat immer Priorität 0, unabhängig von der Datenlage. + Die Priorität kombiniert Fit und Bereitschaft zu einer handlungsorientierten Empfehlung. + Fit wirkt als Multiplikator (Gate), nicht als Summand — eine Stiftung + ohne thematischen Fit hat immer Priorität 0, unabhängig von der Datenlage.

Formel

-

basis = fit_normiert × ({PRIORITY_FORMULA.baseFitFloor} + {PRIORITY_FORMULA.readinessScale} × bereitschaft / 100)

+

+ basis = fit_normiert × ({PRIORITY_FORMULA.baseFitFloor} +{' '} + {PRIORITY_FORMULA.readinessScale} × bereitschaft / 100) +

priorität = min(100, basis × min(anwendbare_abzüge) + förder_bonus)

- fit_normiert = fitScore / 10 (0-1). Bei Fit 10 + Bereitschaft 100 → Priorität 100. - Bei Fit 10 + Bereitschaft 0 → Priorität {PRIORITY_FORMULA.baseFitFloor}. Bei Fit 0 → immer 0. + fit_normiert = fitScore / 10 (0-1). Bei Fit 10 + Bereitschaft 100 → Priorität 100. Bei + Fit 10 + Bereitschaft 0 → Priorität {PRIORITY_FORMULA.baseFitFloor}. Bei Fit 0 → immer + 0.

@@ -39,22 +42,32 @@ export default function PrioritySection({ priorityCounts, priorityAvg, total }: - - - + + + - {([ - ['Operative Stiftung', PRIORITY_FORMULA.penalties.operative], - ['Keine Gesuche angenommen', PRIORITY_FORMULA.penalties.noApplications], - ['Geschlossen', PRIORITY_FORMULA.penalties.closed], - ['Nur auf Einladung', PRIORITY_FORMULA.penalties.invitationOnly], - ] as [string, number][]).map(([label, mult]) => ( + {( + [ + ['Operative Stiftung', PRIORITY_FORMULA.penalties.operative], + ['Keine Gesuche angenommen', PRIORITY_FORMULA.penalties.noApplications], + ['Geschlossen', PRIORITY_FORMULA.penalties.closed], + ['Nur auf Einladung', PRIORITY_FORMULA.penalties.invitationOnly], + ] as [string, number][] + ).map(([label, mult]) => ( - + ))} @@ -68,48 +81,83 @@ export default function PrioritySection({ priorityCounts, priorityAvg, total }:

Förder-Bonus

- Wenn der Förderbereich der Stiftung mit unserem Zielbetrag - (CHF {formatNumber(PRIORITY_FORMULA.grantMatch.targetMin)}–{formatNumber(PRIORITY_FORMULA.grantMatch.targetMax)}) übereinstimmt: + Wenn der Förderbereich der Stiftung mit unserem Zielbetrag (CHF{' '} + {formatNumber(PRIORITY_FORMULA.grantMatch.targetMin)}– + {formatNumber(PRIORITY_FORMULA.grantMatch.targetMax)}) übereinstimmt:

-

Perfekte Überlappung: +{PRIORITY_FORMULA.grantMatch.perfectBonus}

-

Nahe dran (max ≥ Hälfte unseres Minimums): +{PRIORITY_FORMULA.grantMatch.closeBonus}

-

Betrag bekannt aber klein: +{PRIORITY_FORMULA.grantMatch.smallBonus}

+

+ Perfekte Überlappung:{' '} + + +{PRIORITY_FORMULA.grantMatch.perfectBonus} + +

+

+ Nahe dran (max ≥ Hälfte unseres Minimums):{' '} + + +{PRIORITY_FORMULA.grantMatch.closeBonus} + +

+

+ Betrag bekannt aber klein:{' '} + + +{PRIORITY_FORMULA.grantMatch.smallBonus} + +

-

Prioritätsstufen (aktuell über {total} Stiftungen)

+

+ Prioritätsstufen (aktuell über {total} Stiftungen) +

{PRIORITY_FORMULA.display.map((d) => { const pc = PRIORITY_CONFIG[d.level]; return ( -
+
- {pc.label} + + {pc.label} + {pc.description}
≥{d.minScore} - {priorityCounts[d.level]} + + {priorityCounts[d.level]} +
); })}
- {PRIORITY_CONFIG[4].label} - {PRIORITY_CONFIG[4].description} + + {PRIORITY_CONFIG[4].label} + + + {PRIORITY_CONFIG[4].description} +
- <{PRIORITY_FORMULA.display[PRIORITY_FORMULA.display.length - 1].minScore} - {priorityCounts[4]} + + <{PRIORITY_FORMULA.display[PRIORITY_FORMULA.display.length - 1].minScore} + + + {priorityCounts[4]} +

- Durchschnittlicher Prioritäts-Score: {priorityAvg}/100. - Manuell überschriebene Prioritäten sind mit «manuell» gekennzeichnet. + Durchschnittlicher Prioritäts-Score: {priorityAvg}/100. Manuell überschriebene + Prioritäten sind mit «manuell» gekennzeichnet.

diff --git a/src/app/(tenant)/fundraising/scoring-methodik/sections/ReadinessSection.tsx b/src/app/(tenant)/fundraising/scoring-methodik/sections/ReadinessSection.tsx index 0445f481..fa9b43bd 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/sections/ReadinessSection.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/sections/ReadinessSection.tsx @@ -1,7 +1,12 @@ import Card from '@/components/ui/Card'; import Badge from '@/components/ui/Badge'; import { READINESS_ENGINE } from '@/lib/config/fit-scoring'; -import { TIER_LABELS, TIER_COLORS, TIER_DESCRIPTIONS, computeTierCounts } from '@/lib/domain/foundation-helpers'; +import { + TIER_LABELS, + TIER_COLORS, + TIER_DESCRIPTIONS, + computeTierCounts, +} from '@/lib/domain/foundation-helpers'; import { TIER_FROM_LEVEL } from '@/lib/domain/foundation-scores'; import type { Foundation, QualityTier } from '@/lib/schemas/foundation'; import type { AdditiveChecksConfig } from '@/lib/config/fit-scoring'; @@ -19,9 +24,9 @@ export default function ReadinessSection({ readinessAvg, foundations }: Props) {

Ebene 2: Bereitschafts-Score

- Der Bereitschafts-Score misst, wie gut unsere Datenlage ist, um ein überzeugendes, - auf die Stiftung massgeschneidertes Gesuch zu erstellen. Er besteht aus vier - Dimensionen, die jeweils eine konkrete Frage beantworten: + Der Bereitschafts-Score misst, wie gut unsere Datenlage ist, um ein überzeugendes, auf die + Stiftung massgeschneidertes Gesuch zu erstellen. Er besteht aus vier Dimensionen, die + jeweils eine konkrete Frage beantworten:

{READINESS_ENGINE.dimensions.map((dim) => { @@ -30,21 +35,29 @@ export default function ReadinessSection({ readinessAvg, foundations }: Props) {

{dim.label}

- max {dim.maxScore} + + max {dim.maxScore} +
BedingungMultiplikatorEffekt + Bedingung + + Multiplikator + + Effekt +
{label} ×{mult}−{Math.round((1 - mult) * 100)}% + −{Math.round((1 - mult) * 100)}% +
- - + + {checks.map((check) => ( - + ))} @@ -57,25 +70,35 @@ export default function ReadinessSection({ readinessAvg, foundations }: Props) {

Score → Qualitätsstufe

- Der Bereitschafts-Score wird in fünf Qualitätsstufen übersetzt. - Aktueller Durchschnitt: {readinessAvg}/100 über {total} Stiftungen. + Der Bereitschafts-Score wird in fünf Qualitätsstufen übersetzt. Aktueller Durchschnitt:{' '} + {readinessAvg}/100 über {total} Stiftungen.

{[ - ...READINESS_ENGINE.display.thresholds.map((t) => [ - TIER_FROM_LEVEL[t.level] ?? ('verzeichnet' as QualityTier), - t.minScore, - ] as [QualityTier, number]), + ...READINESS_ENGINE.display.thresholds.map( + (t) => + [TIER_FROM_LEVEL[t.level] ?? ('verzeichnet' as QualityTier), t.minScore] as [ + QualityTier, + number, + ], + ), ['verzeichnet' as QualityTier, 0] as [QualityTier, number], ].map(([tier, minScore]) => ( -
+
- {TIER_LABELS[tier]} + + {TIER_LABELS[tier]} + {TIER_DESCRIPTIONS[tier]}
≥{minScore} - {tierCounts[tier]} + + {tierCounts[tier]} +
))} diff --git a/src/app/(tenant)/fundraising/scoring-methodik/sections/ScoringArchitectureSection.tsx b/src/app/(tenant)/fundraising/scoring-methodik/sections/ScoringArchitectureSection.tsx index 878ce145..7bb0a415 100644 --- a/src/app/(tenant)/fundraising/scoring-methodik/sections/ScoringArchitectureSection.tsx +++ b/src/app/(tenant)/fundraising/scoring-methodik/sections/ScoringArchitectureSection.tsx @@ -12,25 +12,31 @@ export default function ScoringArchitectureSection() { Passt diese Stiftung zu unserer Mission? Thematische, geografische und Zugangs-Übereinstimmung.

-

Berechnet bei der Recherche, gespeichert pro Stiftung.

+

+ Berechnet bei der Recherche, gespeichert pro Stiftung. +

📊

Bereitschaft (0-100)

- Können wir ein massgeschneidertes Gesuch schreiben? Misst die - Vollständigkeit unserer Recherche-Daten. + Können wir ein massgeschneidertes Gesuch schreiben? Misst die Vollständigkeit unserer + Recherche-Daten. +

+

+ Berechnet in Echtzeit aus Stiftungsfeldern.

-

Berechnet in Echtzeit aus Stiftungsfeldern.

Priorität (0-100)

- Sollten wir jetzt Aufwand investieren? Kombiniert Fit und Bereitschaft - zu einer handlungsorientierten Empfehlung. + Sollten wir jetzt Aufwand investieren? Kombiniert Fit und Bereitschaft zu einer + handlungsorientierten Empfehlung. +

+

+ Berechnet in Echtzeit. Fit ist Multiplikator.

-

Berechnet in Echtzeit. Fit ist Multiplikator.

diff --git a/src/app/(tenant)/fundraising/sections/BudgetDetail.tsx b/src/app/(tenant)/fundraising/sections/BudgetDetail.tsx index 5893b1b8..e1791c3a 100644 --- a/src/app/(tenant)/fundraising/sections/BudgetDetail.tsx +++ b/src/app/(tenant)/fundraising/sections/BudgetDetail.tsx @@ -16,7 +16,11 @@ function BudgetLineItemCard({ item, borderColor }: { item: BudgetLineItem; borde return (
- {item.icon && } + {item.icon && ( + + )}

{item.label}

@@ -42,19 +46,43 @@ function BudgetLineItemCard({ item, borderColor }: { item: BudgetLineItem; borde export default function BudgetDetail({ inspector }: { inspector: InspectorHandle }) { const inspectBudgetTotal = NumberSources.budget_total_y1 ? metricToInspectorData(NumberSources.budget_total_y1, formatCHF(BUDGET_SUMMARY.total)) - : { label: 'Gesamtbudget Jahr 1', value: formatCHF(BUDGET_SUMMARY.total), sourceType: 'derived' as const, source: 'BUDGET_MODULES', confidence: 'Hoch' }; + : { + label: 'Gesamtbudget Jahr 1', + value: formatCHF(BUDGET_SUMMARY.total), + sourceType: 'derived' as const, + source: 'BUDGET_MODULES', + confidence: 'Hoch', + }; const inspectEinmalig = NumberSources.budget_einmalig ? metricToInspectorData(NumberSources.budget_einmalig, formatCHF(BUDGET_EINMALIG_TOTAL)) - : { label: 'Einmalige Investitionen', value: formatCHF(BUDGET_EINMALIG_TOTAL), sourceType: 'derived' as const, source: 'BUDGET_MODULES', confidence: 'Hoch' }; + : { + label: 'Einmalige Investitionen', + value: formatCHF(BUDGET_EINMALIG_TOTAL), + sourceType: 'derived' as const, + source: 'BUDGET_MODULES', + confidence: 'Hoch', + }; const inspectJaehrlich = NumberSources.budget_jaehrlich ? metricToInspectorData(NumberSources.budget_jaehrlich, formatCHF(BUDGET_JAEHRLICH_TOTAL)) - : { label: 'Jährliche Kosten', value: formatCHF(BUDGET_JAEHRLICH_TOTAL), sourceType: 'derived' as const, source: 'BUDGET_MODULES', confidence: 'Hoch' }; + : { + label: 'Jährliche Kosten', + value: formatCHF(BUDGET_JAEHRLICH_TOTAL), + sourceType: 'derived' as const, + source: 'BUDGET_MODULES', + confidence: 'Hoch', + }; const inspectStiftungen3Y = NumberSources.stiftungen_3y_total ? metricToInspectorData(NumberSources.stiftungen_3y_total, formatCHF(STIFTUNGEN_3Y_TOTAL)) - : { label: 'Stiftungsfinanzierung 3 Jahre', value: formatCHF(STIFTUNGEN_3Y_TOTAL), sourceType: 'derived' as const, source: 'fundraising/data.ts → THREE_YEAR_MODEL', confidence: 'Hoch' }; + : { + label: 'Stiftungsfinanzierung 3 Jahre', + value: formatCHF(STIFTUNGEN_3Y_TOTAL), + sourceType: 'derived' as const, + source: 'fundraising/data.ts → THREE_YEAR_MODEL', + confidence: 'Hoch', + }; return (
@@ -63,7 +91,8 @@ export default function BudgetDetail({ inspector }: { inspector: InspectorHandle 7 Module, einzeln förderbar. Einmalige Investitionen + jährliche Kosten ={' '} {formatCHF(BUDGET_SUMMARY.total)} - . + + .

{/* Einmalige Investitionen */} @@ -73,7 +102,11 @@ export default function BudgetDetail({ inspector }: { inspector: InspectorHandle Einmalige Investitionen - + {formatCHF(BUDGET_EINMALIG_TOTAL)}
@@ -90,10 +123,17 @@ export default function BudgetDetail({ inspector }: { inspector: InspectorHandle

Jährliche Kosten - (degressiv finanziert über 3 Jahre) + + (degressiv finanziert über 3 Jahre) +

- - {formatCHF(BUDGET_JAEHRLICH_TOTAL)}/Jahr + + {formatCHF(BUDGET_JAEHRLICH_TOTAL)} + /Jahr
@@ -110,16 +150,21 @@ export default function BudgetDetail({ inspector }: { inspector: InspectorHandle
{formatCHF(BUDGET_SUMMARY.total)} - - {' '}Jahr 1 + {' '} + Jahr 1

- Davon {formatCHF(BUDGET_SUMMARY.eigenleistung)} Eigenleistung* ({BUDGET_SUMMARY.selfFinancingPct}%) + Davon {formatCHF(BUDGET_SUMMARY.eigenleistung)} Eigenleistung* ( + {BUDGET_SUMMARY.selfFinancingPct}%)

Stiftungs-Förderbedarf
- + {formatCHF(STIFTUNGEN_3Y_TOTAL)}
über 3 Jahre (degressiv)
diff --git a/src/app/(tenant)/fundraising/sections/BusinessModelChallenge.tsx b/src/app/(tenant)/fundraising/sections/BusinessModelChallenge.tsx index 4e3cb3e6..dc48d89d 100644 --- a/src/app/(tenant)/fundraising/sections/BusinessModelChallenge.tsx +++ b/src/app/(tenant)/fundraising/sections/BusinessModelChallenge.tsx @@ -9,30 +9,41 @@ export default function BusinessModelChallenge() {

- Unsere aktuelle Situation: Wir haben 2 grosse Kivitendo-Kunden verloren. Das hat - unsere B2B-Einnahmen stark reduziert. + Unsere aktuelle Situation: Wir haben 2 grosse Kivitendo-Kunden + verloren. Das hat unsere B2B-Einnahmen stark reduziert.

-

Warum wir nicht einfach „mehr verkaufen“ können:

+

+ Warum wir nicht einfach „mehr verkaufen“ können: +

    -
  • Unser Kivitendo-System braucht dringend eine Überarbeitung — aber uns fehlen die Ressourcen
  • +
  • + Unser Kivitendo-System braucht dringend eine Überarbeitung — aber uns fehlen die + Ressourcen +
  • Web-Design-Praxis ist aus demselben Grund nicht funktionsfähig
  • -
  • Problem ist nicht fehlende Geräte, sondern fehlende Organisation für Verkauf & Ausführung
  • +
  • + Problem ist nicht fehlende Geräte, sondern fehlende Organisation für Verkauf & + Ausführung +
  • Wir haben bereits zu viel Inventar in 2 Lagern — mehr Geräte helfen nicht

- Was wir brauchen: Nicht mehr Verkauf, sondern bessere Prozesse und - diversifizierte Einnahmen (Tech-Bildung, Workshops, AI Lab Services, Corporate Training, - Community Events). + Was wir brauchen: Nicht mehr Verkauf, sondern{' '} + bessere Prozesse und + diversifizierte Einnahmen (Tech-Bildung, Workshops, AI Lab Services, + Corporate Training, Community Events).

Detaillierte Finanzdaten sind verfügbar im{' '} - Finanzen-Bereich — alle Zahlen aus Kivitendo, - klickbar mit Quellenangabe. + + Finanzen-Bereich + {' '} + — alle Zahlen aus Kivitendo, klickbar mit Quellenangabe.

diff --git a/src/app/(tenant)/fundraising/sections/CostStructure.tsx b/src/app/(tenant)/fundraising/sections/CostStructure.tsx index da5acacc..dc26fbec 100644 --- a/src/app/(tenant)/fundraising/sections/CostStructure.tsx +++ b/src/app/(tenant)/fundraising/sections/CostStructure.tsx @@ -10,9 +10,13 @@ export default function CostStructure() {

Kostenstruktur 2023: Warum wir Verlust machen

- 2023 war das letzte vollständige Geschäftsjahr vor unserer aktuellen Krise. - Einnahmen {formatCHF(COST_STRUCTURE_2023.totalRevenue)} vs. Ausgaben {formatCHF(COST_STRUCTURE_2023.totalExpenses)} ={' '} - Verlust {formatCHF(COST_STRUCTURE_2023.result)}. + 2023 war das letzte vollständige Geschäftsjahr vor unserer aktuellen Krise. Einnahmen{' '} + {formatCHF(COST_STRUCTURE_2023.totalRevenue)} vs. Ausgaben{' '} + {formatCHF(COST_STRUCTURE_2023.totalExpenses)} ={' '} + + Verlust {formatCHF(COST_STRUCTURE_2023.result)} + + .

Wohin geht das Geld?

@@ -24,7 +28,8 @@ export default function CostStructure() {
    {COST_STRUCTURE_2023.categories.map((cat) => (
  • - {cat.label}: {formatCHF(cat.amount)} ({cat.pctOfExpenses}% der Ausgaben) + {cat.label}: {formatCHF(cat.amount)} ({cat.pctOfExpenses}% der + Ausgaben)
  • ))}
@@ -32,8 +37,16 @@ export default function CostStructure() {

Das Problem:

- Die Miete allein ({formatCHF(COST_STRUCTURE_2023.categories[0].amount)}) übersteigt unsere gesamten Einnahmen 2025 ({formatCHF(FINANCIAL_CONTEXT.total_2025)}). - Die Ausgaben 2023 waren {Math.round((COST_STRUCTURE_2023.totalExpenses / COST_STRUCTURE_2023.totalRevenue) * 100)}% der Einnahmen — das ist nicht nachhaltig. + Die Miete allein ({formatCHF(COST_STRUCTURE_2023.categories[0].amount)}) übersteigt + unsere gesamten Einnahmen 2025 ({formatCHF(FINANCIAL_CONTEXT.total_2025)}). Die Ausgaben + 2023 waren{' '} + + {Math.round( + (COST_STRUCTURE_2023.totalExpenses / COST_STRUCTURE_2023.totalRevenue) * 100, + )} + % der Einnahmen + {' '} + — das ist nicht nachhaltig.

@@ -41,16 +54,18 @@ export default function CostStructure() {

Der Hub bringt Laden + Lager + Werkstatt unter ein Dach. Dadurch sparen wir Doppelmiete, - gewinnen Effizienz, und schaffen Platz für neue Einnahmequellen (Workshops, Corporate Training, Events). + gewinnen Effizienz, und schaffen Platz für neue Einnahmequellen (Workshops, Corporate + Training, Events).

- Unser Ziel: Nicht mehr mit Defizit arbeiten, sondern durch Effizienzgewinn + neue Revenue-Streams - einen nachhaltigen Betrieb aufbauen. + Unser Ziel: Nicht mehr mit Defizit arbeiten, sondern durch + Effizienzgewinn + neue Revenue-Streams einen nachhaltigen Betrieb aufbauen.

- Datenquelle: {COST_STRUCTURE_2023.source}. Alle Zahlen verifiziert 11.02.2026. + Datenquelle: {COST_STRUCTURE_2023.source}. Alle Zahlen verifiziert + 11.02.2026.
diff --git a/src/app/(tenant)/fundraising/sections/FinancialSituation.tsx b/src/app/(tenant)/fundraising/sections/FinancialSituation.tsx index 5006f52c..14788147 100644 --- a/src/app/(tenant)/fundraising/sections/FinancialSituation.tsx +++ b/src/app/(tenant)/fundraising/sections/FinancialSituation.tsx @@ -25,8 +25,8 @@ export default function FinancialSituation({ inspector }: { inspector: Inspector className="text-warning" > {FINANCIAL_CONTEXT.decline_pct}% -
- {' '}reduziert. Von ~ + {' '} + reduziert. Von ~ {formatCHF(FINANCIAL_CONTEXT.services_avg_2022_23)} - - {' '}(Durchschnitt 2022-23) auf{' '} + {' '} + (Durchschnitt 2022-23) auf{' '} {formatCHF(FINANCIAL_CONTEXT.services_2025)} - - {' '}(2025). Gesamteinnahmen fielen von {formatCHF(FINANCIAL_CONTEXT.total_2023)} auf {formatCHF(FINANCIAL_CONTEXT.total_2025)}. + {' '} + (2025). Gesamteinnahmen fielen von {formatCHF(FINANCIAL_CONTEXT.total_2023)} auf{' '} + {formatCHF(FINANCIAL_CONTEXT.total_2025)}.

Der Grund: Abhängigkeit von wenigen B2B-Hosting-Kunden. Keine diversifizierten Einnahmequellen. Keine aktive Akquise.

- Das Positive: Geräteverkauf ({formatCHF(FINANCIAL_CONTEXT.warenverkauf_2025)} in 2025) bleibt - stabil. {TRACK_RECORD.yearsActive} Jahre Erfahrung, {formatNumber(TRACK_RECORD.totalInvoices)} Rechnungen,{' '} - {formatNumber(TRACK_RECORD.totalCustomers)} Kunden — die Kompetenz ist da. Der Hub ist unsere Turnaround-Strategie. + Das Positive: Geräteverkauf ( + {formatCHF(FINANCIAL_CONTEXT.warenverkauf_2025)} in 2025) bleibt stabil.{' '} + {TRACK_RECORD.yearsActive} Jahre Erfahrung, {formatNumber(TRACK_RECORD.totalInvoices)}{' '} + Rechnungen, {formatNumber(TRACK_RECORD.totalCustomers)} Kunden — die Kompetenz ist da. Der + Hub ist unsere Turnaround-Strategie.

diff --git a/src/app/(tenant)/fundraising/sections/PipelineStatus.tsx b/src/app/(tenant)/fundraising/sections/PipelineStatus.tsx index 0e737dd8..fec2d5c4 100644 --- a/src/app/(tenant)/fundraising/sections/PipelineStatus.tsx +++ b/src/app/(tenant)/fundraising/sections/PipelineStatus.tsx @@ -28,7 +28,7 @@ export default function PipelineStatus({ foundations }: { foundations: Foundatio
{count}
{STATUS_LABELS[status].desc}
- ) + ), )}
diff --git a/src/app/(tenant)/fundraising/sections/RevenueStreams.tsx b/src/app/(tenant)/fundraising/sections/RevenueStreams.tsx index 8067dc27..127b03ff 100644 --- a/src/app/(tenant)/fundraising/sections/RevenueStreams.tsx +++ b/src/app/(tenant)/fundraising/sections/RevenueStreams.tsx @@ -2,21 +2,29 @@ import Card from '@/components/ui/Card'; import Badge from '@/components/ui/Badge'; import { formatCHF } from '@/lib/utils/format'; import { NumberSources, metricToInspectorData } from '@/lib/config/metrics'; -import { - REVENUE_STREAMS, - REVENUE_CURRENT_TOTAL, - REVENUE_YEAR3_TOTAL, -} from '../data'; +import { REVENUE_STREAMS, REVENUE_CURRENT_TOTAL, REVENUE_YEAR3_TOTAL } from '../data'; import Inspectable, { type InspectorHandle } from './Inspectable'; export default function RevenueStreamsSection({ inspector }: { inspector: InspectorHandle }) { const inspectRevenueCurrent = NumberSources.revenue_current ? metricToInspectorData(NumberSources.revenue_current, formatCHF(REVENUE_CURRENT_TOTAL)) - : { label: 'Einnahmen aktuell', value: formatCHF(REVENUE_CURRENT_TOTAL), sourceType: 'estimated' as const, source: 'Kivitendo', confidence: 'Mittel' }; + : { + label: 'Einnahmen aktuell', + value: formatCHF(REVENUE_CURRENT_TOTAL), + sourceType: 'estimated' as const, + source: 'Kivitendo', + confidence: 'Mittel', + }; const inspectRevenueYear3 = NumberSources.revenue_year3 ? metricToInspectorData(NumberSources.revenue_year3, formatCHF(REVENUE_YEAR3_TOTAL)) - : { label: 'Einnahmen-Prognose Jahr 3', value: formatCHF(REVENUE_YEAR3_TOTAL), sourceType: 'estimated' as const, source: 'Geschäftsplan', confidence: 'Mittel' }; + : { + label: 'Einnahmen-Prognose Jahr 3', + value: formatCHF(REVENUE_YEAR3_TOTAL), + sourceType: 'estimated' as const, + source: 'Geschäftsplan', + confidence: 'Mittel', + }; return (
@@ -25,23 +33,27 @@ export default function RevenueStreamsSection({ inspector }: { inspector: Inspec Von{' '} {formatCHF(REVENUE_CURRENT_TOTAL)} - - {' '}heute auf{' '} + {' '} + heute auf{' '} {formatCHF(REVENUE_YEAR3_TOTAL)} - - {' '}in Jahr 3 — das ist der Weg zur Unabhängigkeit. + {' '} + in Jahr 3 — das ist der Weg zur Unabhängigkeit.

{/* Revenue narrative */}

- Aktuell: {formatCHF(REVENUE_CURRENT_TOTAL)}/Jahr - (B2B Services + Geräteverkauf + Integration + Spenden) + Aktuell: {formatCHF(REVENUE_CURRENT_TOTAL)}/Jahr (B2B Services + + Geräteverkauf + Integration + Spenden)

- Ziel Jahr 3: {formatCHF(REVENUE_YEAR3_TOTAL)}/Jahr (Prognose) - — das wäre +{Math.round(((REVENUE_YEAR3_TOTAL - REVENUE_CURRENT_TOTAL) / REVENUE_CURRENT_TOTAL) * 100)}% Wachstum + Ziel Jahr 3: {formatCHF(REVENUE_YEAR3_TOTAL)}/Jahr (Prognose) — das wäre + + + {Math.round( + ((REVENUE_YEAR3_TOTAL - REVENUE_CURRENT_TOTAL) / REVENUE_CURRENT_TOTAL) * 100, + )} + % Wachstum

Durch neue Einnahmequellen (Workshops, Corporate Training, Events, erhöhter Geräteverkauf) @@ -51,9 +63,10 @@ export default function RevenueStreamsSection({ inspector }: { inspector: Inspec

{REVENUE_STREAMS.map((stream) => { - const growth = stream.current > 0 - ? Math.round(((stream.year3 - stream.current) / stream.current) * 100) - : null; + const growth = + stream.current > 0 + ? Math.round(((stream.year3 - stream.current) / stream.current) * 100) + : null; const isNew = stream.current === 0; return ( @@ -64,9 +77,13 @@ export default function RevenueStreamsSection({ inspector }: { inspector: Inspec
Heute
-
{formatCHF(stream.current)}
+
+ {formatCHF(stream.current)} +
- +
Jahr 3 (Ziel)
{growth !== null && ( -
- Wachstum: +{growth}% -
+
Wachstum: +{growth}%
)} ); diff --git a/src/app/(tenant)/fundraising/sections/SpaceConcept.tsx b/src/app/(tenant)/fundraising/sections/SpaceConcept.tsx index c2052206..7fb18bfd 100644 --- a/src/app/(tenant)/fundraising/sections/SpaceConcept.tsx +++ b/src/app/(tenant)/fundraising/sections/SpaceConcept.tsx @@ -1,11 +1,10 @@ import Card from '@/components/ui/Card'; import Callout from '@/components/ui/Callout'; -import { DEVICES_PER_YEAR_TARGET_DISPLAY, DEVICES_PER_YEAR_CURRENT_DISPLAY } from '@/lib/config/projections'; import { - SPACE_PLAN, - SPACE_PLAN_TOTAL, - SPACE_TOTAL_WITH_CIRCULATION, -} from '../data'; + DEVICES_PER_YEAR_TARGET_DISPLAY, + DEVICES_PER_YEAR_CURRENT_DISPLAY, +} from '@/lib/config/projections'; +import { SPACE_PLAN, SPACE_PLAN_TOTAL, SPACE_TOTAL_WITH_CIRCULATION } from '../data'; export default function SpaceConcept() { return ( @@ -14,13 +13,14 @@ export default function SpaceConcept() {

Raumkonzept: Von 250 m² auf {SPACE_TOTAL_WITH_CIRCULATION} m²

- Heute haben wir: 250 m² verteilt auf zwei getrennte Standorte (Laden + Lager). - Das ist ineffizient, teuer, und hemmt unser Wachstum. + Heute haben wir: 250 m² verteilt auf zwei getrennte Standorte (Laden + + Lager). Das ist ineffizient, teuer, und hemmt unser Wachstum.

- Der Hub bringt alles unter ein Dach: ~{SPACE_TOTAL_WITH_CIRCULATION} m² Gesamtfläche - (inkl. Verkehrsfläche) mit {SPACE_PLAN_TOTAL} m² Nutzfläche für {SPACE_PLAN.length} Bereiche. + Der Hub bringt alles unter ein Dach: ~{SPACE_TOTAL_WITH_CIRCULATION} m² + Gesamtfläche (inkl. Verkehrsfläche) mit {SPACE_PLAN_TOTAL} m² Nutzfläche für{' '} + {SPACE_PLAN.length} Bereiche.

Die {SPACE_PLAN.length} Bereiche:

@@ -40,18 +40,21 @@ export default function SpaceConcept() {

Warum so viel Raum?

- Unser Ziel ist {DEVICES_PER_YEAR_TARGET_DISPLAY} Geräte/Jahr bis Ende Jahr 3 (von aktuell {DEVICES_PER_YEAR_CURRENT_DISPLAY}/Jahr geschätzt). - Dafür brauchen wir effizientere Infrastruktur und strukturierte Prozesse. + Unser Ziel ist{' '} + {DEVICES_PER_YEAR_TARGET_DISPLAY} Geräte/Jahr bis Ende Jahr 3 (von + aktuell {DEVICES_PER_YEAR_CURRENT_DISPLAY}/Jahr geschätzt). Dafür brauchen wir + effizientere Infrastruktur und strukturierte Prozesse.

- Plus: Workshops, Trainings, Events, und Community-Treffpunkt generieren neue Einnahmequellen, - die langfristig unsere Unabhängigkeit sichern. + Plus: Workshops, Trainings, Events, und Community-Treffpunkt generieren neue + Einnahmequellen, die langfristig unsere Unabhängigkeit sichern.

- Gesamtfläche: {SPACE_PLAN_TOTAL} m² Nutzfläche + ~{SPACE_TOTAL_WITH_CIRCULATION - SPACE_PLAN_TOTAL} m² Verkehrsfläche (Flure, Treppen, Toiletten) - = ~{SPACE_TOTAL_WITH_CIRCULATION} m² Total. + Gesamtfläche: {SPACE_PLAN_TOTAL} m² Nutzfläche + ~ + {SPACE_TOTAL_WITH_CIRCULATION - SPACE_PLAN_TOTAL} m² Verkehrsfläche (Flure, Treppen, + Toiletten) = ~{SPACE_TOTAL_WITH_CIRCULATION} m² Total.

diff --git a/src/app/(tenant)/fundraising/sections/ThreeYearModel.tsx b/src/app/(tenant)/fundraising/sections/ThreeYearModel.tsx index 46ac2378..692744e9 100644 --- a/src/app/(tenant)/fundraising/sections/ThreeYearModel.tsx +++ b/src/app/(tenant)/fundraising/sections/ThreeYearModel.tsx @@ -2,33 +2,48 @@ import Badge from '@/components/ui/Badge'; import { formatCHF } from '@/lib/utils/format'; import { EIGENLEISTUNG_CONFIG } from '@/lib/config/budget-scenarios'; import { NumberSources, metricToInspectorData } from '@/lib/config/metrics'; -import { - THREE_YEAR_MODEL, - BUDGET_EINMALIG_TOTAL, - EIGEN_3Y_TOTAL, - PROJECT_3Y_TOTAL, -} from '../data'; +import { THREE_YEAR_MODEL, BUDGET_EINMALIG_TOTAL, EIGEN_3Y_TOTAL, PROJECT_3Y_TOTAL } from '../data'; import Inspectable, { type InspectorHandle } from './Inspectable'; export default function ThreeYearModel({ inspector }: { inspector: InspectorHandle }) { const inspectProject3Y = NumberSources.project_3y_total ? metricToInspectorData(NumberSources.project_3y_total, formatCHF(PROJECT_3Y_TOTAL)) - : { label: 'Gesamtprojekt 3 Jahre', value: formatCHF(PROJECT_3Y_TOTAL), sourceType: 'derived' as const, source: 'fundraising/data.ts → THREE_YEAR_MODEL', formula: 'SUM(THREE_YEAR_MODEL[].total)', confidence: 'Hoch', description: 'Summe aller 3 Jahresbudgets.' }; + : { + label: 'Gesamtprojekt 3 Jahre', + value: formatCHF(PROJECT_3Y_TOTAL), + sourceType: 'derived' as const, + source: 'fundraising/data.ts → THREE_YEAR_MODEL', + formula: 'SUM(THREE_YEAR_MODEL[].total)', + confidence: 'Hoch', + description: 'Summe aller 3 Jahresbudgets.', + }; const inspectEigen3Y = NumberSources.eigen_3y_total ? metricToInspectorData(NumberSources.eigen_3y_total, formatCHF(EIGEN_3Y_TOTAL)) - : { label: 'Eigenleistung 3 Jahre', value: formatCHF(EIGEN_3Y_TOTAL), sourceType: 'derived' as const, source: 'fundraising/data.ts → THREE_YEAR_MODEL', confidence: 'Hoch' }; + : { + label: 'Eigenleistung 3 Jahre', + value: formatCHF(EIGEN_3Y_TOTAL), + sourceType: 'derived' as const, + source: 'fundraising/data.ts → THREE_YEAR_MODEL', + confidence: 'Hoch', + }; const inspectEinmalig = NumberSources.budget_einmalig ? metricToInspectorData(NumberSources.budget_einmalig, formatCHF(BUDGET_EINMALIG_TOTAL)) - : { label: 'Einmalige Investitionen', value: formatCHF(BUDGET_EINMALIG_TOTAL), sourceType: 'derived' as const, source: 'BUDGET_MODULES', confidence: 'Hoch' }; + : { + label: 'Einmalige Investitionen', + value: formatCHF(BUDGET_EINMALIG_TOTAL), + sourceType: 'derived' as const, + source: 'BUDGET_MODULES', + confidence: 'Hoch', + }; return (

3-Jahres-Modell: Weg zur Selbständigkeit

- Einmalige Investitionen nur im Jahr 1. Stiftungsgelder sinken jedes Jahr. - Eigenleistung (bewertete Freiwilligenarbeit, kein Cash) wächst durch Community-Aufbau. + Einmalige Investitionen nur im Jahr 1. Stiftungsgelder sinken jedes Jahr. Eigenleistung + (bewertete Freiwilligenarbeit, kein Cash) wächst durch Community-Aufbau.

{/* Year cards with visual bars */} @@ -38,10 +53,15 @@ export default function ThreeYearModel({ inspector }: { inspector: InspectorHand const stiftungenPct = Math.round((stiftungenAmt / year.total) * 100); const eigenPct = Math.round((year.eigen / year.total) * 100); return ( -
+
{year.year} - {year.label} + + {year.label} +
+ ))} - + {THREE_YEAR_MODEL.map((y) => ( - + ))} {THREE_YEAR_MODEL.map((y) => ( - + ))} - + {THREE_YEAR_MODEL.map((y) => ( - + ))} {THREE_YEAR_MODEL.map((y) => ( - + ))} -
CheckPunkte + Check + + Punkte +
{check.label}+{check.score} + +{check.score} +
{THREE_YEAR_MODEL.map((y) => ( - {y.year} + {y.year} + 3-Jahres-Total + 3-Jahres-Total +
Einmalige Investitionen{y.einmalig > 0 ? formatCHF(y.einmalig) : '—'} + {y.einmalig > 0 ? formatCHF(y.einmalig) : '—'} + @@ -135,9 +163,13 @@ export default function ThreeYearModel({ inspector }: { inspector: InspectorHand {formatCHF(y.stiftungen)} + {formatCHF(y.stiftungen)} + {formatCHF(THREE_YEAR_MODEL.reduce((s, y) => s + y.stiftungen, 0))} + {formatCHF(THREE_YEAR_MODEL.reduce((s, y) => s + y.stiftungen, 0))} +
@@ -147,7 +179,9 @@ export default function ThreeYearModel({ inspector }: { inspector: InspectorHand {formatCHF(y.eigen)} + {formatCHF(y.eigen)} + @@ -158,7 +192,9 @@ export default function ThreeYearModel({ inspector }: { inspector: InspectorHand
Total{formatCHF(y.total)} + {formatCHF(y.total)} + @@ -171,8 +207,9 @@ export default function ThreeYearModel({ inspector }: { inspector: InspectorHand

- * Eigenleistung = bewertete Freiwilligenarbeit (Stunden × CHF {EIGENLEISTUNG_CONFIG.ratePerHour}/h, NGO-Standard), kein Cashflow. - Jahr 3 setzt ~6.857 Freiwilligen-Stunden voraus (~3.4 Vollzeit-Äquivalente). + * Eigenleistung = bewertete Freiwilligenarbeit (Stunden × CHF{' '} + {EIGENLEISTUNG_CONFIG.ratePerHour}/h, NGO-Standard), kein Cashflow. Jahr 3 setzt ~6.857 + Freiwilligen-Stunden voraus (~3.4 Vollzeit-Äquivalente).

); diff --git a/src/app/(tenant)/fundraising/sections/TrackRecord.tsx b/src/app/(tenant)/fundraising/sections/TrackRecord.tsx index 7ab1f9f8..a4d178d8 100644 --- a/src/app/(tenant)/fundraising/sections/TrackRecord.tsx +++ b/src/app/(tenant)/fundraising/sections/TrackRecord.tsx @@ -6,12 +6,24 @@ import Inspectable, { type InspectorHandle } from './Inspectable'; export default function TrackRecord({ inspector }: { inspector: InspectorHandle }) { const items = [ - { value: `${TRACK_RECORD.yearsActive}+`, label: 'Jahre aktiv', sub: `Seit ${CORE_FACTS.organization.founded}` }, + { + value: `${TRACK_RECORD.yearsActive}+`, + label: 'Jahre aktiv', + sub: `Seit ${CORE_FACTS.organization.founded}`, + }, { value: formatNumber(TRACK_RECORD.totalCustomers), label: 'Kunden', sub: 'im Kivitendo ERP' }, - { value: formatNumber(TRACK_RECORD.totalInvoices), label: 'Rechnungen', sub: `seit ${ORG_PROFILE.milestones.kivitendoStart}` }, + { + value: formatNumber(TRACK_RECORD.totalInvoices), + label: 'Rechnungen', + sub: `seit ${ORG_PROFILE.milestones.kivitendoStart}`, + }, { value: formatNumber(TRACK_RECORD.productsInCatalog), label: 'Produkte', sub: 'im Katalog' }, { value: formatNumber(TRACK_RECORD.deliveryNotes), label: 'Lieferungen', sub: 'ausgeführt' }, - { value: `${TRACK_RECORD.quoteConversion}%`, label: 'Offerten-Konversion', sub: 'Zuverlässigkeit' }, + { + value: `${TRACK_RECORD.quoteConversion}%`, + label: 'Offerten-Konversion', + sub: 'Zuverlässigkeit', + }, ]; return ( diff --git a/src/app/(tenant)/fundraising/sections/TwoAsks.tsx b/src/app/(tenant)/fundraising/sections/TwoAsks.tsx index 0b832584..ede09fd1 100644 --- a/src/app/(tenant)/fundraising/sections/TwoAsks.tsx +++ b/src/app/(tenant)/fundraising/sections/TwoAsks.tsx @@ -16,14 +16,18 @@ export default function TwoAsks() { {/* Hub */}
- +

1. Community Tech Hub (Raum)

- {HUB_SPACE_DISPLAY}: Werkstatt, AI Lab, Event Space, Shop, Offices — alles unter einem Dach + {HUB_SPACE_DISPLAY}: Werkstatt, AI Lab, Event Space, Shop, Offices + — alles unter einem Dach

- Ergebnis: Effizientere Prozesse, mehr parallele Arbeitsplätze, neue Einnahmequellen + Ergebnis: Effizientere Prozesse, mehr parallele Arbeitsplätze, neue + Einnahmequellen

- +

2. Bildungsprogrammleiter (Team)

2× neue Stellen: Hardware-BPL + Software/AI-BPL

- Train-the-Trainer: Strukturierte Ausbildung statt informellem Wissenstransfer + Train-the-Trainer: Strukturierte Ausbildung statt informellem + Wissenstransfer

- Ergebnis: {PEOPLE_REACHED_DISPLAY} erreicht (Techniker + Entwickler + Workshop-Teilnehmer) + Ergebnis: {PEOPLE_REACHED_DISPLAY} erreicht (Techniker + Entwickler + + Workshop-Teilnehmer)

{DEVICES_PER_YEAR_TARGET_DISPLAY} Geräte/Jahr + {PEOPLE_REACHED_DISPLAY} trainiert
-
- {REVENUE_GROWTH_DISPLAY} -
+
{REVENUE_GROWTH_DISPLAY}
+ {PEOPLE_REACHED_DISPLAY} in Tech-Bildung & Integration
diff --git a/src/app/(tenant)/fundraising/sections/WhyWeNeedFunding.tsx b/src/app/(tenant)/fundraising/sections/WhyWeNeedFunding.tsx index af357f60..5191f5b7 100644 --- a/src/app/(tenant)/fundraising/sections/WhyWeNeedFunding.tsx +++ b/src/app/(tenant)/fundraising/sections/WhyWeNeedFunding.tsx @@ -22,57 +22,75 @@ export default function WhyWeNeedFunding() {
-

Die Situation: Warum wir Stiftungen brauchen

+

+ Die Situation: Warum wir Stiftungen brauchen +

- Seit {ORG_PROFILE.founded} reparieren und verkaufen wir refurbished Computer. Aber unser aktuelles Geschäftsmodell ist nicht nachhaltig. + Seit {ORG_PROFILE.founded} reparieren und verkaufen wir refurbished Computer.{' '} + Aber unser aktuelles Geschäftsmodell ist nicht nachhaltig.

- Die Herausforderung: Unsere Einnahmen sind von {REVENUE_PEAK_DISPLAY} ({REVENUE_PEAK_YEAR.year}) auf {REVENUE_CURRENT_DISPLAY} (2025) gefallen — B2B-Hosting-Kunden verloren, - Dienstleistungen von {SERVICES_PEAK_DISPLAY} auf {SERVICES_CURRENT_DISPLAY} geschrumpft. Das aktuelle Modell — abhängig von wenigen Einzelkunden — ist fragil. + Die Herausforderung: Unsere Einnahmen sind von {REVENUE_PEAK_DISPLAY} ( + {REVENUE_PEAK_YEAR.year}) auf {REVENUE_CURRENT_DISPLAY} (2025) gefallen — + B2B-Hosting-Kunden verloren, Dienstleistungen von {SERVICES_PEAK_DISPLAY} auf{' '} + {SERVICES_CURRENT_DISPLAY} geschrumpft. Das aktuelle Modell — abhängig von wenigen + Einzelkunden — ist fragil.

- Das eigentliche Problem: Nicht fehlende Refurbishment-Kapazität, sondern fehlende Organisation - für Verkauf und Ausführung. Wir haben bereits zu viel Inventar in 2 Lagern. Viele Organisationen in Zürich - entsorgen alte Technologie — aber wir brauchen keine zusätzlichen Geräte, sondern bessere Prozesse. + Das eigentliche Problem: Nicht fehlende Refurbishment-Kapazität, + sondern fehlende Organisation für Verkauf und Ausführung. Wir haben + bereits zu viel Inventar in 2 Lagern. Viele Organisationen in Zürich entsorgen alte + Technologie — aber wir brauchen keine zusätzlichen Geräte, sondern bessere Prozesse.

Das Problem in einem Satz:

- Wir haben {TEAM_MEMBERS.length} Menschen im Team (Techniker, Betrieb, Leitung) — aber zu wenig bezahlte Kapazität und keine - dedizierte Bildungsstruktur, um Prozesse zu professionalisieren und das volle Potenzial auszuschöpfen. -
Leitung: Andreas, Veronica (Sozialpädagogin), Dani. Technik & Betrieb: Freiwillige, Praktikanten, Reintegrations-Teilnehmer. + Wir haben {TEAM_MEMBERS.length} Menschen im Team (Techniker, Betrieb, Leitung) — aber + zu wenig bezahlte Kapazität und keine dedizierte Bildungsstruktur, um Prozesse zu + professionalisieren und das volle Potenzial auszuschöpfen. +
+ + Leitung: Andreas, Veronica (Sozialpädagogin), Dani. Technik & Betrieb: Freiwillige, + Praktikanten, Reintegrations-Teilnehmer. +

-

Die Lösung: Professionalisierung + Bildung + Community

+

+ Die Lösung: Professionalisierung + Bildung + Community +

- Statt nur Refurbishment zu skalieren, wollen wir unser Modell transformieren: + Statt nur Refurbishment zu skalieren, wollen wir unser Modell{' '} + transformieren:

  • - Professionelle Prozesse: 2 Bildungsprogrammleiter organisieren Refurbishment-Workflows, - bilden Techniker aus (mit sozialpädagogischer Begleitung durch Veronica) + Professionelle Prozesse: 2 Bildungsprogrammleiter organisieren + Refurbishment-Workflows, bilden Techniker aus (mit sozialpädagogischer Begleitung + durch Veronica)
  • - Tech-Bildung & AI Lab: Nicht nur Geräte reparieren, sondern Menschen befähigen. - AI-Souveränität durch eigene GPUs (gespendet oder gekauft — verschiedene Setups möglich, siehe Hub-Details) + Tech-Bildung & AI Lab: Nicht nur Geräte reparieren, sondern Menschen + befähigen. AI-Souveränität durch eigene GPUs (gespendet oder gekauft — verschiedene + Setups möglich, siehe Hub-Details)
  • - Community Hub: Ein Ort für Kreislaufwirtschaft, Makerspace, Tech-Kultur — wo Menschen - nicht nur konsumieren, sondern lernen und gestalten + Community Hub: Ein Ort für Kreislaufwirtschaft, Makerspace, + Tech-Kultur — wo Menschen nicht nur konsumieren, sondern lernen und gestalten

- Warum Stiftungen? Weil wir eine soziale Mission haben, keine Silicon-Valley-Startup-Mentalität. - Wir arbeiten mit Reintegrations-Programmen (z.B. GEP), Freiwilligen, Praktikanten. Professionalisierung braucht - Ressourcen — und die kommen nicht aus Verkaufserlösen allein. + Warum Stiftungen? Weil wir eine soziale Mission haben, keine + Silicon-Valley-Startup-Mentalität. Wir arbeiten mit Reintegrations-Programmen (z.B. + GEP), Freiwilligen, Praktikanten. Professionalisierung braucht Ressourcen — und die + kommen nicht aus Verkaufserlösen allein.

Was wir von Stiftungen brauchen

@@ -88,19 +106,31 @@ export default function WhyWeNeedFunding() { const stiftungenPct = Math.round((stiftungenAmt / year.total) * 100); const display = THREE_YEAR_DISPLAY[i]; return ( -
-
{year.year} ({PROJECT_START + i})
-
{formatCHF(stiftungenAmt)}
-
{stiftungenPct}% von Stiftungen
{display.description}
+
+
+ {year.year} ({PROJECT_START + i}) +
+
+ {formatCHF(stiftungenAmt)} +
+
+ {stiftungenPct}% von Stiftungen +
+ {display.description} +
); })}

- Degressives Modell: Je mehr wir wachsen, desto weniger Stiftungsgelder brauchen wir. - Jahr 1 → Jahr 3: -{REDUCTION_PCT}% weniger Stiftungsgelder. Ab Jahr 4: Nur noch Impact-Finanzierung - (kostenlose Geräte, Stipendien), keine Betriebskosten mehr. + Degressives Modell: Je mehr wir wachsen, desto weniger Stiftungsgelder + brauchen wir. Jahr 1 → Jahr 3:{' '} + -{REDUCTION_PCT}% weniger Stiftungsgelder. Ab Jahr 4: Nur noch + Impact-Finanzierung (kostenlose Geräte, Stipendien), keine Betriebskosten mehr.

diff --git a/src/app/(tenant)/fundraising/stiftungen/FoundationListClient.tsx b/src/app/(tenant)/fundraising/stiftungen/FoundationListClient.tsx index 5670bd87..f09f044d 100644 --- a/src/app/(tenant)/fundraising/stiftungen/FoundationListClient.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/FoundationListClient.tsx @@ -15,7 +15,11 @@ import CsvExportModal from '@/components/foundation/CsvExportModal'; import { useFoundationFilters } from '@/hooks/useFoundationFilters'; import { usePipelineEntries } from '@/hooks/usePipelineEntries'; import { computeResearchStats } from '@/lib/domain/foundation-research-stats'; -import { computeTierCounts, hasGesuchPage, hasGesuchDataGaps } from '@/lib/domain/foundation-helpers'; +import { + computeTierCounts, + hasGesuchPage, + hasGesuchDataGaps, +} from '@/lib/domain/foundation-helpers'; import { fitScoreToDisplay } from '@/lib/domain/fit-scoring'; import type { SortField } from '@/lib/domain/foundation-filter'; import { findActivePreset } from '@/lib/domain/foundation-filter'; @@ -72,7 +76,9 @@ export default function FoundationListClient({ foundations }: { foundations: Fou const goToPage = useCallback((next: number) => { setPage(next); // Back to the top of the results, not the page top (the overview stays visible) - document.getElementById('stiftungen-liste')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + document + .getElementById('stiftungen-liste') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); const [drawerOpen, setDrawerOpen] = useState(false); @@ -83,7 +89,10 @@ export default function FoundationListClient({ foundations }: { foundations: Fou const researchStats = useMemo(() => computeResearchStats(foundations), [foundations]); const tierCounts = useMemo(() => computeTierCounts(foundations), [foundations]); const gesuchCount = useMemo(() => foundations.filter(hasGesuchPage).length, [foundations]); - const gesuchGapCount = useMemo(() => foundations.filter(f => hasGesuchPage(f) && hasGesuchDataGaps(f)).length, [foundations]); + const gesuchGapCount = useMemo( + () => foundations.filter((f) => hasGesuchPage(f) && hasGesuchDataGaps(f)).length, + [foundations], + ); const priorityDist = useMemo(() => { const counts: Record = { 1: 0, 2: 0, 3: 0, 4: 0 }; for (const f of foundations) counts[f.priority]++; @@ -162,12 +171,28 @@ export default function FoundationListClient({ foundations }: { foundations: Fou className="min-w-0 flex-1 rounded-lg border border-border-default px-2 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" > {SORT_OPTIONS.map((opt) => ( - + ))} - diff --git a/src/app/(tenant)/fundraising/stiftungen/[slug]/FoundationDetailTabs.tsx b/src/app/(tenant)/fundraising/stiftungen/[slug]/FoundationDetailTabs.tsx index 9ccefd62..bc76e8c1 100644 --- a/src/app/(tenant)/fundraising/stiftungen/[slug]/FoundationDetailTabs.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/[slug]/FoundationDetailTabs.tsx @@ -7,9 +7,19 @@ import ApproachChecklist from '@/components/foundation/ApproachChecklist'; import Card from '@/components/ui/Card'; import type { Foundation } from '@/lib/schemas/foundation'; import { TYPE_LABELS } from '@/lib/config/foundations'; -import { getQualityTier, tierAtLeast, TIER_LABELS, isActionablePriority } from '@/lib/domain/foundation-helpers'; +import { + getQualityTier, + tierAtLeast, + TIER_LABELS, + isActionablePriority, +} from '@/lib/domain/foundation-helpers'; import { getFoundationPresentation } from '@/lib/domain/foundation-presenter'; -import type { FitNarrative, ThemeAlignment, ApproachStep, ReadinessItem } from '@/lib/domain/foundation-contextualization'; +import type { + FitNarrative, + ThemeAlignment, + ApproachStep, + ReadinessItem, +} from '@/lib/domain/foundation-contextualization'; import type { QualityTier } from '@/lib/schemas/foundation'; interface Props { @@ -37,7 +47,13 @@ const TIER_BANNER: Record {showTrustWarning && ( - Automatisch eingestuft — Fit-Score und Priorität basieren auf KI-Analyse, nicht auf manueller Recherche. Vor einer Bewerbung bitte über die Recherche-Links verifizieren. + Automatisch eingestuft — Fit-Score und Priorität basieren auf KI-Analyse, nicht auf + manueller Recherche. Vor einer Bewerbung bitte über die Recherche-Links verifizieren. )} {banner && ( @@ -82,169 +99,196 @@ export default function FoundationDetailTabs({ foundation: f, fitNarrative, them /> ); - case 'strategy': - return ( -
- -

Bewerbungsstrategie

-
-
-

Stiftungstyp

-

- Typ {typeLabel.short}: {typeLabel.desc} -

-
-
-

Empfohlener Ansatz

-

{typeLabel.approach}

-
- {f.deadlines && f.deadlines.length > 0 && ( + case 'strategy': + return ( +
+ +

Bewerbungsstrategie

+
+
+

Stiftungstyp

+

+ Typ {typeLabel.short}: {typeLabel.desc} +

+
-

Eingabefristen

- - - - - - - - - {f.deadlines.map((d, i) => ( - - - +

Empfohlener Ansatz

+

{typeLabel.approach}

+ + {f.deadlines && f.deadlines.length > 0 && ( +
+

Eingabefristen

+
Eingabe bisAntwort
{d.date}{d.response}
+ + + + + + + {f.deadlines.map((d, i) => ( + + + + + ))} + +
+ Eingabe bis + + Antwort +
{d.date}{d.response}
+
+ )} + {f.responseTime && ( +
+

Antwortzeit

+

{f.responseTime}

+
+ )} + {f.decisionCycle && ( +
+

Entscheidungszyklus

+

{f.decisionCycle}

+
+ )} + {f.applicationProcess && f.applicationProcess.length > 0 && ( +
+

Bewerbungsprozess

+
    + {f.applicationProcess.map((step, i) => ( +
  1. {step}
  2. ))} -
+ +
+ )} + + + + {/* Approach checklist — derived from foundation data */} + {approachSteps && readiness && ( + + )} + + ); + + case 'details': + return ( + +

Details

+
+ {f.purposeSummary && ( +
+

Stiftungszweck

+

{f.purposeSummary}

)} - {f.responseTime && ( + {f.criteria && (
-

Antwortzeit

-

{f.responseTime}

+

Förderkriterien

+ {f.criteria.nature && ( +

{f.criteria.nature}

+ )} + {f.criteria.education && ( +

{f.criteria.education}

+ )}
)} - {f.decisionCycle && ( + {f.sdgs && f.sdgs.length > 0 && (
-

Entscheidungszyklus

-

{f.decisionCycle}

+

SDGs

+
+ {f.sdgs.map((sdg) => ( + + SDG {sdg} + + ))} +
)} - {f.applicationProcess && f.applicationProcess.length > 0 && ( + {f.boardMembers && f.boardMembers.length > 0 && (
-

Bewerbungsprozess

-
    - {f.applicationProcess.map((step, i) => ( -
  1. {step}
  2. +

    Stiftungsrat

    +
    + {f.boardMembers.map((m) => ( +
    + {m.name} + {m.role} +
    ))} -
+
)} - -
- - {/* Approach checklist — derived from foundation data */} - {approachSteps && readiness && ( - - )} - - ); - - case 'details': - return ( - -

Details

-
- {f.purposeSummary && ( -
-

Stiftungszweck

-

{f.purposeSummary}

-
- )} - {f.criteria && ( -
-

Förderkriterien

- {f.criteria.nature &&

{f.criteria.nature}

} - {f.criteria.education &&

{f.criteria.education}

} -
- )} - {f.sdgs && f.sdgs.length > 0 && ( -
-

SDGs

-
- {f.sdgs.map((sdg) => ( - - SDG {sdg} - - ))} + {f.pastGrantees && f.pastGrantees.length > 0 && ( +
+

Bisherige Förderempfänger

+
+ {f.pastGrantees.map((g) => ( + + {g} + + ))} +
-
- )} - {f.boardMembers && f.boardMembers.length > 0 && ( -
-

Stiftungsrat

-
- {f.boardMembers.map((m) => ( -
- {m.name} - {m.role} + )} + {(f.supervisoryAuthority || (f.memberships && f.memberships.length > 0)) && ( +
+ {f.supervisoryAuthority && ( +
+

Aufsichtsbehörde

+

{f.supervisoryAuthority}

- ))} + )} + {f.memberships && f.memberships.length > 0 && ( +
+

Mitgliedschaften

+
+ {f.memberships.map((m) => ( + + {m} + + ))} +
+
+ )}
-
- )} - {f.pastGrantees && f.pastGrantees.length > 0 && ( -
-

Bisherige Förderempfänger

-
- {f.pastGrantees.map((g) => ( - {g} - ))} + )} + {f.uid && ( +
+

UID

+

{f.uid}

-
- )} - {(f.supervisoryAuthority || (f.memberships && f.memberships.length > 0)) && ( -
- {f.supervisoryAuthority && ( -
-

Aufsichtsbehörde

-

{f.supervisoryAuthority}

-
- )} - {f.memberships && f.memberships.length > 0 && ( -
-

Mitgliedschaften

-
- {f.memberships.map((m) => ( - {m} - ))} -
-
- )} -
- )} - {f.uid && ( -
-

UID

-

{f.uid}

-
- )} - {f.researchNotes && ( -
-

Recherche-Notizen

-

{f.researchNotes}

-
- )} -
- - ); + )} + {f.researchNotes && ( +
+

Recherche-Notizen

+

{f.researchNotes}

+
+ )} +
+ + ); - default: - return null; - } - }} - + default: + return null; + } + }} + ); } diff --git a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/GesuchPageClient.tsx b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/GesuchPageClient.tsx index d0cc6ded..2ec148c5 100644 --- a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/GesuchPageClient.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/GesuchPageClient.tsx @@ -63,7 +63,7 @@ export default function GesuchPageClient({ const gesuch = variants[variantKey]; const primaryColor = primaryColors[variantKey] ?? DEFAULT_THEME_COLOR; const activeGesuch = gesuch ?? variants['auto']; - const activeColor = gesuch ? primaryColor : primaryColors['auto'] ?? DEFAULT_THEME_COLOR; + const activeColor = gesuch ? primaryColor : (primaryColors['auto'] ?? DEFAULT_THEME_COLOR); const { overrides, @@ -85,19 +85,22 @@ export default function GesuchPageClient({ } = useGesuchOverrides(slug, foundationData, variantKey, schwerpunktLabel); const foundationContext = useMemo( - () => foundationData ? buildAIContext(foundationData) : undefined, + () => (foundationData ? buildAIContext(foundationData) : undefined), [foundationData], ); // Auto-save dirty changes before navigating away from the edit step - const navigateStep = useCallback(async (n: 1 | 2 | 3) => { - await saveIfDirty(); - setStep(n); - const url = new URL(window.location.href); - url.searchParams.set('step', String(n)); - window.history.replaceState(null, '', url.toString()); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }, [saveIfDirty]); + const navigateStep = useCallback( + async (n: 1 | 2 | 3) => { + await saveIfDirty(); + setStep(n); + const url = new URL(window.location.href); + url.searchParams.set('step', String(n)); + window.history.replaceState(null, '', url.toString()); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [saveIfDirty], + ); if (!activeGesuch || !activeGesuch.ready) { return ; @@ -167,7 +170,8 @@ export default function GesuchPageClient({ )} {autoDraftError && ( - KI-Entwurf wurde generiert, konnte aber nicht gespeichert werden — bitte manuell speichern. + KI-Entwurf wurde generiert, konnte aber nicht gespeichert werden — bitte manuell + speichern. )} diff --git a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/dokument/page.tsx b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/dokument/page.tsx index 85e6aae7..4e3b1b60 100644 --- a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/dokument/page.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/dokument/page.tsx @@ -40,9 +40,8 @@ export default async function GesuchDokumentPage({ params, searchParams }: Props notFound(); } - const schwerpunktId = schwerpunktParam && isSchwerpunktId(schwerpunktParam) - ? schwerpunktParam - : undefined; + const schwerpunktId = + schwerpunktParam && isSchwerpunktId(schwerpunktParam) ? schwerpunktParam : undefined; const baseDok = composeGesuchDokument(foundation, schwerpunktId); const overrides = await loadGesuchOverrides(slug, schwerpunktId ?? 'auto'); @@ -51,9 +50,7 @@ export default async function GesuchDokumentPage({ params, searchParams }: Props if (!dok.ready) { return (
-

- Gesuch-Dokument für {dok.foundation.name} -

+

Gesuch-Dokument für {dok.foundation.name}

{dok.readyReason}

PDF öffnen - + Interaktive Seite diff --git a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/page.tsx b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/page.tsx index 1e418e68..91bf8c84 100644 --- a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/page.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/page.tsx @@ -50,9 +50,7 @@ export default async function GesuchPage({ params }: Props) { return (
-

- Gesuch für {autoGesuch.foundation.name} -

+

Gesuch für {autoGesuch.foundation.name}

{autoGesuch.readyReason}

{heroDescription && (

{heroDescription}

)} - {tagline && ( -

„{tagline}“

- )} + {tagline &&

„{tagline}“

} {/* Schwerpunkt selector */} @@ -73,7 +73,8 @@ export default function StepFocus({

Partnerschafts-Brücke

- {foundationBridge.split(/\.\s+/).slice(0, 2).join('. ') + (foundationBridge.includes('. ') ? '.' : '')} + {foundationBridge.split(/\.\s+/).slice(0, 2).join('. ') + + (foundationBridge.includes('. ') ? '.' : '')}

)} diff --git a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepReview.tsx b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepReview.tsx index 186e3318..662355b2 100644 --- a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepReview.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepReview.tsx @@ -3,7 +3,14 @@ import { useState } from 'react'; import type { RefObject } from 'react'; import Link from 'next/link'; -import type { WhySection, TrackRecord, CompetencySection, Project, Evidence, CoreFacts } from '@/lib/schemas/story'; +import type { + WhySection, + TrackRecord, + CompetencySection, + Project, + Evidence, + CoreFacts, +} from '@/lib/schemas/story'; import type { ThemeKey } from '@/lib/config/stories'; import type { GesuchOverridesData } from '@/lib/db/schema'; import type { AnschreibenText } from '../GesuchPageClient'; @@ -156,10 +163,16 @@ export default function StepReview({ )} {/* Edit toggle toolbar */} - +
{hasOverrides && ( - Angepasst + + Angepasst + )} - )} + {why && } - + - + {/* Readiness checklist */} diff --git a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepSubmit.tsx b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepSubmit.tsx index 6ba52ca3..261579ff 100644 --- a/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepSubmit.tsx +++ b/src/app/(tenant)/fundraising/stiftungen/[slug]/gesuch/steps/StepSubmit.tsx @@ -50,7 +50,9 @@ export default function StepSubmit({

Vollständiges Gesuch (PDF)

-

4 Seiten — Anschreiben, Projektbeschrieb, Budget, Kurzportrait

+

+ 4 Seiten — Anschreiben, Projektbeschrieb, Budget, Kurzportrait +

📄
@@ -70,7 +72,9 @@ export default function StepSubmit({

Kurzübersicht (1 Seite)

-

Concept Note — häufig als Ersteinreichung gefordert

+

+ Concept Note — häufig als Ersteinreichung gefordert +

📋
@@ -90,22 +94,20 @@ export default function StepSubmit({

Öffentlicher Link

-

Lesbare Landing Page — direkt an Stiftungs-Programme schicken

+

+ Lesbare Landing Page — direkt an Stiftungs-Programme schicken +

🔗
{shareToken ? ( - ) : (

- Setze SHARE_SECRET in den Umgebungsvariablen, um Links zu aktivieren. + Setze SHARE_SECRET in + den Umgebungsvariablen, um Links zu aktivieren.

)} @@ -146,11 +148,7 @@ export default function StepSubmit({ {/* Navigation */}
-