---
title: "Placement Fill-Rate Dashboard"
slug: placement-fill-rate-dashboard
section: Placement & Matching
status: draft
description: "Frozen contract for the Layer 7 Season Fill-Rate Dashboard — KPI card shape, query and recompute endpoints, the 30-second poll cadence, the program-manager recompute gate, and the < 3s render budget at 10K+ placements."
last-updated: 2026-10-07
---

# Placement Fill-Rate Dashboard

Frozen contract for the Layer 7 Season Fill-Rate Dashboard — KPI card shape, query and recompute endpoints, the 30-second poll cadence, the program-manager recompute gate, and the < 3s render budget at 10K+ placements. The live dashboard ships in the Globus app; this page is the canonical spec it is built against.

> **Who reads this**
>
> Globus platform engineers (P5) building the Season Fill-Rate Dashboard, plus the program managers and operations staff who consume it. This is a sibling of Placements Overview Engineering — the Overview spec defines the row-level browse / export contract; this page defines the KPI roll-up landing surface and its manual recompute action. Both belong to Layer 7.

## Purpose & Scope

The Season Fill-Rate Dashboard is the operational answer to “how full are we?” for a program and season. Staff with dashboard.view (the live permission) land here to see total positions, filled, reserved, available, and fill rate % at a glance. Program managers with matching.recompute can also trigger a manual match recomputation (live: POST /api/admin/job-orders/recompute) when the underlying supply or demand moved and they want the counters re-derived ahead of the next scheduled cycle.

The dashboard is read-only apart from that single recompute button. Reservation creation, placement confirmation, and capacity release happen on other Layer 7 surfaces (see Capability Layers → Layer 7 ). The placements list / table view and CSV/XLSX export are owned by Placements Overview Engineering and are out of scope here.

> **As-built reality (verified 2026-07-04) — different routes, simpler math**
>
> A fill-rate surface is live, but under different routes than this spec: GET /api/dashboard/season-fill-rates (auth dashboard.read + perm dashboard.view, brand-scoped via resolveBrandScope) and GET /api/dashboard/executive-kpis (perm executive_overview.view or reporting.view), both in server/routes/dashboard.ts sharing one querySeasonFillRates() so they never drift. Row shape: programId, programName, seasonId, seasonName, totalPositions, filled, reserved, available, fillRatePercent. Live formulas: totalPositions = SUM(job_orders.positions_total) excluding draft/rejected/cancelled; filled = COUNT(placements status='confirmed' in season); reserved = COUNT(reservation_holds status='active' in season); available = max(0, total − filled − reserved); fillRatePercent is rounded to one decimal (round1), matching the one-decimal figure specced below, and a zero denominator returns 0, not an em-dash. Not built: GET /api/placements/fill-rate, POST /api/placements/recompute, the placements.recompute permission, the filter-scoped async recompute job, materialized view, the placements.recompute_requested event, and a dedicated fill-rate poll contract (the live route sets no-cache headers; the dashboard client does poll it every 30 seconds via refetchInterval 30000). A manual recompute of match evaluations does exist as POST /api/admin/job-orders/recompute, gated on matching.recompute. The spec below remains the target-state contract for those pieces.

## System of Record

Layer 7 owns reservations and confirmed placements. The dashboard derives every KPI from a join of placements, reservations, and job_orders against programs and seasons. Capacity totals come from approved job orders (Layer 6) using the existing positionsTotal / positionsReserved / positionsPlaced counter model documented in Job Orders → Capacity Counters .

> **The dashboard never writes counters**
>
> The dashboard reads the counter projections; it does not maintain them. Counters are mutated only by Layer 7 reservation / placement transitions and by the nightly integrity scan. If a counter looks wrong, the fix lives in Layer 7, not here.

## KPI Card Contract

### Visual Reference

A non-functional preview of the five KPI cards as they should appear on the live dashboard. Switch scenarios to see the formatting rules below in action — locale-formatted integers, the one-decimal Fill Rate %, the “—” treatment when Total = 0, and the over-recruitment badge on the Available card.

### Per-Card Fields

| Card | Source | Display |
| --- | --- | --- |
| card | source | display |

_(row above is a template, repeated for each data row)_

### Counting & Display Rules

## Aggregation Grain

The default grain is program × season. Brand is a roll-up; job order is a roll-down. The same query parameters control which grain the response lands on — the dashboard never re-computes a grain client-side.

| Grain | Scope | Filters |
| --- | --- | --- |
| grain | scope | filters |

_(row above is a template, repeated for each data row)_

## Endpoints

### GET /api/placements/fill-rate

Returns one row per (programId, seasonId) inside the caller's RBAC scope plus an aggregate object that sums every returned row. All filters are AND-joined; an omitted filter means “every value in scope”. Requires placements.view.

| Param | Type | Description |
| --- | --- | --- |
| name | type | desc |

_(row above is a template, repeated for each data row)_

Response:

