2b8e9168c3
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>
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""
|
||
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"]))
|
||
|
||
# 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
|