diff --git a/prisma/migrations/20260901020000_living_skills_support/migration.sql b/prisma/migrations/20260901020000_living_skills_support/migration.sql
new file mode 100644
index 00000000..9d15b555
--- /dev/null
+++ b/prisma/migrations/20260901020000_living_skills_support/migration.sql
@@ -0,0 +1,28 @@
+-- Wohnfähigkeit: how much help running a household a person needs.
+--
+-- The City's owner strategy fixes "Berücksichtigung von vulnerablen Personen"
+-- as a minimum standard, and the Gemeinderat asked AOZ specifically for the
+-- "systematische Erfassung der Anzahl vulnerabler Geflüchteter". AOZ answered
+-- both on paper — a medical vulnerability assessment form in 2025, plus a
+-- separate project to record "Klient*innen mit eingeschränkten Wohnfähigkeiten
+-- sowie insbesondere ältere Geflüchtete" in order to close "bestehende
+-- Angebotslücken im Bereich Wohnen und Wohnbegleitung".
+--
+-- Everything in that sentence except Wohnfähigkeit was already recordable here
+-- as a functional need. This column is the missing one.
+--
+-- It is NOT a second `supportLevel`. That column is contact frequency; this is
+-- everyday competence — cooking, cleaning, post, appointments, keeping a
+-- tenancy. They come apart in both directions, which is why one cannot stand
+-- in for the other.
+--
+-- INDEPENDENT is the default so no existing row acquires a support need nobody
+-- assessed. An unasked question must not read as an answer.
+CREATE TYPE "LivingSkillsSupport" AS ENUM ('INDEPENDENT', 'SOME_SUPPORT', 'REGULAR_SUPPORT');
+
+ALTER TABLE "Resident"
+ ADD COLUMN "livingSkillsSupport" "LivingSkillsSupport" NOT NULL DEFAULT 'INDEPENDENT';
+
+-- The reportable figure is a COUNT of people needing more than the default, so
+-- the index serves the one query this column exists to answer.
+CREATE INDEX "Resident_livingSkillsSupport_idx" ON "Resident"("livingSkillsSupport");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 872c04e7..b84d8b96 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -67,6 +67,17 @@ model Resident {
hasSleepEquipment Boolean @default(false) // CPAP, etc.
supportLevel SupportLevel @default(STANDARD)
+ /// How much help running a household this person needs — AOZ's
+ /// "Wohnfähigkeit". Distinct from `supportLevel`, which is how often someone
+ /// checks in on them: a person can need weekly contact and still cook, clean
+ /// and open their post unaided, and another can be seen rarely and still be
+ /// unable to keep a tenancy. Conflating them is why AOZ was recording this on
+ /// paper in a separate project rather than reading it off a system.
+ ///
+ /// FUNCTIONAL, never a diagnosis: it says what support the household needs,
+ /// not why. @see lib/vulnerability/
+ livingSkillsSupport LivingSkillsSupport @default(INDEPENDENT)
+
// Roommate preferences (from portal self-service)
roommatePreferences String?
preferencesCompletedAt DateTime? // Set when portal preferences form is submitted
@@ -344,6 +355,21 @@ enum SupportLevel {
INTENSIVE // Close support needed
}
+/// How much support running a household this person needs — AOZ's
+/// "Wohnfähigkeit". A capability, never a diagnosis: the values say what help
+/// the household needs, and nothing about why it is needed.
+///
+/// Deliberately separate from `SupportLevel`. That one is contact FREQUENCY;
+/// this is everyday competence with cooking, cleaning, post, appointments and
+/// keeping a tenancy. The two come apart in both directions, and AOZ named
+/// "Klient*innen mit eingeschränkten Wohnfähigkeiten" as a group it could not
+/// find in any system.
+enum LivingSkillsSupport {
+ INDEPENDENT // Runs the household unaided
+ SOME_SUPPORT // Occasional help: post, appointments, paperwork
+ REGULAR_SUPPORT // Ongoing accompaniment in daily living
+}
+
enum RecyclingKnowledge {
NONE // No experience with Swiss recycling
BASIC // Knows basics (paper, glass, PET)
diff --git a/src/app/(admin)/analytics/page.tsx b/src/app/(admin)/analytics/page.tsx
index 4f1ac02e..46eb78e9 100644
--- a/src/app/(admin)/analytics/page.tsx
+++ b/src/app/(admin)/analytics/page.tsx
@@ -20,6 +20,8 @@ import { ConflictAnalysisSection } from '@/components/analytics/ConflictAnalysis
import { RecentPlacementsTable } from '@/components/analytics/RecentPlacementsTable'
import { MissionKPISection } from '@/components/analytics/MissionKPISection'
import { AlgorithmAccuracySection } from '@/components/analytics/AlgorithmAccuracySection'
+import { VulnerabilitySection } from '@/components/analytics/VulnerabilitySection'
+import { summariseVulnerability } from '@/lib/vulnerability'
import { calculateMissionKPIs } from '@/lib/analytics/mission-kpis'
import { calculateAlgorithmAccuracy } from '@/lib/analytics/algorithm-accuracy'
import { getSystemConfig } from '@/lib/actions/config'
@@ -159,6 +161,10 @@ export default async function AnalyticsPage({ searchParams }: Props) {
const unresolvedIncidents = recentIncidents.filter((i) => !i.resolvedAt)
+ // Derived from the rows already fetched above — no extra query, and no
+ // stored flag that could drift from the needs it summarises.
+ const vulnerability = summariseVulnerability(residents)
+
// Incident type breakdown (conflicts only)
const incidentsByType = recentIncidents.reduce(
(acc, i) => {
@@ -415,6 +421,13 @@ export default async function AnalyticsPage({ searchParams }: Props) {
{VULNERABILITY_LABELS.eyebrow}
+{VULNERABILITY_LABELS.subtitle}
+ +{VULNERABILITY_LABELS.none}
+ ) : ( + <> +{ground.housingImplication}
+