feat(tools): add Gantt view tab to deployment-history page
The Calendar grid (day-cells with project bars) is great for seeing
which projects had activity on a given day, but bad for seeing how
long any single deployment lasted. The Gantt view inverts that —
one row per project, horizontal bars per assignment window — so an
operator can read durations at a glance.
Service layer
- backend/services/deployment_history.py extends each project's
payload with `bars`: a list of {unit_id, location_id, location_name,
start, end, is_active, source} for every UnitAssignment clipped to
the visible 12-month window. Location names are batch-resolved.
Same cost as before since the underlying assignment scan is the
same; just additional data in the response.
Template
- Tab switcher at the top of /tools/deployment-history toggles
between Calendar and Gantt views. URL hash (#gantt) preserves the
active view across month-nav (Prev / Next / Recent buttons within
the Gantt view link to ?...#gantt to stay on the same tab).
- Gantt view is a plain SVG with:
- Left 220px label gutter: project color dot + truncated name,
whole row clickable → opens the project page
- Right area: horizontal time axis with month gridlines + labels,
"today" dashed orange line, one row per project
- One bar per assignment in that row, colored by project, reduced
opacity for closed assignments, blue outline for metadata-
backfilled assignments, white tip on the right edge of active
bars
- Hover any bar → tooltip with unit + location + window
- Alternating row backgrounds for readability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -138,14 +138,26 @@ def get_deployment_history_data(
|
||||
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:
|
||||
@@ -169,6 +181,20 @@ def get_deployment_history_data(
|
||||
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 = []
|
||||
@@ -186,6 +212,7 @@ def get_deployment_history_data(
|
||||
"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({
|
||||
@@ -197,6 +224,7 @@ def get_deployment_history_data(
|
||||
"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"]))
|
||||
|
||||
Reference in New Issue
Block a user