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>
This commit is contained in:
2026-03-27 21:52:52 +00:00
parent e8e155556a
commit 95fedca8c9
10 changed files with 1066 additions and 84 deletions

View File

@@ -5,6 +5,7 @@
{% set s = item.session %}
{% set loc = item.location %}
{% set unit = item.unit %}
{% set effective_range = item.effective_range %}
{# Period display maps #}
{% set period_labels = {
@@ -49,25 +50,74 @@
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
{% endif %}
<!-- Period type badge (click to change) -->
<!-- Period type badge (click to open hour editor) -->
<div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="togglePeriodMenu('{{ s.id }}')"
<button onclick="openPeriodEditor('{{ s.id }}')"
id="period-badge-{{ s.id }}"
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
title="Click to change period type">
title="Click to edit period type and hours">
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="period-menu-{{ s.id }}"
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
{{ pt_label }}
</button>
{% endfor %}
<!-- Period editor panel -->
<div id="period-editor-{{ s.id }}"
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg w-64 p-3 space-y-3">
<!-- Period type selector -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
<div class="grid grid-cols-2 gap-1">
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
id="pt-btn-{{ s.id }}-{{ pt }}"
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
{{ pt_label }}
</button>
{% endfor %}
</div>
</div>
<!-- Hour inputs -->
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (023)</label>
<input type="number" min="0" max="23"
id="period-start-hr-{{ s.id }}"
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
placeholder="e.g. 19"
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (023)</label>
<input type="number" min="0" max="23"
id="period-end-hr-{{ s.id }}"
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
placeholder="e.g. 7"
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
<!-- Actions -->
<div class="flex gap-2 pt-1">
<button onclick="savePeriodEditor('{{ s.id }}')"
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
Save
</button>
<button onclick="closePeriodEditor('{{ s.id }}')"
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
Cancel
</button>
<button onclick="clearPeriodEditor('{{ s.id }}')"
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
title="Clear period type and hours">
Clear
</button>
</div>
</div>
</div>
</div>
@@ -131,8 +181,17 @@
{% endif %}
</div>
{% if s.notes %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
<!-- Effective window (when period hours are set) -->
{% if effective_range %}
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
</div>
{% else %}
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
id="effective-range-{{ s.id }}"></div>
{% endif %}
</div>
@@ -163,6 +222,8 @@
{% endif %}
<script>
const PROJECT_ID = '{{ project_id }}';
const PERIOD_COLORS = {
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
@@ -175,46 +236,145 @@ const PERIOD_LABELS = {
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
// Default hours for each period type
const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7},
weekend_day: {start: 7, end: 19},
weekend_night: {start: 19, end: 7},
};
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
const ALL_BADGE_COLORS = [...new Set([
...FALLBACK_COLORS,
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
])];
function togglePeriodMenu(sessionId) {
const menu = document.getElementById('period-menu-' + sessionId);
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
// Track which period type is selected in the editor before saving
const _editorState = {};
// ---- Period editor ----
function openPeriodEditor(sessionId) {
// Close all other editors first
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
});
menu.classList.toggle('hidden');
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
}
function closePeriodEditor(sessionId) {
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
delete _editorState[sessionId];
}
function selectPeriodType(sessionId, pt) {
_editorState[sessionId] = pt;
// Highlight selected button
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
btn.classList.toggle('border-seismo-orange', isSelected);
btn.classList.toggle('bg-orange-50', isSelected);
btn.classList.toggle('text-seismo-orange', isSelected);
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
btn.classList.toggle('border-gray-200', !isSelected);
btn.classList.toggle('dark:border-gray-600', !isSelected);
btn.classList.toggle('text-gray-600', !isSelected);
btn.classList.toggle('dark:text-gray-400', !isSelected);
});
// Fill default hours
const defaults = PERIOD_DEFAULT_HOURS[pt];
if (defaults) {
const sh = document.getElementById('period-start-hr-' + sessionId);
const eh = document.getElementById('period-end-hr-' + sessionId);
if (sh && !sh.value) sh.value = defaults.start;
if (eh && !eh.value) eh.value = defaults.end;
}
}
async function savePeriodEditor(sessionId) {
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
?.dataset?.currentPeriod || null;
const shInput = document.getElementById('period-start-hr-' + sessionId);
const ehInput = document.getElementById('period-end-hr-' + sessionId);
const payload = {};
if (pt !== undefined) payload.period_type = pt || null;
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
// Update badge
const badge = document.getElementById('period-badge-' + sessionId);
const label = document.getElementById('period-label-' + sessionId);
const newPt = result.period_type;
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
if (newPt && PERIOD_COLORS[newPt]) {
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
if (label) label.textContent = PERIOD_LABELS[newPt];
} else {
badge.classList.add(...FALLBACK_COLORS);
if (label) label.textContent = 'Set period';
}
// Update effective range display
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
closePeriodEditor(sessionId);
} catch (err) {
alert('Failed to save period: ' + err.message);
}
}
async function clearPeriodEditor(sessionId) {
const shInput = document.getElementById('period-start-hr-' + sessionId);
const ehInput = document.getElementById('period-end-hr-' + sessionId);
if (shInput) shInput.value = '';
if (ehInput) ehInput.value = '';
_editorState[sessionId] = null;
// Reset period type button highlights
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
});
}
// ---- Effective range helper ----
function _updateEffectiveRange(sessionId, startHour, endHour) {
const el = document.getElementById('effective-range-' + sessionId);
if (!el) return;
if (startHour == null || endHour == null) {
el.textContent = '';
el.classList.add('hidden');
return;
}
function _fmt(h) {
const ampm = h < 12 ? 'AM' : 'PM';
const h12 = h % 12 || 12;
return `${h12}:00 ${ampm}`;
}
// We don't have the session start date in JS so just show the hours pattern
el.textContent = `Effective window: ${_fmt(startHour)}${_fmt(endHour)}`;
el.classList.remove('hidden');
}
// ---- Close editors on outside click ----
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="period-wrap-"]')) {
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
}
});
async function setPeriodType(sessionId, periodType) {
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
const badge = document.getElementById('period-badge-' + sessionId);
badge.disabled = true;
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({period_type: periodType}),
});
if (!resp.ok) throw new Error(await resp.text());
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
} catch(err) {
alert('Failed to update period type: ' + err.message);
} finally {
badge.disabled = false;
}
}
// ---- Label editing ----
function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden');
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
const input = document.getElementById('label-input-' + sessionId);
const newLabel = input.value.trim();
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_label: newLabel}),
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
}
}
// ---- Session details ----
function viewSession(sessionId) {
alert('Session details coming soon: ' + sessionId);
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
}
function stopRecording(sessionId) {

View File

@@ -0,0 +1,91 @@
<!-- Monthly Sessions Calendar -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Calendar header: month navigation -->
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
hx-target="#sessions-calendar"
hx-swap="innerHTML"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
title="Previous month">
<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="M15 19l-7-7 7-7"></path>
</svg>
</button>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
hx-target="#sessions-calendar"
hx-swap="innerHTML"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
title="Next month">
<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>
</button>
</div>
<!-- Legend -->
{% if legend %}
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap gap-3">
{% for loc in legend %}
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
<span>{{ loc.name }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{% for day_name in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] %}
<div class="py-2 text-center text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide
{% if loop.index >= 6 %}text-amber-500 dark:text-amber-400{% endif %}">
{{ day_name }}
</div>
{% endfor %}
</div>
<!-- Calendar grid -->
{% for week in weeks %}
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
{% for day in week %}
<div class="min-h-[72px] p-1.5 {% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
<!-- Date number -->
<div class="text-right mb-1">
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
{{ day.date.day }}
</span>
</div>
<!-- Session dots/chips -->
{% if day.sessions %}
<div class="space-y-0.5">
{% for s in day.sessions[:4] %}
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail"
class="flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate hover:opacity-80 transition-opacity"
style="background-color: {{ s.color }}22; border-left: 2px solid {{ s.color }};"
title="{{ s.label }}">
<span class="truncate text-gray-700 dark:text-gray-300" style="color: {{ s.color }}; font-size: 0.65rem; line-height: 1.2;">
{{ s.location_name }}
{% if s.period_type %}
<span class="opacity-60">{{ '☀' if 'day' in s.period_type else '☾' }}</span>
{% endif %}
</span>
</a>
{% endfor %}
{% if day.sessions|length > 4 %}
<div class="text-center text-xs text-gray-400 dark:text-gray-500">+{{ day.sessions|length - 4 }} more</div>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>

View File

@@ -208,6 +208,19 @@
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
</div>
</div>
<!-- Monthly Calendar -->
<div class="mt-6 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Calendar View</h3>
</div>
<div id="sessions-calendar"
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
</div>
</div>
</div>
<!-- Data Files Tab -->

View File

@@ -181,6 +181,27 @@ 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();
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
});
}
function renderTable(headers, data) {
const headerRow = document.getElementById('table-header');
const tbody = document.getElementById('table-body');
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);
}
// Render headers
headerRow.innerHTML = '<tr>' + headers.map(h =>
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
).join('') + '</tr>';
// Render rows (limit to first 500 for performance)
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
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;
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).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` : `${data.length.toLocaleString()} rows`;
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) {
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
);
const displayFiltered = filtered.slice(0, 500);
tbody.innerHTML = displayFiltered.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
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>'
).join('');
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`;

View File

@@ -0,0 +1,371 @@
{% extends "base.html" %}
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto">
<!-- Breadcrumb -->
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<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 }}</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 truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
</nav>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<svg class="w-7 h-7 text-seismo-orange" 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>
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
</h1>
{% if location %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if session.status == 'completed' %}
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
{% elif session.status == 'recording' %}
<span class="px-3 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
</span>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- LEFT COLUMN: Info + Edit -->
<div class="lg:col-span-1 space-y-4">
<!-- Session Info Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
id="info-label">{{ session.session_label or '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
{{ PLABELS.get(session.period_type, '—') }}
</dd>
</div>
{% if effective_range %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
</div>
{% else %}
<div class="flex justify-between hidden" id="info-effective-row">
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
</div>
{% endif %}
{% if session.started_at %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
</div>
{% endif %}
{% if session.stopped_at %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
</div>
{% endif %}
{% if session.duration_seconds %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
</div>
{% endif %}
{% if session.device_model %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
</div>
{% endif %}
{% if session_meta.get('store_name') %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
</div>
{% endif %}
{% if session_meta.get('serial_number') %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Edit Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Edit Session</h2>
<form id="edit-form" class="space-y-3" onsubmit="saveSession(event)">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
<input type="text" id="edit-label" name="session_label"
value="{{ session.session_label or '' }}"
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
<select id="edit-period-type" name="period_type"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
<option value="">— Not Set —</option>
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (023)</label>
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
placeholder="e.g. 19"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (023)</label>
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
placeholder="e.g. 7"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time (local)</label>
<input type="datetime-local" id="edit-started-at" name="started_at"
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Time (local)</label>
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<div class="flex gap-2 pt-1">
<button type="submit"
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
Save Changes
</button>
<button type="button" onclick="fillPeriodDefaults()"
class="text-sm py-2 px-3 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
title="Fill default hours for selected period type">
Defaults
</button>
</div>
<div id="save-status" class="hidden text-xs text-center pt-1"></div>
</form>
</div>
</div>
<!-- RIGHT COLUMN: Files + Report Actions -->
<div class="lg:col-span-2 space-y-5">
<!-- Files List -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Data Files
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
</h2>
</div>
{% if files %}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{% for f in files %}
{% set fname = f.file_path.split('/')[-1] %}
{% set is_rnd = fname.lower().endswith('.rnd') %}
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<!-- Icon -->
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
{% if is_rnd %}
<svg class="w-4 h-4 text-green-600 dark:text-green-400" 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>
{% else %}
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
{% endif %}
</div>
<!-- Name + meta -->
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
<span>{{ f.file_type | upper }}</span>
{% if f.file_size_bytes %}
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
{% endif %}
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
{% if is_rnd %}
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
View
</a>
{% if is_leq %}
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
Report
</button>
{% endif %}
{% endif %}
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
Download
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="px-5 py-10 text-center text-gray-400">
<p>No files found for this session.</p>
</div>
{% endif %}
</div>
<!-- Report Actions -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
{% if session.status == 'completed' %}
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
{% if has_rnd %}
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
<p class="text-xs text-gray-600 dark:text-gray-400">
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
{% if session.period_start_hour is not none %}
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00{{ session.period_end_hour }}:00 will be applied.</span>
{% endif %}
</p>
<a href="/projects/{{ project_id }}?tab=data"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<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 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>
Go to Combined Report Wizard
</a>
</div>
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
{% endif %}
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
{% endif %}
</div>
</div>
</div>
</div>
<script>
const PROJECT_ID = '{{ project_id }}';
const SESSION_ID = '{{ session.id }}';
const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7},
weekend_day: {start: 7, end: 19},
weekend_night: {start: 19, end: 7},
};
function fillPeriodDefaults() {
const pt = document.getElementById('edit-period-type').value;
const defaults = PERIOD_DEFAULT_HOURS[pt];
if (defaults) {
document.getElementById('edit-start-hour').value = defaults.start;
document.getElementById('edit-end-hour').value = defaults.end;
}
}
async function saveSession(e) {
e.preventDefault();
const status = document.getElementById('save-status');
status.className = 'text-xs text-center pt-1 text-gray-400';
status.textContent = 'Saving…';
status.classList.remove('hidden');
const form = document.getElementById('edit-form');
const payload = {};
const label = form.session_label.value.trim();
payload.session_label = label || null;
const pt = form.period_type.value;
payload.period_type = pt || null;
const sh = form.period_start_hour.value;
const eh = form.period_end_hour.value;
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
const sa = form.started_at.value;
if (sa) payload.started_at = sa;
const st = form.stopped_at.value;
if (st) payload.stopped_at = st;
else if (form.stopped_at.value === '') payload.stopped_at = null;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
// Update displayed label
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
document.getElementById('header-label').textContent = newLabel;
document.getElementById('info-label').textContent = result.session_label || '—';
document.getElementById('info-period').textContent = {
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
}[result.period_type] || '—';
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
status.textContent = 'Saved!';
setTimeout(() => status.classList.add('hidden'), 2500);
} catch(err) {
status.className = 'text-xs text-center pt-1 text-red-500';
status.textContent = 'Error: ' + err.message;
}
}
function openSingleFileReport(fileId, filename) {
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
}
</script>
{% endblock %}