fleettickets/docs/phase-2-dashboard.md
david kiania f2408f113e docs: add docs/ — phase-1/phase-2 PRDs + implementation record
- docs/phase-1-ingestion.md  — Phase 1 PRD (INC hourly CSV ingestion; deployed)
- docs/phase-2-dashboard.md  — Phase 2 PRD (inc_dashboard read-API for FleetOps map)
- docs/implementation.md     — as-built record (pipeline, migrations 01-08, deploy, DQ)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:05:18 +03:00

141 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PRD (Phase 2) — INC operations dashboard: read-API layer
> Phase 1 (hourly INC CSV ingestion → `tickets.inc`, geocoding, typed generated
> columns, `inc_open_sla` view) is **complete and deployed** (migrations 0108,
> Coolify hourly `15 7-19 * * *` EAT). See `docs/phase-1-ingestion.md` /
> `docs/implementation.md`. This document is Phase 2.
## Context
FleetOps needs a **live INC operations map** (modelled on FleetNow):
- A map showing **all currently-open INC tickets** alongside **live vehicle
positions from FleetNow**.
- A **bottom timeline bar** that overlays **closed tickets** (alongside FleetNow
vehicle routes) for a selected period.
- **Bottom filters**: `cluster`, ticket `status`, and **time** = today / this week /
this month / custom date.
- **Top metric cards** that react to the selected filters — **ticket** metrics
(not vehicle metrics).
**Scope of THIS repo (confirmed): the data / read-API layer only.** `fleettickets`
exposes parameterized SQL in `tracksolid_db` that `dashboard_api` serves to the
**FleetOps SPA**. The map UI, timeline bar, filter controls, metric cards, and the
**FleetNow vehicle positions/routes** are **other repos/systems**. There is no
vehicle id in the INC feed, so we serve **tickets only**; the SPA overlays FleetNow
vehicles/routes.
## Confirmed behaviour
- **Open layer (live):** all `is_actionable = true` INC tickets matching the
cluster/status filter — **not** time-filtered (open = needs action now).
- **Closed overlay (windowed):** closed tickets whose `closed_at` falls in the
selected window, matching cluster/status.
- **Metric cards (windowed):** computed for the current selection.
- **Filters combine with AND**, each optional. **Windows are calendar EAT**
(today / ISO-week / month) or an explicit custom `[from, to)`.
- **Delivery:** one parameterized function returning a single JSON payload
`{ open: GeoJSON, closed: GeoJSON, metrics: {…}, window, freshness }`, mirroring
the existing `reporting.fn_tickets_for_map` style.
## Deliverable — `migrations/09_inc_dashboard_fn.sql`
A new read function (and supporting index if needed); additive, idempotent
(`CREATE OR REPLACE`), no change to existing objects.
### `reporting.fn_inc_dashboard(...)`
```
reporting.fn_inc_dashboard(
p_cluster text DEFAULT NULL, -- exact cluster (matches tickets.inc.cluster)
p_status text DEFAULT NULL, -- normalized_status
p_window text DEFAULT 'today', -- 'today' | 'week' | 'month' | 'custom'
p_from timestamptz DEFAULT NULL, -- custom window start (inclusive)
p_to timestamptz DEFAULT NULL -- custom window end (exclusive)
) RETURNS jsonb
```
- **Window resolution:** if `p_from`/`p_to` given → use them (custom). Else compute
**EAT calendar bounds** from `p_window`: `today` = `[date_trunc('day', now_eat),
+1 day)`, `week` = `date_trunc('week', …)`, `month` = `date_trunc('month', …)`
converted back to `timestamptz` via `… AT TIME ZONE 'Africa/Nairobi'`.
- **Returned JSON:**
```jsonc
{
"window": { "from": "...", "to": "...", "preset": "today" },
"open": { "type":"FeatureCollection", "features":[ … ] }, // all open, filtered by cluster/status
"closed": { "type":"FeatureCollection", "features":[ … ] }, // closed_at in window, filtered
"metrics": {
"open_now": int,
"closed_in_window": int,
"sla": {
"open": { "breached": int, "at_risk": int, "ok": int, "unknown": int },
"closed": { "compliant": int, "breached": int }
},
"by_status": { "<status>": int, … },
"by_cluster": { "<cluster>": int, … },
"closure_rate": { "per_day_avg": num, "series": [ { "day":"YYYY-MM-DD", "count":int }, … ] },
"avg_mttr_min": num
},
"freshness": { … } // from tickets.import_meta
}
```
- **Feature properties** (both layers): `ticket_id, normalized_status, cluster,
region, location_name, assigned_team, owner, geo_source`. Open adds `sla_state,
hours_open`; closed adds `closed_at, mttr, sla_status`. Geometry from `geom`
(`ST_AsGeoJSON`). Only `geom IS NOT NULL` rows become features; `metrics` count the
full filtered set (note the small geocoding gap).
### Reuse (don't reinvent)
- **`tickets.inc_open_sla`** (migration 08) — `sla_state` / `hours_open` for the open
layer + open-SLA metrics.
- **Typed generated columns** (migrations 0307): `cluster`, `normalized_status`,
`closed_at`, `mttr` (minutes), `assigned_team`, `geom`, `geo_source`.
- **`reporting.fn_tickets_for_map`** (migrations 0102) — GeoJSON
`jsonb_build_object`/`ST_AsGeoJSON` + `summary.freshness` patterns.
- **Derived SLA logic** — `now() COALESCE(created_at_service, first_seen_at)` vs
48h/36h.
### Indexes
In place: `ix_inc_closed_at`, `ix_inc_cluster_col`, `ix_inc_norm_status_col`,
`ix_inc_actionable_col`, `ix_inc_geom`, `ix_inc_geog`. Add composite
`(closed_at, cluster)` only if EXPLAIN shows it's needed.
### Grants
`GRANT EXECUTE ON FUNCTION reporting.fn_inc_dashboard(...) TO dashboard_ro` (guarded).
## Dependencies (other repos)
- **`dashboard_api`** — endpoint e.g. `GET /webhook/inc-dashboard?cluster=&status=&window=&from=&to=`
calling `fn_inc_dashboard`. *(Contract here; impl there.)*
- **FleetOps SPA** (`fleetops`) — map, timeline bar, filter UI, metric cards;
overlays FleetNow vehicles/routes.
- **FleetNow** — live vehicle positions + historical routes.
## Data-quality caveats (affect metrics, not delivery)
- Source `sla_status` only meaningful for **closed**; open SLA is derived.
- `created_at_service` null on ~30% → some open are SLA `unknown` (fallback flagged).
- `mttr` is **minutes**, null until closed; closure/MTTR metrics filter accordingly.
- Content lag ~2 days → recent days under-count.
- A few tickets lack `geom` → counted in metrics, absent from map features.
## Verification
1. `SELECT reporting.fn_inc_dashboard();` → valid JSON (open/closed FCs, metrics,
window=today, freshness).
2. Filters: `p_cluster`, `p_status`, `p_window := 'month'`, and a custom `p_from/p_to`
— counts match ad-hoc `SELECT`s on `tickets.inc` / `tickets.inc_open_sla`.
3. Window math: today/week/month are correct **EAT** calendar ranges.
4. SLA metrics match the `inc_open_sla` distribution / source `sla_status` in window.
5. `EXPLAIN ANALYZE` on the windowed closed query uses `ix_inc_closed_at`.
6. Apply via `run_migrations.py`; ledgered in `tickets.schema_migrations`.
## Out of scope (future)
- **Open-backlog-over-time** / observed open→closed transitions need the append-only
history capture (`tickets.closure_events` + daily snapshot) — separate plan.
- **Dispatch surface** (nearest-vehicle off `geog`) — after analytics.