Update to 0.9.3 #43

Merged
serversdown merged 9 commits from dev into main 2026-03-28 13:49:01 -04:00
16 changed files with 1753 additions and 171 deletions

View File

@@ -5,6 +5,33 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.3] - 2026-03-28
### Added
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
### Fixed
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
### Changed
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
```
---
## [0.9.2] - 2026-03-27 ## [0.9.2] - 2026-03-27
### Added ### Added

View File

@@ -1,4 +1,4 @@
# Terra-View v0.9.2 # Terra-View v0.9.3
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.9.2" VERSION = "0.9.3"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":
@@ -355,8 +355,11 @@ async def nrl_detail_page(
).first() ).first()
assigned_unit = None assigned_unit = None
assigned_modem = None
if assignment: if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if assigned_unit and assigned_unit.deployed_with_modem_id:
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
# Get session count # Get session count
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count() session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
@@ -393,6 +396,7 @@ async def nrl_detail_page(
"location": location, "location": location,
"assignment": assignment, "assignment": assignment,
"assigned_unit": assigned_unit, "assigned_unit": assigned_unit,
"assigned_modem": assigned_modem,
"session_count": session_count, "session_count": session_count,
"file_count": file_count, "file_count": file_count,
"active_session": active_session, "active_session": active_session,

View File

@@ -0,0 +1,42 @@
"""
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
Run once:
python backend/migrate_add_session_period_hours.py
Or inside the container:
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import engine
from sqlalchemy import text
def run():
with engine.connect() as conn:
# Check which columns already exist
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
existing = {row[1] for row in result}
added = []
for col, definition in [
("period_start_hour", "INTEGER"),
("period_end_hour", "INTEGER"),
]:
if col not in existing:
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
added.append(col)
else:
print(f" Column '{col}' already exists — skipping.")
conn.commit()
if added:
print(f" Added columns: {', '.join(added)}")
print("Migration complete.")
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,41 @@
"""
Migration: add report_date to monitoring_sessions.
Run once:
python backend/migrate_add_session_report_date.py
Or inside the container:
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import engine
from sqlalchemy import text
def run():
with engine.connect() as conn:
# Check which columns already exist
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
existing = {row[1] for row in result}
added = []
for col, definition in [
("report_date", "DATE"),
]:
if col not in existing:
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
added.append(col)
else:
print(f" Column '{col}' already exists — skipping.")
conn.commit()
if added:
print(f" Added columns: {', '.join(added)}")
print("Migration complete.")
if __name__ == "__main__":
run()

View File

@@ -303,6 +303,17 @@ class MonitoringSession(Base):
# weekday_day | weekday_night | weekend_day | weekend_night # weekday_day | weekday_night | weekend_day | weekend_night
period_type = Column(String, nullable=True) period_type = Column(String, nullable=True)
# Effective monitoring window (hours 023). Night sessions cross midnight
# (period_end_hour < period_start_hour). NULL = no filtering applied.
# e.g. Day: start=7, end=19 Night: start=19, end=7
period_start_hour = Column(Integer, nullable=True)
period_end_hour = Column(Integer, nullable=True)
# For day sessions: the specific calendar date to use for report filtering.
# Overrides the automatic "last date with daytime rows" heuristic.
# Null = use heuristic.
report_date = Column(Date, nullable=True)
# Snapshot of device configuration at recording time # Snapshot of device configuration at recording time
session_metadata = Column(Text, nullable=True) # JSON session_metadata = Column(Text, nullable=True) # JSON

View File

@@ -31,10 +31,24 @@ from backend.models import (
DataFile, DataFile,
) )
from backend.templates_config import templates from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
# ============================================================================
# Shared helpers
# ============================================================================
def _require_sound_project(project) -> None:
"""Raise 400 if the project is not a sound_monitoring project."""
if not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
)
# ============================================================================ # ============================================================================
# Session period helpers # Session period helpers
# ============================================================================ # ============================================================================
@@ -98,11 +112,11 @@ async def get_project_locations(
# Enrich with assignment info # Enrich with assignment info
locations_data = [] locations_data = []
for location in locations: for location in locations:
# Get active assignment # Get active assignment (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter( assignment = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.location_id == location.id, UnitAssignment.location_id == location.id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).first() ).first()
@@ -258,11 +272,11 @@ async def delete_location(
if not location: if not location:
raise HTTPException(status_code=404, detail="Location not found") raise HTTPException(status_code=404, detail="Location not found")
# Check if location has active assignments # Check if location has active assignments (active = assigned_until IS NULL)
active_assignments = db.query(UnitAssignment).filter( active_assignments = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.location_id == location_id, UnitAssignment.location_id == location_id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).count() ).count()
@@ -353,18 +367,18 @@ async def assign_unit_to_location(
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
) )
# Check if location already has an active assignment # Check if location already has an active assignment (active = assigned_until IS NULL)
existing_assignment = db.query(UnitAssignment).filter( existing_assignment = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.location_id == location_id, UnitAssignment.location_id == location_id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).first() ).first()
if existing_assignment: if existing_assignment:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.", detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
) )
# Create new assignment # Create new assignment
@@ -433,10 +447,120 @@ async def unassign_unit(
return {"success": True, "message": "Unit unassigned successfully"} return {"success": True, "message": "Unit unassigned successfully"}
@router.post("/locations/{location_id}/swap")
async def swap_unit_on_location(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Swap the unit assigned to a vibration monitoring location.
Ends the current active assignment (if any), creates a new one,
and optionally updates modem pairing on the seismograph.
Works for first-time assignments too (no current assignment = just create).
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
form_data = await request.form()
unit_id = form_data.get("unit_id")
modem_id = form_data.get("modem_id") or None
notes = form_data.get("notes") or None
if not unit_id:
raise HTTPException(status_code=400, detail="unit_id is required")
# Validate new unit
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
if unit.device_type != expected_device_type:
raise HTTPException(
status_code=400,
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
)
# End current active assignment if one exists (active = assigned_until IS NULL)
current = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None,
)
).first()
if current:
current.assigned_until = datetime.utcnow()
current.status = "completed"
# Create new assignment
new_assignment = UnitAssignment(
id=str(uuid.uuid4()),
unit_id=unit_id,
location_id=location_id,
project_id=project_id,
device_type=unit.device_type,
assigned_until=None,
status="active",
notes=notes,
)
db.add(new_assignment)
# Update modem pairing on the seismograph if modem provided
if modem_id:
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
unit.deployed_with_modem_id = modem_id
modem.deployed_with_unit_id = unit_id
else:
# Clear modem pairing if not provided
unit.deployed_with_modem_id = None
db.commit()
return JSONResponse({
"success": True,
"assignment_id": new_assignment.id,
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
})
# ============================================================================ # ============================================================================
# Available Units for Assignment # Available Units for Assignment
# ============================================================================ # ============================================================================
@router.get("/available-modems", response_class=JSONResponse)
async def get_available_modems(
project_id: str,
db: Session = Depends(get_db),
):
"""
Get all deployed, non-retired modems for the modem assignment dropdown.
"""
modems = db.query(RosterUnit).filter(
and_(
RosterUnit.device_type == "modem",
RosterUnit.deployed == True,
RosterUnit.retired == False,
)
).order_by(RosterUnit.id).all()
return [
{
"id": m.id,
"hardware_model": m.hardware_model,
"ip_address": m.ip_address,
}
for m in modems
]
@router.get("/available-units", response_class=JSONResponse) @router.get("/available-units", response_class=JSONResponse)
async def get_available_units( async def get_available_units(
project_id: str, project_id: str,
@@ -459,9 +583,9 @@ async def get_available_units(
) )
).all() ).all()
# Filter out units that already have active assignments # Filter out units that already have active assignments (active = assigned_until IS NULL)
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter( assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
UnitAssignment.status == "active" UnitAssignment.assigned_until == None
).distinct().all() ).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids] assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
@@ -637,6 +761,9 @@ async def upload_nrl_data(
from datetime import datetime from datetime import datetime
# Verify project and location exist # Verify project and location exist
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by( location = db.query(MonitoringLocation).filter_by(
id=location_id, project_id=project_id id=location_id, project_id=project_id
).first() ).first()
@@ -698,8 +825,15 @@ async def upload_nrl_data(
rnh_meta = _parse_rnh(fbytes) rnh_meta = _parse_rnh(fbytes)
break break
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow() # RNH files store local time (no UTC offset). Use local values for period
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str")) # classification / label generation, then convert to UTC for DB storage so
# the local_datetime Jinja filter displays the correct time.
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
started_at = local_to_utc(started_at_local)
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
duration_seconds = None duration_seconds = None
if started_at and stopped_at: if started_at and stopped_at:
duration_seconds = int((stopped_at - started_at).total_seconds()) duration_seconds = int((stopped_at - started_at).total_seconds())
@@ -709,8 +843,9 @@ async def upload_nrl_data(
index_number = rnh_meta.get("index_number", "") index_number = rnh_meta.get("index_number", "")
# --- Step 3: Create MonitoringSession --- # --- Step 3: Create MonitoringSession ---
period_type = _derive_period_type(started_at) if started_at else None # Use local times for period/label so classification reflects the clock at the site.
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None period_type = _derive_period_type(started_at_local) if started_at_local else None
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
monitoring_session = MonitoringSession( monitoring_session = MonitoringSession(
@@ -815,15 +950,18 @@ async def get_nrl_live_status(
Fetch cached status from SLMM for the unit assigned to this NRL and Fetch cached status from SLMM for the unit assigned to this NRL and
return a compact HTML status card. Used in the NRL overview tab for return a compact HTML status card. Used in the NRL overview tab for
connected NRLs. Gracefully shows an offline message if SLMM is unreachable. connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
Sound Monitoring projects only.
""" """
import os import os
import httpx import httpx
# Find the assigned unit _require_sound_project(db.query(Project).filter_by(id=project_id).first())
# Find the assigned unit (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter( assignment = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.location_id == location_id, UnitAssignment.location_id == location_id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).first() ).first()

View File

@@ -20,7 +20,7 @@ import json
import logging import logging
import io import io
from backend.utils.timezone import utc_to_local, format_local_datetime from backend.utils.timezone import utc_to_local, format_local_datetime, local_to_utc
from backend.database import get_db from backend.database import get_db
from fastapi import UploadFile, File from fastapi import UploadFile, File
@@ -45,6 +45,21 @@ router = APIRouter(prefix="/api/projects", tags=["projects"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ============================================================================
# Shared helpers
# ============================================================================
def _require_sound_project(project: Project) -> None:
"""Raise 400 if the project is not a sound_monitoring project.
Call this at the top of any endpoint that only makes sense for sound projects
(report generation, FTP browser, RND file viewer, etc.)."""
if not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
)
# ============================================================================ # ============================================================================
# RND file normalization — maps AU2 (older Rion) column names to the NL-43 # RND file normalization — maps AU2 (older Rion) column names to the NL-43
# equivalents so report generation and the web viewer work for both formats. # equivalents so report generation and the web viewer work for both formats.
@@ -398,11 +413,11 @@ async def get_projects_list(
project_id=project.id project_id=project.id
).scalar() ).scalar()
# Count assigned units # Count assigned units (active = assigned_until IS NULL)
unit_count = db.query(func.count(UnitAssignment.id)).filter( unit_count = db.query(func.count(UnitAssignment.id)).filter(
and_( and_(
UnitAssignment.project_id == project.id, UnitAssignment.project_id == project.id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).scalar() ).scalar()
@@ -806,11 +821,11 @@ async def get_project_dashboard(
# Get locations # Get locations
locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all() locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()
# Get assigned units with details # Get assigned units with details (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter( assignments = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.project_id == project_id, UnitAssignment.project_id == project_id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).all() ).all()
@@ -899,11 +914,11 @@ async def get_project_units(
""" """
from backend.models import DataFile from backend.models import DataFile
# Get all assignments for this project # Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter( assignments = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.project_id == project_id, UnitAssignment.project_id == project_id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).all() ).all()
@@ -1128,7 +1143,7 @@ async def get_project_sessions(
sessions = query.order_by(MonitoringSession.started_at.desc()).all() sessions = query.order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit and location details # Enrich with unit, location, and effective time window details
sessions_data = [] sessions_data = []
for session in sessions: for session in sessions:
unit = None unit = None
@@ -1139,10 +1154,34 @@ async def get_project_sessions(
if session.location_id: if session.location_id:
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
# Compute "Effective: date time → date time" string when period hours are set
effective_range = None
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
from datetime import date as _date
local_start = utc_to_local(session.started_at)
start_day = session.report_date if session.report_date else local_start.date()
sh = session.period_start_hour
eh = session.period_end_hour
def _fmt_h(h):
ampm = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:00 {ampm}"
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
if eh > sh: # same calendar day
end_day = start_day
else: # crosses midnight
from datetime import timedelta as _td
end_day = start_day + _td(days=1)
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
effective_range = f"{start_str}{end_str}"
sessions_data.append({ sessions_data.append({
"session": session, "session": session,
"unit": unit, "unit": unit,
"location": location, "location": location,
"effective_range": effective_range,
}) })
return templates.TemplateResponse("partials/projects/session_list.html", { return templates.TemplateResponse("partials/projects/session_list.html", {
@@ -1152,6 +1191,173 @@ async def get_project_sessions(
}) })
@router.get("/{project_id}/sessions-calendar", response_class=HTMLResponse)
async def get_sessions_calendar(
project_id: str,
request: Request,
db: Session = Depends(get_db),
month: Optional[int] = Query(None),
year: Optional[int] = Query(None),
):
"""
Monthly calendar view of monitoring sessions.
Color-coded by NRL location. Returns HTML partial.
"""
from calendar import monthrange
from datetime import date as _date, timedelta as _td
# Default to current month
now_local = utc_to_local(datetime.utcnow())
if not year:
year = now_local.year
if not month:
month = now_local.month
# Clamp month to valid range
month = max(1, min(12, month))
# Load all sessions for this project
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
# Build location -> color map (deterministic)
PALETTE = [
"#f97316", "#3b82f6", "#10b981", "#8b5cf6",
"#ec4899", "#14b8a6", "#f59e0b", "#6366f1",
"#ef4444", "#84cc16",
]
loc_ids = sorted({s.location_id for s in sessions if s.location_id})
loc_color = {lid: PALETTE[i % len(PALETTE)] for i, lid in enumerate(loc_ids)}
# Load location names
loc_names = {}
for lid in loc_ids:
loc = db.query(MonitoringLocation).filter_by(id=lid).first()
if loc:
loc_names[lid] = loc.name
# Build calendar grid bounds first (needed for session spanning logic)
first_day = _date(year, month, 1)
last_day = _date(year, month, monthrange(year, month)[1])
days_before = (first_day.isoweekday() % 7)
grid_start = first_day - _td(days=days_before)
days_after = 6 - (last_day.isoweekday() % 7)
grid_end = last_day + _td(days=days_after)
def _period_hours(s):
"""Return (start_hour, end_hour) for a session, falling back to period_type defaults."""
psh, peh = s.period_start_hour, s.period_end_hour
if psh is None or peh is None:
if s.period_type and "night" in s.period_type:
return 19, 7
if s.period_type and "day" in s.period_type:
return 7, 19
return psh, peh
# Build day -> list of gantt segments
day_sessions: dict = {}
for s in sessions:
if not s.started_at:
continue
local_start = utc_to_local(s.started_at)
local_end = utc_to_local(s.stopped_at) if s.stopped_at else now_local
span_start = local_start.date()
span_end = local_end.date()
psh, peh = _period_hours(s)
cur_d = span_start
while cur_d <= span_end:
if grid_start <= cur_d <= grid_end:
# Device bar bounds (hours 024 within this day)
dev_sh = (local_start.hour + local_start.minute / 60.0) if cur_d == span_start else 0.0
dev_eh = (local_end.hour + local_end.minute / 60.0) if cur_d == span_end else 24.0
# Effective window within this day
eff_sh = eff_eh = None
if psh is not None and peh is not None:
if psh < peh:
# Day window e.g. 7→19
eff_sh, eff_eh = float(psh), float(peh)
else:
# Night window crossing midnight e.g. 19→7
if cur_d == span_start:
eff_sh, eff_eh = float(psh), 24.0
else:
eff_sh, eff_eh = 0.0, float(peh)
# Format tooltip labels
def _fmt_h(h):
hh = int(h) % 24
mm = int((h % 1) * 60)
suffix = "AM" if hh < 12 else "PM"
return f"{hh % 12 or 12}:{mm:02d} {suffix}"
if cur_d not in day_sessions:
day_sessions[cur_d] = []
day_sessions[cur_d].append({
"session_id": s.id,
"label": s.session_label or f"Session {s.id[:8]}",
"location_id": s.location_id,
"location_name": loc_names.get(s.location_id, "Unknown"),
"color": loc_color.get(s.location_id, "#9ca3af"),
"status": s.status,
"period_type": s.period_type,
# Gantt bar percentages (0100 scale across 24 hours)
"dev_start_pct": round(dev_sh / 24 * 100, 1),
"dev_width_pct": max(1.5, round((dev_eh - dev_sh) / 24 * 100, 1)),
"eff_start_pct": round(eff_sh / 24 * 100, 1) if eff_sh is not None else None,
"eff_width_pct": max(1.0, round((eff_eh - eff_sh) / 24 * 100, 1)) if eff_sh is not None else None,
"dev_start_label": _fmt_h(dev_sh),
"dev_end_label": _fmt_h(dev_eh),
"eff_start_label": f"{int(psh):02d}:00" if eff_sh is not None else None,
"eff_end_label": f"{int(peh):02d}:00" if eff_sh is not None else None,
})
cur_d += _td(days=1)
weeks = []
cur = grid_start
while cur <= grid_end:
week = []
for _ in range(7):
week.append({
"date": cur,
"in_month": cur.month == month,
"is_today": cur == now_local.date(),
"sessions": day_sessions.get(cur, []),
})
cur += _td(days=1)
weeks.append(week)
# Prev/next month navigation
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
next_month = month + 1 if month < 12 else 1
next_year = year if month < 12 else year + 1
import calendar as _cal
month_name = _cal.month_name[month]
# Legend: only locations that have sessions this month
used_lids = {s["location_id"] for day in day_sessions.values() for s in day}
legend = [
{"location_id": lid, "name": loc_names.get(lid, lid[:8]), "color": loc_color[lid]}
for lid in loc_ids if lid in used_lids
]
return templates.TemplateResponse("partials/projects/sessions_calendar.html", {
"request": request,
"project_id": project_id,
"weeks": weeks,
"month": month,
"year": year,
"month_name": month_name,
"prev_month": prev_month,
"prev_year": prev_year,
"next_month": next_month,
"next_year": next_year,
"legend": legend,
})
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse) @router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
async def get_ftp_browser( async def get_ftp_browser(
project_id: str, project_id: str,
@@ -1160,15 +1366,18 @@ async def get_ftp_browser(
): ):
""" """
Get FTP browser interface for downloading files from assigned SLMs. Get FTP browser interface for downloading files from assigned SLMs.
Returns HTML partial with FTP browser. Returns HTML partial with FTP browser. Sound Monitoring projects only.
""" """
from backend.models import DataFile from backend.models import DataFile
# Get all assignments for this project project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
# Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter( assignments = db.query(UnitAssignment).filter(
and_( and_(
UnitAssignment.project_id == project_id, UnitAssignment.project_id == project_id,
UnitAssignment.status == "active", UnitAssignment.assigned_until == None,
) )
).all() ).all()
@@ -1202,6 +1411,7 @@ async def ftp_download_to_server(
""" """
Download a file from an SLM to the server via FTP. Download a file from an SLM to the server via FTP.
Creates a DataFile record and stores the file in data/Projects/{project_id}/ Creates a DataFile record and stores the file in data/Projects/{project_id}/
Sound Monitoring projects only.
""" """
import httpx import httpx
import os import os
@@ -1209,6 +1419,8 @@ async def ftp_download_to_server(
from pathlib import Path from pathlib import Path
from backend.models import DataFile from backend.models import DataFile
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
data = await request.json() data = await request.json()
unit_id = data.get("unit_id") unit_id = data.get("unit_id")
remote_path = data.get("remote_path") remote_path = data.get("remote_path")
@@ -1367,12 +1579,15 @@ async def ftp_download_folder_to_server(
Download an entire folder from an SLM to the server via FTP. Download an entire folder from an SLM to the server via FTP.
Extracts all files from the ZIP and preserves folder structure. Extracts all files from the ZIP and preserves folder structure.
Creates individual DataFile records for each file. Creates individual DataFile records for each file.
Sound Monitoring projects only.
""" """
import httpx import httpx
import os import os
import hashlib import hashlib
import zipfile import zipfile
import io import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
from pathlib import Path from pathlib import Path
from backend.models import DataFile from backend.models import DataFile
@@ -1800,6 +2015,23 @@ async def delete_session(
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"} VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
def _derive_period_type(dt: datetime) -> str:
is_weekend = dt.weekday() >= 5
is_night = dt.hour >= 22 or dt.hour < 7
if is_weekend:
return "weekend_night" if is_night else "weekend_day"
return "weekday_night" if is_night else "weekday_day"
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
day_abbr = dt.strftime("%a")
date_str = f"{dt.month}/{dt.day}"
period_str = {"weekday_day": "Day", "weekday_night": "Night", "weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
return "".join(parts)
@router.patch("/{project_id}/sessions/{session_id}") @router.patch("/{project_id}/sessions/{session_id}")
async def patch_session( async def patch_session(
project_id: str, project_id: str,
@@ -1807,13 +2039,53 @@ async def patch_session(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update session_label and/or period_type on a monitoring session.""" """Update session fields: started_at, stopped_at, session_label, period_type."""
session = db.query(MonitoringSession).filter_by(id=session_id).first() session = db.query(MonitoringSession).filter_by(id=session_id).first()
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id: if session.project_id != project_id:
raise HTTPException(status_code=403, detail="Session does not belong to this project") raise HTTPException(status_code=403, detail="Session does not belong to this project")
times_changed = False
if "started_at" in data and data["started_at"]:
try:
local_dt = datetime.fromisoformat(data["started_at"])
session.started_at = local_to_utc(local_dt)
times_changed = True
except ValueError:
raise HTTPException(status_code=400, detail="Invalid started_at datetime format")
if "stopped_at" in data:
if data["stopped_at"]:
try:
local_dt = datetime.fromisoformat(data["stopped_at"])
session.stopped_at = local_to_utc(local_dt)
times_changed = True
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stopped_at datetime format")
else:
session.stopped_at = None
times_changed = True
if times_changed and session.started_at and session.stopped_at:
delta = session.stopped_at - session.started_at
session.duration_seconds = max(0, int(delta.total_seconds()))
elif times_changed and not session.stopped_at:
session.duration_seconds = None
# Re-derive period_type and session_label from new started_at unless explicitly provided
if times_changed and session.started_at and "period_type" not in data:
local_start = utc_to_local(session.started_at)
session.period_type = _derive_period_type(local_start)
if times_changed and session.started_at and "session_label" not in data:
from backend.models import MonitoringLocation
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
location_name = location.name if location else ""
local_start = utc_to_local(session.started_at)
session.session_label = _build_session_label(local_start, location_name, session.period_type or "")
if "session_label" in data: if "session_label" in data:
session.session_label = str(data["session_label"]).strip() or None session.session_label = str(data["session_label"]).strip() or None
if "period_type" in data: if "period_type" in data:
@@ -1822,8 +2094,111 @@ async def patch_session(
raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}") raise HTTPException(status_code=400, detail=f"Invalid period_type. Must be one of: {', '.join(sorted(VALID_PERIOD_TYPES))}")
session.period_type = pt or None session.period_type = pt or None
# Configurable period window (023 integers; null = no filter)
for field in ("period_start_hour", "period_end_hour"):
if field in data:
val = data[field]
if val is None or val == "":
setattr(session, field, None)
else:
try:
h = int(val)
if not (0 <= h <= 23):
raise ValueError
setattr(session, field, h)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"{field} must be an integer 023 or null")
if "report_date" in data:
val = data["report_date"]
if val is None or val == "":
session.report_date = None
else:
try:
from datetime import date as _date
session.report_date = _date.fromisoformat(str(val))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid report_date format. Use YYYY-MM-DD.")
db.commit() db.commit()
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type}) return JSONResponse({
"status": "success",
"session_label": session.session_label,
"period_type": session.period_type,
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
"report_date": session.report_date.isoformat() if session.report_date else None,
})
@router.get("/{project_id}/sessions/{session_id}/detail", response_class=HTMLResponse)
async def view_session_detail(
request: Request,
project_id: str,
session_id: str,
db: Session = Depends(get_db),
):
"""
Session detail page: shows files, editable session info, data preview, and report actions.
"""
from backend.models import DataFile
from pathlib import Path
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
# Load all data files for this session
files = db.query(DataFile).filter_by(session_id=session_id).order_by(DataFile.created_at).all()
# Compute effective time range string for display
effective_range = None
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
local_start = utc_to_local(session.started_at)
start_day = session.report_date if session.report_date else local_start.date()
sh = session.period_start_hour
eh = session.period_end_hour
def _fmt_h(h):
ampm = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:00 {ampm}"
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
if eh > sh:
end_day = start_day
else:
from datetime import timedelta as _td
end_day = start_day + _td(days=1)
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
effective_range = f"{start_str}{end_str}"
# Parse session_metadata if present
session_meta = {}
if session.session_metadata:
try:
session_meta = json.loads(session.session_metadata)
except Exception:
pass
return templates.TemplateResponse("session_detail.html", {
"request": request,
"project": project,
"project_id": project_id,
"session": session,
"location": location,
"unit": unit,
"files": files,
"effective_range": effective_range,
"session_meta": session_meta,
"report_date": session.report_date.isoformat() if session.report_date else "",
})
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse) @router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
@@ -1858,6 +2233,7 @@ async def view_rnd_file(
# Get project info # Get project info
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
# Get location info if available # Get location info if available
location = None location = None
@@ -1889,6 +2265,8 @@ async def view_rnd_file(
"metadata": metadata, "metadata": metadata,
"filename": file_path.name, "filename": file_path.name,
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)), "is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
}) })
@@ -1901,12 +2279,15 @@ async def get_rnd_data(
""" """
Get parsed RND file data as JSON. Get parsed RND file data as JSON.
Returns the measurement data for charts and tables. Returns the measurement data for charts and tables.
Sound Monitoring projects only.
""" """
from backend.models import DataFile from backend.models import DataFile
from pathlib import Path from pathlib import Path
import csv import csv
import io import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
# Get the file record # Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first() file_record = db.query(DataFile).filter_by(id=file_id).first()
if not file_record: if not file_record:
@@ -1995,6 +2376,8 @@ async def get_rnd_data(
"summary": summary, "summary": summary,
"headers": summary["headers"], "headers": summary["headers"],
"data": rows, "data": rows,
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
} }
except Exception as e: except Exception as e:
@@ -2063,6 +2446,7 @@ async def generate_excel_report(
# Get related data for report context # Get related data for report context
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path # Build full file path
@@ -2349,7 +2733,7 @@ async def generate_excel_report(
_plot_border.ln.solidFill = "000000" _plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700 _plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "I4")
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 --- # --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
@@ -2493,6 +2877,7 @@ async def preview_report_data(
# Get related data for report context # Get related data for report context
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path # Build full file path
@@ -2704,6 +3089,7 @@ async def generate_report_from_preview(
raise HTTPException(status_code=403, detail="File does not belong to this project") raise HTTPException(status_code=403, detail="File does not belong to this project")
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Extract data from request # Extract data from request
@@ -2835,7 +3221,7 @@ async def generate_report_from_preview(
_plot_border.ln.solidFill = "000000" _plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700 _plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "I4")
# --- Stats block starting at I28 --- # --- Stats block starting at I28 ---
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35 # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
@@ -2984,6 +3370,7 @@ async def generate_combined_excel_report(
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
# Get all sessions with measurement files # Get all sessions with measurement files
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all() sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
@@ -3182,7 +3569,7 @@ async def generate_combined_excel_report(
_plot_border.ln.solidFill = "000000" _plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700 _plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "I4")
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35 # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
@@ -3329,6 +3716,7 @@ async def combined_report_wizard(
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all() sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
@@ -3421,7 +3809,10 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
"loc_name": loc_name, "loc_name": loc_name,
"session_label": session.session_label or "", "session_label": session.session_label or "",
"period_type": session.period_type or "", "period_type": session.period_type or "",
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
"started_at": session.started_at, "started_at": session.started_at,
"report_date": session.report_date,
"rows": [], "rows": [],
} }
@@ -3462,25 +3853,39 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
pass pass
parsed.append((dt, row)) parsed.append((dt, row))
# Determine which rows to keep based on period_type # Determine effective hour window.
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
if sh is None or eh is None:
# Legacy defaults based on period_type
is_day_session = period_type in ('weekday_day', 'weekend_day') is_day_session = period_type in ('weekday_day', 'weekend_day')
sh = 7 if is_day_session else 19
eh = 19 if is_day_session else 7
else:
is_day_session = eh > sh # crosses midnight when end < start
target_date = None target_date = None
if is_day_session: if is_day_session:
# Day: 07:0018:59 only, restricted to the LAST calendar date that has daytime rows # Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
in_window = lambda h: sh <= h < eh
if entry.get("report_date"):
target_date = entry["report_date"]
else:
daytime_dates = sorted({ daytime_dates = sorted({
dt.date() for dt, row in parsed dt.date() for dt, row in parsed if dt and in_window(dt.hour)
if dt and 7 <= dt.hour < 19
}) })
target_date = daytime_dates[-1] if daytime_dates else None target_date = daytime_dates[-1] if daytime_dates else None
filtered = [ filtered = [
(dt, row) for dt, row in parsed (dt, row) for dt, row in parsed
if dt and dt.date() == target_date and 7 <= dt.hour < 19 if dt and dt.date() == target_date and in_window(dt.hour)
] ]
else: else:
# Night: 19:0006:59, spanning both calendar days — no date restriction # Night-style: hour >= start_h OR hour < end_h (crosses midnight)
in_window = lambda h: h >= sh or h < eh
filtered = [ filtered = [
(dt, row) for dt, row in parsed (dt, row) for dt, row in parsed
if dt and (dt.hour >= 19 or dt.hour < 7) if dt and in_window(dt.hour)
] ]
# Fall back to all rows if filtering removed everything # Fall back to all rows if filtering removed everything
@@ -3598,6 +4003,7 @@ async def generate_combined_from_preview(
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
report_title = data.get("report_title", "Background Noise Study") report_title = data.get("report_title", "Background Noise Study")
project_name = data.get("project_name", project.name) project_name = data.get("project_name", project.name)
@@ -3760,7 +4166,7 @@ async def generate_combined_from_preview(
_plot_border.ln.solidFill = "000000" _plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700 _plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "I4")
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
@@ -4073,6 +4479,7 @@ async def upload_all_project_data(
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
# Load all sound monitoring locations for this project # Load all sound monitoring locations for this project
locations = db.query(MonitoringLocation).filter_by( locations = db.query(MonitoringLocation).filter_by(

View File

@@ -73,10 +73,16 @@ def jinja_log_tail_display(s):
return str(s) return str(s)
def jinja_local_datetime_input(dt):
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
# Register Jinja filters and globals # Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time templates.env.filters["local_time"] = jinja_local_time
templates.env.filters["local_date"] = jinja_local_date templates.env.filters["local_date"] = jinja_local_date
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
templates.env.filters["fromjson"] = jinja_fromjson templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone templates.env.globals["get_user_timezone"] = get_user_timezone

View File

@@ -5,6 +5,7 @@
{% set s = item.session %} {% set s = item.session %}
{% set loc = item.location %} {% set loc = item.location %}
{% set unit = item.unit %} {% set unit = item.unit %}
{% set effective_range = item.effective_range %}
{# Period display maps #} {# Period display maps #}
{% set period_labels = { {% set period_labels = {
@@ -49,27 +50,76 @@
<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> <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 %} {% endif %}
<!-- Period type badge (click to change) --> <!-- Period type badge (click to open hour editor) -->
<div class="relative" id="period-wrap-{{ s.id }}"> <div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="togglePeriodMenu('{{ s.id }}')" <button onclick="openPeriodEditor('{{ s.id }}')"
id="period-badge-{{ 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') }}" 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> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg> </svg>
</button> </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"> <!-- Period editor panel -->
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %} <div id="period-editor-{{ s.id }}"
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')" 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">
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 %}">
<!-- 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 }} {{ pt_label }}
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
</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> </div>
<!-- Info grid --> <!-- Info grid -->
@@ -131,8 +181,17 @@
{% endif %} {% endif %}
</div> </div>
{% if s.notes %} <!-- Effective window (when period hours are set) -->
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p> {% 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 %} {% endif %}
</div> </div>
@@ -163,6 +222,8 @@
{% endif %} {% endif %}
<script> <script>
const PROJECT_ID = '{{ project_id }}';
const PERIOD_COLORS = { const PERIOD_COLORS = {
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', 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', weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
@@ -175,47 +236,146 @@ const PERIOD_LABELS = {
weekend_day: 'Weekend Day', weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night', 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 FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
const ALL_BADGE_COLORS = [...new Set([ const ALL_BADGE_COLORS = [...new Set([
...FALLBACK_COLORS, ...FALLBACK_COLORS,
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' ')) ...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
])]; ])];
function togglePeriodMenu(sessionId) { // Track which period type is selected in the editor before saving
const menu = document.getElementById('period-menu-' + sessionId); const _editorState = {};
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden'); // ---- 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');
} }
document.addEventListener('click', function(e) { function closePeriodEditor(sessionId) {
if (!e.target.closest('[id^="period-wrap-"]')) { document.getElementById('period-editor-' + sessionId).classList.add('hidden');
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden')); delete _editorState[sessionId];
} }
});
async function setPeriodType(sessionId, periodType) { function selectPeriodType(sessionId, pt) {
document.getElementById('period-menu-' + sessionId).classList.add('hidden'); _editorState[sessionId] = pt;
const badge = document.getElementById('period-badge-' + sessionId); // Highlight selected button
badge.disabled = true; 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 { try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, { const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH', method: 'PATCH',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({period_type: periodType}), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error(await resp.text()); 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)); ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean)); if (newPt && PERIOD_COLORS[newPt]) {
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType; 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) { } catch (err) {
alert('Failed to update period type: ' + err.message); alert('Failed to save period: ' + err.message);
} finally {
badge.disabled = false;
} }
} }
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-editor-"]').forEach(m => m.classList.add('hidden'));
}
});
// ---- Label editing ----
function startEditLabel(sessionId) { function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden'); document.getElementById('label-display-' + sessionId).classList.add('hidden');
const input = document.getElementById('label-input-' + sessionId); const input = document.getElementById('label-input-' + sessionId);
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
const input = document.getElementById('label-input-' + sessionId); const input = document.getElementById('label-input-' + sessionId);
const newLabel = input.value.trim(); const newLabel = input.value.trim();
try { try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, { const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH', method: 'PATCH',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_label: newLabel}), body: JSON.stringify({session_label: newLabel}),
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
} }
} }
// ---- Session details ----
function viewSession(sessionId) { function viewSession(sessionId) {
alert('Session details coming soon: ' + sessionId); window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
} }
function stopRecording(sessionId) { function stopRecording(sessionId) {

View File

@@ -0,0 +1,124 @@
<!-- Monthly Sessions Calendar — Gantt Style -->
<div class="sessions-cal-wrap bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- 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 + key -->
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-x-5 gap-y-1.5">
{% if legend %}
<div class="flex flex-wrap gap-x-4 gap-y-1">
{% 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 %}
<!-- Bar key -->
<div class="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
<div class="flex items-center gap-1.5">
<div class="w-8 h-2 rounded-sm" style="background:rgba(100,100,100,0.25)"></div>
<span>Device on</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-8 h-2 rounded-sm bg-blue-500"></div>
<span>Effective window</span>
</div>
</div>
</div>
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
<div class="py-2 text-center text-xs font-medium uppercase tracking-wide
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% else %}text-gray-400 dark:text-gray-500{% 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-[80px] 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.5">
<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>
<!-- Gantt bars -->
{% if day.sessions %}
<div class="space-y-2">
{% for s in day.sessions %}
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
<!-- 24-hour timeline bar -->
<div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
<div class="absolute top-0 bottom-0 w-px" style="left:25%; background:rgba(128,128,128,0.18)"></div>
<div class="absolute top-0 bottom-0 w-px" style="left:50%; background:rgba(128,128,128,0.28)"></div>
<div class="absolute top-0 bottom-0 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
<div class="absolute top-0 bottom-0"
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
{% if s.eff_start_pct is not none %}
<div class="absolute"
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
{% endif %}
</div>
<!-- Label -->
<div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
{{ s.location_name }} · {{ day.date.strftime('%-m/%-d') }} · {% if s.period_type %}{{ 'Night' if 'night' in s.period_type else 'Day' }}{% else %}—{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
<!-- Time scale footer hint -->
<div class="px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 flex justify-between text-gray-300 dark:text-gray-600" style="font-size:0.6rem;">
<span>12 AM</span>
<span>6 AM</span>
<span>12 PM</span>
<span>6 PM</span>
<span>12 AM</span>
</div>
</div>

View File

@@ -56,6 +56,15 @@
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}"> {% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ session.status or 'unknown' }} {{ session.status or 'unknown' }}
</span> </span>
<!-- Edit Session Times -->
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Edit session times">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
Edit
</button>
<!-- Download All Files in Session --> <!-- Download All Files in Session -->
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')" <button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1" class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
alert(`Error deleting session: ${error.message}`); alert(`Error deleting session: ${error.message}`);
} }
} }
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
document.getElementById('editSessionId').value = sessionId;
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
document.getElementById('editSessionModal').classList.remove('hidden');
}
function closeEditSessionModal() {
document.getElementById('editSessionModal').classList.add('hidden');
}
async function saveSessionTimes() {
const sessionId = document.getElementById('editSessionId').value;
const startedAt = document.getElementById('editStartedAt').value;
const stoppedAt = document.getElementById('editStoppedAt').value;
try {
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
started_at: startedAt || null,
stopped_at: stoppedAt || null,
})
});
if (response.ok) {
closeEditSessionModal();
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error updating session: ${error.message}`);
}
}
</script> </script>
<!-- Edit Session Times Modal -->
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
<input type="hidden" id="editSessionId">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
<input type="datetime-local" id="editStartedAt"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stop Time</label>
<input type="datetime-local" id="editStoppedAt"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
<div class="flex justify-end gap-3 mt-5">
<button onclick="closeEditSessionModal()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button onclick="saveSessionTimes()"
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
Save
</button>
</div>
</div>
</div>

