feat(tools): add 'Gantt by Unit' tab to deployment history
Third view on /tools/deployment-history. Where 'Gantt by Project' has
one row per project showing that project's deployments, 'Gantt by Unit'
inverts it — one row per seismograph, bars colored by the project the
unit was deployed to.
The natural use case: "where has BE11529 been across all my jobs?"
Spotting unit rotation patterns, idle gaps, and concurrent assignments
gets immediate visually.
Service
- deployment_history.get_deployment_history_data() now also returns a
`units` array. Each unit dict carries:
{id, bars[], first_active, assignment_count, any_active}
Each bar has the project_name + project_color baked in so the
renderer can paint by job without a second lookup.
- Units sorted: currently-active first, then by first_active ascending.
UI
- Third tab "Gantt by Unit" added next to Calendar / Gantt by Project.
- Tab switcher refactored to a small registry (_DH_TABS) so adding more
views in the future is a one-line addition.
- URL hash sync now supports #gantt and #byunit; nav buttons preserve
the active tab across month-paging.
- SVG layout: 160px label gutter (smaller than the project Gantt's
220px since unit IDs are short), 32px row height, green dot for
units with at least one active deployment. Unit ID is clickable
→ /unit/{id}; each bar is clickable → /projects/{p}.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -229,6 +229,48 @@ def get_deployment_history_data(
|
|||||||
|
|
||||||
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
|
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.
|
# Now build the months array.
|
||||||
months_data = []
|
months_data = []
|
||||||
cur_year, cur_month = start_year, start_month
|
cur_year, cur_month = start_year, start_month
|
||||||
@@ -267,6 +309,7 @@ def get_deployment_history_data(
|
|||||||
return {
|
return {
|
||||||
"months": months_data,
|
"months": months_data,
|
||||||
"projects": projects_data,
|
"projects": projects_data,
|
||||||
|
"units": units_data,
|
||||||
"total_assignments": len(assignments),
|
"total_assignments": len(assignments),
|
||||||
"total_active_units": len(distinct_units),
|
"total_active_units": len(distinct_units),
|
||||||
"window": {
|
"window": {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View tabs: Calendar | Gantt -->
|
<!-- View tabs: Calendar | Gantt (by project) | By Unit (gantt by unit) -->
|
||||||
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
|
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
|
||||||
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
|
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
|
||||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
||||||
@@ -105,7 +105,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
|
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
|
||||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
Gantt
|
Gantt by Project
|
||||||
|
</button>
|
||||||
|
<button id="dh-tab-byunit" onclick="switchDhView('byunit')"
|
||||||
|
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
Gantt by Unit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -272,6 +276,50 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div> {# /#dh-view-gantt #}
|
</div> {# /#dh-view-gantt #}
|
||||||
|
|
||||||
|
<!-- ─── Gantt-by-Unit view ─── -->
|
||||||
|
<div id="dh-view-byunit" class="hidden">
|
||||||
|
{% if calendar.units %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
One row per seismograph that had ≥1 assignment in this window.
|
||||||
|
Bars are colored by <strong>project</strong>, 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.
|
||||||
|
</p>
|
||||||
|
<svg id="dh-byunit-svg" preserveAspectRatio="none"
|
||||||
|
style="width: 100%; min-width: 800px;"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#byunit"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Previous month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
||||||
|
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
||||||
|
</span>
|
||||||
|
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#byunit"
|
||||||
|
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||||
|
title="Next month">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/tools/deployment-history#byunit"
|
||||||
|
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
||||||
|
Recent
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p class="text-sm">No unit deployments in this window.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div> {# /#dh-view-byunit #}
|
||||||
|
|
||||||
<!-- Day-detail side panel -->
|
<!-- Day-detail side panel -->
|
||||||
<div id="dh-day-panel-backdrop"
|
<div id="dh-day-panel-backdrop"
|
||||||
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
|
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
|
||||||
@@ -405,36 +453,41 @@ document.addEventListener('keydown', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Tab switcher ────────────────────────────────────────────────────
|
// ── 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) {
|
function switchDhView(which) {
|
||||||
const cal = document.getElementById('dh-view-calendar');
|
const target = _DH_TABS[which] || _DH_TABS.calendar;
|
||||||
const gantt = document.getElementById('dh-view-gantt');
|
|
||||||
const btnC = document.getElementById('dh-tab-calendar');
|
|
||||||
const btnG = document.getElementById('dh-tab-gantt');
|
|
||||||
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
|
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'];
|
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
|
||||||
if (which === 'gantt') {
|
// Hide all views, dormant-style all tabs, then activate the chosen one.
|
||||||
cal.classList.add('hidden');
|
for (const key of Object.keys(_DH_TABS)) {
|
||||||
gantt.classList.remove('hidden');
|
const t = _DH_TABS[key];
|
||||||
btnG.classList.add(...activeCls); btnG.classList.remove(...dormantCls);
|
const v = document.getElementById(t.view);
|
||||||
btnC.classList.remove(...activeCls); btnC.classList.add(...dormantCls);
|
const b = document.getElementById(t.btn);
|
||||||
if (window.location.hash !== '#gantt') {
|
if (v) v.classList.add('hidden');
|
||||||
history.replaceState(null, '', window.location.pathname + window.location.search + '#gantt');
|
if (b) { b.classList.remove(...activeCls); b.classList.add(...dormantCls); }
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.
|
// Land on the requested view if the URL hash matches.
|
||||||
if (window.location.hash === '#gantt') {
|
const _initialHash = window.location.hash;
|
||||||
document.addEventListener('DOMContentLoaded', () => switchDhView('gantt'));
|
if (_initialHash === '#gantt' || _initialHash === '#byunit') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => switchDhView(_initialHash.slice(1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gantt renderer ──────────────────────────────────────────────────
|
// ── Gantt renderer ──────────────────────────────────────────────────
|
||||||
@@ -546,5 +599,113 @@ function renderDhGantt() {
|
|||||||
svg.setAttribute('height', height);
|
svg.setAttribute('height', height);
|
||||||
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
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(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let todayMarker = '';
|
||||||
|
if (now >= tStart && now <= tEnd) {
|
||||||
|
const x = xFor(now);
|
||||||
|
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
||||||
|
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowParts = [];
|
||||||
|
_dhUnits.forEach((unit, idx) => {
|
||||||
|
const y = padTop + idx * rowH;
|
||||||
|
if (idx % 2 === 1) {
|
||||||
|
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
||||||
|
}
|
||||||
|
// Unit label: opens /unit/{id} when clicked. Active-now dot.
|
||||||
|
const activeDot = unit.any_active
|
||||||
|
? `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="#22c55e"/>`
|
||||||
|
: `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="${labelColor}" opacity="0.4"/>`;
|
||||||
|
rowParts.push(`<a href="/unit/${_dhEsc(unit.id)}" target="_top">
|
||||||
|
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
||||||
|
${activeDot}
|
||||||
|
<text x="22" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelStrong}"
|
||||||
|
font-family="system-ui,sans-serif" font-weight="500">
|
||||||
|
${_dhEsc(unit.id)}
|
||||||
|
</text>
|
||||||
|
<text x="${labelW - 8}" y="${y + rowH / 2 + 4}" font-size="10" fill="${labelColor}"
|
||||||
|
text-anchor="end" font-family="system-ui,sans-serif">
|
||||||
|
${unit.assignment_count}
|
||||||
|
</text>
|
||||||
|
</a>`);
|
||||||
|
// 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(`<a href="/projects/${_dhEsc(bar.project_id)}" target="_top">
|
||||||
|
<g style="cursor: pointer;">
|
||||||
|
<title>${_dhEsc(tip)}</title>
|
||||||
|
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
||||||
|
rx="3"
|
||||||
|
fill="${bar.project_color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
||||||
|
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
||||||
|
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
||||||
|
</g>
|
||||||
|
</a>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user