feat(tools): fleet-wide deployment history calendar (Phase 2)

The per-unit Gantt chart on /unit/{id} (Phase 1, v0.11.0) was scoped
to one unit's deployment timeline.  This adds the fleet-wide view as
a new entry under /tools.

What it shows
- 12-month calendar grid styled like the Job Planner (4 months per
  row, responsive down to single column on mobile).
- Each day cell shows up to 4 colored mini-bars — one per project
  that had ≥1 active UnitAssignment that day, color deterministically
  hashed from project_id.  Days with >4 active projects show "+N".
- KPI strip at the top: project count, distinct unit count, total
  assignment count in the window.
- Collapsible project legend: ordered by first-active date (which
  matches the deployment-history reading order), each row links to
  the project page, shows the assignment count.

Click-a-day side panel
- Click any populated day cell → slide-over panel from the right
- Groups by project, lists every (unit, location) active that day
- Per-deployment: unit link, location link, window dates, active /
  closed badge, "auto-backfilled" tag for metadata_backfill source
- Sources from a new GET /api/admin/deployment-history/day endpoint

Navigation
- Prev / Next month buttons shift the 12-month window by one month
- "Recent" button jumps back to default (12 months ending now)
- Default window is 11 months back from current month — operator
  sees the recent past on first load, not future emptiness

Files
- backend/services/deployment_history.py — data builder + day-detail
  helper.  Walks UnitAssignment windows, intersects with the 12-month
  range, computes per-project active-day sets.
- backend/routers/deployment_history.py — page route + day-detail JSON
  endpoint.  Wired into main.py.
- templates/admin/deployment_history.html — page + side-panel
- templates/tools.html — new card linking to the page

