diff --git a/backend/services/deployment_history.py b/backend/services/deployment_history.py
index d9002fc..0186eb8 100644
--- a/backend/services/deployment_history.py
+++ b/backend/services/deployment_history.py
@@ -229,6 +229,48 @@ def get_deployment_history_data(
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
+ # ── Per-unit view data (Gantt-by-Unit tab) ────────────────────────
+ # Same source assignments, re-grouped by unit_id. Each bar carries
+ # the project's color + name so the renderer can paint by job
+ # without doing a second lookup.
+ unit_bars: dict[str, list[dict]] = {}
+ project_lookup = {p["id"]: p for p in projects_data}
+ 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
+ p_info = project_lookup.get(a.project_id, {})
+ unit_bars.setdefault(a.unit_id, []).append({
+ "project_id": a.project_id,
+ "project_name": p_info.get("name", "(deleted project)"),
+ "project_color": p_info.get("color", _color_for_project(a.project_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,
+ })
+
+ # Sort units by first-active date so the most-recently-deployed
+ # units sit at the top. Reverse if we want oldest-first.
+ units_data = []
+ for uid, bars in unit_bars.items():
+ bars.sort(key=lambda b: b["start"])
+ first_start = bars[0]["start"]
+ # "active now" flag = any bar is still active
+ any_active = any(b["is_active"] for b in bars)
+ units_data.append({
+ "id": uid,
+ "bars": bars,
+ "first_active": first_start,
+ "assignment_count": len(bars),
+ "any_active": any_active,
+ })
+ units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"]))
+
# Now build the months array.
months_data = []
cur_year, cur_month = start_year, start_month
@@ -267,6 +309,7 @@ def get_deployment_history_data(
return {
"months": months_data,
"projects": projects_data,
+ "units": units_data,
"total_assignments": len(assignments),
"total_active_units": len(distinct_units),
"window": {
diff --git a/templates/admin/deployment_history.html b/templates/admin/deployment_history.html
index e77d0b0..e80b6a8 100644
--- a/templates/admin/deployment_history.html
+++ b/templates/admin/deployment_history.html
@@ -97,7 +97,7 @@
-
+
@@ -105,7 +105,11 @@
- Gantt
+ Gantt by Project
+
+
+ Gantt by Unit
@@ -272,6 +276,50 @@
{% endif %}
{# /#dh-view-gantt #}
+
+
+{% if calendar.units %}
+
+
+ One row per seismograph that had ≥1 assignment in this window.
+ Bars are colored by project , so an operator can read where each unit travelled
+ across jobs. Active deployments end at "now" with a small white tip.
+ Click a unit ID to open its detail page; click a bar to open the bar's project.
+
+
+
+
+
+
+
+
+
+
+
+ {{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
+
+
+
+
+
+
+
+ Recent
+
+
+{% else %}
+
+
No unit deployments in this window.
+
+{% endif %}
+
{# /#dh-view-byunit #}
+
{
});
// ── Tab switcher ────────────────────────────────────────────────────
+const _DH_TABS = {
+ calendar: { view: 'dh-view-calendar', btn: 'dh-tab-calendar', hash: '', render: null },
+ gantt: { view: 'dh-view-gantt', btn: 'dh-tab-gantt', hash: '#gantt', render: () => renderDhGantt() },
+ byunit: { view: 'dh-view-byunit', btn: 'dh-tab-byunit', hash: '#byunit', render: () => renderDhByUnit() },
+};
+
function switchDhView(which) {
- const cal = document.getElementById('dh-view-calendar');
- const gantt = document.getElementById('dh-view-gantt');
- const btnC = document.getElementById('dh-tab-calendar');
- const btnG = document.getElementById('dh-tab-gantt');
+ const target = _DH_TABS[which] || _DH_TABS.calendar;
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
- if (which === 'gantt') {
- cal.classList.add('hidden');
- gantt.classList.remove('hidden');
- btnG.classList.add(...activeCls); btnG.classList.remove(...dormantCls);
- btnC.classList.remove(...activeCls); btnC.classList.add(...dormantCls);
- if (window.location.hash !== '#gantt') {
- history.replaceState(null, '', window.location.pathname + window.location.search + '#gantt');
- }
- renderDhGantt();
- } else {
- gantt.classList.add('hidden');
- cal.classList.remove('hidden');
- btnC.classList.add(...activeCls); btnC.classList.remove(...dormantCls);
- btnG.classList.remove(...activeCls); btnG.classList.add(...dormantCls);
- if (window.location.hash === '#gantt') {
- history.replaceState(null, '', window.location.pathname + window.location.search);
- }
+ // Hide all views, dormant-style all tabs, then activate the chosen one.
+ for (const key of Object.keys(_DH_TABS)) {
+ const t = _DH_TABS[key];
+ const v = document.getElementById(t.view);
+ const b = document.getElementById(t.btn);
+ if (v) v.classList.add('hidden');
+ if (b) { b.classList.remove(...activeCls); b.classList.add(...dormantCls); }
}
+ const v = document.getElementById(target.view);
+ const b = document.getElementById(target.btn);
+ if (v) v.classList.remove('hidden');
+ if (b) { b.classList.add(...activeCls); b.classList.remove(...dormantCls); }
+ // URL hash sync.
+ const wanted = target.hash;
+ const cur = window.location.hash || '';
+ if (cur !== wanted) {
+ history.replaceState(null, '', window.location.pathname + window.location.search + wanted);
+ }
+ if (target.render) target.render();
}
-// Land on the Gantt view if URL hash is #gantt.
-if (window.location.hash === '#gantt') {
- document.addEventListener('DOMContentLoaded', () => switchDhView('gantt'));
+// Land on the requested view if the URL hash matches.
+const _initialHash = window.location.hash;
+if (_initialHash === '#gantt' || _initialHash === '#byunit') {
+ document.addEventListener('DOMContentLoaded', () => switchDhView(_initialHash.slice(1)));
}
// ── Gantt renderer ──────────────────────────────────────────────────
@@ -546,5 +599,113 @@ function renderDhGantt() {
svg.setAttribute('height', height);
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
}
+
+// ── Gantt-by-Unit renderer ──────────────────────────────────────────
+// Inverts the Gantt: rows = seismographs, bars = assignment windows
+// colored by project (so an operator can read where each unit went
+// across jobs). Shares the time-axis geometry with renderDhGantt().
+const _dhUnits = {{ calendar.units | tojson }};
+let _byunitRendered = false;
+function renderDhByUnit() {
+ if (_byunitRendered) return;
+ _byunitRendered = true;
+ const svg = document.getElementById('dh-byunit-svg');
+ if (!svg) return;
+ if (!_dhUnits || _dhUnits.length === 0) {
+ svg.innerHTML = '';
+ return;
+ }
+
+ const isDark = document.documentElement.classList.contains('dark');
+ const labelColor = isDark ? '#9ca3af' : '#6b7280';
+ const labelStrong = isDark ? '#e5e7eb' : '#111827';
+ const gridColor = isDark ? '#374151' : '#e5e7eb';
+ const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
+ const todayColor = '#f48b1c';
+
+ const containerW = svg.parentElement.clientWidth || 1000;
+ const width = Math.max(containerW, 800);
+ const labelW = 160;
+ const padTop = 36;
+ const padBottom = 16;
+ const rowH = 32;
+ const barH = 16;
+ const height = padTop + padBottom + _dhUnits.length * rowH;
+
+ const usableW = width - labelW - 8;
+ const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
+ const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
+ const tRange = tEnd - tStart;
+ const xFor = (d) => {
+ const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
+ return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
+ };
+
+ // Month gridlines + labels (same as renderDhGantt).
+ const monthsParts = [];
+ const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
+ cursor.setUTCDate(1);
+ while (cursor.getTime() <= tEnd) {
+ const x = xFor(cursor);
+ const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
+ monthsParts.push(`
`);
+ monthsParts.push(`
${label} `);
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1);
+ }
+
+ const now = Date.now();
+ let todayMarker = '';
+ if (now >= tStart && now <= tEnd) {
+ const x = xFor(now);
+ todayMarker = `
+
today `;
+ }
+
+ const rowParts = [];
+ _dhUnits.forEach((unit, idx) => {
+ const y = padTop + idx * rowH;
+ if (idx % 2 === 1) {
+ rowParts.push(`
`);
+ }
+ // Unit label: opens /unit/{id} when clicked. Active-now dot.
+ const activeDot = unit.any_active
+ ? `
`
+ : `
`;
+ rowParts.push(`
+
+ ${activeDot}
+
+ ${_dhEsc(unit.id)}
+
+
+ ${unit.assignment_count}
+
+ `);
+ // Bars — colored by the bar's project (not the row).
+ for (const bar of (unit.bars || [])) {
+ const x1 = xFor(bar.start);
+ const x2 = xFor(bar.end);
+ const barW = Math.max(x2 - x1, 2);
+ const by = y + (rowH - barH) / 2;
+ const tip = `${bar.project_name}\n${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
+ rowParts.push(`
+
+ ${_dhEsc(tip)}
+
+ ${bar.is_active ? ` ` : ''}
+
+ `);
+ }
+ });
+
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
+ svg.setAttribute('height', height);
+ svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
+}
{% endblock %}