feat(timeline): merge consecutive same-location assignments + per-unit Gantt chart

When a unit had its assignment closed-then-reopened (e.g. via the
recent location remove/restore flow) or had metadata-backfill auto-
create a retroactive window adjacent to a manual one, the deployment
timeline showed N stacked rows that represented one continuous
deployment.  Visual noise that didn't match reality.

Merge feature
- New endpoint POST /api/projects/{p}/assignments/merge
  - Body: { assignment_ids: [uuid, ...] }
  - Keeps earliest record, extends its window to span all inputs,
    deletes the others, logs `assignment_merged` to UnitHistory
  - Validates: all assignments share same unit + location, all
    belong to the same project
- deployment_timeline_for_unit() now auto-detects mergeable groups
  (consecutive same-location assignments within 7-day gap tolerance)
  and returns them in `merge_groups` as a list of id-lists
- Unit detail page shows a blue banner above the timeline list when
  groups exist, with one "Merge into one" button per group.  Each
  mergeable row gets a small "mergeable" badge to make the
  relationship obvious.

Per-unit Gantt chart (Phase 1 of the deployment-history calendar)
- Plain-SVG horizontal timeline rendered above the existing Deployment
  Timeline list, ~140px tall
- One colored bar per assignment, color-keyed by location (auto-
  assigned palette + legend)
- Reduced opacity for closed bars; small white dot at the right edge
  of active bars; today marker as a dashed orange vertical line
- Month gridlines (or every-3-month gridlines when domain > 24 months)
- Metadata-backfilled assignments get a blue outline so you spot
  which were auto-attributed
- Mergeable groups get a dashed blue underline tying their bars
  together visually
- Click any bar → smooth-scrolls the matching list row into view
  and flashes a ring around it
- Hover any bar → tooltip with location + window + event count
- Auto-hides on units with no deployment history

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:29:51 +00:00
parent f13158e7bf
commit ef0008822e
3 changed files with 492 additions and 11 deletions
+40 -4
View File
@@ -46,6 +46,13 @@ log = logging.getLogger("backend.services.deployment_timeline")
# clutter from a sub-second handoff during a swap workflow.
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
# When detecting "mergeable" groups of consecutive same-location assignments,
# treat assignments separated by no more than this many seconds as adjacent.
# Generous enough to catch overnight handoffs and weekend gaps where the
# operator forgot to log, but tight enough that genuinely separate
# deployments months apart don't get suggested for merging.
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
# Per-call timeout when querying SFM for the event overlay.
_SFM_TIMEOUT = 10.0
_SFM_FETCH_CEILING = 5000
@@ -245,12 +252,41 @@ async def deployment_timeline_for_unit(
"history_notes": h.notes,
})
# 6. Sort newest first. Active assignments (no end) sort by start time,
# 6. Detect mergeable groups — runs of consecutive assignments to the
# same location with small gaps between them. Each group becomes a
# list of assignment_ids; the UI offers a "Merge into one" action
# on any group >= 2.
merge_groups: list[list[str]] = []
if len(assignments) >= 2:
# Sort ascending for the linear scan.
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
for a in sorted_assignments[1:]:
prev = cur_group[-1]
same_location = a.location_id == prev.location_id
prev_end = prev.assigned_until or now
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
# Within tolerance and same location → extend the current group.
# Negative gaps (overlap) also count as adjacent.
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
cur_group.append(a)
else:
if len(cur_group) >= 2:
merge_groups.append([x.id for x in cur_group])
cur_group = [a]
if len(cur_group) >= 2:
merge_groups.append([x.id for x in cur_group])
# 7. Sort newest first. Active assignments (no end) sort by start time,
# same as everything else.
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
return {
"unit_id": unit.id,
"device_type": unit.device_type,
"entries": entries,
"unit_id": unit.id,
"device_type": unit.device_type,
"entries": entries,
# List of assignment_id lists; each inner list is a mergeable group.
# Empty if nothing is mergeable. UI shows a "Merge" button on any
# row whose assignment_id appears in a group.
"merge_groups": merge_groups,
}