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"]))
|
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