Files
terra-view/templates/rnd_viewer.html
serversdown 95fedca8c9 feat: monitoring session improvements — UTC fix, period hours, calendar, session detail
- 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>
2026-03-27 21:52:52 +00:00

889 lines
45 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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. 719
return hour >= sh && hour < eh;
} else {
// Crosses midnight, e.g. 197
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 %}