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) {