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:
@@ -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
|
||||
Reference in New Issue
Block a user