- Fix UTC display bug: upload_nrl_data now wraps RNH datetimes with
local_to_utc() before storing, matching patch_session behavior.
Period type and label are derived from local time before conversion.
- Add period_start_hour / period_end_hour to MonitoringSession model
(nullable integers 0–23). Migration: migrate_add_session_period_hours.py
- Update patch_session to accept and store period_start_hour / period_end_hour.
Response now includes both fields.
- Update get_project_sessions to compute "Effective: M/D H:MM AM → M/D H:MM AM"
string from period hours and pass it to session_list.html.
- Rework period edit UI in session_list.html: clicking the period badge now
opens an inline editor with period type selector + start/end hour inputs.
Selecting a period type pre-fills default hours (Day: 7–19, Night: 19–7).
- Wire period hours into _build_location_data_from_sessions: uses
period_start/end_hour when set, falls back to hardcoded defaults.
- RND viewer: inject SESSION_PERIOD_START/END_HOUR from template context.
renderTable() dims rows outside the period window (opacity-40) with a
tooltip; shows "(N in period window)" in the row count.
- New session detail page at /api/projects/{id}/sessions/{id}/detail:
shows breadcrumb, files list with View/Download/Report actions,
editable session info form (label, period type, hours, times).
- Add local_datetime_input Jinja filter for datetime-local input values.
- Monthly calendar view: new get_sessions_calendar endpoint returns
sessions_calendar.html partial; added below sessions list in detail.html.
Color-coded per NRL with legend, HTMX prev/next navigation, session dots
link to detail page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
889 lines
45 KiB
HTML
889 lines
45 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}{{ filename }} - Sound Level Data Viewer{% endblock %}
|
||
|
||
{% block extra_head %}
|
||
<!-- Chart.js -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
|
||
<style>
|
||
.data-table {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
.data-table table {
|
||
font-size: 0.75rem;
|
||
}
|
||
.data-table th {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
.metric-card {
|
||
transition: transform 0.2s;
|
||
}
|
||
.metric-card:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="max-w-7xl mx-auto">
|
||
<!-- Header with breadcrumb -->
|
||
<div class="mb-6">
|
||
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
||
<svg class="w-4 h-4" 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"></path>
|
||
</svg>
|
||
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name if project else 'Project' }}</a>
|
||
<svg class="w-4 h-4" 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"></path>
|
||
</svg>
|
||
<span class="text-gray-900 dark:text-white">{{ filename }}</span>
|
||
</nav>
|
||
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||
</svg>
|
||
{{ filename }}
|
||
</h1>
|
||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
Sound Level Meter Measurement Data
|
||
{% if unit %} - {{ unit.id }}{% endif %}
|
||
{% if location %} @ {{ location.name }}{% endif %}
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
||
{% if is_leq %}
|
||
<!-- Generate Excel Report Button -->
|
||
<button onclick="openReportModal()"
|
||
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
|
||
<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 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
Generate Excel Report
|
||
</button>
|
||
{% endif %}
|
||
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/download"
|
||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-2">
|
||
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||
</svg>
|
||
Download RND
|
||
</a>
|
||
<a href="/projects/{{ project_id }}"
|
||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||
Back to Project
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading State -->
|
||
<div id="loading-state" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
|
||
<svg class="w-12 h-12 mx-auto mb-4 text-seismo-orange animate-spin" 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>
|
||
<p class="text-gray-500 dark:text-gray-400">Loading measurement data...</p>
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<div id="error-state" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
|
||
<svg class="w-12 h-12 mx-auto mb-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||
</svg>
|
||
<p class="text-red-500 font-semibold mb-2">Error Loading Data</p>
|
||
<p id="error-message" class="text-gray-500 dark:text-gray-400"></p>
|
||
</div>
|
||
|
||
<!-- Data Content (hidden until loaded) -->
|
||
<div id="data-content" class="hidden space-y-6">
|
||
<!-- Summary Cards -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">File Type</div>
|
||
<div id="summary-file-type" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||
</div>
|
||
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Rows</div>
|
||
<div id="summary-rows" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||
</div>
|
||
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Start Time</div>
|
||
<div id="summary-start" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||
</div>
|
||
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">End Time</div>
|
||
<div id="summary-end" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||
</div>
|
||
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-blue-50 dark:bg-blue-900/20">
|
||
<div class="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wide">Avg Level</div>
|
||
<div id="summary-avg" class="text-lg font-bold text-blue-700 dark:text-blue-300 mt-1">- dB</div>
|
||
</div>
|
||
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-red-50 dark:bg-red-900/20">
|
||
<div class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">Max Level</div>
|
||
<div id="summary-max" class="text-lg font-bold text-red-700 dark:text-red-300 mt-1">- dB</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chart -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Over Time</h2>
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-sm text-gray-500 dark:text-gray-400">Show:</label>
|
||
<select id="chart-metric-select" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||
<option value="all">All Metrics</option>
|
||
<option value="leq">Leq Only</option>
|
||
<option value="lmax">Lmax Only</option>
|
||
<option value="lmin">Lmin Only</option>
|
||
<option value="lpeak">Lpeak Only</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="relative" style="height: 400px;">
|
||
<canvas id="soundLevelChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data Table -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Measurement Data</h2>
|
||
<div class="flex items-center gap-4">
|
||
<input type="text" id="table-search" placeholder="Search..."
|
||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-48">
|
||
<span id="row-count" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||
</div>
|
||
</div>
|
||
<div class="data-table">
|
||
<table class="w-full">
|
||
<thead id="table-header" class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400 text-xs uppercase">
|
||
<!-- Headers will be inserted here -->
|
||
</thead>
|
||
<tbody id="table-body" class="divide-y divide-gray-100 dark:divide-gray-700">
|
||
<!-- Data rows will be inserted here -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let chartInstance = null;
|
||
let allData = [];
|
||
let allHeaders = [];
|
||
|
||
// Session period window (null = no filtering)
|
||
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
|
||
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
|
||
|
||
/**
|
||
* Returns true if the given hour integer is within the session's period window.
|
||
* Always returns true when no period window is configured.
|
||
*/
|
||
function _isInPeriodWindow(hour) {
|
||
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
|
||
const sh = SESSION_PERIOD_START_HOUR;
|
||
const eh = SESSION_PERIOD_END_HOUR;
|
||
if (eh > sh) {
|
||
// Same-day window, e.g. 7–19
|
||
return hour >= sh && hour < eh;
|
||
} else {
|
||
// Crosses midnight, e.g. 19–7
|
||
return hour >= sh || hour < eh;
|
||
}
|
||
}
|
||
|
||
// Load data on page load
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadRndData();
|
||
});
|
||
|
||
async function loadRndData() {
|
||
try {
|
||
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/rnd-data');
|
||
const result = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(result.detail || 'Failed to load data');
|
||
}
|
||
|
||
// Store data globally
|
||
allData = result.data;
|
||
allHeaders = result.headers;
|
||
|
||
// Update summary cards
|
||
updateSummary(result.summary);
|
||
|
||
// Render chart
|
||
renderChart(result.data, result.summary.file_type);
|
||
|
||
// Render table
|
||
renderTable(result.headers, result.data);
|
||
|
||
// Show content, hide loading
|
||
document.getElementById('loading-state').classList.add('hidden');
|
||
document.getElementById('data-content').classList.remove('hidden');
|
||
|
||
} catch (error) {
|
||
console.error('Error loading RND data:', error);
|
||
document.getElementById('loading-state').classList.add('hidden');
|
||
document.getElementById('error-state').classList.remove('hidden');
|
||
document.getElementById('error-message').textContent = error.message;
|
||
}
|
||
}
|
||
|
||
function updateSummary(summary) {
|
||
document.getElementById('summary-file-type').textContent =
|
||
summary.file_type === 'leq' ? 'Leq (Time-Averaged)' :
|
||
summary.file_type === 'lp' ? 'Lp (Instantaneous)' : 'Unknown';
|
||
|
||
document.getElementById('summary-rows').textContent = summary.total_rows.toLocaleString();
|
||
document.getElementById('summary-start').textContent = summary.time_start || '-';
|
||
document.getElementById('summary-end').textContent = summary.time_end || '-';
|
||
|
||
// Find the main metric based on file type
|
||
const avgKey = summary.file_type === 'leq' ? 'Leq(Main)_avg' : 'Lp(Main)_avg';
|
||
const maxKey = summary.file_type === 'leq' ? 'Lmax(Main)_max' : 'Lmax(Main)_max';
|
||
|
||
if (summary[avgKey] !== undefined) {
|
||
document.getElementById('summary-avg').textContent = summary[avgKey].toFixed(1) + ' dB';
|
||
}
|
||
if (summary[maxKey] !== undefined) {
|
||
document.getElementById('summary-max').textContent = summary[maxKey].toFixed(1) + ' dB';
|
||
} else if (summary['Lpeak(Main)_max'] !== undefined) {
|
||
document.getElementById('summary-max').textContent = summary['Lpeak(Main)_max'].toFixed(1) + ' dB';
|
||
}
|
||
}
|
||
|
||
function renderChart(data, fileType) {
|
||
const ctx = document.getElementById('soundLevelChart').getContext('2d');
|
||
|
||
// Prepare datasets based on file type
|
||
const datasets = [];
|
||
const labels = data.map(row => row['Start Time'] || '');
|
||
|
||
// Define metrics to chart based on file type
|
||
const metricsConfig = fileType === 'leq' ? [
|
||
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(59, 130, 246)', fill: false },
|
||
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
|
||
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
|
||
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
|
||
] : [
|
||
{ key: 'Lp(Main)', label: 'Lp', color: 'rgb(59, 130, 246)', fill: false },
|
||
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(249, 115, 22)', fill: false },
|
||
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
|
||
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
|
||
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
|
||
];
|
||
|
||
metricsConfig.forEach(metric => {
|
||
const values = data.map(row => {
|
||
const val = row[metric.key];
|
||
return typeof val === 'number' ? val : null;
|
||
});
|
||
|
||
// Only add if we have data
|
||
if (values.some(v => v !== null)) {
|
||
datasets.push({
|
||
label: metric.label,
|
||
data: values,
|
||
borderColor: metric.color,
|
||
backgroundColor: metric.color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
||
fill: metric.fill,
|
||
tension: 0.1,
|
||
pointRadius: data.length > 100 ? 0 : 2,
|
||
borderWidth: 1.5,
|
||
});
|
||
}
|
||
});
|
||
|
||
// Downsample if too many points
|
||
let chartLabels = labels;
|
||
let chartDatasets = datasets;
|
||
|
||
if (data.length > 1000) {
|
||
const sampleRate = Math.ceil(data.length / 1000);
|
||
chartLabels = labels.filter((_, i) => i % sampleRate === 0);
|
||
chartDatasets = datasets.map(ds => ({
|
||
...ds,
|
||
data: ds.data.filter((_, i) => i % sampleRate === 0)
|
||
}));
|
||
}
|
||
|
||
chartInstance = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: chartLabels,
|
||
datasets: chartDatasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false,
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
position: 'top',
|
||
labels: {
|
||
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||
usePointStyle: true,
|
||
}
|
||
},
|
||
tooltip: {
|
||
backgroundColor: document.documentElement.classList.contains('dark') ? '#1f2937' : '#ffffff',
|
||
titleColor: document.documentElement.classList.contains('dark') ? '#f9fafb' : '#111827',
|
||
bodyColor: document.documentElement.classList.contains('dark') ? '#d1d5db' : '#4b5563',
|
||
borderColor: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
||
borderWidth: 1,
|
||
callbacks: {
|
||
label: function(context) {
|
||
return context.dataset.label + ': ' + (context.parsed.y !== null ? context.parsed.y.toFixed(1) + ' dB' : 'N/A');
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
display: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Time',
|
||
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||
},
|
||
ticks: {
|
||
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||
maxTicksLimit: 10,
|
||
maxRotation: 45,
|
||
},
|
||
grid: {
|
||
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
||
}
|
||
},
|
||
y: {
|
||
display: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Sound Level (dB)',
|
||
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||
},
|
||
ticks: {
|
||
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||
},
|
||
grid: {
|
||
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Chart metric filter
|
||
document.getElementById('chart-metric-select').addEventListener('change', function(e) {
|
||
const value = e.target.value;
|
||
chartInstance.data.datasets.forEach((ds, i) => {
|
||
if (value === 'all') {
|
||
ds.hidden = false;
|
||
} else if (value === 'leq') {
|
||
ds.hidden = !['Leq', 'Lp'].includes(ds.label);
|
||
} else if (value === 'lmax') {
|
||
ds.hidden = ds.label !== 'Lmax';
|
||
} else if (value === 'lmin') {
|
||
ds.hidden = ds.label !== 'Lmin';
|
||
} else if (value === 'lpeak') {
|
||
ds.hidden = ds.label !== 'Lpeak';
|
||
}
|
||
});
|
||
chartInstance.update();
|
||
});
|
||
}
|
||
|
||
function _rowHour(row) {
|
||
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
|
||
const t = row['Start Time'];
|
||
if (!t) return null;
|
||
const parts = t.split(' ');
|
||
if (parts.length < 2) return null;
|
||
return parseInt(parts[1].split(':')[0], 10);
|
||
}
|
||
|
||
function _buildRow(headers, row) {
|
||
const hour = _rowHour(row);
|
||
const inWindow = hour === null || _isInPeriodWindow(hour);
|
||
const dimClass = inWindow ? '' : 'opacity-40';
|
||
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
|
||
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
|
||
headers.map(h => {
|
||
const val = row[h];
|
||
let displayVal = val;
|
||
if (val === null || val === undefined) {
|
||
displayVal = '-';
|
||
} else if (typeof val === 'number') {
|
||
displayVal = val.toFixed(1);
|
||
}
|
||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||
}).join('') +
|
||
'</tr>';
|
||
}
|
||
|
||
function renderTable(headers, data) {
|
||
const headerRow = document.getElementById('table-header');
|
||
const tbody = document.getElementById('table-body');
|
||
|
||
// Render headers — add period window indicator if configured
|
||
let periodNote = '';
|
||
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
|
||
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
|
||
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)} → ${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
|
||
}
|
||
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
|
||
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
|
||
).join('') + '</tr>';
|
||
|
||
// Render rows (limit to first 500 for performance)
|
||
const displayData = data.slice(0, 500);
|
||
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
|
||
|
||
// Update row count
|
||
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
|
||
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
|
||
? ` (${inWindowCount} in period window)`
|
||
: '';
|
||
document.getElementById('row-count').textContent =
|
||
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
|
||
|
||
// Search functionality
|
||
document.getElementById('table-search').addEventListener('input', function(e) {
|
||
const searchTerm = e.target.value.toLowerCase();
|
||
const filtered = data.filter(row =>
|
||
Object.values(row).some(v =>
|
||
String(v).toLowerCase().includes(searchTerm)
|
||
)
|
||
);
|
||
|
||
const displayFiltered = filtered.slice(0, 500);
|
||
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
|
||
|
||
document.getElementById('row-count').textContent =
|
||
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
||
});
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Report Generation Modal Functions
|
||
let reportTemplates = [];
|
||
|
||
async function loadTemplates() {
|
||
try {
|
||
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
||
if (response.ok) {
|
||
reportTemplates = await response.json();
|
||
populateTemplateDropdown();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading templates:', error);
|
||
}
|
||
}
|
||
|
||
function populateTemplateDropdown() {
|
||
const select = document.getElementById('template-select');
|
||
if (!select) return;
|
||
|
||
select.innerHTML = '<option value="">-- Select a template --</option>';
|
||
reportTemplates.forEach(template => {
|
||
const option = document.createElement('option');
|
||
option.value = template.id;
|
||
option.textContent = template.name;
|
||
option.dataset.config = JSON.stringify(template);
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function applyTemplate() {
|
||
const select = document.getElementById('template-select');
|
||
const selectedOption = select.options[select.selectedIndex];
|
||
|
||
if (!selectedOption.value) return;
|
||
|
||
const template = JSON.parse(selectedOption.dataset.config);
|
||
|
||
if (template.report_title) {
|
||
document.getElementById('report-title').value = template.report_title;
|
||
}
|
||
if (template.start_time) {
|
||
document.getElementById('start-time').value = template.start_time;
|
||
}
|
||
if (template.end_time) {
|
||
document.getElementById('end-time').value = template.end_time;
|
||
}
|
||
if (template.start_date) {
|
||
document.getElementById('start-date').value = template.start_date;
|
||
}
|
||
if (template.end_date) {
|
||
document.getElementById('end-date').value = template.end_date;
|
||
}
|
||
|
||
// Update preset buttons
|
||
updatePresetButtons();
|
||
}
|
||
|
||
function setTimePreset(preset) {
|
||
const startTimeInput = document.getElementById('start-time');
|
||
const endTimeInput = document.getElementById('end-time');
|
||
|
||
// Remove active state from all preset buttons
|
||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||
});
|
||
|
||
// Set time values based on preset
|
||
switch(preset) {
|
||
case 'night':
|
||
startTimeInput.value = '19:00';
|
||
endTimeInput.value = '07:00';
|
||
break;
|
||
case 'day':
|
||
startTimeInput.value = '07:00';
|
||
endTimeInput.value = '19:00';
|
||
break;
|
||
case 'all':
|
||
startTimeInput.value = '';
|
||
endTimeInput.value = '';
|
||
break;
|
||
case 'custom':
|
||
// Just enable custom input, don't change values
|
||
break;
|
||
}
|
||
|
||
// Highlight active preset
|
||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||
if (activeBtn) {
|
||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||
}
|
||
}
|
||
|
||
function updatePresetButtons() {
|
||
const startTime = document.getElementById('start-time').value;
|
||
const endTime = document.getElementById('end-time').value;
|
||
|
||
// Remove active state from all
|
||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||
btn.classList.remove('bg-emerald-600', 'text-white');
|
||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||
});
|
||
|
||
// Check which preset matches
|
||
let preset = 'custom';
|
||
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
|
||
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
|
||
else if (!startTime && !endTime) preset = 'all';
|
||
|
||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
||
if (activeBtn) {
|
||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
||
}
|
||
}
|
||
|
||
function openReportModal() {
|
||
document.getElementById('report-modal').classList.remove('hidden');
|
||
loadTemplates();
|
||
|
||
// Pre-fill fields if available
|
||
const locationInput = document.getElementById('report-location');
|
||
if (locationInput && !locationInput.value) {
|
||
locationInput.value = '{{ location.name if location else "" }}';
|
||
}
|
||
const projectInput = document.getElementById('report-project');
|
||
if (projectInput && !projectInput.value) {
|
||
projectInput.value = '{{ project.name if project else "" }}';
|
||
}
|
||
const clientInput = document.getElementById('report-client');
|
||
if (clientInput && !clientInput.value) {
|
||
clientInput.value = '{{ project.client_name if project and project.client_name else "" }}';
|
||
}
|
||
|
||
// Set default to "All Day"
|
||
setTimePreset('all');
|
||
}
|
||
|
||
function closeReportModal() {
|
||
document.getElementById('report-modal').classList.add('hidden');
|
||
}
|
||
|
||
function generateReport(preview = false) {
|
||
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
|
||
const projectName = document.getElementById('report-project').value || '';
|
||
const clientName = document.getElementById('report-client').value || '';
|
||
const locationName = document.getElementById('report-location').value || '';
|
||
const startTime = document.getElementById('start-time').value || '';
|
||
const endTime = document.getElementById('end-time').value || '';
|
||
const startDate = document.getElementById('start-date').value || '';
|
||
const endDate = document.getElementById('end-date').value || '';
|
||
|
||
// Build the URL with query parameters
|
||
const params = new URLSearchParams({
|
||
report_title: reportTitle,
|
||
project_name: projectName,
|
||
client_name: clientName,
|
||
location_name: locationName,
|
||
start_time: startTime,
|
||
end_time: endTime,
|
||
start_date: startDate,
|
||
end_date: endDate
|
||
});
|
||
|
||
if (preview) {
|
||
// Open preview page
|
||
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/preview-report?${params.toString()}`;
|
||
} else {
|
||
// Direct download
|
||
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
|
||
}
|
||
|
||
closeReportModal();
|
||
}
|
||
|
||
async function saveAsTemplate() {
|
||
const name = prompt('Enter a name for this template:');
|
||
if (!name) return;
|
||
|
||
const templateData = {
|
||
name: name,
|
||
project_id: '{{ project_id }}',
|
||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||
start_time: document.getElementById('start-time').value || null,
|
||
end_time: document.getElementById('end-time').value || null,
|
||
start_date: document.getElementById('start-date').value || null,
|
||
end_date: document.getElementById('end-date').value || null
|
||
};
|
||
|
||
try {
|
||
const response = await fetch('/api/report-templates', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(templateData)
|
||
});
|
||
|
||
if (response.ok) {
|
||
alert('Template saved successfully!');
|
||
loadTemplates();
|
||
} else {
|
||
alert('Failed to save template');
|
||
}
|
||
} catch (error) {
|
||
alert('Error saving template: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Close modal on escape key
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
closeReportModal();
|
||
}
|
||
});
|
||
|
||
// Update preset buttons when time inputs change
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const startTimeInput = document.getElementById('start-time');
|
||
const endTimeInput = document.getElementById('end-time');
|
||
if (startTimeInput) startTimeInput.addEventListener('change', updatePresetButtons);
|
||
if (endTimeInput) endTimeInput.addEventListener('change', updatePresetButtons);
|
||
});
|
||
</script>
|
||
|
||
<!-- Report Generation Modal -->
|
||
<div id="report-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||
<!-- Background overlay -->
|
||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity" onclick="closeReportModal()"></div>
|
||
|
||
<!-- Modal panel -->
|
||
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||
<div class="sm:flex sm:items-start">
|
||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 sm:mx-0 sm:h-10 sm:w-10">
|
||
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
</div>
|
||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
||
Generate Excel Report
|
||
</h3>
|
||
|
||
<div class="mt-4 space-y-4">
|
||
<!-- Template Selection -->
|
||
<div class="flex items-center gap-2">
|
||
<div class="flex-1">
|
||
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Load Template
|
||
</label>
|
||
<select id="template-select" onchange="applyTemplate()"
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
<option value="">-- Select a template --</option>
|
||
</select>
|
||
</div>
|
||
<button type="button" onclick="saveAsTemplate()"
|
||
class="mt-6 px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
|
||
title="Save current settings as template">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Report Title -->
|
||
<div>
|
||
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Report Title
|
||
</label>
|
||
<input type="text" id="report-title" value="Background Noise Study"
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
|
||
<!-- Project and Client in a row -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Project Name
|
||
</label>
|
||
<input type="text" id="report-project" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
<div>
|
||
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Client Name
|
||
</label>
|
||
<input type="text" id="report-client" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Location Name -->
|
||
<div>
|
||
<label for="report-location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Location Name
|
||
</label>
|
||
<input type="text" id="report-location" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
|
||
<!-- Time Filter Section -->
|
||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Time Filter
|
||
</label>
|
||
|
||
<!-- Preset Buttons -->
|
||
<div class="flex gap-2 mb-3">
|
||
<button type="button" onclick="setTimePreset('night')" data-preset="night"
|
||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||
7PM - 7AM
|
||
</button>
|
||
<button type="button" onclick="setTimePreset('day')" data-preset="day"
|
||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||
7AM - 7PM
|
||
</button>
|
||
<button type="button" onclick="setTimePreset('all')" data-preset="all"
|
||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
|
||
All Day
|
||
</button>
|
||
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
|
||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||
Custom
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Custom Time Inputs -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400">Start Time</label>
|
||
<input type="time" id="start-time" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
<div>
|
||
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400">End Time</label>
|
||
<input type="time" id="end-time" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Date Range (Optional) -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Date Range <span class="text-gray-400 font-normal">(optional)</span>
|
||
</label>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400">From</label>
|
||
<input type="date" id="start-date" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
<div>
|
||
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400">To</label>
|
||
<input type="date" id="end-date" value=""
|
||
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Info about what's included -->
|
||
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
<strong>Report will include:</strong>
|
||
</p>
|
||
<ul class="mt-1 text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
|
||
<li>Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)</li>
|
||
<li>Line chart visualization</li>
|
||
<li>Time period summary (Evening, Night, Morning, Daytime)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 flex flex-col sm:flex-row-reverse gap-2">
|
||
<button type="button" onclick="generateReport(false)"
|
||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||
</svg>
|
||
Download Excel
|
||
</button>
|
||
<button type="button" onclick="generateReport(true)"
|
||
class="w-full inline-flex justify-center rounded-md border border-emerald-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||
</svg>
|
||
Preview & Edit
|
||
</button>
|
||
<button type="button" onclick="closeReportModal()"
|
||
class="w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|