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, + })), + ) +}