From ef0008822e5ea69c4ef214526c8fff4f29288f36 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 14 May 2026 23:29:51 +0000 Subject: [PATCH] feat(timeline): merge consecutive same-location assignments + per-unit Gantt chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/project_locations.py | 136 ++++++++++ backend/services/deployment_timeline.py | 44 +++- templates/unit_detail.html | 323 +++++++++++++++++++++++- 3 files changed, 492 insertions(+), 11 deletions(-) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index d62d6ff..02003d6 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -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": ["", "", ...] } + + 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 ()" + - 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, diff --git a/backend/services/deployment_timeline.py b/backend/services/deployment_timeline.py index 21fa8af..6690b52 100644 --- a/backend/services/deployment_timeline.py +++ b/backend/services/deployment_timeline.py @@ -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, } diff --git a/templates/unit_detail.html b/templates/unit_detail.html index ff2fcc6..beec92b 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -287,6 +287,16 @@ ↻ Refresh + + + +

Loading timeline…

@@ -1986,6 +1996,10 @@ loadUnitData().then(() => { // Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair. // Derives entries from unit_assignments + unit_history + SFM event overlay. +// Cache the most recent timeline payload so the merge action can look up +// which assignment_ids belong together in a mergeable group. +let _dtCurrentTimeline = { entries: [], merge_groups: [] }; + async function loadDeploymentTimeline() { const container = document.getElementById('deploymentTimeline'); container.innerHTML = '

Loading timeline…

'; @@ -1994,12 +2008,58 @@ async function loadDeploymentTimeline() { const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`); if (!r.ok) throw new Error('HTTP ' + r.status); const d = await r.json(); - renderDeploymentTimeline(d.entries || [], container); + _dtCurrentTimeline = { + entries: d.entries || [], + merge_groups: d.merge_groups || [], + }; + renderDeploymentTimeline(_dtCurrentTimeline.entries, container, _dtCurrentTimeline.merge_groups); } catch (e) { container.innerHTML = `

Failed to load timeline: ${e.message}

`; } } +// Returns the merge_group (list of assignment_ids) that this assignment is +// part of, or null if it isn't in any mergeable group. +function _dtFindMergeGroup(assignmentId) { + for (const group of _dtCurrentTimeline.merge_groups || []) { + if (group.includes(assignmentId)) return group; + } + return null; +} + +async function mergeAssignmentGroup(assignmentIds) { + if (!Array.isArray(assignmentIds) || assignmentIds.length < 2) return; + const msg = `Merge ${assignmentIds.length} consecutive assignment records into one?\n\n` + + `The earliest record is kept and its window extended to span all ` + + `of them. The other ${assignmentIds.length - 1} record(s) are deleted.\n\n` + + `Original metadata (notes + ingest source) is preserved. This is ` + + `logged to the unit's history as "assignment_merged".`; + if (!confirm(msg)) return; + + try { + // All assignments share the same project_id (validated server-side). + // Pick the first entry's project_id from the cache. + const first = (_dtCurrentTimeline.entries || []).find(e => + e.kind === 'assignment' && assignmentIds.includes(e.assignment_id) + ); + const projectId = first ? first.project_id : null; + if (!projectId) throw new Error('Could not resolve project id for this group'); + + const r = await fetch(`/api/projects/${projectId}/assignments/merge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assignment_ids: assignmentIds }), + }); + if (!r.ok) { + const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status})); + throw new Error(err.detail || 'HTTP ' + r.status); + } + await loadDeploymentTimeline(); + } catch (e) { + alert(e.message || 'Failed to merge assignments.'); + } +} + function _dtFmtDate(iso) { if (!iso) return '—'; return iso.slice(0, 10); @@ -2044,6 +2104,17 @@ function _dtRenderAssignment(e) { ? 'active' : ''; + // If this assignment belongs to a mergeable group, show a small + // indicator badge — the group-level "Merge" action lives in the + // banner at the top of the section to avoid N redundant buttons. + const mergeGroup = _dtFindMergeGroup(e.assignment_id); + const mergeableBadge = mergeGroup + ? ` + mergeable + ` + : ''; + const overlay = evCount > 0 ? `
${evCount.toLocaleString()} event${evCount === 1 ? '' : 's'} @@ -2056,7 +2127,7 @@ function _dtRenderAssignment(e) { ? `
${_dtEsc(e.notes)}
` : ''; - return `
+ return `
@@ -2065,7 +2136,10 @@ function _dtRenderAssignment(e) {
${start}${end}${dur}
- ${activeBadge} +
+ ${mergeableBadge} + ${activeBadge} +
${locLink}
${projLine} @@ -2118,18 +2192,253 @@ function _dtRenderStateChange(e) {
`; } -function renderDeploymentTimeline(entries, container) { - if (!entries.length) { - container.innerHTML = '

No deployment history yet. Assign this unit to a project location to start a deployment record.

'; +// ── Gantt chart ───────────────────────────────────────────────────────────── +// Renders all assignment windows as colored horizontal bars on an SVG +// timeline. Click a bar to scroll its detail row into view in the list +// below. Color per location, opacity reduced for closed assignments. +// "Mergeable" groups get a unifying outline overlay so they're visible at +// a glance as one logical deployment. +const _ganttColorPalette = [ + '#f48b1c', '#142a66', '#7d234d', '#0e7490', '#15803d', '#a16207', + '#9333ea', '#dc2626', '#0d9488', '#1d4ed8', '#be185d', '#65a30d', +]; +function _ganttColorFor(locId, locColorMap) { + if (locColorMap[locId]) return locColorMap[locId]; + const idx = Object.keys(locColorMap).length % _ganttColorPalette.length; + locColorMap[locId] = _ganttColorPalette[idx]; + return locColorMap[locId]; +} + +function _ganttParseDate(iso) { + if (!iso) return null; + const d = new Date(iso.replace(' ', 'T')); + return isNaN(d.getTime()) ? null : d; +} + +function _ganttFmtMonth(d) { + return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); +} + +function renderDeploymentGantt(entries, mergeGroups) { + const wrapper = document.getElementById('deploymentGantt'); + const svg = document.getElementById('deploymentGanttSvg'); + const legend = document.getElementById('deploymentGanttLegend'); + if (!wrapper || !svg) return; + + const assignments = (entries || []).filter(e => e.kind === 'assignment' && e.starts_at); + if (assignments.length === 0) { + wrapper.classList.add('hidden'); return; } + wrapper.classList.remove('hidden'); + + // Compute time domain. Pad the end by a few days when an active + // assignment is present so the "active" bar doesn't reach the very + // edge of the chart. + const now = new Date(); + let minDate = null, maxDate = null; + for (const a of assignments) { + const start = _ganttParseDate(a.starts_at); + const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now); + if (start && (!minDate || start < minDate)) minDate = start; + if (end && (!maxDate || end > maxDate)) maxDate = end; + } + if (!minDate || !maxDate) { wrapper.classList.add('hidden'); return; } + // Tiny padding at both ends (3% of total span). + const span = maxDate - minDate; + const pad = Math.max(span * 0.03, 24 * 3600 * 1000); // at least 1 day + minDate = new Date(minDate.getTime() - pad); + maxDate = new Date(maxDate.getTime() + pad); + + // Build a quick "which mergeGroup is this id in?" map. + const idToGroup = {}; + (mergeGroups || []).forEach((g, idx) => g.forEach(id => { idToGroup[id] = idx; })); + + // Compute SVG geometry. + const width = Math.max(svg.clientWidth || svg.parentElement.clientWidth || 800, 400); + const height = 140; + const padLeft = 8; + const padRight = 8; + const padTop = 32; // room for month labels above the bars + const padBottom = 18; // room for assignment-count axis below + const usableW = width - padLeft - padRight; + const usableH = height - padTop - padBottom; + const totalRange = maxDate - minDate; + const xFor = (d) => padLeft + (d - minDate) / totalRange * usableW; + + // Choose one-row-per-bar OR stack overlapping bars. Since same-unit + // assignments rarely overlap (only via the brief unassign/reassign + // race), a single row is usually fine. But just in case, stack with + // simple top-down packing. + const lanes = []; // each lane = [{x1, x2, ...}, ...] + function placeInLane(start, end) { + for (let i = 0; i < lanes.length; i++) { + const last = lanes[i][lanes[i].length - 1]; + if (last.x2 + 2 < start) { + lanes[i].push({ x1: start, x2: end }); + return i; + } + } + lanes.push([{ x1: start, x2: end }]); + return lanes.length - 1; + } + const placed = assignments.map(a => { + const start = _ganttParseDate(a.starts_at); + const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now); + const x1 = xFor(start); + const x2 = xFor(end); + const lane = placeInLane(x1, x2); + return { a, x1, x2, lane }; + }); + const laneCount = Math.max(lanes.length, 1); + const barH = Math.max(14, Math.min(28, Math.floor(usableH / laneCount) - 4)); + const laneSpacing = barH + 4; + + // Month gridlines + labels. Tick on the 1st of each month inside the + // domain. If span > 24mo, tick every 3 months instead. + const months = []; + let monthCursor = new Date(minDate.getFullYear(), minDate.getMonth(), 1); + const tickEveryMonths = (totalRange > 24 * 30 * 86400 * 1000) ? 3 : 1; + while (monthCursor <= maxDate) { + if (monthCursor >= minDate) months.push(new Date(monthCursor)); + monthCursor.setMonth(monthCursor.getMonth() + tickEveryMonths); + } + + // Build the SVG string. + const isDark = document.documentElement.classList.contains('dark'); + const gridColor = isDark ? '#374151' : '#e5e7eb'; + const labelColor = isDark ? '#9ca3af' : '#6b7280'; + const todayColor = '#f48b1c'; + + const locColorMap = {}; + const usedLocs = {}; + + let parts = []; + // Month gridlines. + months.forEach(m => { + const x = xFor(m); + parts.push(``); + parts.push(`${_ganttFmtMonth(m)}`); + }); + // Today marker. + if (now >= minDate && now <= maxDate) { + const x = xFor(now); + parts.push(``); + parts.push(`today`); + } + + // Bars. + placed.forEach(p => { + const a = p.a; + const color = _ganttColorFor(a.location_id || '_', locColorMap); + usedLocs[a.location_name || '(no location)'] = color; + const y = padTop + p.lane * laneSpacing; + const opacity = a.is_active ? 1.0 : 0.85; + const stroke = (a.source === 'metadata_backfill') ? '#3b82f6' : 'none'; + const strokeWidth = (a.source === 'metadata_backfill') ? 2 : 0; + + const barW = Math.max(p.x2 - p.x1, 3); + const tipDates = `${(a.starts_at || '').slice(0,10)} → ${a.is_active ? 'active' : (a.ends_at || '').slice(0,10)}`; + const tip = `${(a.location_name || '?').replace(/"/g, '"')} (${tipDates})${a.event_overlay && a.event_overlay.event_count ? ' • ' + a.event_overlay.event_count + ' events' : ''}`; + + parts.push(` + ${tip} + + ${a.is_active ? `` : ''} + `); + + // Mergeable highlight — thin dashed underline below the bar. + if (idToGroup[a.assignment_id] !== undefined) { + const uy = y + barH + 1; + parts.push(``); + } + }); + + svg.innerHTML = parts.join(''); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + + // Build legend (one swatch per distinct location). + const legendItems = Object.entries(usedLocs).map(([name, color]) => + `${_dtEsc(name)}` + ); + if (mergeGroups && mergeGroups.length > 0) { + legendItems.push(`mergeable group`); + } + if (placed.some(p => p.a.source === 'metadata_backfill')) { + legendItems.push(`auto-backfilled`); + } + legend.innerHTML = legendItems.join(''); +} + +// Click-on-bar handler. Just scroll the matching list row into view + +// briefly flash it so the eye finds it. +function _ganttScrollTo(assignmentId) { + const target = document.querySelector(`[data-assignment-row="${assignmentId}"]`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.classList.add('ring-2', 'ring-seismo-orange'); + setTimeout(() => target.classList.remove('ring-2', 'ring-seismo-orange'), 1500); +} + +function renderDeploymentTimeline(entries, container, mergeGroups) { + if (!entries.length) { + container.innerHTML = '

No deployment history yet. Assign this unit to a project location to start a deployment record.

'; + // Hide the Gantt block too. + const g = document.getElementById('deploymentGantt'); + if (g) g.classList.add('hidden'); + return; + } + + // Render the Gantt chart first (above the list). + renderDeploymentGantt(entries, mergeGroups); + + // Build the mergeable-groups banner. Each group offers one "Merge into + // one" button. Skipped when no groups exist. + let bannerHtml = ''; + if (mergeGroups && mergeGroups.length > 0) { + const rows = mergeGroups.map(group => { + // Look up the entries to describe what we're merging. + const groupEntries = (entries || []).filter(e => + e.kind === 'assignment' && group.includes(e.assignment_id) + ); + if (groupEntries.length === 0) return ''; + const locName = groupEntries[0].location_name || 'unnamed location'; + const earliest = groupEntries.map(e => e.starts_at).filter(Boolean).sort()[0] || ''; + const latest = groupEntries.map(e => e.ends_at).filter(Boolean).sort().reverse()[0] || 'present'; + const idsJson = JSON.stringify(group).replace(/"/g, '"'); + return `
+
+ ${group.length} consecutive records at ${_dtEsc(locName)} + ${_dtFmtDate(earliest)} → ${_dtFmtDate(latest)} +
+ +
`; + }).join(''); + bannerHtml = `
+
+ + + +
+ Consecutive deployments at the same location detected. Combine them into a single record to clean up the view (notes + ingest sources are preserved). +
+
+ ${rows} +
`; + } + const html = entries.map(e => { if (e.kind === 'assignment') return _dtRenderAssignment(e); if (e.kind === 'gap') return _dtRenderGap(e); if (e.kind === 'state_change') return _dtRenderStateChange(e); return ''; }).join(''); - container.innerHTML = html; + + container.innerHTML = bannerHtml + '
' + html + '
'; } // ── SFM Events section ──────────────────────────────────────────────────────