From 8de35d30bd85619788293cf1420119dcac170647 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:14:21 +0200 Subject: [PATCH] feat(jobcoach): a coach with a client was told there was nothing to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed in production on 2026-09-02. Simon B. (Jobcoach) had one client assigned — George B, created that morning: job-seeking, two years without work, an unrecognised trade qualification, German at A2. His dashboard said "Alles unter Kontrolle! Keine dringenden Aufgaben" and never mentioned him. `openTaskCount` was criticalIncidents + overdueCheckIns + unplacedResidents + pendingTransfers + proposalsAwaitingStaff + problemUnits. Every term is a HOUSING queue, and a Jobcoach holds none of those permissions — so the count was structurally zero, the state was always `quiet`, and the screen always congratulated them. Same failure the `unassigned` state was added to fix, one level deeper. Then it was "nobody is assigned to me". Now it is "somebody is, with real work outstanding, and the queue is written in another domain's vocabulary". The fix had to be evidence-shaped rather than invented, because the job side had NO documented scientific basis at all while housing has had algorithm-docs since the beginning. That asymmetry is the deeper problem: placement and employment are the two things AOZ is measured on, and only one of them could explain itself. `config/job-integration-docs.ts` carries the evidence, Swiss and German first because institutions do not transfer even where mechanisms do: Integrationsagenda Schweiz (the binding Wirkungsziele AOZ operates under), IAB on lock-in effects and on refugee employment trajectories, IPS supported employment, OECD on parallel language and work, SBFI/SEM on recognition and de-skilling. Each principle declares whether the product ACTS on it. Three raise work; three raise nothing on purpose — recognition, post-start support and stated job goals matter and are not detectable from data this product holds. Inventing a signal from data that does not exist would be worse than the gap, and an evidence page implying more than the software does is worse than none. The queue is pure, with `now` injected: no contact past a grace period, a course running with nothing alongside it, a record untouched for six weeks. The thresholds are named constants carrying their reasoning, because they are numbers to argue with — 42 days and not 14, since a language course legitimately runs for months and a queue that fires fortnightly is one a coach learns to dismiss. Tiles name the CLIENTS. A bare count would reproduce the original failure of never mentioning George. Structure: EvidenceStrength and ResearchSource move to config/evidence.ts so the second domain does not import the housing algorithm's module for a shared type; algorithm-docs re-exports them, so no import path changes. Signal copy lives in the config SSOT and the queue keeps no opinion about rendering. The caseload query is scoped to the coach's own JOB seat and only runs for a viewer holding `learning:write`. A test pins that every `signal` principle has an implementation and every signal has a principle — the guard against evidence-as-decoration, in both directions. verify exit 0 (unpiped): 206 suites, 3624 passed. Build exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cd183M6472xBgTKWA2is6h --- scripts/db/real/aoz-team.ts | 2 +- src/app/(admin)/page.tsx | 43 ++- src/components/dashboard/ActionDashboard.tsx | 46 +++- .../__tests__/ActionDashboard.test.tsx | 1 + src/lib/config/algorithm-docs.ts | 18 +- src/lib/config/evidence.ts | 44 ++++ src/lib/config/job-integration-docs.ts | 245 ++++++++++++++++++ src/lib/jobcoach/__tests__/queue.test.ts | 210 +++++++++++++++ src/lib/jobcoach/queue.ts | 174 +++++++++++++ 9 files changed, 767 insertions(+), 16 deletions(-) create mode 100644 src/lib/config/evidence.ts create mode 100644 src/lib/config/job-integration-docs.ts create mode 100644 src/lib/jobcoach/__tests__/queue.test.ts create mode 100644 src/lib/jobcoach/queue.ts diff --git a/scripts/db/real/aoz-team.ts b/scripts/db/real/aoz-team.ts index 77aa656f..7f9af4a2 100644 --- a/scripts/db/real/aoz-team.ts +++ b/scripts/db/real/aoz-team.ts @@ -49,7 +49,7 @@ export const AOZ_TEAM: readonly RealStaffSeed[] = [ note: 'Betreuerin — Wohnen ist ihr Bereich, sie sieht zusätzlich alle Klient*innen.', }, { - name: 'Simon Binder', + name: 'Simon B.', role: 'JOBCOACH', scope: 'OWN_DOMAIN', isSystemAdmin: false, diff --git a/src/app/(admin)/page.tsx b/src/app/(admin)/page.tsx index 0280e45c..5bdf2d71 100644 --- a/src/app/(admin)/page.tsx +++ b/src/app/(admin)/page.tsx @@ -29,7 +29,8 @@ const GREETING_BY_DAY_PART: Record ({ + residentId: resident.id, + name: residentName(resident), + createdAt: resident.createdAt, + learningRecords: resident.learningRecords, + applications: resident.opportunityApplications, + })), + new Date(), + ) + const totalBeds = units.reduce((sum, u) => sum + u.totalBeds, 0) // ============================================================================= @@ -376,6 +416,7 @@ export default async function AdminDashboard() { residentCount={residentCount} housingUnitCount={housingUnitCount} assignedResidentCount={assignedResidentCount} + jobQueue={jobQueue} occupiedBeds={occupiedBeds} totalBeds={totalBeds} totalPlacements={totalPlacements} diff --git a/src/components/dashboard/ActionDashboard.tsx b/src/components/dashboard/ActionDashboard.tsx index 9a2f1c58..1dea978f 100644 --- a/src/components/dashboard/ActionDashboard.tsx +++ b/src/components/dashboard/ActionDashboard.tsx @@ -10,6 +10,8 @@ import { type DashboardSection, } from '@/lib/config/dashboard' import type { StaffCapabilities, StaffRole } from '@/lib/auth/role-policy' +import { JOB_SIGNAL_IDS, type JobQueueItem } from '@/lib/jobcoach/queue' +import { JOB_SIGNAL_COPY } from '@/lib/config/job-integration-docs' import { INCIDENT_TYPE_LABELS_SHORT, DASHBOARD_LABELS } from '@/lib/constants/labels' import { daysSinceCeil } from '@/lib/utils' import { residentName } from '@/lib/utils/resident-name' @@ -58,6 +60,13 @@ interface ActionDashboardProps { * you", which the global count above cannot see. */ assignedResidentCount: number | null + /** + * The Job domain's own work, one row per (client, signal). + * + * Empty for a viewer who does not hold `learning:write` — the page does not + * even run the query for them. @see lib/jobcoach/queue.ts + */ + jobQueue: JobQueueItem[] // Action items overdueCheckIns: OverdueCheckIn[] @@ -116,6 +125,7 @@ export function ActionDashboard({ residentCount, housingUnitCount, assignedResidentCount, + jobQueue, occupiedBeds, totalBeds, totalPlacements, @@ -154,12 +164,18 @@ export function ActionDashboard({ // Count total issues — every queue that waits on a staff answer, not just // the placement ones. + // Every term here used to be a HOUSING queue — check-ins, placements, + // transfers, governance. A Jobcoach holds none of those permissions, so + // their count was structurally zero and the dashboard congratulated them on + // a day with real work in it. Observed in production 2026-09-02 with a + // client assigned the same morning. @see lib/jobcoach/queue.ts const totalIssues = criticalIncidents.length + overdueCheckIns.length + unplacedResidents.length + pendingTransfers.length + - proposalsAwaitingStaff.length + proposalsAwaitingStaff.length + + jobQueue.length // "Nothing to do" and "nothing entered yet" are different facts and get // different screens. @see lib/config/dashboard.ts @@ -342,6 +358,34 @@ export function ActionDashboard({ /> )} + {/* The Job domain's work, one tile per signal. Named clients, not + a bare count: the screen this replaces reported "keine + dringenden Aufgaben" to a coach whose client was created that + morning, and never mentioned him. */} + {JOB_SIGNAL_IDS.map((signal) => { + const rows = jobQueue.filter((row) => row.signal === signal) + if (rows.length === 0) return null + const copy = JOB_SIGNAL_COPY[signal] + return ( + ({ + label: row.name, + // The signal is already the tile's title, so the sublabel + // carries the move rather than repeating it. + sublabel: copy.action, + href: `/residents/${row.residentId}`, + }))} + allHref="/learning?board=job" + /> + ) + })} + {unplacedResidents.length > 0 && ( JOB_RESEARCH_SOURCES.find((source) => source.id === id)) + .filter((source): source is ResearchSource => source !== undefined) +} diff --git a/src/lib/jobcoach/__tests__/queue.test.ts b/src/lib/jobcoach/__tests__/queue.test.ts new file mode 100644 index 00000000..f6244c6d --- /dev/null +++ b/src/lib/jobcoach/__tests__/queue.test.ts @@ -0,0 +1,210 @@ +import { + JOB_SIGNAL_IDS, + NO_CONTACT_GRACE_DAYS, + STALLED_RECORD_DAYS, + buildJobQueue, + hasLabourMarketContact, + signalsFor, + type JobClientInput, +} from '../queue' +import { INTEGRATION_PRINCIPLES } from '@/lib/config/job-integration-docs' + +const NOW = new Date('2026-09-02T09:00:00Z') +const daysAgo = (d: number) => new Date(NOW.getTime() - d * 24 * 60 * 60 * 1000) + +const client = (over: Partial = {}): JobClientInput => ({ + residentId: 'r1', + name: 'George B', + createdAt: daysAgo(1), + learningRecords: [], + applications: [], + ...over, +}) + +describe('the production case this was built from', () => { + /** + * Simon B. had one client assigned the day he was created — job-seeking, two + * years without work, an unrecognised trade qualification, German at A2 — + * and the dashboard said "Alles unter Kontrolle! Keine dringenden Aufgaben" + * without naming him. Every component of `openTaskCount` was a housing + * queue, and a Jobcoach holds none of those permissions. + */ + it('a newly registered job-seeker with nothing arranged is not "nothing to do"', () => { + const george = client({ createdAt: daysAgo(NO_CONTACT_GRACE_DAYS + 1) }) + const queue = buildJobQueue([george], NOW) + + expect(queue.length).toBeGreaterThan(0) + expect(queue[0]).toMatchObject({ residentId: 'r1', name: 'George B' }) + }) + + it('names the person, so the dashboard can too', () => { + // The old screen did not mention the client at all. A count alone would + // reproduce that: "1 Aufgabe" tells a coach nothing about whom. + const queue = buildJobQueue([client({ createdAt: daysAgo(30) })], NOW) + expect(queue.every((row) => row.name === 'George B')).toBe(true) + }) +}) + +describe('no labour-market contact', () => { + it('raises once the grace period has passed', () => { + expect(signalsFor(client({ createdAt: daysAgo(NO_CONTACT_GRACE_DAYS) }), NOW)).toContain( + 'NO_LABOUR_MARKET_CONTACT', + ) + }) + + it('stays quiet during intake week', () => { + // Firing on day one would make the signal noise, and a queue a coach + // learns to dismiss protects nobody. + expect(signalsFor(client({ createdAt: daysAgo(1) }), NOW)).not.toContain( + 'NO_LABOUR_MARKET_CONTACT', + ) + }) + + it.each([['INTERESTED'], ['APPLIED'], ['INTERVIEW'], ['ACCEPTED'], ['STARTED']] as const)( + 'a %s application counts as contact', + (stage) => { + const c = client({ createdAt: daysAgo(90), applications: [{ stage }] }) + expect(hasLabourMarketContact(c)).toBe(true) + expect(signalsFor(c, NOW)).not.toContain('NO_LABOUR_MARKET_CONTACT') + }, + ) + + it.each([['ENDED'], ['DECLINED']] as const)('a %s application does not', (stage) => { + // A finished or refused application is history. Treating it as contact + // would hide exactly the person who needs the next one. + const c = client({ createdAt: daysAgo(90), applications: [{ stage }] }) + expect(hasLabourMarketContact(c)).toBe(false) + expect(signalsFor(c, NOW)).toContain('NO_LABOUR_MARKET_CONTACT') + }) + + it('an employment or internship record counts as contact', () => { + for (const kind of ['EMPLOYMENT', 'INTERNSHIP'] as const) { + const c = client({ + createdAt: daysAgo(90), + learningRecords: [{ kind, status: 'IN_PROGRESS', updatedAt: daysAgo(1) }], + }) + expect(hasLabourMarketContact(c)).toBe(true) + } + }) + + it('a language course alone does not count as contact', () => { + // The whole point of the parallel-track principle: a course is not a job. + const c = client({ + createdAt: daysAgo(90), + learningRecords: [{ kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(1) }], + }) + expect(hasLabourMarketContact(c)).toBe(false) + }) +}) + +describe('course without work', () => { + it('flags a running course with no labour-market contact, inside the grace period', () => { + const c = client({ + createdAt: daysAgo(3), + learningRecords: [{ kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(1) }], + }) + expect(signalsFor(c, NOW)).toContain('COURSE_WITHOUT_WORK') + }) + + it('does not double-report once the person is simply overdue', () => { + // Past the grace period the stronger signal already covers it. Two rows + // for one conversation is how a queue stops being read. + const c = client({ + createdAt: daysAgo(90), + learningRecords: [{ kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(1) }], + }) + const signals = signalsFor(c, NOW) + expect(signals).toContain('NO_LABOUR_MARKET_CONTACT') + expect(signals).not.toContain('COURSE_WITHOUT_WORK') + }) +}) + +describe('stalled records', () => { + it('flags an IN_PROGRESS record nobody has touched', () => { + const c = client({ + applications: [{ stage: 'STARTED' }], + learningRecords: [ + { kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(STALLED_RECORD_DAYS) }, + ], + }) + expect(signalsFor(c, NOW)).toContain('STALLED_RECORD') + }) + + it('leaves a recently updated record alone', () => { + const c = client({ + applications: [{ stage: 'STARTED' }], + learningRecords: [{ kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(3) }], + }) + expect(signalsFor(c, NOW)).not.toContain('STALLED_RECORD') + }) + + it('ignores completed records however old', () => { + // A finished course does not go stale. Only work in flight can stall. + const c = client({ + applications: [{ stage: 'STARTED' }], + learningRecords: [{ kind: 'COURSE', status: 'COMPLETED', updatedAt: daysAgo(400) }], + }) + expect(signalsFor(c, NOW)).toEqual([]) + }) +}) + +describe('the queue as a whole', () => { + it('is empty for a client with work running and records moving', () => { + const c = client({ + createdAt: daysAgo(200), + applications: [{ stage: 'STARTED' }], + learningRecords: [{ kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(2) }], + }) + expect(buildJobQueue([c], NOW)).toEqual([]) + }) + + it('emits one row per signal, so two problems are two pieces of work', () => { + const c = client({ + createdAt: daysAgo(90), + learningRecords: [ + { kind: 'COURSE', status: 'IN_PROGRESS', updatedAt: daysAgo(STALLED_RECORD_DAYS + 5) }, + ], + }) + const queue = buildJobQueue([c], NOW) + expect(queue).toHaveLength(2) + expect(queue.map((r) => r.signal).sort()).toEqual( + ['NO_LABOUR_MARKET_CONTACT', 'STALLED_RECORD'].sort(), + ) + }) + + it('an empty caseload produces an empty queue, not an error', () => { + expect(buildJobQueue([], NOW)).toEqual([]) + }) +}) + +describe('every signal traces to a principle marked as acted upon', () => { + /** + * The guard against evidence-as-decoration. A principle claiming the system + * acts on it, with no signal behind it, is a lie told to a caseworker; a + * signal with no principle behind it is a hunch wearing a citation. + */ + it('each `signal` principle is implemented, and each signal is principled', () => { + const signalPrinciples = INTEGRATION_PRINCIPLES.filter((p) => p.status === 'signal') + expect(signalPrinciples.length).toBe(JOB_SIGNAL_IDS.length) + }) + + it('documented-only principles raise nothing, and say so', () => { + // Recognition, post-start support and stated job goals all matter and are + // NOT detectable from the data the product holds. Claiming otherwise is + // the failure this test exists to prevent. + const documented = INTEGRATION_PRINCIPLES.filter((p) => p.status === 'documented') + expect(documented.length).toBeGreaterThan(0) + for (const principle of documented) { + expect(principle.implication.length).toBeGreaterThan(20) + } + }) + + it('every principle cites at least one source', () => { + for (const principle of INTEGRATION_PRINCIPLES) { + expect({ id: principle.id, sources: principle.sourceIds.length > 0 }).toEqual({ + id: principle.id, + sources: true, + }) + } + }) +}) diff --git a/src/lib/jobcoach/queue.ts b/src/lib/jobcoach/queue.ts new file mode 100644 index 00000000..4d2bf8ec --- /dev/null +++ b/src/lib/jobcoach/queue.ts @@ -0,0 +1,174 @@ +import type { ApplicationStageId } from '@/lib/config/opportunities' +import type { LearningKindId, LearningStatusId } from '@/lib/config/learning' + +/** + * What a job coach has waiting — derived from evidence, not invented. + * + * ## The bug this exists to fix + * + * The dashboard decides "busy" or "quiet" from `openTaskCount`, and every + * component of that count was a housing queue: overdue check-ins, unplaced + * residents, pending transfers, problem units, governance proposals. A + * Jobcoach holds none of those permissions, so the count was structurally + * always zero. + * + * Observed in production on 2026-09-02: Simon B. had a client assigned that + * same day — job-seeking, two years without work, an unrecognised trade + * qualification, German at A2 — and his dashboard said + * "🎉 Alles unter Kontrolle! Keine dringenden Aufgaben", without naming him. + * + * That is the same failure the `unassigned` state was added to fix, one level + * deeper. Then it was "nobody is assigned to me". Now it is "somebody is, with + * real work outstanding, and the queue cannot see it" — because the queue was + * defined in another domain's vocabulary. + * + * ## Why these three signals and not others + * + * Each maps to a principle in `config/job-integration-docs.ts` marked + * `status: 'signal'`. Nothing here is a hunch: + * + * - NO_LABOUR_MARKET_CONTACT — place-then-train. Rapid entry into real work + * beats lengthy pre-training, so a client with no application and no active + * placement is an open task rather than a neutral state. + * - COURSE_WITHOUT_WORK — language and work in parallel, not sequential. A + * running course with no labour-market contact is exactly the lock-in + * pattern the IAB evidence describes, where search intensity falls during a + * measure. + * - STALLED_RECORD — an IN_PROGRESS record nobody has touched. Not a + * judgement about the person; a record that has stopped moving is a record + * nobody is working. + * + * Principles marked `documented` deliberately raise nothing: qualification + * recognition, post-start support and stated job goals all matter, and the + * product cannot currently detect them. Inventing a signal from data that does + * not exist would be worse than the gap. + * + * Pure. No Prisma, no dates read from the clock — `now` is passed in, so the + * same input always produces the same queue. + */ + +export const JOB_SIGNAL_IDS = [ + 'NO_LABOUR_MARKET_CONTACT', + 'COURSE_WITHOUT_WORK', + 'STALLED_RECORD', +] as const + +export type JobSignalId = (typeof JOB_SIGNAL_IDS)[number] + +/** + * How long a record may sit untouched before it counts as stalled. + * + * Six weeks, not two: a language course legitimately runs for months without + * an update, and a queue that fires every fortnight is one a coach learns to + * dismiss. The number is a threshold to argue with, which is why it is here + * and not buried in a comparison. + */ +export const STALLED_RECORD_DAYS = 42 + +/** + * How long after intake a client with no labour-market contact is overdue. + * + * The evidence says early contact predicts the later trajectory, and that long + * initial unemployment leaves a scar. It does not say "fourteen days" — that + * is a working default, deliberately short enough to be noticed and long + * enough that intake week is not immediately an alarm. + */ +export const NO_CONTACT_GRACE_DAYS = 14 + +/** Work kinds. A started application of one of these IS labour-market contact. */ +const WORK_KINDS: readonly LearningKindId[] = ['EMPLOYMENT', 'INTERNSHIP'] + +/** Application stages that mean a real process is running. */ +const LIVE_STAGES: readonly ApplicationStageId[] = [ + 'INTERESTED', + 'APPLIED', + 'INTERVIEW', + 'ACCEPTED', + 'STARTED', +] + +export interface JobClientInput { + residentId: string + /** For display. Never a bare code — see utils/resident-name. */ + name: string + /** When this person entered the register. */ + createdAt: Date + learningRecords: { + kind: LearningKindId + status: LearningStatusId + updatedAt: Date + }[] + applications: { stage: ApplicationStageId }[] +} + +export interface JobQueueItem { + residentId: string + name: string + signal: JobSignalId +} + +function hasLiveApplication(client: JobClientInput): boolean { + return client.applications.some((a) => LIVE_STAGES.includes(a.stage)) +} + +function hasWorkRecord(client: JobClientInput): boolean { + return client.learningRecords.some((r) => WORK_KINDS.includes(r.kind) && r.status !== 'EXPIRED') +} + +/** Any labour-market contact at all: a live application or a work record. */ +export function hasLabourMarketContact(client: JobClientInput): boolean { + return hasLiveApplication(client) || hasWorkRecord(client) +} + +function daysBetween(from: Date, to: Date): number { + return (to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24) +} + +/** + * The signals raised for ONE client. + * + * At most one contact-related signal: a client with no contact at all already + * gets NO_LABOUR_MARKET_CONTACT, and also telling the coach "and they are on a + * course" would be two rows for one conversation. + */ +export function signalsFor(client: JobClientInput, now: Date): JobSignalId[] { + const signals: JobSignalId[] = [] + const contact = hasLabourMarketContact(client) + + if (!contact && daysBetween(client.createdAt, now) >= NO_CONTACT_GRACE_DAYS) { + signals.push('NO_LABOUR_MARKET_CONTACT') + } else if (!contact) { + // Still inside the grace period. A running course with nothing alongside + // it is worth naming early, because the lock-in effect starts immediately + // rather than at the point somebody notices. + const onCourse = client.learningRecords.some( + (r) => r.status === 'IN_PROGRESS' && !WORK_KINDS.includes(r.kind), + ) + if (onCourse) signals.push('COURSE_WITHOUT_WORK') + } + + const stalled = client.learningRecords.some( + (r) => r.status === 'IN_PROGRESS' && daysBetween(r.updatedAt, now) >= STALLED_RECORD_DAYS, + ) + if (stalled) signals.push('STALLED_RECORD') + + return signals +} + +/** + * The whole queue, one row per (client, signal). + * + * Rows rather than clients because a coach works a signal, not a person: "who + * has no contact yet" and "whose record has stopped" are different sittings. + * The dashboard counts rows, which is why a client with two signals correctly + * represents two pieces of work. + */ +export function buildJobQueue(clients: JobClientInput[], now: Date): JobQueueItem[] { + return clients.flatMap((client) => + signalsFor(client, now).map((signal) => ({ + residentId: client.residentId, + name: client.name, + signal, + })), + ) +}