View File

@@ -208,6 +208,19 @@
<div class="text-center py-8 text-gray-500">Loading sessions...</div> <div class="text-center py-8 text-gray-500">Loading sessions...</div>
</div> </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> </div>
<!-- Data Files Tab --> <!-- Data Files Tab -->
@@ -838,6 +851,7 @@ async function loadProjectDetails() {
// Update tab labels and visibility based on project type // Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring'; const isSoundProject = projectTypeId === 'sound_monitoring';
const isVibrationProject = projectTypeId === 'vibration_monitoring';
if (isSoundProject) { if (isSoundProject) {
document.getElementById('locations-tab-label').textContent = 'NRLs'; document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations'; document.getElementById('locations-header').textContent = 'Noise Recording Locations';
@@ -848,9 +862,9 @@ async function loadProjectDetails() {
const isRemote = mode === 'remote'; const isRemote = mode === 'remote';
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject); document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject); document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
// Schedules and Assigned Units are remote-only (manual projects collect data by hand) // Schedules and Assigned Units: hidden for vibration; for sound, only show if remote
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote); document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote); document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
// FTP browser within Data Files tab // FTP browser within Data Files tab
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote); document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
@@ -1828,5 +1842,6 @@ document.addEventListener('DOMContentLoaded', function() {
switchTab(hash); switchTab(hash);
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -181,6 +181,27 @@ let chartInstance = null;
let allData = []; let allData = [];
let allHeaders = []; 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 // Load data on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadRndData(); loadRndData();
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
}); });
} }
function renderTable(headers, data) { function _rowHour(row) {
const headerRow = document.getElementById('table-header'); // Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
const tbody = document.getElementById('table-body'); 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 function _buildRow(headers, row) {
headerRow.innerHTML = '<tr>' + headers.map(h => const hour = _rowHour(row);
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>` const inWindow = hour === null || _isInPeriodWindow(hour);
).join('') + '</tr>'; const dimClass = inWindow ? '' : 'opacity-40';
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
// Render rows (limit to first 500 for performance) return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
headers.map(h => { headers.map(h => {
const val = row[h]; const val = row[h];
let displayVal = val; 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>`; return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') + }).join('') +
'</tr>' '</tr>';
).join(''); }
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 // 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 = 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 // Search functionality
document.getElementById('table-search').addEventListener('input', function(e) { document.getElementById('table-search').addEventListener('input', function(e) {
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
); );
const displayFiltered = filtered.slice(0, 500); const displayFiltered = filtered.slice(0, 500);
tbody.innerHTML = displayFiltered.map(row => tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
'<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('');
document.getElementById('row-count').textContent = document.getElementById('row-count').textContent =
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`; filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;

View File

@@ -0,0 +1,437 @@
{% 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 %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
{{ report_date or '— (auto)' }}
</dd>
</div>
{% 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-4">Edit Session</h2>
<form id="edit-form" onsubmit="saveSession(event)">
<!-- Label -->
<div class="mb-4">
<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>
<!-- Section: Required Recording Window -->
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
<div class="space-y-2">
<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"
onchange="fillPeriodDefaults()"
class="w-full text-sm bg-white 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">From (hour)</label>
<div class="relative">
<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"
oninput="updateWindowPreview()"
class="w-full text-sm bg-white 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">To (hour)</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"
oninput="updateWindowPreview()"
class="w-full text-sm bg-white 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>
<!-- Live preview -->
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
{% set sh = session.period_start_hour %}
{% set eh = session.period_end_hour %}
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
{% endif %}
</div>
<div class="mt-2">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
</label>
<input type="date" id="edit-report-date" name="report_date"
value="{{ report_date or '' }}"
class="w-full text-sm bg-white 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">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
</div>
</div>
</div>
<!-- Section: Device On/Off Times -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
<div class="space-y-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</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-white 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">Powered off</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-white 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>
<div class="flex gap-2">
<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>
</div>
<div id="save-status" class="hidden text-xs text-center pt-2"></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;
}
updateWindowPreview();
}
function updateWindowPreview() {
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
const el = document.getElementById('window-preview');
if (!el) return;
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
const crosses = eh <= sh;
el.textContent = `Window: ${fmt(sh)}${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
}
// Run once on load to populate preview if values already set
document.addEventListener('DOMContentLoaded', updateWindowPreview);
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 rd = form.report_date.value;
payload.report_date = rd || 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] || '—';
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
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 %}

View File

@@ -37,7 +37,7 @@
{{ location.name }} {{ location.name }}
</h1> </h1>
<p class="text-gray-600 dark:text-gray-400 mt-1"> <p class="text-gray-600 dark:text-gray-400 mt-1">
Monitoring Location {{ project.name }} Monitoring Location &bull; {{ project.name }}
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -116,20 +116,36 @@
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
{% if assigned_unit %} {% if assigned_unit %}
<div class="space-y-4"> <div class="space-y-4">
<!-- Seismograph row -->
<div> <div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div> <div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
<div class="text-lg font-medium text-gray-900 dark:text-white"> <div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy"> <a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_unit.id }} {{ assigned_unit.id }}
</a> </a>
</div> </div>
{% if assigned_unit.unit_type %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
{% endif %}
</div> </div>
{% if assigned_unit.device_type %} <!-- Modem row -->
<div> <div>
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div> <div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div> {% if assigned_modem %}
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_modem.id }}
</a>
</div>
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} &bull; {% endif %}{{ assigned_modem.ip_address or '' }}
</div> </div>
{% endif %} {% endif %}
{% else %}
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
{% endif %}
</div>
{% if assignment %} {% if assignment %}
<div> <div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div> <div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
@@ -142,10 +158,14 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="pt-2"> <div class="pt-2 flex gap-2 flex-wrap">
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
Swap Unit / Modem
</button>
<button onclick="unassignUnit('{{ assignment.id }}')" <button onclick="unassignUnit('{{ assignment.id }}')"
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors"> class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors text-sm">
Unassign Unit Unassign
</button> </button>
</div> </div>
</div> </div>
@@ -155,7 +175,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg> </svg>
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p> <p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
<button onclick="openAssignModal()" <button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"> class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Assign a Unit Assign a Unit
</button> </button>
@@ -214,47 +234,55 @@
</div> </div>
</div> </div>
<!-- Assign Unit Modal --> <!-- Assign / Swap Modal -->
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center"> <div id="swap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between"> <div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div> <div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2> <h2 id="swap-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p> <p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
</div> </div>
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> <button onclick="closeSwapModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
</div> </div>
<form id="assign-form" class="p-6 space-y-4"> <form id="swap-form" class="p-6 space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Seismograph <span class="text-red-500">*</span></label>
<select id="assign-unit-id" name="unit_id" <select id="swap-unit-id" name="unit_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required> class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
<option value="">Loading units...</option> <option value="">Loading units...</option>
</select> </select>
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p> <p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Modem <span class="text-xs text-gray-400">(optional)</span></label>
<select id="swap-modem-id" name="modem_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">No modem</option>
</select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="assign-notes" name="notes" rows="2" <textarea id="swap-notes" name="notes" rows="2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea> class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div> </div>
<div id="assign-error" class="hidden text-sm text-red-600"></div> <div id="swap-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignModal()" <button type="button" onclick="closeSwapModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"> class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel Cancel
</button> </button>
<button type="submit" <button type="submit" id="swap-submit-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium"> class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign Unit Assign
</button> </button>
</div> </div>
</form> </form>
@@ -264,6 +292,7 @@
<script> <script>
const projectId = "{{ project_id }}"; const projectId = "{{ project_id }}";
const locationId = "{{ location_id }}"; const locationId = "{{ location_id }}";
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
// Tab switching // Tab switching
function switchTab(tabName) { function switchTab(tabName) {
@@ -314,60 +343,89 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
} }
}); });
// Assign modal // Swap / Assign modal
function openAssignModal() { async function openSwapModal() {
document.getElementById('assign-modal').classList.remove('hidden'); document.getElementById('swap-modal').classList.remove('hidden');
loadAvailableUnits(); document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
document.getElementById('swap-error').classList.add('hidden');
document.getElementById('swap-notes').value = '';
await Promise.all([loadSwapUnits(), loadSwapModems()]);
} }
function closeAssignModal() { function closeSwapModal() {
document.getElementById('assign-modal').classList.add('hidden'); document.getElementById('swap-modal').classList.add('hidden');
} }
async function loadAvailableUnits() { async function loadSwapUnits() {
try { try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`); const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load available units'); if (!response.ok) throw new Error('Failed to load units');
const data = await response.json(); const data = await response.json();
const select = document.getElementById('assign-unit-id'); const select = document.getElementById('swap-unit-id');
select.innerHTML = '<option value="">Select a unit</option>'; select.innerHTML = '<option value="">Select a seismograph</option>';
if (!data.length) { if (!data.length) {
document.getElementById('assign-empty').classList.remove('hidden'); document.getElementById('swap-units-empty').classList.remove('hidden');
return; } else {
document.getElementById('swap-units-empty').classList.add('hidden');
} }
data.forEach(unit => { data.forEach(unit => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = unit.id; option.value = unit.id;
option.textContent = `${unit.id}${unit.model || unit.device_type}`; option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
select.appendChild(option); select.appendChild(option);
}); });
} catch (err) { } catch (err) {
const errorEl = document.getElementById('assign-error'); document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
errorEl.textContent = err.message || 'Failed to load units.'; document.getElementById('swap-error').classList.remove('hidden');
errorEl.classList.remove('hidden');
} }
} }
document.getElementById('assign-form').addEventListener('submit', async function(e) { async function loadSwapModems() {
try {
const response = await fetch(`/api/projects/${projectId}/available-modems`);
if (!response.ok) throw new Error('Failed to load modems');
const data = await response.json();
const select = document.getElementById('swap-modem-id');
select.innerHTML = '<option value="">No modem</option>';
data.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
let label = modem.id;
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
option.textContent = label;
select.appendChild(option);
});
} catch (err) {
// Modem list failure is non-fatal — just leave blank
console.warn('Failed to load modems:', err);
}
}
document.getElementById('swap-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const unitId = document.getElementById('assign-unit-id').value; const unitId = document.getElementById('swap-unit-id').value;
const notes = document.getElementById('assign-notes').value.trim(); const modemId = document.getElementById('swap-modem-id').value;
const notes = document.getElementById('swap-notes').value.trim();
if (!unitId) { if (!unitId) {
document.getElementById('assign-error').textContent = 'Select a unit to assign.'; document.getElementById('swap-error').textContent = 'Please select a seismograph.';
document.getElementById('assign-error').classList.remove('hidden'); document.getElementById('swap-error').classList.remove('hidden');
return; return;
} }
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('unit_id', unitId); formData.append('unit_id', unitId);
formData.append('notes', notes); if (modemId) formData.append('modem_id', modemId);
if (notes) formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, { const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -379,9 +437,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
const errorEl = document.getElementById('assign-error'); document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
errorEl.textContent = err.message || 'Failed to assign unit.'; document.getElementById('swap-error').classList.remove('hidden');
errorEl.classList.remove('hidden');
} }
}); });
@@ -405,11 +462,11 @@ async function unassignUnit(assignmentId) {
} }
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAssignModal(); if (e.key === 'Escape') closeSwapModal();
}); });
document.getElementById('assign-modal')?.addEventListener('click', function(e) { document.getElementById('swap-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeAssignModal(); if (e.target === this) closeSwapModal();
}); });
</script> </script>
{% endblock %} {% endblock %}