Change: user sets date of previous calibration, not upcoming expire dates.

- seismograph list page enhanced with better visabilty, filtering, sorting, and calibration dates color coded.
This commit is contained in:
serversdwn
2026-02-06 21:17:14 +00:00
parent eb0a99796d
commit 89662d2fa5
8 changed files with 408 additions and 86 deletions

View File

@@ -9,12 +9,20 @@ import httpx
import os import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
from backend.services.slmm_sync import sync_slm_to_slmm from backend.services.slmm_sync import sync_slm_to_slmm
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_calibration_interval(db: Session) -> int:
"""Get calibration interval from user preferences, default 365 days."""
prefs = db.query(UserPreferences).first()
if prefs and prefs.calibration_interval_days:
return prefs.calibration_interval_days
return 365
# SLMM backend URL for syncing device configs to cache # SLMM backend URL for syncing device configs to cache
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
@@ -185,11 +193,11 @@ async def add_roster_unit(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
# Auto-calculate next_calibration_due (1 year from last_calibrated) # Auto-calculate next_calibration_due from last_calibrated using calibration interval
# This is calculated internally but not shown to user (they just see last_calibrated)
next_cal_date = None next_cal_date = None
if last_cal_date: if last_cal_date:
next_cal_date = last_cal_date + timedelta(days=365) cal_interval = get_calibration_interval(db)
next_cal_date = last_cal_date + timedelta(days=cal_interval)
elif next_calibration_due: elif next_calibration_due:
# Fallback: allow explicit setting if no last_calibrated # Fallback: allow explicit setting if no last_calibrated
try: try:
@@ -522,11 +530,11 @@ async def edit_roster_unit(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
# Auto-calculate next_calibration_due (1 year from last_calibrated) # Auto-calculate next_calibration_due from last_calibrated using calibration interval
# This is calculated internally but not shown to user (they just see last_calibrated)
next_cal_date = None next_cal_date = None
if last_cal_date: if last_cal_date:
next_cal_date = last_cal_date + timedelta(days=365) cal_interval = get_calibration_interval(db)
next_cal_date = last_cal_date + timedelta(days=cal_interval)
elif next_calibration_due: elif next_calibration_due:
# Fallback: allow explicit setting if no last_calibrated # Fallback: allow explicit setting if no last_calibrated
try: try:
@@ -1007,9 +1015,10 @@ async def import_csv(
if row.get('last_calibrated'): if row.get('last_calibrated'):
last_cal = _parse_date(row.get('last_calibrated')) last_cal = _parse_date(row.get('last_calibrated'))
existing_unit.last_calibrated = last_cal existing_unit.last_calibrated = last_cal
# Auto-calculate next_calibration_due (1 year from last_calibrated) # Auto-calculate next_calibration_due using calibration interval
if last_cal: if last_cal:
existing_unit.next_calibration_due = last_cal + timedelta(days=365) cal_interval = get_calibration_interval(db)
existing_unit.next_calibration_due = last_cal + timedelta(days=cal_interval)
elif row.get('next_calibration_due'): elif row.get('next_calibration_due'):
# Only use explicit next_calibration_due if no last_calibrated # Only use explicit next_calibration_due if no last_calibrated
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due')) existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
@@ -1048,6 +1057,14 @@ async def import_csv(
results["updated"].append(unit_id) results["updated"].append(unit_id)
else: else:
# Calculate next_calibration_due from last_calibrated
last_cal = _parse_date(row.get('last_calibrated', ''))
if last_cal:
cal_interval = get_calibration_interval(db)
next_cal = last_cal + timedelta(days=cal_interval)
else:
next_cal = _parse_date(row.get('next_calibration_due', ''))
# Create new unit with all fields # Create new unit with all fields
new_unit = RosterUnit( new_unit = RosterUnit(
id=unit_id, id=unit_id,
@@ -1062,12 +1079,8 @@ async def import_csv(
coordinates=_get_csv_value(row, 'coordinates'), coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(), last_updated=datetime.utcnow(),
# Seismograph fields - auto-calc next_calibration_due from last_calibrated # Seismograph fields - auto-calc next_calibration_due from last_calibrated
last_calibrated=_parse_date(row.get('last_calibrated', '')), last_calibrated=last_cal,
next_calibration_due=( next_calibration_due=next_cal,
_parse_date(row.get('last_calibrated', '')) + timedelta(days=365)
if _parse_date(row.get('last_calibrated', ''))
else _parse_date(row.get('next_calibration_due', ''))
),
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'), deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
# Modem fields # Modem fields
ip_address=_get_csv_value(row, 'ip_address'), ip_address=_get_csv_value(row, 'ip_address'),

View File

@@ -3,6 +3,8 @@ Seismograph Dashboard API Router
Provides endpoints for the seismograph-specific dashboard Provides endpoints for the seismograph-specific dashboard
""" """
from datetime import date
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -49,10 +51,14 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
async def get_seismo_units( async def get_seismo_units(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
search: str = Query(None) search: str = Query(None),
sort: str = Query("id"),
order: str = Query("asc"),
status: str = Query(None),
modem: str = Query(None)
): ):
""" """
Returns HTML partial with filterable seismograph unit list Returns HTML partial with filterable and sortable seismograph unit list
""" """
query = db.query(RosterUnit).filter_by( query = db.query(RosterUnit).filter_by(
device_type="seismograph", device_type="seismograph",
@@ -61,20 +67,52 @@ async def get_seismo_units(
# Apply search filter # Apply search filter
if search: if search:
search_lower = search.lower()
query = query.filter( query = query.filter(
(RosterUnit.id.ilike(f"%{search}%")) | (RosterUnit.id.ilike(f"%{search}%")) |
(RosterUnit.note.ilike(f"%{search}%")) | (RosterUnit.note.ilike(f"%{search}%")) |
(RosterUnit.address.ilike(f"%{search}%")) (RosterUnit.address.ilike(f"%{search}%"))
) )
seismos = query.order_by(RosterUnit.id).all() # Apply status filter
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False)
# Apply modem filter
if modem == "with":
query = query.filter(RosterUnit.deployed_with_modem_id.isnot(None))
elif modem == "without":
query = query.filter(RosterUnit.deployed_with_modem_id.is_(None))
# Apply sorting
sort_column_map = {
"id": RosterUnit.id,
"status": RosterUnit.deployed,
"modem": RosterUnit.deployed_with_modem_id,
"location": RosterUnit.address,
"last_calibrated": RosterUnit.last_calibrated,
"notes": RosterUnit.note
}
sort_column = sort_column_map.get(sort, RosterUnit.id)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
seismos = query.all()
return templates.TemplateResponse( return templates.TemplateResponse(
"partials/seismo_unit_list.html", "partials/seismo_unit_list.html",
{ {
"request": request, "request": request,
"units": seismos, "units": seismos,
"search": search or "" "search": search or "",
"sort": sort,
"order": order,
"status": status or "",
"modem": modem or "",
"today": date.today()
} }
) )

View File

@@ -115,10 +115,10 @@
</div> </div>
{% endif %} {% endif %}
{% else %} {% else %}
{% if unit.next_calibration_due %} {% if unit.last_calibrated %}
<div> <div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span> <span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span> <span class="font-medium">{{ unit.last_calibrated }}</span>
</div> </div>
{% endif %} {% endif %}
{% if unit.deployed_with_modem_id %} {% if unit.deployed_with_modem_id %}

View File

@@ -108,10 +108,10 @@
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div> <div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% endif %} {% endif %}
{% else %} {% else %}
{% if unit.next_calibration_due %} {% if unit.last_calibrated %}
<div> <div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span> <span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span> <span class="font-medium">{{ unit.last_calibrated }}</span>
</div> </div>
{% endif %} {% endif %}
{% if unit.deployed_with_modem_id %} {% if unit.deployed_with_modem_id %}

View File

@@ -1,13 +1,92 @@
{% if units %} {% if units is defined %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600"> <thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th> {% set next_order = 'desc' if (sort == 'id' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th> hx-get="/api/seismo-dashboard/units?sort=id&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th> hx-target="#seismo-units-list"
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th> hx-swap="innerHTML">
<span class="flex items-center gap-1">
Unit ID
{% if sort == 'id' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'status' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=status&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Status
{% if sort == 'status' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'modem' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=modem&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Modem
{% if sort == 'modem' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'location' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=location&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Location
{% if sort == 'location' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'last_calibrated' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=last_calibrated&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Last Calibrated
{% if sort == 'last_calibrated' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'notes' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=notes&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Notes
{% if sort == 'notes' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th> <th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -54,6 +133,27 @@
<span class="text-gray-400 dark:text-gray-600"></span> <span class="text-gray-400 dark:text-gray-600"></span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400"> <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %} {% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span> <span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
@@ -72,9 +172,12 @@
</table> </table>
</div> </div>
{% if search %} {% if search or status or modem %}
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400"> <div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Found {{ units|length }} seismograph(s) matching "{{ search }}" Found {{ units|length }} seismograph(s)
{% if search %} matching "{{ search }}"{% endif %}
{% if status %} ({{ status }}){% endif %}
{% if modem %} ({{ 'with modem' if modem == 'with' else 'without modem' }}){% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -145,16 +145,12 @@
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"> <div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p> <p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" <input type="date" name="last_calibrated" id="addLastCalibrated"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Next calibration due date will be calculated automatically</p>
</div> </div>
<div> <input type="hidden" name="next_calibration_due" id="addNextCalibrationDue">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Typically 1 year after last calibration</p>
</div>
<div id="modemPairingField" class="hidden"> <div id="modemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
{% set picker_id = "-add-seismo" %} {% set picker_id = "-add-seismo" %}
@@ -325,15 +321,12 @@
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"> <div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p> <p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="editLastCalibrated" <input type="date" name="last_calibrated" id="editLastCalibrated"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Next calibration due date will be calculated automatically</p>
</div> </div>
<div> <input type="hidden" name="next_calibration_due" id="editNextCalibrationDue">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="editNextCalibrationDue"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div id="editModemPairingField" class="hidden"> <div id="editModemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
{% set picker_id = "-edit-seismo" %} {% set picker_id = "-edit-seismo" %}
@@ -598,6 +591,58 @@
</div> </div>
<script> <script>
// Calibration interval in days (default 365, will be loaded from preferences)
let calibrationIntervalDays = 365;
// Load calibration interval from preferences
async function loadCalibrationInterval() {
try {
const response = await fetch('/api/settings/preferences');
if (response.ok) {
const prefs = await response.json();
calibrationIntervalDays = prefs.calibration_interval_days || 365;
}
} catch (e) {
console.error('Failed to load calibration interval:', e);
}
}
// Calculate next calibration due date from last calibrated date
function calculateNextCalibrationDue(lastCalibratedStr) {
if (!lastCalibratedStr) return '';
const lastCalibrated = new Date(lastCalibratedStr);
const nextDue = new Date(lastCalibrated);
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
return nextDue.toISOString().split('T')[0];
}
// Setup auto-calculation for calibration fields
function setupCalibrationAutoCalc() {
// Add form
const addLastCal = document.getElementById('addLastCalibrated');
const addNextCal = document.getElementById('addNextCalibrationDue');
if (addLastCal && addNextCal) {
addLastCal.addEventListener('change', function() {
addNextCal.value = calculateNextCalibrationDue(this.value);
});
}
// Edit form
const editLastCal = document.getElementById('editLastCalibrated');
const editNextCal = document.getElementById('editNextCalibrationDue');
if (editLastCal && editNextCal) {
editLastCal.addEventListener('change', function() {
editNextCal.value = calculateNextCalibrationDue(this.value);
});
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
loadCalibrationInterval();
setupCalibrationAutoCalc();
});
// Add Unit Modal // Add Unit Modal
function openAddUnitModal() { function openAddUnitModal() {
document.getElementById('addUnitModal').classList.remove('hidden'); document.getElementById('addUnitModal').classList.remove('hidden');
@@ -891,8 +936,11 @@
document.getElementById('editRetiredCheckbox').checked = unit.retired; document.getElementById('editRetiredCheckbox').checked = unit.retired;
// Seismograph fields // Seismograph fields
document.getElementById('editLastCalibrated').value = unit.last_calibrated; document.getElementById('editLastCalibrated').value = unit.last_calibrated || '';
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due; // Calculate next calibration due from last calibrated
document.getElementById('editNextCalibrationDue').value = unit.last_calibrated
? calculateNextCalibrationDue(unit.last_calibrated)
: '';
// Populate modem picker for seismograph (uses -edit-seismo suffix) // Populate modem picker for seismograph (uses -edit-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo'); const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');

View File

@@ -27,7 +27,8 @@
<!-- Seismograph List --> <!-- Seismograph List -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="mb-4 flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
<!-- Search Box --> <!-- Search Box -->
@@ -37,10 +38,6 @@
id="seismo-search" id="seismo-search"
placeholder="Search seismographs..." placeholder="Search seismographs..."
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
hx-get="/api/seismo-dashboard/units"
hx-trigger="keyup changed delay:300ms"
hx-target="#seismo-units-list"
hx-include="[name='search']"
name="search" name="search"
/> />
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -49,6 +46,34 @@
</div> </div>
</div> </div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-600 dark:text-gray-400">Filter:</span>
<!-- Status Filter -->
<select id="seismo-status-filter" name="status"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">All Status</option>
<option value="deployed">Deployed</option>
<option value="benched">Benched</option>
</select>
<!-- Modem Filter -->
<select id="seismo-modem-filter" name="modem"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">All Modems</option>
<option value="with">With Modem</option>
<option value="without">Without Modem</option>
</select>
<!-- Clear Filters Button -->
<button id="seismo-clear-filters" type="button"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear Filters
</button>
</div>
</div>
<!-- Units List (loaded via HTMX) --> <!-- Units List (loaded via HTMX) -->
<div id="seismo-units-list" <div id="seismo-units-list"
hx-get="/api/seismo-dashboard/units" hx-get="/api/seismo-dashboard/units"
@@ -59,17 +84,53 @@
</div> </div>
<script> <script>
// Clear search input on escape key
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('seismo-search'); const searchInput = document.getElementById('seismo-search');
if (searchInput) { const statusFilter = document.getElementById('seismo-status-filter');
const modemFilter = document.getElementById('seismo-modem-filter');
const clearBtn = document.getElementById('seismo-clear-filters');
const unitsList = document.getElementById('seismo-units-list');
// Build URL with current filter values
function buildUrl() {
const params = new URLSearchParams();
if (searchInput.value) params.set('search', searchInput.value);
if (statusFilter.value) params.set('status', statusFilter.value);
if (modemFilter.value) params.set('modem', modemFilter.value);
return '/api/seismo-dashboard/units' + (params.toString() ? '?' + params.toString() : '');
}
// Trigger HTMX refresh
function refreshList() {
htmx.ajax('GET', buildUrl(), {target: '#seismo-units-list', swap: 'innerHTML'});
}
// Search input with debounce
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshList, 300);
});
// Clear search on escape
searchInput.addEventListener('keydown', function(e) { searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
this.value = ''; this.value = '';
htmx.trigger(this, 'keyup'); refreshList();
} }
}); });
}
// Filter changes
statusFilter.addEventListener('change', refreshList);
modemFilter.addEventListener('change', refreshList);
// Clear all filters
clearBtn.addEventListener('click', function() {
searchInput.value = '';
statusFilter.value = '';
modemFilter.value = '';
refreshList();
});
}); });
</script> </script>

View File

@@ -144,12 +144,12 @@
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Date of Last Calibration</label>
<p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p> <p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p> <p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium inline-flex items-center gap-2">--</p>
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
@@ -378,15 +378,12 @@
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="lastCalibrated" <input type="date" name="last_calibrated" id="lastCalibrated"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Next calibration due date will be calculated automatically</p>
</div> </div>
<div> <input type="hidden" name="next_calibration_due" id="nextCalibrationDue">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="nextCalibrationDue"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -589,6 +586,42 @@ let currentSnapshot = null;
let unitMap = null; let unitMap = null;
let mapMarker = null; let mapMarker = null;
// Calibration interval in days (default 365, will be loaded from preferences)
let calibrationIntervalDays = 365;
// Load calibration interval from preferences
async function loadCalibrationInterval() {
try {
const response = await fetch('/api/settings/preferences');
if (response.ok) {
const prefs = await response.json();
calibrationIntervalDays = prefs.calibration_interval_days || 365;
}
} catch (e) {
console.error('Failed to load calibration interval:', e);
}
}
// Calculate next calibration due date from last calibrated date
function calculateNextCalibrationDue(lastCalibratedStr) {
if (!lastCalibratedStr) return '';
const lastCalibrated = new Date(lastCalibratedStr);
const nextDue = new Date(lastCalibrated);
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
return nextDue.toISOString().split('T')[0];
}
// Setup auto-calculation for calibration fields
function setupCalibrationAutoCalc() {
const lastCal = document.getElementById('lastCalibrated');
const nextCal = document.getElementById('nextCalibrationDue');
if (lastCal && nextCal) {
lastCal.addEventListener('change', function() {
nextCal.value = calculateNextCalibrationDue(this.value);
});
}
}
// Fetch project display name (combines project_number, client_name, name) // Fetch project display name (combines project_number, client_name, name)
async function fetchProjectDisplay(projectId) { async function fetchProjectDisplay(projectId) {
if (!projectId) return ''; if (!projectId) return '';
@@ -819,7 +852,28 @@ function populateViewMode() {
// Seismograph fields // Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--'; document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
// Calculate next calibration due and show with status indicator
const nextCalDueEl = document.getElementById('viewNextCalibrationDue');
if (currentUnit.last_calibrated) {
const nextDue = calculateNextCalibrationDue(currentUnit.last_calibrated);
const today = new Date().toISOString().split('T')[0];
const daysUntil = Math.floor((new Date(nextDue) - new Date(today)) / (1000 * 60 * 60 * 24));
let dotColor = 'bg-green-500';
let tooltip = `Calibration valid (${daysUntil} days remaining)`;
if (daysUntil < 0) {
dotColor = 'bg-red-500';
tooltip = `Calibration expired ${-daysUntil} days ago`;
} else if (daysUntil <= 14) {
dotColor = 'bg-yellow-500';
tooltip = `Calibration expires in ${daysUntil} days`;
}
nextCalDueEl.innerHTML = `<span class="w-2 h-2 rounded-full ${dotColor}" title="${tooltip}"></span>${nextDue}`;
} else {
nextCalDueEl.textContent = '--';
}
// Deployed with modem - show as clickable link // Deployed with modem - show as clickable link
const modemLink = document.getElementById('viewDeployedWithModemLink'); const modemLink = document.getElementById('viewDeployedWithModemLink');
@@ -960,7 +1014,10 @@ function populateEditForm() {
// Seismograph fields // Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || ''; document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || ''; // Calculate next calibration due from last calibrated
document.getElementById('nextCalibrationDue').value = currentUnit.last_calibrated
? calculateNextCalibrationDue(currentUnit.last_calibrated)
: '';
// Populate modem picker for seismograph (uses -detail-seismo suffix) // Populate modem picker for seismograph (uses -detail-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo'); const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
@@ -1535,6 +1592,8 @@ async function pingModem() {
} }
// Load data when page loads // Load data when page loads
loadCalibrationInterval();
setupCalibrationAutoCalc();
loadUnitData().then(() => { loadUnitData().then(() => {
loadPhotos(); loadPhotos();
loadUnitHistory(); loadUnitHistory();