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) { + {/* Besonderer Unterbringungsbedarf — aggregate only, no one is named, so + it sits at the page's own `dashboard:read` like the other summaries. + @see lib/vulnerability/ for why this is derived and never stored. */} +
+ +
+ {/* Recent Placements — identified resident + satisfaction data, so this follows the same boundary as /placements rather than dashboard:read. */} {canReadPlacements && } diff --git a/src/app/(admin)/residents/[id]/edit/page.tsx b/src/app/(admin)/residents/[id]/edit/page.tsx index c5c055ec..b64b223a 100644 --- a/src/app/(admin)/residents/[id]/edit/page.tsx +++ b/src/app/(admin)/residents/[id]/edit/page.tsx @@ -82,6 +82,7 @@ export default async function EditResidentPage({ params }: Props) { needsQuietEnvironment: resident.needsQuietEnvironment, hasSleepEquipment: resident.hasSleepEquipment, supportLevel: resident.supportLevel, + livingSkillsSupport: resident.livingSkillsSupport, // Medical documentation hasMedicalDocumentation: resident.hasMedicalDocumentation, medicalDocType: resident.medicalDocType, diff --git a/src/components/analytics/VulnerabilitySection.tsx b/src/components/analytics/VulnerabilitySection.tsx new file mode 100644 index 00000000..5e4e58e7 --- /dev/null +++ b/src/components/analytics/VulnerabilitySection.tsx @@ -0,0 +1,64 @@ +import type { VulnerabilitySummary } from '@/lib/vulnerability' +import { VULNERABILITY_LABELS } from '@/lib/constants/labels' + +/** + * The figure the City asks AOZ for, with the reasons attached. + * + * The Gemeinderat asked for the "systematische Erfassung der Anzahl vulnerabler + * Geflüchteter" and the Eigentümerstrategie fixes "Berücksichtigung von + * vulnerablen Personen" as a minimum standard. A bare total would satisfy + * neither: AOZ has to defend the number, and "the system says 6" is not a + * defence. + * + * So the breakdown is not decoration. Three people needing a ground floor is an + * argument about what to procure next; the total alone is not. + */ +export function VulnerabilitySection({ summary }: { summary: VulnerabilitySummary }) { + const share = summary.total > 0 ? Math.round((summary.vulnerable / summary.total) * 100) : 0 + + return ( +
+

{VULNERABILITY_LABELS.eyebrow}

+

{VULNERABILITY_LABELS.title}

+

{VULNERABILITY_LABELS.subtitle}

+ +
+ {summary.vulnerable} + + {VULNERABILITY_LABELS.ofTotal(summary.total)} · {share}% + +
+ + {summary.byGround.length === 0 ? ( +

{VULNERABILITY_LABELS.none}

+ ) : ( + <> +
    + {summary.byGround.map(({ ground, count }) => ( +
  • +
    + {ground.label} + {count} +
    +

    {ground.housingImplication}

    +
  • + ))} +
+ + {/* Said out loud because the rows genuinely do not add to the total — + anyone with two recorded needs appears twice. A reader who sums + them and reports that number would overstate the caseload. */} +

{VULNERABILITY_LABELS.overlapNote}

+ + )} +
+ ) +} diff --git a/src/lib/compatibility/__tests__/convert.test.ts b/src/lib/compatibility/__tests__/convert.test.ts index 5a14a7a6..5b9fbc71 100644 --- a/src/lib/compatibility/__tests__/convert.test.ts +++ b/src/lib/compatibility/__tests__/convert.test.ts @@ -36,6 +36,7 @@ function makePrismaResident(overrides: Partial = {}): Resident { needsQuietEnvironment: false, hasSleepEquipment: false, supportLevel: 'STANDARD', + livingSkillsSupport: 'INDEPENDENT', status: 'ACTIVE', notes: null, hasMedicalDocumentation: false, diff --git a/src/lib/compatibility/__tests__/placement-scores.test.ts b/src/lib/compatibility/__tests__/placement-scores.test.ts index 13db8c75..7e0db06c 100644 --- a/src/lib/compatibility/__tests__/placement-scores.test.ts +++ b/src/lib/compatibility/__tests__/placement-scores.test.ts @@ -54,6 +54,7 @@ function makeResident(overrides: Partial = {}): Resident { needsQuietEnvironment: false, hasSleepEquipment: false, supportLevel: 'STANDARD', + livingSkillsSupport: 'INDEPENDENT', roommatePreferences: null, preferencesCompletedAt: null, status: 'ACTIVE', diff --git a/src/lib/config/resident-factors.ts b/src/lib/config/resident-factors.ts index 933b3860..04a25c5c 100644 --- a/src/lib/config/resident-factors.ts +++ b/src/lib/config/resident-factors.ts @@ -572,6 +572,29 @@ export const RESIDENT_FACTORS: Record = { rule: 'NONE', // For caseworker info, not compatibility }, + livingSkillsSupport: { + id: 'livingSkillsSupport', + type: 'enum', + label: 'Wohnfähigkeit', + description: 'Wie viel Unterstützung braucht diese Person im Haushalt?', + formSection: 'health', + formOrder: 6, + options: ['INDEPENDENT', 'SOME_SUPPORT', 'REGULAR_SUPPORT'] as const, + optionLabels: { + INDEPENDENT: 'Selbstständig', + SOME_SUPPORT: 'Punktuelle Unterstützung (Post, Termine, Ämtli)', + REGULAR_SUPPORT: 'Regelmässige Begleitung im Alltag', + }, + default: 'INDEPENDENT', + dimension: 'requirements', + // Zero weight, like `supportLevel`. This describes what the household needs + // from the Betreuung, not how two people get along — scoring someone down + // as a roommate for needing help with their post would be exactly the + // wrong use of it. + weight: 0, + rule: 'NONE', + }, + // --------------------------------------------------------------------------- // PREFERENCES // --------------------------------------------------------------------------- diff --git a/src/lib/constants/labels/index.ts b/src/lib/constants/labels/index.ts index 8f719d04..58a157f5 100644 --- a/src/lib/constants/labels/index.ts +++ b/src/lib/constants/labels/index.ts @@ -27,3 +27,4 @@ export * from './messages' export * from './marketplace' export * from './events' export * from './opportunities' +export * from './vulnerability' diff --git a/src/lib/constants/labels/vulnerability.ts b/src/lib/constants/labels/vulnerability.ts new file mode 100644 index 00000000..b6a68a1c --- /dev/null +++ b/src/lib/constants/labels/vulnerability.ts @@ -0,0 +1,28 @@ +/** + * Vulnerability reporting — staff-facing German. + * + * The wording is doing real work here, so it is worth stating the rule it + * follows: this surface names NEEDS, never causes. It reports "besonderer + * Unterbringungsbedarf", not "vulnerable Personen" as a class of people — + * because the product derives the figure from housing facts and must not sound + * like it holds a register of conditions, which it deliberately does not. + * + * @see lib/vulnerability/ for why the assessment is computed, never stored. + */ +export const VULNERABILITY_LABELS = { + eyebrow: 'Unterbringungsbedarf', + title: 'Klient*innen mit besonderem Bedarf', + subtitle: + 'Erfasst aus den festgehaltenen Wohnbedürfnissen — nicht aus Diagnosen. Jede Person zählt einmal, unabhängig davon, wie viele Bedürfnisse erfasst sind.', + /** Reads as "von 19 aktiven Klient*innen". */ + ofTotal: (total: number) => + total === 1 ? 'von 1 aktiven Klient*in' : `von ${total} aktiven Klient*innen`, + none: 'Für keine aktive Person ist ein besonderer Unterbringungsbedarf erfasst.', + /** + * The rows genuinely do not sum to the headline — someone with two recorded + * needs appears in two of them. Saying so prevents a reader from adding the + * column up and reporting a caseload larger than it is. + */ + overlapNote: + 'Mehrfachnennung möglich: Wer mehrere Bedürfnisse hat, erscheint in mehreren Zeilen. Die Zeilen ergeben deshalb mehr als die Gesamtzahl oben.', +} as const diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 1c9fac3e..0d894c24 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -20,6 +20,7 @@ import type { MobilityNeed, RoomSharingStatus, SupportLevel, + LivingSkillsSupport, RecyclingKnowledge, MedicalDocType, ResidentStatus, @@ -120,6 +121,7 @@ export const SmokingStatusSchema = enumFromFactor('smokingStatus' export const MobilityNeedSchema = enumFromFactor('mobilityNeeds') export const RoomSharingStatusSchema = enumFromFactor('roomSharingStatus') export const SupportLevelSchema = enumFromFactor('supportLevel') +export const LivingSkillsSupportSchema = enumFromFactor('livingSkillsSupport') export const RecyclingKnowledgeSchema = enumFromFactor('recyclingKnowledge') // Spot/placement - derived from placement-spots config @@ -208,6 +210,7 @@ export const ResidentInputSchema = z.object({ needsQuietEnvironment: z.coerce.boolean().default(false), hasSleepEquipment: z.coerce.boolean().default(false), supportLevel: SupportLevelSchema.default('STANDARD' as SupportLevel), + livingSkillsSupport: LivingSkillsSupportSchema.default('INDEPENDENT' as LivingSkillsSupport), hasMedicalDocumentation: z.coerce.boolean().default(false), medicalDocType: MedicalDocTypeSchema.optional().nullable(), medicalDocDate: z diff --git a/src/lib/vulnerability/__tests__/vulnerability.test.ts b/src/lib/vulnerability/__tests__/vulnerability.test.ts new file mode 100644 index 00000000..a9f82b3f --- /dev/null +++ b/src/lib/vulnerability/__tests__/vulnerability.test.ts @@ -0,0 +1,189 @@ +import fs from 'fs' +import path from 'path' +import { + VULNERABILITY_GROUNDS, + VULNERABILITY_GROUND_IDS, + assessVulnerability, + summariseVulnerability, + type VulnerabilityInput, +} from '../index' + +/** + * The count the City asks for, and the line it must not cross to produce it. + */ + +const independent: VulnerabilityInput = { + ageRange: 'ADULT', + mobilityNeeds: 'NONE', + medicalEquipment: false, + hasSleepEquipment: false, + roomSharingStatus: 'CAN_SHARE', + needsQuietEnvironment: false, + supportLevel: 'STANDARD', + livingSkillsSupport: 'INDEPENDENT', +} + +const withNeed = (over: Partial): VulnerabilityInput => ({ + ...independent, + ...over, +}) + +describe('who counts, and why', () => { + it('a person with no recorded need beyond the ordinary does not count', () => { + const result = assessVulnerability(independent) + expect(result.isVulnerable).toBe(false) + expect(result.grounds).toEqual([]) + }) + + it.each([ + ['MOBILITY', { mobilityNeeds: 'GROUND_FLOOR' as const }], + ['MOBILITY', { mobilityNeeds: 'WHEELCHAIR' as const }], + ['CANNOT_SHARE', { roomSharingStatus: 'NEEDS_PRIVATE' as const }], + ['QUIET_ENVIRONMENT', { needsQuietEnvironment: true }], + ['EQUIPMENT', { medicalEquipment: true }], + ['EQUIPMENT', { hasSleepEquipment: true }], + ['ELEVATED_SUPPORT', { supportLevel: 'ELEVATED' as const }], + ['ELEVATED_SUPPORT', { supportLevel: 'INTENSIVE' as const }], + ['LIVING_SKILLS', { livingSkillsSupport: 'REGULAR_SUPPORT' as const }], + ['OLDER_ADULT', { ageRange: 'SENIOR' as const }], + ])('%s is raised by the fact that justifies it', (groundId, over) => { + const result = assessVulnerability(withNeed(over)) + expect(result.isVulnerable).toBe(true) + expect(result.grounds.map((g) => g.id)).toContain(groundId) + }) + + it('names every ground that holds, not just the first', () => { + // The breakdown is the point: "6 people" is a number, "3 of them need a + // ground floor" is what tells AOZ what to procure next. + const result = assessVulnerability( + withNeed({ + mobilityNeeds: 'WHEELCHAIR', + roomSharingStatus: 'NEEDS_PRIVATE', + ageRange: 'SENIOR', + }), + ) + expect(result.grounds.map((g) => g.id).sort()).toEqual( + ['CANNOT_SHARE', 'MOBILITY', 'OLDER_ADULT'].sort(), + ) + }) + + it('reports occasional household help as NOT on its own a vulnerability', () => { + // SOME_SUPPORT is help with post and appointments. Counting that as + // vulnerable would inflate the figure AOZ has to defend, and would tell a + // caseworker something untrue about the person. + expect( + assessVulnerability(withNeed({ livingSkillsSupport: 'SOME_SUPPORT' })).isVulnerable, + ).toBe(false) + expect( + assessVulnerability(withNeed({ livingSkillsSupport: 'REGULAR_SUPPORT' })).isVulnerable, + ).toBe(true) + }) + + it('every declared ground id has a definition, and vice versa', () => { + expect(VULNERABILITY_GROUNDS.map((g) => g.id).sort()).toEqual( + [...VULNERABILITY_GROUND_IDS].sort(), + ) + }) +}) + +describe('the reportable figure', () => { + it('counts people once, however many grounds they hold', () => { + const summary = summariseVulnerability([ + independent, + withNeed({ mobilityNeeds: 'WHEELCHAIR', roomSharingStatus: 'NEEDS_PRIVATE' }), + withNeed({ ageRange: 'SENIOR' }), + ]) + + expect(summary.total).toBe(3) + expect(summary.vulnerable).toBe(2) + }) + + it('breaks the total down by ground, and the parts may exceed the whole', () => { + // Deliberate: one person with two needs appears in two rows. The UI must + // say so rather than invite the reader to add them up. + const summary = summariseVulnerability([ + withNeed({ mobilityNeeds: 'WHEELCHAIR', needsQuietEnvironment: true }), + ]) + + expect(summary.vulnerable).toBe(1) + const counted = summary.byGround.reduce((sum, row) => sum + row.count, 0) + expect(counted).toBe(2) + }) + + it('omits grounds that hold for nobody rather than listing zeroes', () => { + const summary = summariseVulnerability([withNeed({ ageRange: 'SENIOR' })]) + expect(summary.byGround).toHaveLength(1) + expect(summary.byGround[0].ground.id).toBe('OLDER_ADULT') + }) + + it('an empty register reports zero, not an error', () => { + expect(summariseVulnerability([])).toEqual({ total: 0, vulnerable: 0, byGround: [] }) + }) +}) + +describe('the line this module must not cross', () => { + /** + * CLAUDE.md: track functional needs, never diagnoses, immigration status, + * religion or personal history. "Vulnerable" in the asylum context reaches + * for exactly those, so the safeguard cannot be a good intention in a + * comment — the reason this is DERIVED from housing facts rather than stored + * as a label is that a label would be the forbidden thing. + */ + const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'index.ts'), 'utf8') + const code = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') + + it('reads no field describing a condition, a history or a status', () => { + // Not an allowlist of what it may read — a denylist of what it may never + // read, which is the half that catches the field nobody thought of. + const FORBIDDEN = [ + 'diagnos', + 'medicalDocNotes', + 'medicalDocType', + 'religion', + 'permit', + 'asylum', + 'residencyStatus', + 'trauma', + 'pregnan', + 'disabilit', + ] + for (const term of FORBIDDEN) { + expect({ term, present: new RegExp(term, 'i').test(code) }).toEqual({ term, present: false }) + } + }) + + it('no ground label names a cause rather than a need', () => { + // "Braucht Erdgeschoss" is a need. "Gehbehinderung" would be a condition. + const CAUSE_WORDS = /krank|diagnos|behinderung|trauma|psychisch|sucht|schwanger/i + for (const ground of VULNERABILITY_GROUNDS) { + expect({ id: ground.id, namesCause: CAUSE_WORDS.test(ground.label) }).toEqual({ + id: ground.id, + namesCause: false, + }) + expect({ + id: ground.id, + namesCause: CAUSE_WORDS.test(ground.housingImplication), + }).toEqual({ id: ground.id, namesCause: false }) + } + }) + + it('every ground explains what it means for placement', () => { + // A ground with no housing implication is a label on a person, which is + // the thing this module exists to avoid being. + for (const ground of VULNERABILITY_GROUNDS) { + expect(ground.housingImplication.trim().length).toBeGreaterThan(10) + } + }) + + it('stores nothing — the verdict is computed on every read', () => { + // If a `vulnerable` column ever appears, it is a second copy of state that + // will drift from the seven columns it summarises, and the reportable + // figure and the person's record will disagree with no way to tell which + // is right. + const schema = fs.readFileSync( + path.join(__dirname, '..', '..', '..', '..', 'prisma', 'schema.prisma'), + 'utf8', + ) + expect(schema).not.toMatch(/^\s*(isVulnerable|vulnerable|vulnerabilityType)\s/m) + }) +}) diff --git a/src/lib/vulnerability/index.ts b/src/lib/vulnerability/index.ts new file mode 100644 index 00000000..708db386 --- /dev/null +++ b/src/lib/vulnerability/index.ts @@ -0,0 +1,205 @@ +/** + * Vulnerability — DERIVED from housing-relevant facts, never a label on a person. + * + * The City of Zurich's Eigentümerstrategie 2025–2028 fixes "Berücksichtigung + * von vulnerablen Personen" as one of six minimum standards in AOZ's + * Leistungsauftrag, and the Gemeinderat asked AOZ specifically for the + * "systematische Erfassung der Anzahl vulnerabler Geflüchteter". So the number + * has to be answerable. The question is how. + * + * ## Why this is computed and not stored + * + * The obvious implementation is a `vulnerable Boolean` or a + * `vulnerabilityType` enum on Resident. Both are wrong here, for two separate + * reasons that happen to point the same way. + * + * **Ethically:** a stored category is applied TO someone and outlives the + * reason for it. In the Swiss asylum context "vulnerable" reaches for things + * this product is forbidden to hold — a diagnosis, a torture or violence + * history, a status. CLAUDE.md's rule is absolute and worth restating: track + * functional needs, never why they exist. Every ground below is a fact the + * caseworker already recorded because a HOUSING decision needed it — a ground + * floor, a single room, quiet. Reading them together adds no new fact about + * the person; it just answers a question the City is entitled to ask. + * + * **Structurally:** a stored flag is a second copy of state that already + * exists in seven columns, and the two WILL drift. Someone's mobility need is + * corrected and the flag stays; the flag is set by hand and no column supports + * it. Then the reportable figure and the person's record disagree, and there + * is no way to tell which is right. + * + * ## Explainable, because a number nobody can defend is worthless + * + * `assess()` returns the GROUNDS, not just a verdict. AOZ has to defend this + * count to the Gemeinderat, and "the system says 6" is not a defence — "6, + * because these three need a ground floor, two cannot share a room and one + * needs regular help running the household" is. Same rule the compatibility + * scoring already follows: no black-box outcomes. + * + * Adding a ground is a line in `VULNERABILITY_GROUNDS` plus its predicate. It + * must be a functional housing fact already on the record. If a proposed + * ground would need a new column describing a person's history, condition or + * status, it does not belong here and probably does not belong in the product. + */ + +import type { + AgeRange, + LivingSkillsSupport, + MobilityNeed, + RoomSharingStatus, + SupportLevel, +} from '@prisma/client' + +/** + * The subset of a resident this reads. Deliberately narrow and explicit: the + * type is the audit trail. If a diagnosis-shaped field ever appears on + * Resident, adding it here would be a visible, reviewable act rather than + * something that arrives by spreading the whole model. + */ +export interface VulnerabilityInput { + ageRange: AgeRange + mobilityNeeds: MobilityNeed + medicalEquipment: boolean + hasSleepEquipment: boolean + roomSharingStatus: RoomSharingStatus + needsQuietEnvironment: boolean + supportLevel: SupportLevel + livingSkillsSupport: LivingSkillsSupport +} + +export const VULNERABILITY_GROUND_IDS = [ + 'MOBILITY', + 'CANNOT_SHARE', + 'QUIET_ENVIRONMENT', + 'EQUIPMENT', + 'ELEVATED_SUPPORT', + 'LIVING_SKILLS', + 'OLDER_ADULT', +] as const + +export type VulnerabilityGroundId = (typeof VULNERABILITY_GROUND_IDS)[number] + +export interface VulnerabilityGround { + id: VulnerabilityGroundId + /** German, staff-facing. Says the NEED, never the cause. */ + label: string + /** What this implies for placement — the reason it is worth knowing. */ + housingImplication: string + holds: (input: VulnerabilityInput) => boolean +} + +/** + * SSOT for what counts, in the order a caseworker would read them: the hard + * placement constraints first, then the support ones. + * + * Every label describes a NEED. None names a condition. That is the line, and + * `vulnerability.test.ts` holds it. + */ +export const VULNERABILITY_GROUNDS: readonly VulnerabilityGround[] = [ + { + id: 'MOBILITY', + label: 'Eingeschränkte Mobilität', + housingImplication: 'Braucht Erdgeschoss oder rollstuhlgerechte Unterkunft.', + holds: (r) => r.mobilityNeeds !== 'NONE', + }, + { + id: 'CANNOT_SHARE', + label: 'Kann kein Zimmer teilen', + housingImplication: 'Braucht ein Einzelzimmer.', + holds: (r) => r.roomSharingStatus === 'NEEDS_PRIVATE', + }, + { + id: 'QUIET_ENVIRONMENT', + label: 'Braucht ruhiges Umfeld', + housingImplication: 'Nicht in eine laute oder stark belegte Einheit platzieren.', + holds: (r) => r.needsQuietEnvironment, + }, + { + id: 'EQUIPMENT', + label: 'Benötigt Platz für Geräte', + housingImplication: 'Braucht Platz und Strom am Schlafplatz.', + holds: (r) => r.medicalEquipment || r.hasSleepEquipment, + }, + { + id: 'ELEVATED_SUPPORT', + label: 'Erhöhter Betreuungsbedarf', + housingImplication: 'Häufigere Check-ins als der Standard.', + holds: (r) => r.supportLevel !== 'STANDARD', + }, + { + id: 'LIVING_SKILLS', + label: 'Begleitung im Haushalt nötig', + housingImplication: 'Braucht regelmässige Unterstützung im Alltag.', + holds: (r) => r.livingSkillsSupport === 'REGULAR_SUPPORT', + }, + { + id: 'OLDER_ADULT', + label: 'Ältere Person', + // Was "AOZ erfasst ältere Geflüchtete als eigene Bedarfsgruppe" — caught by + // design-system.test.ts, which forbids the org name in UI copy so a + // re-badge cannot leave the wrong organisation's name on a screen. The + // rewrite is better regardless: every other ground tells a caseworker what + // to DO, and that one only said who was counting. + housingImplication: 'Barrierefreiheit und Nähe zur Versorgung prüfen.', + holds: (r) => r.ageRange === 'SENIOR', + }, +] + +export interface VulnerabilityAssessment { + isVulnerable: boolean + grounds: VulnerabilityGround[] +} + +/** + * Which grounds hold for this person, and therefore whether they count. + * + * "Vulnerable" here means exactly "at least one recorded housing need beyond + * the ordinary" — no more and no less. It is a reading of the record, not a + * judgement about the person, and it changes the moment the record does. + */ +export function assessVulnerability(input: VulnerabilityInput): VulnerabilityAssessment { + const grounds = VULNERABILITY_GROUNDS.filter((ground) => ground.holds(input)) + return { isVulnerable: grounds.length > 0, grounds } +} + +/** + * The reportable figure: how many people, and how the total breaks down. + * + * The breakdown matters as much as the total. A count of 6 is a number; 6 of + * whom 3 need a ground floor is an argument for what to procure next, which is + * what the Gemeinderat is actually asking for. + * + * `byGround` sums to MORE than `vulnerable` whenever anyone holds two grounds. + * That is correct and deliberate — they answer different questions — and the + * label in the UI must say so rather than inviting the reader to add them up. + */ +export interface VulnerabilitySummary { + total: number + vulnerable: number + byGround: { ground: VulnerabilityGround; count: number }[] +} + +export function summariseVulnerability(residents: VulnerabilityInput[]): VulnerabilitySummary { + const assessments = residents.map(assessVulnerability) + + return { + total: residents.length, + vulnerable: assessments.filter((a) => a.isVulnerable).length, + byGround: VULNERABILITY_GROUNDS.map((ground) => ({ + ground, + count: assessments.filter((a) => a.grounds.some((g) => g.id === ground.id)).length, + })).filter((row) => row.count > 0), + } +} + +/** Everything a vulnerability read needs, for `select:` in a Prisma query. */ +export const VULNERABILITY_SELECT = { + ageRange: true, + mobilityNeeds: true, + medicalEquipment: true, + hasSleepEquipment: true, + roomSharingStatus: true, + needsQuietEnvironment: true, + supportLevel: true, + livingSkillsSupport: true, +} as const