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>
This commit is contained in:
2026-05-15 23:29:44 +00:00
parent 2b8e9168c3
commit 7ed94cd8fc
2 changed files with 230 additions and 26 deletions
+43
View File
@@ -229,6 +229,48 @@ def get_deployment_history_data(
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
@@ -267,6 +309,7 @@ def get_deployment_history_data(
return {
"months": months_data,
"projects": projects_data,
"units": units_data,
"total_assignments": len(assignments),
"total_active_units": len(distinct_units),
"window": {