2b8e9168c3
The Calendar grid (day-cells with project bars) is great for seeing
which projects had activity on a given day, but bad for seeing how
long any single deployment lasted. The Gantt view inverts that —
one row per project, horizontal bars per assignment window — so an
operator can read durations at a glance.
Service layer
- backend/services/deployment_history.py extends each project's
payload with `bars`: a list of {unit_id, location_id, location_name,
start, end, is_active, source} for every UnitAssignment clipped to
the visible 12-month window. Location names are batch-resolved.
Same cost as before since the underlying assignment scan is the
same; just additional data in the response.
Template
- Tab switcher at the top of /tools/deployment-history toggles
between Calendar and Gantt views. URL hash (#gantt) preserves the
active view across month-nav (Prev / Next / Recent buttons within
the Gantt view link to ?...#gantt to stay on the same tab).
- Gantt view is a plain SVG with:
- Left 220px label gutter: project color dot + truncated name,
whole row clickable → opens the project page
- Right area: horizontal time axis with month gridlines + labels,
"today" dashed orange line, one row per project
- One bar per assignment in that row, colored by project, reduced
opacity for closed assignments, blue outline for metadata-
backfilled assignments, white tip on the right edge of active
bars
- Hover any bar → tooltip with unit + location + window
- Alternating row backgrounds for readability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
551 lines
25 KiB
HTML
551 lines
25 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 -->
|
||
<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
|
||
</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 #}
|
||
|
||
<!-- 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 ────────────────────────────────────────────────────
|
||
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 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Land on the Gantt view if URL hash is #gantt.
|
||
if (window.location.hash === '#gantt') {
|
||
document.addEventListener('DOMContentLoaded', () => switchDhView('gantt'));
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
</script>
|
||
{% endblock %}
|