7ed94cd8fc
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>
712 lines
33 KiB
HTML
712 lines
33 KiB
HTML
{% 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>
|
||
|
||
<!-- 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">
|
||
Calendar
|
||
</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 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>
|
||
|
||
<!-- 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 view ─── -->
|
||
<div id="dh-view-calendar">
|
||
|
||
<!-- 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 %}
|
||
|
||
</div> {# /#dh-view-calendar #}
|
||
|
||
<!-- ─── Gantt view ─── -->
|
||
<div id="dh-view-gantt" class="hidden">
|
||
{% if calendar.projects %}
|
||
<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 project. Each bar is one assignment window (unit at a location).
|
||
Hover for details, click to open the project.
|
||
Active deployments end at "now" with a small white tip.
|
||
</p>
|
||
<svg id="dh-gantt-svg" preserveAspectRatio="none"
|
||
style="width: 100%; min-width: 800px;"></svg>
|
||
</div>
|
||
|
||
<!-- Same nav as calendar view, repeated here so the Gantt view has its own. -->
|
||
<div class="flex items-center justify-center gap-3 mb-8">
|
||
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#gantt"
|
||
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 }}#gantt"
|
||
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#gantt"
|
||
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 deployments in this window.</p>
|
||
</div>
|
||
{% 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"
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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();
|
||
});
|
||
|
||
// ── 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 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'];
|
||
// 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 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 ──────────────────────────────────────────────────
|
||
// Server-rendered data: per-project list with each project's bars
|
||
// (assignment windows clipped to the visible window).
|
||
const _dhProjects = {{ calendar.projects | tojson }};
|
||
const _dhWindowStart = {{ calendar.window.first_date | tojson }};
|
||
const _dhWindowEnd = {{ calendar.window.last_date | tojson }};
|
||
|
||
let _ganttRendered = false;
|
||
function renderDhGantt() {
|
||
if (_ganttRendered) return; // build once; data doesn't change after page load
|
||
_ganttRendered = true;
|
||
const svg = document.getElementById('dh-gantt-svg');
|
||
if (!svg) return;
|
||
if (!_dhProjects || _dhProjects.length === 0) {
|
||
svg.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
||
const todayColor = '#f48b1c';
|
||
|
||
// Geometry. The Gantt SVG has a left "labels" gutter and a right
|
||
// time-axis area. Width is fluid (matches container); height grows
|
||
// with the number of project rows.
|
||
const containerW = svg.parentElement.clientWidth || 1000;
|
||
const width = Math.max(containerW, 800);
|
||
const labelW = 220;
|
||
const padTop = 36; // room for month labels above
|
||
const padBottom = 16;
|
||
const rowH = 36;
|
||
const barH = 18;
|
||
const height = padTop + padBottom + _dhProjects.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 + month labels along the top.
|
||
// Walk every 1st-of-month inside the window.
|
||
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);
|
||
}
|
||
|
||
// Today marker.
|
||
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>`;
|
||
}
|
||
|
||
// Build rows.
|
||
const rowParts = [];
|
||
_dhProjects.forEach((proj, idx) => {
|
||
const y = padTop + idx * rowH;
|
||
// Alternating row background.
|
||
if (idx % 2 === 1) {
|
||
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
||
}
|
||
// Project label (clipped to gutter, clickable).
|
||
const labelText = proj.name.length > 28 ? proj.name.slice(0, 26) + '…' : proj.name;
|
||
rowParts.push(`<a href="/projects/${_dhEsc(proj.id)}" target="_top">
|
||
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
||
<circle cx="14" cy="${y + rowH / 2}" r="4" fill="${proj.color}"/>
|
||
<text x="24" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelColor}"
|
||
font-family="system-ui,sans-serif"
|
||
title="${_dhEsc(proj.name)}">
|
||
${_dhEsc(labelText)}
|
||
</text>
|
||
</a>`);
|
||
// Bars for each assignment in this project.
|
||
for (const bar of (proj.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.unit_id} @ ${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
||
rowParts.push(`<g style="cursor: pointer;">
|
||
<title>${_dhEsc(tip)}</title>
|
||
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
||
rx="3"
|
||
fill="${proj.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>`);
|
||
}
|
||
});
|
||
|
||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||
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 %}
|