feat(tools): fleet-wide deployment history calendar (Phase 2)

The per-unit Gantt chart on /unit/{id} (Phase 1, v0.11.0) was scoped
to one unit's deployment timeline.  This adds the fleet-wide view as
a new entry under /tools.

What it shows
- 12-month calendar grid styled like the Job Planner (4 months per
  row, responsive down to single column on mobile).
- Each day cell shows up to 4 colored mini-bars — one per project
  that had ≥1 active UnitAssignment that day, color deterministically
  hashed from project_id.  Days with >4 active projects show "+N".
- KPI strip at the top: project count, distinct unit count, total
  assignment count in the window.
- Collapsible project legend: ordered by first-active date (which
  matches the deployment-history reading order), each row links to
  the project page, shows the assignment count.

Click-a-day side panel
- Click any populated day cell → slide-over panel from the right
- Groups by project, lists every (unit, location) active that day
- Per-deployment: unit link, location link, window dates, active /
  closed badge, "auto-backfilled" tag for metadata_backfill source
- Sources from a new GET /api/admin/deployment-history/day endpoint

Navigation
- Prev / Next month buttons shift the 12-month window by one month
- "Recent" button jumps back to default (12 months ending now)
- Default window is 11 months back from current month — operator
  sees the recent past on first load, not future emptiness

Files
- backend/services/deployment_history.py — data builder + day-detail
  helper.  Walks UnitAssignment windows, intersects with the 12-month
  range, computes per-project active-day sets.
- backend/routers/deployment_history.py — page route + day-detail JSON
  endpoint.  Wired into main.py.
- templates/admin/deployment_history.html — page + side-panel
- templates/tools.html — new card linking to the page

