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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
26 changes: 26 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions src/app/(admin)/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -415,6 +421,13 @@ export default async function AnalyticsPage({ searchParams }: Props) {
<AlgorithmAccuracySection report={algorithmAccuracy} />
</div>

{/* 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. */}
<div className="mt-6 sm:mt-8">
<VulnerabilitySection summary={vulnerability} />
</div>

{/* Recent Placements — identified resident + satisfaction data, so this
follows the same boundary as /placements rather than dashboard:read. */}
{canReadPlacements && <RecentPlacementsTable placements={recentPlacements} />}
Expand Down
1 change: 1 addition & 0 deletions src/app/(admin)/residents/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions src/components/analytics/VulnerabilitySection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="card">
<p className="eyebrow">{VULNERABILITY_LABELS.eyebrow}</p>
<h2 className="text-lg font-semibold text-ui-text mt-1">{VULNERABILITY_LABELS.title}</h2>
<p className="text-sm text-ui-muted mt-1 max-w-prose">{VULNERABILITY_LABELS.subtitle}</p>

<div className="mt-5 flex items-baseline gap-3">
<span className="metric">{summary.vulnerable}</span>
<span className="text-sm text-ui-muted">
{VULNERABILITY_LABELS.ofTotal(summary.total)} · {share}%
</span>
</div>

{summary.byGround.length === 0 ? (
<p className="text-sm text-ui-muted mt-4">{VULNERABILITY_LABELS.none}</p>
) : (
<>
<ul className="mt-5 space-y-3">
{summary.byGround.map(({ ground, count }) => (
<li key={ground.id}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-ui-text">{ground.label}</span>
<span className="numeric text-sm text-ui-text">{count}</span>
</div>
<p className="text-xs text-ui-muted mt-0.5">{ground.housingImplication}</p>
<div className="meter mt-1.5" aria-hidden="true">
<div
className="meter-fill bg-brand-secondary"
style={{
width: `${summary.total > 0 ? (count / summary.total) * 100 : 0}%`,
}}
/>
</div>
</li>
))}
</ul>

{/* 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. */}
<p className="text-xs text-ui-muted mt-4">{VULNERABILITY_LABELS.overlapNote}</p>
</>
)}
</section>
)
}
1 change: 1 addition & 0 deletions src/lib/compatibility/__tests__/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function makePrismaResident(overrides: Partial<Resident> = {}): Resident {
needsQuietEnvironment: false,
hasSleepEquipment: false,
supportLevel: 'STANDARD',
livingSkillsSupport: 'INDEPENDENT',
status: 'ACTIVE',
notes: null,
hasMedicalDocumentation: false,
Expand Down
1 change: 1 addition & 0 deletions src/lib/compatibility/__tests__/placement-scores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function makeResident(overrides: Partial<Resident> = {}): Resident {
needsQuietEnvironment: false,
hasSleepEquipment: false,
supportLevel: 'STANDARD',
livingSkillsSupport: 'INDEPENDENT',
roommatePreferences: null,
preferencesCompletedAt: null,
status: 'ACTIVE',
Expand Down
23 changes: 23 additions & 0 deletions src/lib/config/resident-factors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,29 @@ export const RESIDENT_FACTORS: Record<string, CompatibilityFactorDef> = {
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
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants/labels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export * from './messages'
export * from './marketplace'
export * from './events'
export * from './opportunities'
export * from './vulnerability'
28 changes: 28 additions & 0 deletions src/lib/constants/labels/vulnerability.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/lib/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
MobilityNeed,
RoomSharingStatus,
SupportLevel,
LivingSkillsSupport,
RecyclingKnowledge,
MedicalDocType,
ResidentStatus,
Expand Down Expand Up @@ -120,6 +121,7 @@ export const SmokingStatusSchema = enumFromFactor<SmokingStatus>('smokingStatus'
export const MobilityNeedSchema = enumFromFactor<MobilityNeed>('mobilityNeeds')
export const RoomSharingStatusSchema = enumFromFactor<RoomSharingStatus>('roomSharingStatus')
export const SupportLevelSchema = enumFromFactor<SupportLevel>('supportLevel')
export const LivingSkillsSupportSchema = enumFromFactor<LivingSkillsSupport>('livingSkillsSupport')
export const RecyclingKnowledgeSchema = enumFromFactor<RecyclingKnowledge>('recyclingKnowledge')

// Spot/placement - derived from placement-spots config
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading