merge v0.12.0 #51

Merged
serversdown merged 12 commits from dev into main 2026-05-17 19:44:57 -04:00
2 changed files with 230 additions and 26 deletions
Showing only changes of commit 7ed94cd8fc - Show all commits
+43
View File
@@ -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": {
+186 -25
View File
@@ -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 %}