merge v0.12.0 #51
@@ -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": {
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
</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">
|
||||
<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">
|
||||
@@ -105,7 +105,11 @@
|
||||
</button>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +276,50 @@
|
||||
{% endif %}
|
||||
</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 -->
|
||||
<div id="dh-day-panel-backdrop"
|
||||
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
|
||||
@@ -405,36 +453,41 @@ document.addEventListener('keydown', (e) => {
|
||||
});
|
||||
|
||||
// ── 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(`<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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user