Phase 3 (deferred): drag-to-resize bars to retroactively adjust
assignment windows from inside the calendar; per-unit row view
(complement to the project-row view) for "where has unit X been across
all jobs"; horizontal scroll for >12-month windows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 06:33:00 +00:00
parent ba9cdb4347
commit 47c65268e3
5 changed files with 782 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
{% extends "base.html" %}
{% block title %}Deployment History - Seismo Fleet Manager{% endblock %}
{% block extra_head %}
<style>
.dh-calendar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) { .dh-calendar-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .dh-calendar-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 480px) { .dh-calendar-grid { grid-template-columns: 1fr; } }
.dh-month-card {
background: white;
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.dark .dh-month-card { background: rgb(30 41 59); }
.dh-day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.dh-day-header {
text-align: center;
font-weight: 600;
color: #6b7280;
padding: 0.25rem 0;
}
.dark .dh-day-header { color: #9ca3af; }
.dh-day-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border-radius: 0.25rem;
position: relative;
transition: all 0.15s ease;
font-size: 0.7rem;
padding-top: 2px;
background-color: #f3f4f6;
color: #374151;
cursor: pointer;
}
.dh-day-cell:hover {
transform: scale(1.1);
z-index: 10;
}
.dh-day-cell.empty {
background: transparent;
cursor: default;
}
.dh-day-cell.empty:hover { transform: none; }
.dh-day-cell.today {
ring-color: #f48b1c;
font-weight: 700;
color: #b84a12;
}
.dark .dh-day-cell {
background-color: rgba(55, 65, 81, 0.5);
color: #d1d5db;
}
.dh-day-cell .dh-day-num {
text-align: center;
flex-shrink: 0;
}
.dh-day-cell .dh-bars {
display: flex;
flex-direction: column;
gap: 1px;
margin: 2px 2px 0 2px;
}
.dh-bar {
height: 3px;
border-radius: 1px;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between flex-wrap gap-3">
<div>
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Deployment History</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Where every unit has been — actual assignment windows, color-coded by project.
For future / planned deployments use the <a href="/fleet-calendar" class="text-seismo-orange hover:text-seismo-burgundy">Job Planner</a>.
</p>
</div>
</div>
<!-- KPI strip -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
<span class="text-gray-500 dark:text-gray-400">{{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }}</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_active_units }} unique units</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window</span>
</div>
{% if calendar.projects %}
<details class="bg-white dark:bg-slate-800 rounded-lg shadow px-4 py-2">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 select-none">Project legend</summary>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
{% for p in calendar.projects %}
<a href="/projects/{{ p.id }}"
class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-seismo-orange">
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ p.color }};"></span>
{{ p.name }}
<span class="text-xs text-gray-400 dark:text-gray-500">·</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.assignment_count }}</span>
</a>
{% endfor %}
</div>
</details>
{% endif %}
</div>
<!-- Calendar grid -->
{% if calendar.projects %}
<div class="dh-calendar-grid mb-6">
{% for month_data in calendar.months %}
<div class="dh-month-card">
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
{{ month_data.short_name }} '{{ month_data.year_short }}
</h3>
<div class="dh-day-grid">
<div class="dh-day-header">S</div>
<div class="dh-day-header">M</div>
<div class="dh-day-header">T</div>
<div class="dh-day-header">W</div>
<div class="dh-day-header">T</div>
<div class="dh-day-header">F</div>
<div class="dh-day-header">S</div>
{# Sunday-first alignment: shift Monday=0 → Sunday=0 #}
{% set first_offset = (month_data.first_weekday + 1) % 7 %}
{% for i in range(first_offset) %}
<div class="dh-day-cell empty"></div>
{% endfor %}
{% for day_num in range(1, month_data.num_days + 1) %}
{% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %}
{% set is_today = date_str == today %}
{% set day_proj_ids = month_data.active_days.get(day_num, []) %}
<div class="dh-day-cell{% if is_today %} today ring-2 ring-seismo-orange{% endif %}"
onclick="openDhDay('{{ date_str }}')"
title="{{ date_str }} — {{ day_proj_ids | length }} project{{ '' if day_proj_ids | length == 1 else 's' }}">
<span class="dh-day-num">{{ day_num }}</span>
{% if day_proj_ids %}
<span class="dh-bars">
{% for pid in day_proj_ids[:4] %}
{% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %}
{% if p %}
<span class="dh-bar" style="background-color: {{ p.color }};"
title="{{ p.name }}"></span>
{% endif %}
{% endfor %}
{% if day_proj_ids | length > 4 %}
<span class="text-[8px] text-gray-500 leading-none">+{{ day_proj_ids | length - 4 }}</span>
{% endif %}
</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Month navigation -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}"
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 }}"
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"
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">
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm">No deployments in this window.</p>
<p class="text-xs mt-1">Try the navigation buttons below to look at a different range.</p>
</div>
{% endif %}
<!-- Day-detail side panel -->
<div id="dh-day-panel-backdrop"
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
onclick="closeDhDayPanel()"></div>
<div id="dh-day-panel"
class="fixed top-0 right-0 h-screen w-full max-w-md bg-white dark:bg-slate-800 shadow-2xl z-50 hidden transform translate-x-full transition-transform duration-300 overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" id="dh-day-panel-title">Deployments on…</h2>
<p class="text-xs text-gray-500 dark:text-gray-400" id="dh-day-panel-subtitle"></p>
</div>
<button onclick="closeDhDayPanel()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="dh-day-panel-body" class="p-5 space-y-3">
<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
Loading…
</div>
</div>
</div>
<script>
function _dhEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function openDhDay(dateStr) {
const panel = document.getElementById('dh-day-panel');
const backdrop = document.getElementById('dh-day-panel-backdrop');
const title = document.getElementById('dh-day-panel-title');
const sub = document.getElementById('dh-day-panel-subtitle');
const body = document.getElementById('dh-day-panel-body');
title.textContent = 'Deployments on ' + dateStr;
sub.textContent = '';
body.innerHTML = `<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
Loading…
</div>`;
panel.classList.remove('hidden');
backdrop.classList.remove('hidden');
requestAnimationFrame(() => panel.classList.remove('translate-x-full'));
try {
const r = await fetch(`/api/admin/deployment-history/day?target_date=${encodeURIComponent(dateStr)}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
renderDhDayPanel(d, dateStr);
} catch (e) {
body.innerHTML = `<p class="text-sm text-red-500">Failed to load: ${_dhEsc(e.message)}</p>`;
}
}
function renderDhDayPanel(d, dateStr) {
const sub = document.getElementById('dh-day-panel-subtitle');
const body = document.getElementById('dh-day-panel-body');
sub.textContent = `${d.count} active deployment${d.count === 1 ? '' : 's'}`;
if (d.count === 0) {
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No active deployments on this date.</p>';
return;
}
// Group by project for cleaner display.
const byProj = {};
for (const dep of d.deployments) {
const key = dep.project_id || '_none';
if (!byProj[key]) byProj[key] = { name: dep.project_name, color: dep.project_color, items: [] };
byProj[key].items.push(dep);
}
let html = '';
for (const pid of Object.keys(byProj)) {
const proj = byProj[pid];
html += `<div class="space-y-1">
<div class="flex items-center justify-between">
<a href="/projects/${_dhEsc(pid)}" class="flex items-center gap-2 font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
<span class="w-3 h-3 rounded-full" style="background-color: ${proj.color};"></span>
${_dhEsc(proj.name)}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">${proj.items.length} unit${proj.items.length === 1 ? '' : 's'}</span>
</div>
<div class="space-y-1 ml-5">`;
for (const dep of proj.items) {
const activeBadge = dep.is_active
? '<span class="text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
const sourceTag = dep.source === 'metadata_backfill'
? '<span class="text-[10px] text-blue-600 dark:text-blue-400 italic">auto-backfilled</span>'
: '';
const windowEnd = dep.is_active ? 'present' : (dep.assigned_until || '').slice(0, 10);
const windowStart = (dep.assigned_at || '').slice(0, 10);
html += `<div class="flex items-center justify-between gap-2 py-1 text-sm">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a href="/unit/${_dhEsc(dep.unit_id)}" class="font-mono font-medium text-seismo-orange hover:text-seismo-navy">${_dhEsc(dep.unit_id)}</a>
${activeBadge}
${sourceTag}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
📍 <a href="/projects/${_dhEsc(pid)}/nrl/${_dhEsc(dep.location_id)}" class="hover:text-seismo-orange">${_dhEsc(dep.location_name)}</a>
</div>
<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">${windowStart}${windowEnd}</div>
</div>
</div>`;
}
html += `</div></div>`;
}
body.innerHTML = html;
}
function closeDhDayPanel() {
const panel = document.getElementById('dh-day-panel');
const backdrop = document.getElementById('dh-day-panel-backdrop');
panel.classList.add('translate-x-full');
setTimeout(() => {
panel.classList.add('hidden');
backdrop.classList.add('hidden');
}, 300);
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDhDayPanel();
});
</script>
{% endblock %}