e15481884a
Two related operator-facing improvements after the nav reorg. 1) Events as a top-level sidebar entry. The /sfm page (fleet-wide event database) was demoted to Settings → Developer in the previous reorg. Bringing it back to main nav as "Events" — operators do reach for the cross-project, sortable event list, so it earns a top-level slot. Sidebar now (7 items): Dashboard · Devices · Projects · Events · Tools · Job Planner · Settings Settings → Developer card pointing at /sfm is removed. /sfm page title/subtitle updated from "SFM Event Data" to just "Events". URL unchanged. 2) "Peak PVS" KPI tile becomes "Overall Peak" and excludes false triggers from the calculation. When operators ask "what's the biggest event at this location/unit/ project?" they mean the biggest REAL event, not the biggest sensor glitch. A single mis-flagged false trigger could otherwise dominate the tile (the 14.13 in/s spike at Loc 1 was a prime example). backend/services/sfm_events.py: - _compute_stats() skips false_trigger=True events when computing peak_pvs / peak_pvs_at / peak_pvs_serial. Continues counting them in false_trigger_count so the separate "False Triggers" tile still reflects what got filtered out. last_event unchanged (recency, not magnitude). - Same change automatically propagates to events_for_unit() and vibration_summary_for_project() — both call _compute_stats(). Templates: "Peak PVS" → "Overall Peak" in 3 KPI tile locations (vibration_location_detail.html, partials/projects/vibration_summary .html, unit_detail.html). The physical-quantity name "Peak Vector Sum" in the event-detail modal stays — that's the actual physics term, not a summary stat. Verified end-to-end: Overall Peak renders on real data; peak event false_trigger flag confirmed False. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
420 lines
22 KiB
HTML
420 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% if environment == 'development' %}[DEV] {% endif %}{% block title %}Seismo Fleet Manager{% endblock %}</title>
|
|
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<!-- HTMX -->
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
|
|
<!-- Leaflet for maps -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
<!-- Mobile CSS -->
|
|
<link rel="stylesheet" href="/static/mobile.css">
|
|
|
|
<!-- PWA Manifest -->
|
|
<link rel="manifest" href="/static/manifest.json">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16.png">
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-192.png">
|
|
<meta name="theme-color" content="#f48b1c">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="SFM">
|
|
|
|
<!-- Custom Tailwind Config -->
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
seismo: {
|
|
orange: '#f48b1c',
|
|
navy: '#142a66',
|
|
burgundy: '#7d234d',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
/* Custom scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: #888;
|
|
border-radius: 4px;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #555;
|
|
}
|
|
|
|
/* Smooth transitions */
|
|
* {
|
|
transition: background-color 0.2s ease, color 0.2s ease;
|
|
}
|
|
</style>
|
|
|
|
{% block extra_head %}{% endblock %}
|
|
</head>
|
|
<body class="bg-gray-100 dark:bg-slate-800 text-gray-900 dark:text-gray-100">
|
|
|
|
<!-- Offline Indicator -->
|
|
<div id="offlineIndicator" class="offline-indicator">
|
|
📡 Offline - Changes will sync when connected
|
|
</div>
|
|
|
|
<!-- Sync Toast -->
|
|
<div id="syncToast" class="sync-toast">
|
|
✓ Synced successfully
|
|
</div>
|
|
|
|
<div class="flex h-screen overflow-hidden">
|
|
<!-- Sidebar (Responsive) -->
|
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
|
<!-- Logo -->
|
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<a href="/" class="block">
|
|
<img src="/static/terra-view-logo-light.png" srcset="/static/terra-view-logo-light.png 1x, /static/terra-view-logo-light@2x.png 2x" alt="Terra-View" class="block dark:hidden w-44 h-auto">
|
|
<img src="/static/terra-view-logo-dark.png" srcset="/static/terra-view-logo-dark.png 1x, /static/terra-view-logo-dark@2x.png 2x" alt="Terra-View" class="hidden dark:block w-44 h-auto">
|
|
</a>
|
|
<div class="flex items-center justify-between mt-2">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
|
{% if environment == 'development' %}
|
|
<span class="px-2 py-1 text-xs font-bold text-white bg-yellow-500 rounded">DEV</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<nav class="flex-1 p-4 space-y-2">
|
|
<a href="/" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
|
</svg>
|
|
Dashboard
|
|
</a>
|
|
|
|
{# Devices — single sidebar entry covering all device-type
|
|
pages. Lands on /roster (the unified all-devices view);
|
|
the tab strip on each underlying page lets the operator
|
|
drill into seismograph / SLM / modem specifics.
|
|
Active when on any /seismographs, /sound-level-meters,
|
|
/modems, /roster, /pair-devices, /unit/* page. #}
|
|
{% set _is_devices = (
|
|
request.url.path in ('/seismographs', '/sound-level-meters', '/modems', '/roster', '/pair-devices')
|
|
or request.url.path.startswith('/unit/')
|
|
or request.url.path.startswith('/slm/')
|
|
) %}
|
|
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_devices %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
|
</svg>
|
|
Devices
|
|
</a>
|
|
|
|
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
|
</svg>
|
|
Projects
|
|
</a>
|
|
|
|
{# Events — fleet-wide event database (SFM). Cross-project
|
|
sortable/filterable event list. Day-to-day event browsing
|
|
for a specific location or unit lives on those detail
|
|
pages; this is the firehose for cross-cutting queries. #}
|
|
<a href="/sfm" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sfm' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
</svg>
|
|
Events
|
|
</a>
|
|
|
|
{# Tools — operator workflow hub. Active when on /tools
|
|
itself or any of the workflow pages it links into
|
|
(project tidy, metadata backfill, pair devices). #}
|
|
{% set _is_tools = (
|
|
request.url.path == '/tools'
|
|
or request.url.path == '/pair-devices'
|
|
or request.url.path == '/settings/developer/project-tidy'
|
|
or request.url.path == '/settings/developer/metadata-backfill'
|
|
) %}
|
|
<a href="/tools" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if _is_tools %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
</svg>
|
|
Tools
|
|
</a>
|
|
|
|
<a href="/fleet-calendar" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/fleet-calendar') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" 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"></path>
|
|
</svg>
|
|
Job Planner
|
|
</a>
|
|
|
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
Settings
|
|
</a>
|
|
</nav>
|
|
|
|
<!-- Dark mode toggle and utilities -->
|
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
|
<button onclick="toggleDarkMode()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600">
|
|
<svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
|
</svg>
|
|
<svg id="theme-toggle-light-icon" class="w-5 h-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
|
|
</svg>
|
|
<span class="ml-3">Toggle theme</span>
|
|
</button>
|
|
|
|
<button onclick="hardReload()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-400">
|
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
<span class="ml-3">Clear Cache & Reload</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Backdrop (Mobile Only) -->
|
|
<div id="backdrop" class="backdrop" onclick="closeMenuFromBackdrop()"></div>
|
|
|
|
<!-- Main content -->
|
|
<main class="main-content flex-1 overflow-y-auto">
|
|
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Bottom Navigation (Mobile Only) -->
|
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
|
<div class="grid grid-cols-4 h-16">
|
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
</svg>
|
|
<span>Menu</span>
|
|
</button>
|
|
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
|
</svg>
|
|
<span>Dashboard</span>
|
|
</button>
|
|
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
|
</svg>
|
|
<span>Devices</span>
|
|
</button>
|
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
</svg>
|
|
<span>Settings</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<script>
|
|
// Dark mode toggle
|
|
function toggleDarkMode() {
|
|
const html = document.documentElement;
|
|
if (html.classList.contains('dark')) {
|
|
html.classList.remove('dark');
|
|
localStorage.setItem('theme', 'light');
|
|
} else {
|
|
html.classList.add('dark');
|
|
localStorage.setItem('theme', 'dark');
|
|
}
|
|
}
|
|
|
|
// Hard reload function - clears all caches and reloads
|
|
async function hardReload() {
|
|
try {
|
|
// Clear service worker caches
|
|
if ('caches' in window) {
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
|
console.log('Cleared all service worker caches');
|
|
}
|
|
|
|
// Unregister service workers
|
|
if ('serviceWorker' in navigator) {
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
await Promise.all(registrations.map(reg => reg.unregister()));
|
|
console.log('Unregistered all service workers');
|
|
}
|
|
|
|
// Clear IndexedDB
|
|
if ('indexedDB' in window) {
|
|
try {
|
|
indexedDB.deleteDatabase('sfm-offline-db');
|
|
console.log('Cleared IndexedDB');
|
|
} catch (e) {
|
|
console.log('Could not clear IndexedDB:', e);
|
|
}
|
|
}
|
|
|
|
// Force reload with cache bypass
|
|
window.location.reload(true);
|
|
} catch (error) {
|
|
console.error('Error during hard reload:', error);
|
|
// Fallback to regular reload
|
|
window.location.reload(true);
|
|
}
|
|
}
|
|
|
|
// Load saved theme preference
|
|
if (localStorage.getItem('theme') === 'light') {
|
|
document.documentElement.classList.remove('dark');
|
|
} else if (localStorage.getItem('theme') === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
}
|
|
|
|
// Helper function: Convert timestamp to relative time
|
|
function timeAgo(dateString) {
|
|
if (!dateString) return 'Never';
|
|
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const seconds = Math.floor((now - date) / 1000);
|
|
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) {
|
|
const remainingMins = minutes % 60;
|
|
return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`;
|
|
}
|
|
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 7) {
|
|
const remainingHours = hours % 24;
|
|
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
|
|
}
|
|
|
|
const weeks = Math.floor(days / 7);
|
|
if (weeks < 4) {
|
|
const remainingDays = days % 7;
|
|
return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`;
|
|
}
|
|
|
|
const months = Math.floor(days / 30);
|
|
return `${months}mo ago`;
|
|
}
|
|
|
|
// Helper function: Get user's selected timezone
|
|
function getTimezone() {
|
|
return localStorage.getItem('timezone') || 'America/New_York';
|
|
}
|
|
|
|
// Helper function: Format timestamp with tooltip (timezone-aware)
|
|
function formatTimestamp(dateString) {
|
|
if (!dateString) return '<span class="text-gray-400">Never</span>';
|
|
|
|
const date = new Date(dateString);
|
|
const timezone = getTimezone();
|
|
|
|
const fullDate = date.toLocaleString('en-US', {
|
|
weekday: 'short',
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
timeZone: timezone,
|
|
timeZoneName: 'short'
|
|
});
|
|
|
|
return `<span title="${fullDate}" class="cursor-help">${timeAgo(dateString)}</span>`;
|
|
}
|
|
|
|
// Helper function: Format timestamp as full date/time (no relative time)
|
|
// Format: "9/10/2020 8:00 AM EST"
|
|
function formatFullTimestamp(dateString) {
|
|
if (!dateString) return 'Never';
|
|
|
|
const date = new Date(dateString);
|
|
const timezone = getTimezone();
|
|
|
|
return date.toLocaleString('en-US', {
|
|
month: 'numeric',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
timeZone: timezone,
|
|
timeZoneName: 'short'
|
|
});
|
|
}
|
|
|
|
// Update all timestamps on page load and periodically
|
|
function updateAllTimestamps() {
|
|
document.querySelectorAll('[data-timestamp]').forEach(el => {
|
|
const timestamp = el.getAttribute('data-timestamp');
|
|
el.innerHTML = formatTimestamp(timestamp);
|
|
});
|
|
}
|
|
|
|
// Run on load and every minute
|
|
updateAllTimestamps();
|
|
setInterval(updateAllTimestamps, 60000);
|
|
|
|
// Copy to clipboard helper
|
|
function copyToClipboard(text, button) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
// Visual feedback
|
|
const originalHTML = button.innerHTML;
|
|
button.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
|
button.classList.add('text-green-500');
|
|
|
|
setTimeout(() => {
|
|
button.innerHTML = originalHTML;
|
|
button.classList.remove('text-green-500');
|
|
}, 1500);
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<!-- Offline Database -->
|
|
<script src="/static/offline-db.js?v=0.6.1"></script>
|
|
|
|
<!-- Mobile JavaScript -->
|
|
<script src="/static/mobile.js?v=0.6.1"></script>
|
|
|
|
{% block extra_scripts %}{% endblock %}
|
|
</body>
|
|
</html>
|