Phase 3 (deferred): drag-to-resize bars to retroactively adjust
assignment windows from inside the calendar; per-unit row view
(complement to the project-row view) for "where has unit X been across
all jobs"; horizontal scroll for >12-month windows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 06:33:00 +00:00
parent ba9cdb4347
commit 47c65268e3
5 changed files with 782 additions and 0 deletions
+3
View File
@@ -109,6 +109,9 @@ app.include_router(watcher_manager.router)
from backend.routers import admin_modules
app.include_router(admin_modules.router)
from backend.routers import deployment_history
app.include_router(deployment_history.router)
# Projects system routers
app.include_router(projects.router)
app.include_router(project_locations.router)
+99
View File
@@ -0,0 +1,99 @@
"""
Fleet-wide deployment-history calendar — Phase 2 of the
deployment-history visualisation work (Phase 1 is the per-unit Gantt
on /unit/{id}).
Renders all UnitAssignment windows across all projects on a 12-month
calendar grid styled like the Job Planner. Each day cell shows one
mini-bar per project that had ≥1 active assignment that day. Click a
day → side panel with the (unit, location) pairs active.
Routes:
GET /tools/deployment-history — HTML page
GET /api/admin/deployment-history/day — JSON list of deployments
on a specific date (used
by the day-detail panel)
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.services.deployment_history import (
get_deployment_history_data,
get_deployments_on_day,
)
from backend.templates_config import templates
router = APIRouter()
@router.get("/tools/deployment-history", response_class=HTMLResponse)
def deployment_history_page(
request: Request,
year: Optional[int] = Query(None),
month: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
"""Fleet-wide deployment history calendar.
Defaults to a 12-month window ending in the current month (so the
operator sees the recent past, not the future). ?year=&month= can
override the START of the window to scroll backward or forward.
"""
today = date.today()
# Default: 12-month window ending this month → start = 11 months back.
if year is None or month is None:
# 11 months back from current month.
m = today.month - 11
y = today.year
while m < 1:
m += 12
y -= 1
start_year, start_month = y, m
else:
start_year, start_month = year, month
calendar = get_deployment_history_data(db, start_year, start_month)
# Build prev/next navigation values.
prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1)
next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1)
return templates.TemplateResponse("admin/deployment_history.html", {
"request": request,
"calendar": calendar,
"today": today.isoformat(),
"prev_year": prev_y,
"prev_month": prev_m,
"next_year": next_y,
"next_month": next_m,
})
@router.get("/api/admin/deployment-history/day")
def deployment_history_day(
target_date: str = Query(..., description="YYYY-MM-DD"),
db: Session = Depends(get_db),
):
"""Return assignments active on a specific calendar day."""
try:
d = date.fromisoformat(target_date)
except ValueError:
return JSONResponse(
{"error": f"Invalid date: {target_date!r}"},
status_code=400,
)
deployments = get_deployments_on_day(db, d)
return JSONResponse({
"date": target_date,
"count": len(deployments),
"deployments": deployments,
})
+316
View File
@@ -0,0 +1,316 @@
"""
Deployment-history calendar service — builds the data structure for the
fleet-wide deployment-history grid (`/tools/deployment-history`).
For each calendar day in a 12-month window, computes which projects had
at least one unit assigned to a location on that day. Renders as
multi-month grid (job-planner style) with project-colored bars per day.
Distinct from `services/fleet_calendar_service.py` which renders
forward-looking RESERVATIONS for the planner. This one is purely
historical / current — it walks `unit_assignments` instead of
`job_reservations`.
"""
from __future__ import annotations
import hashlib
from datetime import date, datetime, timedelta
from calendar import monthrange
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import Project, UnitAssignment
# Color palette for projects without an explicit color attribute. Chosen
# to have decent contrast on both light and dark backgrounds; cycles
# deterministically by SHA1(project_id).
_PROJECT_COLOR_PALETTE = [
"#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d",
"#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8",
"#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c",
]
def _color_for_project(project_id: str) -> str:
"""Deterministic color assignment from a fixed palette."""
h = hashlib.sha1(project_id.encode("utf-8")).digest()[0]
return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)]
def _month_short(m: int) -> str:
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
def _month_full(m: int) -> str:
return ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"][m - 1]
def get_deployment_history_data(
db: Session,
start_year: int,
start_month: int,
) -> dict:
"""
Build the calendar data structure for a 12-month window starting at
(start_year, start_month).
Returns:
{
"months": [
{
"year": int,
"month": int, # 1-12
"name": "January",
"short_name": "Jan",
"year_short": "26",
"num_days": int,
"first_weekday": int, # 0=Mon..6=Sun (datetime.weekday())
"active_days": {
day_num: [project_id, project_id, ...] # projects with
# ≥1 active assignment
# on that day
},
},
... # 12 entries
],
"projects": [
{
"id": str,
"name": str,
"color": str,
"status": str,
"client_name": str | None,
"assignment_count": int, # total assignments contributing to
# this 12-month window
"first_active": "YYYY-MM-DD" | None,
"last_active": "YYYY-MM-DD" | None,
},
... # only projects with
# ≥1 assignment in the
# window, sorted by
# first_active ASC
],
"total_assignments": int,
"total_active_units": int, # distinct unit_ids across the window
"window": {
"start_year": int,
"start_month": int,
"end_year": int,
"end_month": int,
"first_date": "YYYY-MM-DD",
"last_date": "YYYY-MM-DD",
},
}
"""
# Compute window edges.
first_date = date(start_year, start_month, 1)
# 12 months → end on day-1 of (start + 12)
end_year = start_year + ((start_month + 10) // 12)
end_month = ((start_month + 10) % 12) + 1
last_date = date(end_year, end_month, monthrange(end_year, end_month)[1])
now = datetime.utcnow()
# Fetch every assignment that overlaps the window. An assignment
# overlaps if assigned_at <= last_date AND (assigned_until is NULL
# OR assigned_until >= first_date).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time()))
.filter(
or_(
UnitAssignment.assigned_until == None, # noqa: E711 — active
UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()),
)
)
.all()
)
# Resolve referenced projects in one query.
proj_ids = {a.project_id for a in assignments}
proj_map = {
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
} if proj_ids else {}
# Compute "active days per project" by walking each assignment and
# adding every day in its [start, end] ∩ [first_date, last_date].
# O(N_assignments × avg_window_days); for a typical fleet this is
# bounded (hundreds of assignments × hundreds of days = manageable).
project_active_days: dict[str, set[date]] = {}
project_first_active: dict[str, date] = {}
project_last_active: dict[str, date] = {}
project_assignment_count: dict[str, int] = {}
distinct_units: set[str] = set()
for a in assignments:
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
end_dt = a.assigned_until or now
end = min(end_dt.date(), last_date)
if end < start:
continue
days = project_active_days.setdefault(a.project_id, set())
d = start
while d <= end:
days.add(d)
d += timedelta(days=1)
project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1
distinct_units.add(a.unit_id)
# Track first/last active dates in the window.
prev_first = project_first_active.get(a.project_id)
if prev_first is None or start < prev_first:
project_first_active[a.project_id] = start
prev_last = project_last_active.get(a.project_id)
if prev_last is None or end > prev_last:
project_last_active[a.project_id] = end
# Build the projects array (sorted by first_active ascending so the
# legend reads in deployment-order).
projects_data = []
for pid, days in project_active_days.items():
p = proj_map.get(pid)
if not p:
# Assignment references a deleted project — surface it anyway
# with a placeholder name, since the bars still need a label.
projects_data.append({
"id": pid,
"name": "(deleted project)",
"color": _color_for_project(pid),
"status": "deleted",
"client_name": None,
"assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
})
continue
projects_data.append({
"id": pid,
"name": p.name,
"color": _color_for_project(pid),
"status": p.status or "active",
"client_name": p.client_name,
"assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
})
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
# Now build the months array.
months_data = []
cur_year, cur_month = start_year, start_month
for _ in range(12):
num_days = monthrange(cur_year, cur_month)[1]
first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun
active_days: dict[int, list[str]] = {}
for day_num in range(1, num_days + 1):
d = date(cur_year, cur_month, day_num)
day_projects = [
pid for pid, days in project_active_days.items()
if d in days
]
if day_projects:
# Sort by the project's color-stable order so bars don't
# jitter between days.
day_projects.sort()
active_days[day_num] = day_projects
months_data.append({
"year": cur_year,
"month": cur_month,
"name": _month_full(cur_month),
"short_name": _month_short(cur_month),
"year_short": f"{cur_year % 100:02d}",
"num_days": num_days,
"first_weekday": first_weekday,
"active_days": active_days,
})
# Advance one month.
if cur_month == 12:
cur_year += 1
cur_month = 1
else:
cur_month += 1
return {
"months": months_data,
"projects": projects_data,
"total_assignments": len(assignments),
"total_active_units": len(distinct_units),
"window": {
"start_year": start_year,
"start_month": start_month,
"end_year": end_year,
"end_month": end_month,
"first_date": first_date.isoformat(),
"last_date": last_date.isoformat(),
},
}
def get_deployments_on_day(
db: Session,
target_date: date,
) -> list[dict]:
"""
Return the list of (unit, location, project) tuples that were
actively assigned on a specific calendar date. Used for the
day-detail side panel when an operator clicks a day cell.
"""
from backend.models import MonitoringLocation, RosterUnit
day_start = datetime.combine(target_date, datetime.min.time())
day_end = datetime.combine(target_date, datetime.max.time())
rows = (
db.query(UnitAssignment)
.filter(UnitAssignment.assigned_at <= day_end)
.filter(
or_(
UnitAssignment.assigned_until == None, # noqa: E711
UnitAssignment.assigned_until >= day_start,
)
)
.order_by(UnitAssignment.project_id, UnitAssignment.unit_id)
.all()
)
if not rows:
return []
loc_ids = {a.location_id for a in rows}
proj_ids = {a.project_id for a in rows}
loc_map = {
l.id: l for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
}
proj_map = {
p.id: p for p in db.query(Project).filter(
Project.id.in_(proj_ids)
).all()
}
results = []
for a in rows:
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
results.append({
"assignment_id": a.id,
"unit_id": a.unit_id,
"location_id": a.location_id,
"location_name": loc.name if loc else "(unknown location)",
"project_id": a.project_id,
"project_name": proj.name if proj else "(deleted project)",
"project_color": _color_for_project(a.project_id),
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
"assigned_until": a.assigned_until.isoformat() if a.assigned_until else None,
"is_active": a.assigned_until is None,
"source": a.source,
})
return results