"rows": [

"programId": int,

"programCode": string,

"seasonId": int,

"seasonLabel": string,

"totalPositions": int,

"filled": int,

"reserved": int,

"available": int,

"fillRate": number // one decimal, percentage

"aggregate":

"totalPositions": int,

"filled": int,

"reserved": int,

"available": int,

"fillRate": number

"appliedFilters": ,

"computedAt": ISO8601,

"recompute": status / idle / queued / running / jobId

> **Aggregate is server-computed**
>
> The dashboard surfaces both the per-row counters and the aggregate; the UI must render the server's aggregate object verbatim and never re-sum the rows client-side. This guarantees the headline KPI cards never disagree with the table beneath them — same pattern as Placements Overview → Summary Endpoint .

### POST /api/placements/recompute

Triggers a manual match recomputation scoped to the same filter set as the GET. Requires placements.recompute (program-manager role group). The endpoint enqueues the job, returns its id, and acknowledges in under 500 ms — the recompute itself runs asynchronously on the Layer 7 queue.

| Field | Type | Description |
| --- | --- | --- |
| field | type | desc |

_(row above is a template, repeated for each data row)_

Response (202 Accepted):

"jobId": string,

"status": "queued" | "running",

"queuedAt": ISO8601,

"appliedFilters":

## Refresh Contract

30-second polling, not push

Clients call the GET endpoint via TanStack Query with refetchInterval: 30000. “Real-time” in this surface means a 30-second cadence — there is no WebSocket or SSE channel in v1. The cache key is ['/api/placements/fill-rate', filters] so changing any filter starts a new polling stream.

In-flight recompute reaction

When the GET response includes a non-idle recompute object, the recompute button surfaces the job's status and the dashboard keeps polling on the same 30 s cadence. When the status returns to idle, the next poll triggers an immediate refetch and a “Recompute complete” toast.

Background pause

TanStack Query's refetchIntervalInBackground: false stops polling when the tab loses focus and resumes immediately on focus return — keeps the load on the read-model bounded when staff leave the tab open.

## Recompute Action

The recompute button is the only mutation surface on this dashboard. It is gated by the program_manager role group and scoped to the same brand → program → season hierarchy as the rest of the ecosystem. As built, the button is hidden (not disabled) for callers who lack matching.recompute.

Queue

The job lands on the Layer 7 queue managed by queueEngine.ts (see Capability Layers → Layer 7 ) and registers itself as a job class on the Batch Jobs catalog so operators can pause, resume, and inspect the runs alongside the rest of the platform's scheduled work.

Acknowledgement SLA

The HTTP response must return in under 500 ms with a queued or running status. The actual recompute is async; nominal completion is under 60 seconds for a single program/season at 10K placements.

Downstream effects

On completion the recompute job updates the read-model rows that back this dashboard, may emit Layer 7 placement.* events for any state transitions it commits, and refreshes the gap-alert thresholds documented on the Matching Engine page.

## Performance Budget

| Metric | Budget |
| --- | --- |
| metric | budget |

_(row above is a template, repeated for each data row)_

> **Materialized view is the escape hatch**
>
> At 10K placements the live aggregate query is acceptable. Above ~50K per program/season, switch the GET endpoint to read from a materialized view that the recompute job refreshes — wire shape stays identical so the dashboard does not change. This is the same indexing assumption the Job Orders capacity counters section already documents.

## Empty / Loading / Error States

| State | Display |
| --- | --- |
| state | display |

_(row above is a template, repeated for each data row)_

## RBAC Matrix

Scoping is brand → program → season exactly like the rest of the ecosystem; the API enforces scope server-side and the dashboard never sees rows outside the caller's permission. See Roles & Governance for the full role catalog.

| Permission | Granted to / Notes | Action |
| --- | --- | --- |
| perm | roles | action |

_(row above is a template, repeated for each data row)_

> **Server-side scope, not client-side**
>
> Permission scope is enforced server-side. A program manager scoped to Brand A / Program CC sees only that scope; the API filters every row and the aggregate is scoped to match. Never trust client-side filters for authorization.

## Events & Audit

GET emits no domain events

The fill-rate endpoint is a read view. No placement.* events fire from polling the dashboard.

Every recompute trigger writes an audit row

Fields: actor, timestamp, applied filters, reason (if supplied), job id, queuedAt, startedAt, completedAt, status (succeeded / failed). The audit row is written before the 202 response so a network drop never loses the trigger record.

Each trigger emits a placements.recompute_requested event

Published on the dual-outbox pattern documented in Capability Layers → Layer 9 . Payload: actor, scope (brandId / programId / operationalCycleId / jobOrderId), jobId, requestedAt. The payload's operationalCycleId field replaces the legacy seasonId field; both are emitted side-by-side during the dual-publish deprecation window — see /docs/automation for the cutover contract. The event is the integration hook for downstream consumers (BI, n8n, dashboards) that need to know a manual recompute was requested.

## Acceptance Criteria (Verbatim)

## Cross-References
