Files
serversdown 7ed94cd8fc feat(tools): add 'Gantt by Unit' tab to deployment history
Third view on /tools/deployment-history.  Where 'Gantt by Project' has
one row per project showing that project's deployments, 'Gantt by Unit'
inverts it — one row per seismograph, bars colored by the project the
unit was deployed to.

The natural use case: "where has BE11529 been across all my jobs?"
Spotting unit rotation patterns, idle gaps, and concurrent assignments
gets immediate visually.

Service
- deployment_history.get_deployment_history_data() now also returns a
  `units` array.  Each unit dict carries:
    {id, bars[], first_active, assignment_count, any_active}
  Each bar has the project_name + project_color baked in so the
  renderer can paint by job without a second lookup.
- Units sorted: currently-active first, then by first_active ascending.

UI
- Third tab "Gantt by Unit" added next to Calendar / Gantt by Project.
- Tab switcher refactored to a small registry (_DH_TABS) so adding more
  views in the future is a one-line addition.
- URL hash sync now supports #gantt and #byunit; nav buttons preserve
  the active tab across month-paging.
- SVG layout: 160px label gutter (smaller than the project Gantt's
  220px since unit IDs are short), 32px row height, green dot for
  units with at least one active deployment.  Unit ID is clickable
  → /unit/{id}; each bar is clickable → /projects/{p}.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:29:44 +00:00

388 lines
15 KiB
Python
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.
"""
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 {}
# Resolve location names in one batch query (used by the Gantt view
# for per-bar tooltips).
from backend.models import MonitoringLocation
loc_ids = {a.location_id for a in assignments}
loc_name_map = {
l.id: l.name for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
} if loc_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).
# Also collect raw per-assignment bar data for the Gantt view.
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] = {}
project_bars: dict[str, list[dict]] = {}
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
# Per-assignment bar data — used by the Gantt view's renderer.
# `is_active` reflects whether the assignment_until was still NULL
# at fetch time (open-ended deployment); the clipped `end` here
# is just for visual bar drawing.
project_bars.setdefault(a.project_id, []).append({
"unit_id": a.unit_id,
"location_id": a.location_id,
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
"start": start.isoformat(),
"end": end.isoformat(),
"is_active": a.assigned_until is None,
"source": a.source,
})
# 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,
"bars": project_bars.get(pid, []),
})
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,
"bars": project_bars.get(pid, []),
})
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
# ── Per-unit view data (Gantt-by-Unit tab) ────────────────────────
# Same source assignments, re-grouped by unit_id. Each bar carries
# the project's color + name so the renderer can paint by job
# without doing a second lookup.
unit_bars: dict[str, list[dict]] = {}
project_lookup = {p["id"]: p for p in projects_data}
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
p_info = project_lookup.get(a.project_id, {})
unit_bars.setdefault(a.unit_id, []).append({
"project_id": a.project_id,
"project_name": p_info.get("name", "(deleted project)"),
"project_color": p_info.get("color", _color_for_project(a.project_id)),
"location_id": a.location_id,
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
"start": start.isoformat(),
"end": end.isoformat(),
"is_active": a.assigned_until is None,
"source": a.source,
})
# Sort units by first-active date so the most-recently-deployed
# units sit at the top. Reverse if we want oldest-first.
units_data = []
for uid, bars in unit_bars.items():
bars.sort(key=lambda b: b["start"])
first_start = bars[0]["start"]
# "active now" flag = any bar is still active
any_active = any(b["is_active"] for b in bars)
units_data.append({
"id": uid,
"bars": bars,
"first_active": first_start,
"assignment_count": len(bars),
"any_active": any_active,
})
units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"]))
# 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,
"units": units_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