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:
@@ -916,6 +916,142 @@ async def delete_assignment(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assignments/merge")
|
||||
async def merge_assignments(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Merge multiple consecutive UnitAssignment rows for the same (unit, location)
|
||||
into a single record spanning their combined window.
|
||||
|
||||
Use case: a unit's deployment timeline shows 3 stacked rows for the
|
||||
same location because the assignment was closed-and-reopened (e.g. via
|
||||
location remove + restore) or because metadata-backfill auto-created
|
||||
a retroactive window adjacent to a manual one. Operator sees three
|
||||
rows but they represent one continuous deployment.
|
||||
|
||||
Body JSON:
|
||||
{ "assignment_ids": ["<uuid>", "<uuid>", ...] }
|
||||
|
||||
Validation:
|
||||
- All assignments must belong to project_id
|
||||
- All must share the same unit_id AND location_id
|
||||
- At least 2 ids must be provided
|
||||
|
||||
Merge rules:
|
||||
- Keeps the EARLIEST-starting assignment as the surviving row
|
||||
- assigned_at = min(assigned_at across all)
|
||||
- assigned_until = max(assigned_until), or NULL if any input was active
|
||||
- status = "active" if any input was active, else "completed"
|
||||
- source = source of the earliest record (preserves original ingest provenance)
|
||||
- notes = earliest's notes + "Merged N records (<sources>)"
|
||||
- Other records are DELETED
|
||||
- One UnitHistory `assignment_merged` row is written for audit
|
||||
|
||||
No tolerance check — the operator is asking for the merge, so we trust
|
||||
the intent. The UI can pre-filter to only offer "consecutive" merges
|
||||
if it wants.
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
ids = payload.get("assignment_ids") or []
|
||||
if not isinstance(ids, list) or len(ids) < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Need at least 2 assignment_ids to merge.",
|
||||
)
|
||||
|
||||
assignments = (
|
||||
db.query(UnitAssignment)
|
||||
.filter(UnitAssignment.project_id == project_id)
|
||||
.filter(UnitAssignment.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(assignments) != len(set(ids)):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
|
||||
)
|
||||
|
||||
unit_ids = {a.unit_id for a in assignments}
|
||||
loc_ids = {a.location_id for a in assignments}
|
||||
if len(unit_ids) > 1 or len(loc_ids) > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Can only merge assignments that share the same unit and location.",
|
||||
)
|
||||
|
||||
# Order chronologically.
|
||||
assignments.sort(key=lambda a: a.assigned_at)
|
||||
earliest = assignments[0]
|
||||
others = assignments[1:]
|
||||
|
||||
# Compute merged window.
|
||||
any_active = any(a.assigned_until is None for a in assignments)
|
||||
if any_active:
|
||||
merged_until = None
|
||||
else:
|
||||
merged_until = max(a.assigned_until for a in assignments)
|
||||
|
||||
# Build a brief audit-style note describing what got merged.
|
||||
bits = []
|
||||
for a in assignments:
|
||||
win = (
|
||||
f"{a.assigned_at:%Y-%m-%d}"
|
||||
f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||
)
|
||||
bits.append(f"{win} [{a.source}]")
|
||||
merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
|
||||
new_notes = (earliest.notes + " • " + merge_note_suffix) if earliest.notes else merge_note_suffix
|
||||
|
||||
# Resolve names for the audit log before mutating.
|
||||
location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
|
||||
location_label = location.name if location else earliest.location_id
|
||||
|
||||
# Mutate the survivor.
|
||||
earliest.assigned_at = min(a.assigned_at for a in assignments)
|
||||
earliest.assigned_until = merged_until
|
||||
earliest.status = "active" if any_active else "completed"
|
||||
earliest.notes = new_notes
|
||||
|
||||
# Delete the rest.
|
||||
deleted_ids = [a.id for a in others]
|
||||
for a in others:
|
||||
db.delete(a)
|
||||
|
||||
_record_assignment_history(
|
||||
db,
|
||||
unit_id=earliest.unit_id,
|
||||
change_type="assignment_merged",
|
||||
old_value=f"{len(assignments)} rows at {location_label}",
|
||||
new_value=(
|
||||
f"1 row {earliest.assigned_at:%Y-%m-%d}"
|
||||
f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||
),
|
||||
notes=merge_note_suffix,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(earliest)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Merged {len(assignments)} assignments into one.",
|
||||
"kept_id": earliest.id,
|
||||
"deleted_ids": deleted_ids,
|
||||
"merged_window": {
|
||||
"assigned_at": earliest.assigned_at.isoformat(),
|
||||
"assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/swap")
|
||||
async def swap_unit_on_location(
|
||||
project_id: str,
|
||||
|
||||
Reference in New Issue
Block a user