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:
@@ -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 (0–23)</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 (0–23)</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) {
|
||||
|
||||
Reference in New Issue
Block a user