9 Commits

13 changed files with 1450 additions and 546 deletions

View File

@@ -5,6 +5,60 @@ 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.7.0] - 2026-03-07
### Added
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
- Wizard UI grouped by location with period type badges (day/night)
- Each selected session produces one `.xlsx` in a ZIP archive
- Period type filtering: day sessions keep last calendar date (7AM6:59PM); night sessions span both days (7PM6:59AM)
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
- **Chart Preview**: Live chart preview in the report generator matching final report styling
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
### Changed
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
### Fixed
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
### Technical Details
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
### Migration Notes
Run the following migration scripts once per database before deploying:
```bash
python backend/migrate_rename_recording_to_monitoring_sessions.py
python backend/migrate_add_session_period_type.py
python backend/migrate_add_session_device_model.py
```
---
## [0.6.1] - 2026-02-16 ## [0.6.1] - 2026-02-16
### Added ### Added
@@ -445,6 +499,7 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit - Photo management per unit
- Automated status categorization (OK/Pending/Missing) - Automated status categorization (OK/Pending/Missing)
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0 [0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1 [0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0 [0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0

View File

@@ -1,4 +1,4 @@
# Terra-View v0.6.1 # Terra-View v0.7.0
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
@@ -496,6 +496,16 @@ docker compose down -v
## Release Highlights ## Release Highlights
### v0.7.0 — 2026-03-07
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
- **Chart Preview**: Live chart preview in the report generator matching final output styling
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
### v0.6.1 — 2026-02-16 ### v0.6.1 — 2026-02-16
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes - **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically - **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
@@ -584,11 +594,13 @@ MIT
## Version ## Version
**Current: 0.6.1**One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16) **Current: 0.7.0**Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
Previous: 0.6.0Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06) Previous: 0.6.1One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27) 0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23) 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)

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.6.1" VERSION = "0.7.0"
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":

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Migration: Add session_label and period_type columns to monitoring_sessions.
session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night"
period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night
Auto-derived from started_at when NULL.
Period definitions (used in report stats table):
weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM)
weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM)
weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM)
weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM)
Run once inside the Docker container:
docker exec terra-view python3 backend/migrate_add_session_period_type.py
"""
from pathlib import Path
from datetime import datetime
DB_PATH = Path("data/seismo_fleet.db")
def _derive_period_type(started_at_str: str) -> str | None:
"""Derive period_type from a started_at ISO datetime string."""
if not started_at_str:
return None
try:
dt = datetime.fromisoformat(started_at_str)
except ValueError:
return None
is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun
is_night = dt.hour >= 22 or dt.hour < 7
if is_weekend:
return "weekend_night" if is_night else "weekend_day"
else:
return "weekday_night" if is_night else "weekday_day"
def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None:
"""Build a human-readable session label."""
if not started_at_str:
return None
try:
dt = datetime.fromisoformat(started_at_str)
except ValueError:
return None
day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc.
date_str = dt.strftime("%-m/%-d") # 2/23
period_labels = {
"weekday_day": "Day",
"weekday_night": "Night",
"weekend_day": "Day",
"weekend_night": "Night",
}
period_str = period_labels.get(period_type or "", "")
parts = []
if location_name:
parts.append(location_name)
parts.append(f"{day_abbr} {date_str}")
if period_str:
parts.append(period_str)
return "".join(parts)
def migrate():
import sqlite3
if not DB_PATH.exists():
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# 1. Add columns (idempotent)
cur.execute("PRAGMA table_info(monitoring_sessions)")
existing_cols = {row["name"] for row in cur.fetchall()}
for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]:
if col not in existing_cols:
cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}")
conn.commit()
print(f"✓ Added column {col} to monitoring_sessions")
else:
print(f"○ Column {col} already exists — skipping ALTER TABLE")
# 2. Backfill existing rows
cur.execute(
"""SELECT ms.id, ms.started_at, ms.location_id
FROM monitoring_sessions ms
WHERE ms.period_type IS NULL OR ms.session_label IS NULL"""
)
sessions = cur.fetchall()
print(f"Backfilling {len(sessions)} session(s)...")
updated = 0
for row in sessions:
session_id = row["id"]
started_at = row["started_at"]
location_id = row["location_id"]
# Look up location name
location_name = None
if location_id:
cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,))
loc_row = cur.fetchone()
if loc_row:
location_name = loc_row["name"]
period_type = _derive_period_type(started_at)
label = _build_label(started_at, location_name, period_type)
cur.execute(
"UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?",
(period_type, label, session_id),
)
updated += 1
conn.commit()
conn.close()
print(f"✓ Backfilled {updated} session(s).")
print("Migration complete.")
if __name__ == "__main__":
migrate()

View File

@@ -272,6 +272,14 @@ class MonitoringSession(Base):
duration_seconds = Column(Integer, nullable=True) duration_seconds = Column(Integer, nullable=True)
status = Column(String, default="recording") # recording, completed, failed status = Column(String, default="recording") # recording, completed, failed
# Human-readable label auto-derived from date/location, editable by user.
# e.g. "NRL-1 — Sun 2/23 — Night"
session_label = Column(String, nullable=True)
# Period classification for report stats columns.
# weekday_day | weekday_night | weekend_day | weekend_night
period_type = Column(String, 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

@@ -35,6 +35,39 @@ from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
# ============================================================================
# Session period helpers
# ============================================================================
def _derive_period_type(dt: datetime) -> str:
"""
Classify a session start time into one of four period types.
Night = 22:0007:00, Day = 07:0022:00.
Weekend = Saturday (5) or Sunday (6).
"""
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:
"""Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.
Uses started_at date as-is; user can correct period_type in the wizard.
"""
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)
# ============================================================================ # ============================================================================
# Monitoring Locations CRUD # Monitoring Locations CRUD
# ============================================================================ # ============================================================================
@@ -634,6 +667,30 @@ async def upload_nrl_data(
if not file_entries: if not file_entries:
raise HTTPException(status_code=400, detail="No usable files found in upload.") raise HTTPException(status_code=400, detail="No usable files found in upload.")
# --- Step 1b: Filter to only relevant files ---
# Keep: .rnh (metadata) and measurement .rnd files
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
def _is_wanted(fname: str) -> bool:
n = fname.lower()
if n.endswith(".rnh"):
return True
if n.endswith(".rnd"):
if "_leq_" in n: # NL-43 Leq file
return True
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
return True
if "_lp" not in n and "_leq_" not in n:
# Unknown .rnd format — include it so we don't silently drop data
return True
return False
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
if not file_entries:
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
# --- Step 2: Find and parse .rnh metadata --- # --- Step 2: Find and parse .rnh metadata ---
rnh_meta = {} rnh_meta = {}
for fname, fbytes in file_entries: for fname, fbytes in file_entries:
@@ -652,6 +709,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
session_label = _build_session_label(started_at, location.name, period_type) if started_at else None
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
monitoring_session = MonitoringSession( monitoring_session = MonitoringSession(
id=session_id, id=session_id,
@@ -663,6 +723,8 @@ async def upload_nrl_data(
stopped_at=stopped_at, stopped_at=stopped_at,
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
status="completed", status="completed",
session_label=session_label,
period_type=period_type,
session_metadata=json.dumps({ session_metadata=json.dumps({
"source": "manual_upload", "source": "manual_upload",
"store_name": store_name, "store_name": store_name,

View File

@@ -1794,6 +1794,34 @@ async def delete_session(
}) })
VALID_PERIOD_TYPES = {"weekday_day", "weekday_night", "weekend_day", "weekend_night"}
@router.patch("/{project_id}/sessions/{session_id}")
async def patch_session(
project_id: str,
session_id: str,
data: dict,
db: Session = Depends(get_db),
):
"""Update session_label and/or period_type on a monitoring session."""
session = db.query(MonitoringSession).filter_by(id=session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
raise HTTPException(status_code=403, detail="Session does not belong to this project")
if "session_label" in data:
session.session_label = str(data["session_label"]).strip() or None
if "period_type" in data:
pt = data["period_type"]
if pt and pt not in 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
db.commit()
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse) @router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
async def view_rnd_file( async def view_rnd_file(
request: Request, request: Request,
@@ -2010,6 +2038,7 @@ async def generate_excel_report(
import openpyxl import openpyxl
from openpyxl.chart import LineChart, Reference from openpyxl.chart import LineChart, Reference
from openpyxl.chart.label import DataLabelList from openpyxl.chart.label import DataLabelList
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
except ImportError: except ImportError:
@@ -2312,6 +2341,10 @@ async def generate_excel_report(
chart.series[2].graphicalProperties.line.solidFill = "0070C0" chart.series[2].graphicalProperties.line.solidFill = "0070C0"
chart.series[2].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.width = 19050
_plot_border = GraphicalProperties()
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "H4")
# --- 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 ---
@@ -2651,6 +2684,7 @@ async def generate_report_from_preview(
try: try:
import openpyxl import openpyxl
from openpyxl.chart import LineChart, Reference from openpyxl.chart import LineChart, Reference
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
except ImportError: except ImportError:
@@ -2793,6 +2827,10 @@ async def generate_report_from_preview(
chart.series[1].graphicalProperties.line.width = 19050 chart.series[1].graphicalProperties.line.width = 19050
chart.series[2].graphicalProperties.line.solidFill = "0070C0" chart.series[2].graphicalProperties.line.solidFill = "0070C0"
chart.series[2].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.width = 19050
_plot_border = GraphicalProperties()
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "H4")
# --- Stats block starting at I28 --- # --- Stats block starting at I28 ---
@@ -2929,6 +2967,7 @@ async def generate_combined_excel_report(
try: try:
import openpyxl import openpyxl
from openpyxl.chart import LineChart, Reference from openpyxl.chart import LineChart, Reference
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
except ImportError: except ImportError:
@@ -3135,6 +3174,10 @@ async def generate_combined_excel_report(
chart.series[2].graphicalProperties.line.solidFill = "0070C0" chart.series[2].graphicalProperties.line.solidFill = "0070C0"
chart.series[2].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.width = 19050
_plot_border = GraphicalProperties()
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "H4")
# 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
@@ -3277,32 +3320,59 @@ async def combined_report_wizard(
): ):
"""Configuration page for the combined multi-location report wizard.""" """Configuration page for the combined multi-location report wizard."""
from backend.models import ReportTemplate from backend.models import ReportTemplate
from pathlib import Path as _Path
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")
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all() sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
# Build location list with Leq file counts (no filtering) # Build location -> sessions list, only including sessions that have Leq files
location_file_counts: dict = {} location_sessions: dict = {} # loc_name -> list of session dicts
for session in sessions: for session in sessions:
files = db.query(DataFile).filter_by(session_id=session.id).all() files = db.query(DataFile).filter_by(session_id=session.id).all()
has_leq = False
for file in files: for file in files:
if not file.file_path or not file.file_path.lower().endswith('.rnd'): if not file.file_path or not file.file_path.lower().endswith('.rnd'):
continue continue
from pathlib import Path as _Path
abs_path = _Path("data") / file.file_path abs_path = _Path("data") / file.file_path
peek = _peek_rnd_headers(abs_path) peek = _peek_rnd_headers(abs_path)
if not _is_leq_file(file.file_path, peek): if _is_leq_file(file.file_path, peek):
has_leq = True
break
if not has_leq:
continue continue
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
loc_name = location.name if location else f"Session {session.id[:8]}" loc_name = location.name if location else f"Session {session.id[:8]}"
location_file_counts[loc_name] = location_file_counts.get(loc_name, 0) + 1
if loc_name not in location_sessions:
location_sessions[loc_name] = []
# Build a display date and day-of-week from started_at
date_display = ""
day_of_week = ""
if session.started_at:
date_display = session.started_at.strftime("%-m/%-d/%Y")
day_of_week = session.started_at.strftime("%A") # Monday, Sunday, etc.
location_sessions[loc_name].append({
"session_id": session.id,
"session_label": session.session_label or "",
"date_display": date_display,
"day_of_week": day_of_week,
"started_at": session.started_at.isoformat() if session.started_at else "",
"stopped_at": session.stopped_at.isoformat() if session.stopped_at else "",
"duration_h": (session.duration_seconds // 3600) if session.duration_seconds else 0,
"duration_m": ((session.duration_seconds % 3600) // 60) if session.duration_seconds else 0,
"period_type": session.period_type or "",
"status": session.status,
})
locations = [ locations = [
{"name": name, "file_count": count} {"name": name, "sessions": sess_list}
for name, count in sorted(location_file_counts.items()) for name, sess_list in sorted(location_sessions.items())
] ]
report_templates = db.query(ReportTemplate).all() report_templates = db.query(ReportTemplate).all()
@@ -3312,10 +3382,160 @@ async def combined_report_wizard(
"project": project, "project": project,
"project_id": project_id, "project_id": project_id,
"locations": locations, "locations": locations,
"locations_json": json.dumps(locations),
"report_templates": report_templates, "report_templates": report_templates,
}) })
def _build_location_data_from_sessions(project_id: str, db, selected_session_ids: list) -> dict:
"""
Build per-location spreadsheet data using an explicit list of session IDs.
Only rows from those sessions are included. Per-session period_type is
stored on each row so the report can filter stats correctly.
"""
from pathlib import Path as _Path
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not selected_session_ids:
raise HTTPException(status_code=400, detail="No sessions selected.")
# Load every requested session — one entry per (session_id, loc_name) pair.
# Keyed by session_id so overnight sessions are never split by calendar date.
session_entries: dict = {} # session_id -> {loc_name, session_label, period_type, rows[]}
for session_id in selected_session_ids:
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
if not session:
continue
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
loc_name = location.name if location else f"Session {session_id[:8]}"
session_entries[session_id] = {
"loc_name": loc_name,
"session_label": session.session_label or "",
"period_type": session.period_type or "",
"started_at": session.started_at,
"rows": [],
}
files = db.query(DataFile).filter_by(session_id=session_id).all()
for file in files:
if not file.file_path or not file.file_path.lower().endswith('.rnd'):
continue
abs_path = _Path("data") / file.file_path
peek = _peek_rnd_headers(abs_path)
if not _is_leq_file(file.file_path, peek):
continue
rows = _read_rnd_file_rows(file.file_path)
rows, _ = _normalize_rnd_rows(rows)
session_entries[session_id]["rows"].extend(rows)
if not any(e["rows"] for e in session_entries.values()):
raise HTTPException(status_code=404, detail="No Leq data found in the selected sessions.")
location_data = []
for session_id in selected_session_ids:
entry = session_entries.get(session_id)
if not entry or not entry["rows"]:
continue
loc_name = entry["loc_name"]
period_type = entry["period_type"]
raw_rows = sorted(entry["rows"], key=lambda r: r.get('Start Time', ''))
# Parse all rows to datetimes first so we can apply period-aware filtering
parsed = []
for row in raw_rows:
start_time_str = row.get('Start Time', '')
dt = None
if start_time_str:
try:
dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S')
except ValueError:
pass
parsed.append((dt, row))
# Determine which rows to keep based on period_type
is_day_session = period_type in ('weekday_day', 'weekend_day')
target_date = None
if is_day_session:
# Day: 07:0018:59 only, restricted to the LAST calendar date that has daytime rows
daytime_dates = sorted({
dt.date() for dt, row in parsed
if dt and 7 <= dt.hour < 19
})
target_date = daytime_dates[-1] if daytime_dates else None
filtered = [
(dt, row) for dt, row in parsed
if dt and dt.date() == target_date and 7 <= dt.hour < 19
]
else:
# Night: 19:0006:59, spanning both calendar days — no date restriction
filtered = [
(dt, row) for dt, row in parsed
if dt and (dt.hour >= 19 or dt.hour < 7)
]
# Fall back to all rows if filtering removed everything
if not filtered:
filtered = parsed
spreadsheet_data = []
for idx, (dt, row) in enumerate(filtered, 1):
date_str = dt.strftime('%Y-%m-%d') if dt else ''
time_str = dt.strftime('%H:%M') if dt else ''
lmax = row.get('Lmax(Main)', '')
ln1 = row.get('LN1(Main)', '')
ln2 = row.get('LN2(Main)', '')
spreadsheet_data.append([
idx,
date_str,
time_str,
lmax if lmax else '',
ln1 if ln1 else '',
ln2 if ln2 else '',
'',
period_type, # col index 7 — hidden, used by report gen for day/night bucketing
])
# For the label/filename, use target_date (day sessions) or started_at (night sessions)
from datetime import timedelta as _td
started_at_dt = entry["started_at"]
if is_day_session and target_date:
# Use the actual target date from data filtering (last date with daytime rows)
label_dt = datetime.combine(target_date, datetime.min.time())
else:
label_dt = started_at_dt
# Rebuild session label using the correct label date
if label_dt and entry["loc_name"]:
period_str = {"weekday_day": "Day", "weekday_night": "Night",
"weekend_day": "Day", "weekend_night": "Night"}.get(period_type, "")
day_abbr = label_dt.strftime("%a")
date_label = f"{label_dt.month}/{label_dt.day}"
session_label = "".join(p for p in [loc_name, f"{day_abbr} {date_label}", period_str] if p)
else:
session_label = entry["session_label"]
location_data.append({
"session_id": session_id,
"location_name": loc_name,
"session_label": session_label,
"period_type": period_type,
"started_at": label_dt.isoformat() if label_dt else "",
"raw_count": len(raw_rows),
"filtered_count": len(filtered),
"spreadsheet_data": spreadsheet_data,
})
return {"project": project, "location_data": location_data}
@router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse) @router.get("/{project_id}/combined-report-preview", response_class=HTMLResponse)
async def combined_report_preview( async def combined_report_preview(
request: Request, request: Request,
@@ -3323,38 +3543,19 @@ async def combined_report_preview(
report_title: str = Query("Background Noise Study"), report_title: str = Query("Background Noise Study"),
project_name: str = Query(""), project_name: str = Query(""),
client_name: str = Query(""), client_name: str = Query(""),
start_time: str = Query(""), selected_sessions: str = Query(""), # comma-separated session IDs
end_time: str = Query(""),
start_date: str = Query(""),
end_date: str = Query(""),
enabled_locations: str = Query(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Preview and edit combined report data before generating the Excel file.""" """Preview and edit combined report data before generating the Excel file."""
enabled_list = [loc.strip() for loc in enabled_locations.split(',') if loc.strip()] if enabled_locations else None session_ids = [s.strip() for s in selected_sessions.split(',') if s.strip()] if selected_sessions else []
result = _build_combined_location_data( result = _build_location_data_from_sessions(project_id, db, session_ids)
project_id, db,
start_time=start_time,
end_time=end_time,
start_date=start_date,
end_date=end_date,
enabled_locations=enabled_list,
)
project = result["project"] project = result["project"]
location_data = result["location_data"] location_data = result["location_data"]
total_rows = sum(loc["filtered_count"] for loc in location_data) total_rows = sum(loc["filtered_count"] for loc in location_data)
final_project_name = project_name if project_name else project.name final_project_name = project_name if project_name else project.name
# Build time filter display string
time_filter_desc = ""
if start_time and end_time:
time_filter_desc = f"{start_time} {end_time}"
elif start_time or end_time:
time_filter_desc = f"{start_time or ''} {end_time or ''}"
return templates.TemplateResponse("combined_report_preview.html", { return templates.TemplateResponse("combined_report_preview.html", {
"request": request, "request": request,
"project": project, "project": project,
@@ -3362,11 +3563,7 @@ async def combined_report_preview(
"report_title": report_title, "report_title": report_title,
"project_name": final_project_name, "project_name": final_project_name,
"client_name": client_name, "client_name": client_name,
"start_time": start_time, "time_filter_desc": f"{len(session_ids)} session{'s' if len(session_ids) != 1 else ''} selected",
"end_time": end_time,
"start_date": start_date,
"end_date": end_date,
"time_filter_desc": time_filter_desc,
"location_data": location_data, "location_data": location_data,
"locations_json": json.dumps(location_data), "locations_json": json.dumps(location_data),
"total_rows": total_rows, "total_rows": total_rows,
@@ -3379,12 +3576,18 @@ async def generate_combined_from_preview(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Generate combined Excel report from wizard-edited spreadsheet data.""" """Generate combined Excel report from wizard-edited spreadsheet data.
Produces one .xlsx per day (each with one sheet per location) packaged
into a single .zip file for download.
"""
try: try:
import openpyxl import openpyxl
from openpyxl.chart import LineChart, Reference from openpyxl.chart import LineChart, Reference
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from openpyxl.worksheet.properties import PageSetupProperties
except ImportError: except ImportError:
raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl") raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl")
@@ -3400,12 +3603,13 @@ async def generate_combined_from_preview(
if not locations: if not locations:
raise HTTPException(status_code=400, detail="No location data provided") raise HTTPException(status_code=400, detail="No location data provided")
# Styles # Shared styles
f_title = Font(name='Arial', bold=True, size=12) f_title = Font(name='Arial', bold=True, size=12)
f_bold = Font(name='Arial', bold=True, size=10) f_bold = Font(name='Arial', bold=True, size=10)
f_data = Font(name='Arial', size=10) f_data = Font(name='Arial', size=10)
thin = Side(style='thin') thin = Side(style='thin')
dbl = Side(style='double') dbl = Side(style='double')
med = Side(style='medium')
hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin)
hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin)
hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin)
@@ -3418,31 +3622,25 @@ async def generate_combined_from_preview(
hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
center_a = Alignment(horizontal='center', vertical='center', wrap_text=True) center_a = Alignment(horizontal='center', vertical='center', wrap_text=True)
left_a = Alignment(horizontal='left', vertical='center') left_a = Alignment(horizontal='left', vertical='center')
right_a = Alignment(horizontal='right', vertical='center') thin_border = Border(left=thin, right=thin, top=thin, bottom=thin)
from openpyxl.worksheet.properties import PageSetupProperties tbl_top_left = Border(left=med, right=thin, top=med, bottom=thin)
tbl_top_mid = Border(left=thin, right=thin, top=med, bottom=thin)
tbl_top_right = Border(left=thin, right=med, top=med, bottom=thin)
tbl_mid_left = Border(left=med, right=thin, top=thin, bottom=thin)
tbl_mid_mid = Border(left=thin, right=thin, top=thin, bottom=thin)
tbl_mid_right = Border(left=thin, right=med, top=thin, bottom=thin)
tbl_bot_left = Border(left=med, right=thin, top=thin, bottom=med)
tbl_bot_mid = Border(left=thin, right=thin, top=thin, bottom=med)
tbl_bot_right = Border(left=thin, right=med, top=thin, bottom=med)
wb = openpyxl.Workbook() col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 18.0, 18.0, 14.0, 14.0, 10.0, 8.0, 6.43, 6.43]
wb.remove(wb.active)
all_location_summaries = [] def _build_location_sheet(ws, loc_name, day_rows, final_title):
"""Write one location's data onto ws. day_rows is a list of spreadsheet row arrays."""
for loc_info in locations: for col_i, col_w in zip(range(1, 17), col_widths):
loc_name = loc_info.get("location_name", "Unknown")
rows = loc_info.get("spreadsheet_data", [])
if not rows:
continue
safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_sheet_name)
# Column widths from Soundstudyexample.xlsx NRL_1 (sheet2)
# A B C D E F G H I J K L M N O P
for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]):
ws.column_dimensions[get_column_letter(col_i)].width = col_w ws.column_dimensions[get_column_letter(col_i)].width = col_w
final_title = f"{report_title} - {project_name}"
ws.merge_cells('A1:G1') ws.merge_cells('A1:G1')
ws['A1'] = final_title ws['A1'] = final_title
ws['A1'].font = f_title; ws['A1'].alignment = center_a ws['A1'].font = f_title; ws['A1'].alignment = center_a
@@ -3453,6 +3651,28 @@ async def generate_combined_from_preview(
ws['A3'] = loc_name ws['A3'] = loc_name
ws['A3'].font = f_title; ws['A3'].alignment = center_a ws['A3'].font = f_title; ws['A3'].alignment = center_a
ws.row_dimensions[3].height = 15.75 ws.row_dimensions[3].height = 15.75
# Row 4: date range derived from the data rows
def _fmt_date(d):
try:
from datetime import datetime as _dt
return _dt.strptime(d, '%Y-%m-%d').strftime('%-m/%-d/%y')
except Exception:
return d
dates_in_data = sorted({
row[1] for row in day_rows
if len(row) > 1 and row[1]
})
if len(dates_in_data) >= 2:
date_label = f"{_fmt_date(dates_in_data[0])} to {_fmt_date(dates_in_data[-1])}"
elif len(dates_in_data) == 1:
date_label = _fmt_date(dates_in_data[0])
else:
date_label = ""
ws.merge_cells('A4:G4')
ws['A4'] = date_label
ws['A4'].font = f_data; ws['A4'].alignment = center_a
ws.row_dimensions[4].height = 15 ws.row_dimensions[4].height = 15
ws.row_dimensions[5].height = 15.75 ws.row_dimensions[5].height = 15.75
@@ -3463,27 +3683,25 @@ async def generate_combined_from_preview(
cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner)
ws.row_dimensions[6].height = 39 ws.row_dimensions[6].height = 39
# Data rows starting at row 7
data_start_row = 7 data_start_row = 7
parsed_rows_p = [] parsed_rows = []
lmax_vals = [] lmax_vals, ln1_vals, ln2_vals = [], [], []
ln1_vals = []
ln2_vals = []
for row_idx, row in enumerate(rows): for row_idx, row in enumerate(day_rows):
dr = data_start_row + row_idx dr = data_start_row + row_idx
is_last = (row_idx == len(rows) - 1) is_last = (row_idx == len(day_rows) - 1)
b_left = last_left if is_last else data_left b_left = last_left if is_last else data_left
b_inner = last_inner if is_last else data_inner b_inner = last_inner if is_last else data_inner
b_right = last_right if is_last else data_right b_right = last_right if is_last else data_right
test_num = row[0] if len(row) > 0 else row_idx + 1 test_num = row[0] if len(row) > 0 else row_idx + 1
date_val = row[1] if len(row) > 1 else '' date_val = _fmt_date(row[1]) if len(row) > 1 and row[1] else ''
time_val = row[2] if len(row) > 2 else '' time_val = row[2] if len(row) > 2 else ''
lmax = row[3] if len(row) > 3 else '' lmax = row[3] if len(row) > 3 else ''
ln1 = row[4] if len(row) > 4 else '' ln1 = row[4] if len(row) > 4 else ''
ln2 = row[5] if len(row) > 5 else '' ln2 = row[5] if len(row) > 5 else ''
comment = row[6] if len(row) > 6 else '' comment = row[6] if len(row) > 6 else ''
row_period = row[7] if len(row) > 7 else '' # hidden period_type from session
c = ws.cell(row=dr, column=1, value=test_num) c = ws.cell(row=dr, column=1, value=test_num)
c.font = f_data; c.alignment = center_a; c.border = b_left c.font = f_data; c.alignment = center_a; c.border = b_left
@@ -3508,20 +3726,11 @@ async def generate_combined_from_preview(
if isinstance(ln2, (int, float)): if isinstance(ln2, (int, float)):
ln2_vals.append(ln2) ln2_vals.append(ln2)
# Parse time for evening/nighttime stats if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)):
if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): parsed_rows.append((row_period, time_val, float(lmax), float(ln1), float(ln2)))
try:
try:
row_dt = datetime.strptime(str(time_val), '%H:%M')
except ValueError:
row_dt = datetime.strptime(str(time_val), '%H:%M:%S')
parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2)))
except (ValueError, TypeError):
pass
data_end_row = data_start_row + len(rows) - 1 data_end_row = data_start_row + len(day_rows) - 1
# Chart anchored at H4
chart = LineChart() chart = LineChart()
chart.title = f"{loc_name} - {final_title}" chart.title = f"{loc_name} - {final_title}"
chart.style = 2 chart.style = 2
@@ -3543,71 +3752,120 @@ async def generate_combined_from_preview(
chart.series[2].graphicalProperties.line.solidFill = "0070C0" chart.series[2].graphicalProperties.line.solidFill = "0070C0"
chart.series[2].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.width = 19050
_plot_border = GraphicalProperties()
_plot_border.ln.solidFill = "000000"
_plot_border.ln.w = 12700
chart.plot_area.spPr = _plot_border
ws.add_chart(chart, "H4") ws.add_chart(chart, "H4")
# 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.font = f_data; note1.alignment = left_a
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.")
note2.font = f_data; note2.alignment = left_a
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
# Table header row 31
med = Side(style='medium')
tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin'))
tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin'))
tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin'))
tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin'))
tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med)
tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med)
tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med)
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")
# Header row: blank | Evening | Nighttime def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None
c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold def _max(vals): return round(max(vals), 1) if vals else None
c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)")
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
c.border = tbl_top_mid; c.fill = hdr_fill_tbl
ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11)
c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)")
c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
c.border = tbl_top_right; c.fill = hdr_fill_tbl
ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13)
ws.row_dimensions[31].height = 15
evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22] # --- Period bucketing ------------------------------------------------
nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7] # For night sessions: split into Evening (7PM10PM) and Nighttime (10PM7AM).
# For day sessions: single Daytime bucket.
PERIOD_TYPE_IS_DAY = {"weekday_day", "weekend_day"}
PERIOD_TYPE_IS_NIGHT = {"weekday_night", "weekend_night"}
def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None day_rows_data = []
def _max_p(vals): return round(max(vals), 1) if vals else None evening_rows_data = []
night_rows_data = []
def write_stat_p(row_num, label, eve_val, night_val, is_last=False): for pt, time_v, lmx, l1, l2 in parsed_rows:
bl = tbl_bot_left if is_last else tbl_mid_left if pt in PERIOD_TYPE_IS_DAY:
bm = tbl_bot_mid if is_last else tbl_mid_mid day_rows_data.append((lmx, l1, l2))
br = tbl_bot_right if is_last else tbl_mid_right elif pt in PERIOD_TYPE_IS_NIGHT:
lbl = ws.cell(row=row_num, column=9, value=label) # Split by time: Evening = 19:0021:59, Nighttime = 22:0006:59
lbl.font = f_data; lbl.border = bl hour = 0
if time_v and ':' in str(time_v):
try:
hour = int(str(time_v).split(':')[0])
except ValueError:
pass
if 19 <= hour <= 21:
evening_rows_data.append((lmx, l1, l2))
else:
night_rows_data.append((lmx, l1, l2))
else:
day_rows_data.append((lmx, l1, l2))
all_candidate_periods = [
("Daytime (7AM to 10PM)", day_rows_data),
("Evening (7PM to 10PM)", evening_rows_data),
("Nighttime (10PM to 7AM)", night_rows_data),
]
active_periods = [(label, rows) for label, rows in all_candidate_periods if rows]
if not active_periods:
active_periods = [("Daytime (7AM to 10PM)", [])]
# --- Stats table — fixed position alongside the chart ---
note1 = ws.cell(row=28, column=9,
value="Note: Averages are calculated by determining the arithmetic average ")
note1.font = f_data; note1.alignment = left_a
ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14)
note2 = ws.cell(row=29, column=9,
value="for each specified range of time intervals.")
note2.font = f_data; note2.alignment = left_a
ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14)
for r in [28, 29, 30, 31, 32, 33, 34]:
ws.row_dimensions[r].height = 15
tbl_hdr_row = 31
tbl_data_row = 32
# Layout: col 9 = row label, then pairs: (10,11), (12,13), (14,15)
num_periods = len(active_periods)
period_start_cols = [10 + i * 2 for i in range(num_periods)]
def _hdr_border(i, n):
return Border(
left=med if i == 0 else thin,
right=med if i == n - 1 else thin,
top=med, bottom=thin,
)
c = ws.cell(row=tbl_hdr_row, column=9, value=""); c.border = tbl_top_left; c.font = f_bold
for i, (period_label, _) in enumerate(active_periods):
sc = period_start_cols[i]
c = ws.cell(row=tbl_hdr_row, column=sc, value=period_label)
c.font = f_bold
c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=False)
c.border = _hdr_border(i, num_periods)
c.fill = hdr_fill_tbl
ws.merge_cells(start_row=tbl_hdr_row, start_column=sc,
end_row=tbl_hdr_row, end_column=sc + 1)
def write_stat_dynamic(row_num, row_label, period_vals_list, is_last=False):
lbl = ws.cell(row=row_num, column=9, value=row_label)
lbl.font = f_data; lbl.border = tbl_bot_left if is_last else tbl_mid_left
lbl.alignment = Alignment(horizontal='left', vertical='center') lbl.alignment = Alignment(horizontal='left', vertical='center')
ev_str = f"{eve_val} dBA" if eve_val is not None else "" n = len(period_vals_list)
ev = ws.cell(row=row_num, column=10, value=ev_str) for i, val in enumerate(period_vals_list):
ev.font = f_bold; ev.border = bm sc = period_start_cols[i]
ev.alignment = Alignment(horizontal='center', vertical='center') val_str = f"{val} dBA" if val is not None else ""
ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11) c = ws.cell(row=row_num, column=sc, value=val_str)
ni_str = f"{night_val} dBA" if night_val is not None else "" c.font = f_bold
ni = ws.cell(row=row_num, column=12, value=ni_str) c.alignment = Alignment(horizontal='center', vertical='center')
ni.font = f_bold; ni.border = br c.border = Border(
ni.alignment = Alignment(horizontal='center', vertical='center') left=med if i == 0 else thin,
ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) right=med if i == n - 1 else thin,
top=tbl_bot_mid.top if is_last else tbl_mid_mid.top,
bottom=tbl_bot_mid.bottom if is_last else tbl_mid_mid.bottom,
)
ws.merge_cells(start_row=row_num, start_column=sc,
end_row=row_num, end_column=sc + 1)
write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p])) write_stat_dynamic(tbl_data_row, "LAmax",
write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p])) [_max([v[0] for v in rows]) for _, rows in active_periods])
write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True) write_stat_dynamic(tbl_data_row + 1, "LA01 Average",
[_avg([v[1] for v in rows]) for _, rows in active_periods])
write_stat_dynamic(tbl_data_row + 2, "LA10 Average",
[_avg([v[2] for v in rows]) for _, rows in active_periods], is_last=True)
# Page setup: portrait, letter
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False)
ws.page_setup.orientation = 'portrait' ws.page_setup.orientation = 'portrait'
ws.page_setup.paperSize = 1 ws.page_setup.paperSize = 1
@@ -3618,50 +3876,94 @@ async def generate_combined_from_preview(
ws.page_margins.header = 0.5 ws.page_margins.header = 0.5
ws.page_margins.footer = 0.5 ws.page_margins.footer = 0.5
all_location_summaries.append({ return {
'location': loc_name, 'location': loc_name,
'samples': len(rows), 'samples': len(day_rows),
'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None, 'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None,
'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None, 'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None,
'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None, 'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None,
}) }
# Summary sheet def _build_summary_sheet(wb, day_label, project_name, loc_summaries):
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), summary_ws = wb.create_sheet(title="Summary")
top=Side(style='thin'), bottom=Side(style='thin')) summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}"
summary_ws = wb.create_sheet(title="Summary", index=0)
summary_ws['A1'] = f"{report_title} - {project_name} - Summary"
summary_ws['A1'].font = f_title summary_ws['A1'].font = f_title
summary_ws.merge_cells('A1:E1') summary_ws.merge_cells('A1:E1')
summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg'] summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg']
for col, header in enumerate(summary_headers, 1): for col, header in enumerate(summary_headers, 1):
cell = summary_ws.cell(row=3, column=col, value=header) cell = summary_ws.cell(row=3, column=col, value=header)
cell.font = f_bold cell.font = f_bold; cell.fill = hdr_fill; cell.border = thin_border
cell.fill = hdr_fill
cell.border = thin_border
for i, width in enumerate([30, 10, 12, 12, 12], 1): for i, width in enumerate([30, 10, 12, 12, 12], 1):
summary_ws.column_dimensions[get_column_letter(i)].width = width summary_ws.column_dimensions[get_column_letter(i)].width = width
for idx, s in enumerate(loc_summaries, 4):
summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border
summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border
summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=4, value=s['ln1_avg'] or '-').border = thin_border
summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border
for idx, loc_summary in enumerate(all_location_summaries, 4): # ----------------------------------------------------------------
summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border # Build one workbook per session (each location entry is one session)
summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border # ----------------------------------------------------------------
summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border if not locations:
summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border raise HTTPException(status_code=400, detail="No location data provided")
summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border
output = io.BytesIO() project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_')
wb.save(output) final_title = f"{report_title} - {project_name}"
output.seek(0)
project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip() zip_buffer = io.BytesIO()
filename = f"{project_name_clean}_combined_report.xlsx".replace(' ', '_') with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for loc_info in locations:
loc_name = loc_info.get("location_name", "Unknown")
session_label = loc_info.get("session_label", "")
period_type = loc_info.get("period_type", "")
started_at_str = loc_info.get("started_at", "")
rows = loc_info.get("spreadsheet_data", [])
if not rows:
continue
# Re-number interval # sequentially
for i, row in enumerate(rows):
if len(row) > 0:
row[0] = i + 1
wb = openpyxl.Workbook()
wb.remove(wb.active)
safe_sheet = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31]
ws = wb.create_sheet(title=safe_sheet)
summary = _build_location_sheet(ws, loc_name, rows, final_title)
# Derive a date label for the summary sheet from started_at or first row
day_label = session_label or loc_name
if started_at_str:
try:
_dt = datetime.fromisoformat(started_at_str)
day_label = _dt.strftime('%-m/%-d/%Y')
if session_label:
day_label = session_label
except Exception:
pass
_build_summary_sheet(wb, day_label, project_name, [summary])
xlsx_buf = io.BytesIO()
wb.save(xlsx_buf)
xlsx_buf.seek(0)
# Build a clean filename from label or location+date
label_clean = session_label or loc_name
label_clean = "".join(c for c in label_clean if c.isalnum() or c in (' ', '-', '_', '/')).strip().replace(' ', '_').replace('/', '-')
xlsx_name = f"{label_clean}_{project_name_clean}_report.xlsx"
zf.writestr(xlsx_name, xlsx_buf.read())
zip_buffer.seek(0)
zip_filename = f"{project_name_clean}_reports.zip"
return StreamingResponse( return StreamingResponse(
output, zip_buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'} headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}
) )

12
rebuild-prod.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Production rebuild script — rebuilds and restarts terra-view on :8001
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Building terra-view production..."
docker compose -f docker-compose.yml build terra-view
docker compose -f docker-compose.yml up -d terra-view
echo "Done — terra-view production is running on :8001"

View File

@@ -26,7 +26,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>
Generate Excel Generate Reports (ZIP)
</button> </button>
<a href="/api/projects/{{ project_id }}/combined-report-wizard" <a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm"> class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
@@ -187,7 +187,7 @@ document.addEventListener('DOMContentLoaded', function() {
const el = document.getElementById('spreadsheet-' + idx); const el = document.getElementById('spreadsheet-' + idx);
if (!el) return; if (!el) return;
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data }); const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
spreadsheets[loc.location_name] = jspreadsheet(el, opts); spreadsheets[idx] = jspreadsheet(el, opts);
}); });
if (allLocationData.length > 0) { if (allLocationData.length > 0) {
switchTab(0); switchTab(0);
@@ -228,9 +228,8 @@ function switchTab(idx) {
} }
// Refresh jspreadsheet rendering after showing panel // Refresh jspreadsheet rendering after showing panel
const loc = allLocationData[idx]; if (spreadsheets[idx]) {
if (loc && spreadsheets[loc.location_name]) { try { spreadsheets[idx].updateTable(); } catch(e) {}
try { spreadsheets[loc.location_name].updateTable(); } catch(e) {}
} }
} }
@@ -238,13 +237,17 @@ async function downloadCombinedReport() {
const btn = document.getElementById('download-btn'); const btn = document.getElementById('download-btn');
const originalText = btn.innerHTML; const originalText = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating...'; btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating ZIP...';
try { try {
const locations = allLocationData.map(function(loc) { const locations = allLocationData.map(function(loc, idx) {
return { return {
session_id: loc.session_id || '',
session_label: loc.session_label || '',
period_type: loc.period_type || '',
started_at: loc.started_at || '',
location_name: loc.location_name, location_name: loc.location_name,
spreadsheet_data: spreadsheets[loc.location_name] ? spreadsheets[loc.location_name].getData() : loc.spreadsheet_data, spreadsheet_data: spreadsheets[idx] ? spreadsheets[idx].getData() : loc.spreadsheet_data,
}; };
}); });
@@ -268,7 +271,7 @@ async function downloadCombinedReport() {
a.href = url; a.href = url;
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'combined_report.xlsx'; let filename = 'combined_reports.zip';
if (contentDisposition) { if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/); const match = contentDisposition.match(/filename="(.+)"/);
if (match) filename = match[1]; if (match) filename = match[1];

View File

@@ -74,105 +74,134 @@
</div> </div>
</div> </div>
<!-- Time Filter Card --> <!-- Sessions Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 overflow-hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Time Filter</h2> <div class="flex items-center justify-between mb-1">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Applied to all locations. Leave blank to include all data.</p> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<!-- Preset Buttons -->
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" onclick="setTimePreset('night')" data-preset="night"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Night 7PM 7AM
</button>
<button type="button" onclick="setTimePreset('day')" data-preset="day"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Day 7AM 7PM
</button>
<button type="button" onclick="setTimePreset('all')" data-preset="all"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
All Day
</button>
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Custom
</button>
</div>
<!-- Time Inputs -->
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time</label>
<input type="time" id="start-time" value=""
onchange="updatePresetButtons()"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Time</label>
<input type="time" id="end-time" value=""
onchange="updatePresetButtons()"
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
<!-- Date Range -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Date Range <span class="text-gray-400 font-normal">(optional)</span>
</label>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
<input type="date" id="start-date" value=""
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To</label>
<input type="date" id="end-date" value=""
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
</div>
<!-- Locations Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Locations to Include</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
<span id="selected-count">{{ locations|length }}</span> of {{ locations|length }} selected
</p>
</div>
<div class="flex gap-3 text-sm"> <div class="flex gap-3 text-sm">
<button type="button" onclick="selectAll()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button> <button type="button" onclick="selectAllSessions()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
<button type="button" onclick="deselectAll()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button> <button type="button" onclick="deselectAllSessions()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
</div> </div>
</div> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
<span id="selected-count">0</span> session(s) selected — each selected session becomes one sheet in the ZIP.
Change the period type per session to control how stats are bucketed (Day vs Night).
</p>
{% if locations %} {% if locations %}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{% for loc in locations %} {% for loc in locations %}
<label class="flex items-center gap-3 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/50 px-2 rounded-md transition-colors"> {% set loc_name = loc.name %}
<input type="checkbox" name="location" value="{{ loc.name }}" checked {% set sessions = loc.sessions %}
onchange="updateSelectedCount()" <div class="border border-gray-200 dark:border-gray-700 rounded-lg mb-3 overflow-hidden">
class="h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500"> <!-- Location header / toggle -->
<span class="flex-1 text-sm text-gray-900 dark:text-white font-medium">{{ loc.name }}</span> <button type="button"
<span class="text-xs text-gray-400 dark:text-gray-500">{{ loc.file_count }} file{{ 's' if loc.file_count != 1 else '' }}</span> onclick="toggleLocation('loc-{{ loop.index }}')"
</label> class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700/50 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-left">
<div class="flex items-center gap-3">
<svg id="chevron-loc-{{ loop.index }}" class="w-4 h-4 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ loc_name }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ sessions|length }} session{{ 's' if sessions|length != 1 else '' }}</span>
</div>
<div class="flex items-center gap-3 text-xs" onclick="event.stopPropagation()">
<button type="button" onclick="selectLocation('loc-{{ loop.index }}')"
class="text-emerald-600 dark:text-emerald-400 hover:underline">All</button>
<button type="button" onclick="deselectLocation('loc-{{ loop.index }}')"
class="text-gray-400 hover:underline">None</button>
</div>
</button>
<!-- Session rows -->
<div id="loc-{{ loop.index }}" class="divide-y divide-gray-100 dark:divide-gray-700/50">
{% for s in sessions %}
{% set pt_colors = {
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
} %}
{% set pt_labels = {
'weekday_day': 'Weekday Day',
'weekday_night': 'Weekday Night',
'weekend_day': 'Weekend Day',
'weekend_night': 'Weekend Night',
} %}
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
<!-- Checkbox -->
<input type="checkbox"
class="session-cb loc-{{ loop.index }}-cb h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500 shrink-0"
value="{{ s.session_id }}"
checked
onchange="updateSelectionStats()">
<!-- Date/day info -->
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ s.day_of_week }} {{ s.date_display }}
</span>
{% if s.session_label %}
<span class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ s.session_label }}</span>
{% endif %}
{% if s.status == 'recording' %}
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
</span>
{% endif %}
</div>
<div class="flex items-center gap-3 mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{% if s.started_at %}
<span>{{ s.started_at }}</span>
{% endif %}
{% if s.duration_h is not none %}
<span>{{ s.duration_h }}h {{ s.duration_m }}m</span>
{% endif %}
</div>
</div>
<!-- Period type dropdown -->
<div class="relative shrink-0" id="wiz-period-wrap-{{ s.session_id }}">
<button type="button"
onclick="toggleWizPeriodMenu('{{ s.session_id }}')"
id="wiz-period-badge-{{ s.session_id }}"
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ pt_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">
<span id="wiz-period-label-{{ s.session_id }}">{{ pt_labels.get(s.period_type, 'Set period') }}</span>
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="wiz-period-menu-{{ s.session_id }}"
class="hidden absolute right-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
<button type="button"
onclick="setWizPeriodType('{{ s.session_id }}', '{{ pt }}')"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
{{ pt_label }}
</button>
{% endfor %} {% endfor %}
</div> </div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %} {% else %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400"> <div class="text-center py-10 text-gray-500 dark:text-gray-400">
<p>No Leq measurement files found in this project.</p> <svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p class="text-sm mt-1">Upload RND files with '_Leq_' in the filename to generate reports.</p> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
<p>No monitoring sessions found.</p>
<p class="text-sm mt-1">Upload data files to create sessions first.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Footer Buttons --> <!-- Footer Buttons -->
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6"> <div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
<a href="/api/projects/{{ project_id }}" <a href="/projects/{{ project_id }}"
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium"> class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
Cancel Cancel
</a> </a>
@@ -191,180 +220,173 @@
</div> </div>
<script> <script>
let reportTemplates = []; const PROJECT_ID = '{{ project_id }}';
// ---- Template management (same as rnd_viewer.html) ---- const PERIOD_COLORS = {
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
};
const PERIOD_LABELS = {
weekday_day: 'Weekday Day',
weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
const ALL_PERIOD_BADGE_CLASSES = [
'bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400',
...new Set(Object.values(PERIOD_COLORS).flatMap(s => s.split(' ')))
];
// ── Location accordion ────────────────────────────────────────────
function toggleLocation(locId) {
const body = document.getElementById(locId);
const chevron = document.getElementById('chevron-' + locId);
body.classList.toggle('hidden');
chevron.style.transform = body.classList.contains('hidden') ? 'rotate(-90deg)' : '';
}
function selectLocation(locId) {
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = true);
updateSelectionStats();
}
function deselectLocation(locId) {
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = false);
updateSelectionStats();
}
function selectAllSessions() {
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = true);
updateSelectionStats();
}
function deselectAllSessions() {
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = false);
updateSelectionStats();
}
function updateSelectionStats() {
const count = document.querySelectorAll('.session-cb:checked').length;
document.getElementById('selected-count').textContent = count;
document.getElementById('preview-btn').disabled = count === 0;
}
// ── Period type dropdown (wizard) ─────────────────────────────────
function toggleWizPeriodMenu(sessionId) {
const menu = document.getElementById('wiz-period-menu-' + sessionId);
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => {
if (m.id !== 'wiz-period-menu-' + sessionId) m.classList.add('hidden');
});
menu.classList.toggle('hidden');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="wiz-period-wrap-"]')) {
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => m.classList.add('hidden'));
}
});
async function setWizPeriodType(sessionId, periodType) {
document.getElementById('wiz-period-menu-' + sessionId).classList.add('hidden');
const badge = document.getElementById('wiz-period-badge-' + sessionId);
badge.disabled = true;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({period_type: periodType}),
});
if (!resp.ok) throw new Error(await resp.text());
ALL_PERIOD_BADGE_CLASSES.forEach(c => badge.classList.remove(c));
const colorStr = PERIOD_COLORS[periodType] || 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400';
badge.classList.add(...colorStr.split(' ').filter(Boolean));
document.getElementById('wiz-period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
} catch(err) {
alert('Failed to update period type: ' + err.message);
} finally {
badge.disabled = false;
}
}
// ── Template management ───────────────────────────────────────────
let reportTemplates = [];
async function loadTemplates() { async function loadTemplates() {
try { try {
const response = await fetch('/api/report-templates?project_id={{ project_id }}'); const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
if (response.ok) { if (resp.ok) {
reportTemplates = await response.json(); reportTemplates = await resp.json();
populateTemplateDropdown(); populateTemplateDropdown();
} }
} catch (error) { } catch(e) { console.error('Error loading templates:', e); }
console.error('Error loading templates:', error);
}
} }
function populateTemplateDropdown() { function populateTemplateDropdown() {
const select = document.getElementById('template-select'); const select = document.getElementById('template-select');
if (!select) return; if (!select) return;
select.innerHTML = '<option value="">-- Select a template --</option>'; select.innerHTML = '<option value="">-- Select a template --</option>';
reportTemplates.forEach(template => { reportTemplates.forEach(t => {
const option = document.createElement('option'); const opt = document.createElement('option');
option.value = template.id; opt.value = t.id;
option.textContent = template.name; opt.textContent = t.name;
option.dataset.config = JSON.stringify(template); opt.dataset.config = JSON.stringify(t);
select.appendChild(option); select.appendChild(opt);
}); });
} }
function applyTemplate() { function applyTemplate() {
const select = document.getElementById('template-select'); const select = document.getElementById('template-select');
const selectedOption = select.options[select.selectedIndex]; const opt = select.options[select.selectedIndex];
if (!selectedOption.value) return; if (!opt.value) return;
const template = JSON.parse(selectedOption.dataset.config); const t = JSON.parse(opt.dataset.config);
if (template.report_title) document.getElementById('report-title').value = template.report_title; if (t.report_title) document.getElementById('report-title').value = t.report_title;
if (template.start_time) document.getElementById('start-time').value = template.start_time;
if (template.end_time) document.getElementById('end-time').value = template.end_time;
if (template.start_date) document.getElementById('start-date').value = template.start_date;
if (template.end_date) document.getElementById('end-date').value = template.end_date;
updatePresetButtons();
} }
async function saveAsTemplate() { async function saveAsTemplate() {
const name = prompt('Enter a name for this template:'); const name = prompt('Enter a name for this template:');
if (!name) return; if (!name) return;
const templateData = { const data = {
name: name, name,
project_id: '{{ project_id }}', project_id: PROJECT_ID,
report_title: document.getElementById('report-title').value || 'Background Noise Study', report_title: document.getElementById('report-title').value || 'Background Noise Study',
start_time: document.getElementById('start-time').value || null,
end_time: document.getElementById('end-time').value || null,
start_date: document.getElementById('start-date').value || null,
end_date: document.getElementById('end-date').value || null
}; };
try { try {
const response = await fetch('/api/report-templates', { const resp = await fetch('/api/report-templates', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(templateData) body: JSON.stringify(data),
}); });
if (response.ok) { if (resp.ok) { alert('Template saved!'); loadTemplates(); }
alert('Template saved successfully!'); else alert('Failed to save template');
loadTemplates(); } catch(e) { alert('Error: ' + e.message); }
} else {
alert('Failed to save template');
}
} catch (error) {
alert('Error saving template: ' + error.message);
}
} }
// ---- Time preset buttons ---- // ── Navigate to preview ───────────────────────────────────────────
function setTimePreset(preset) {
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
});
switch (preset) {
case 'night':
startTimeInput.value = '19:00';
endTimeInput.value = '07:00';
break;
case 'day':
startTimeInput.value = '07:00';
endTimeInput.value = '19:00';
break;
case 'all':
startTimeInput.value = '';
endTimeInput.value = '';
break;
case 'custom':
break;
}
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-emerald-600', 'text-white');
}
}
function updatePresetButtons() {
const startTime = document.getElementById('start-time').value;
const endTime = document.getElementById('end-time').value;
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
});
let preset = 'custom';
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
else if (!startTime && !endTime) preset = 'all';
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-emerald-600', 'text-white');
}
}
// ---- Location checkboxes ----
function updateSelectedCount() {
const checked = document.querySelectorAll('input[name="location"]:checked').length;
document.getElementById('selected-count').textContent = checked;
document.getElementById('preview-btn').disabled = checked === 0;
}
function selectAll() {
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = true);
updateSelectedCount();
}
function deselectAll() {
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = false);
updateSelectedCount();
}
function getCheckedLocations() {
return Array.from(document.querySelectorAll('input[name="location"]:checked')).map(cb => cb.value);
}
// ---- Navigate to preview ----
function gotoPreview() { function gotoPreview() {
const checked = getCheckedLocations(); const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
if (checked.length === 0) { if (checked.length === 0) {
alert('Please select at least one location.'); alert('Please select at least one session.');
return; return;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
report_title: document.getElementById('report-title').value || 'Background Noise Study', report_title: document.getElementById('report-title').value || 'Background Noise Study',
project_name: document.getElementById('report-project').value || '', project_name: document.getElementById('report-project').value || '',
client_name: document.getElementById('report-client').value || '', client_name: document.getElementById('report-client').value || '',
start_time: document.getElementById('start-time').value || '', selected_sessions: checked.join(','),
end_time: document.getElementById('end-time').value || '',
start_date: document.getElementById('start-date').value || '',
end_date: document.getElementById('end-date').value || '',
enabled_locations: checked.join(','),
}); });
window.location.href = `/api/projects/${PROJECT_ID}/combined-report-preview?${params.toString()}`;
window.location.href = `/api/projects/{{ project_id }}/combined-report-preview?${params.toString()}`;
} }
// ---- Init ---- // ── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
updateSelectionStats();
loadTemplates(); loadTemplates();
}); });
</script> </script>

View File

@@ -385,16 +385,27 @@
file:text-sm file:font-medium file:bg-seismo-orange file:text-white file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" /> hover:file:bg-seismo-navy file:cursor-pointer" />
<div class="flex items-center gap-3 mt-3"> <div class="flex items-center gap-3 mt-3">
<button onclick="submitUpload()" <button id="upload-btn" onclick="submitUpload()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"> class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import Files Import Files
</button> </button>
<button onclick="toggleUploadPanel()" <button id="upload-cancel-btn" onclick="toggleUploadPanel()"
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"> class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Cancel Cancel
</button> </button>
<span id="upload-status" class="text-sm hidden"></span> <span id="upload-status" class="text-sm hidden"></span>
</div> </div>
<!-- Progress bar (hidden until upload starts) -->
<div id="upload-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
</div> </div>
<div id="data-files-list" <div id="data-files-list"
@@ -629,39 +640,73 @@ function toggleUploadPanel() {
const panel = document.getElementById('upload-panel'); const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status'); const status = document.getElementById('upload-status');
panel.classList.toggle('hidden'); panel.classList.toggle('hidden');
// Reset status when reopening // Reset state when reopening
if (!panel.classList.contains('hidden')) { if (!panel.classList.contains('hidden')) {
status.textContent = ''; status.textContent = '';
status.className = 'text-sm hidden'; status.className = 'text-sm hidden';
document.getElementById('upload-input').value = ''; document.getElementById('upload-input').value = '';
document.getElementById('upload-progress-wrap').classList.add('hidden');
document.getElementById('upload-progress-bar').style.width = '0%';
} }
} }
async function submitUpload() { function submitUpload() {
const input = document.getElementById('upload-input'); const input = document.getElementById('upload-input');
const status = document.getElementById('upload-status'); const status = document.getElementById('upload-status');
const btn = document.getElementById('upload-btn');
const cancelBtn = document.getElementById('upload-cancel-btn');
const progressWrap = document.getElementById('upload-progress-wrap');
const progressBar = document.getElementById('upload-progress-bar');
const progressLabel = document.getElementById('upload-progress-label');
if (!input.files.length) { if (!input.files.length) {
alert('Please select files to upload.'); alert('Please select files to upload.');
return; return;
} }
const fileCount = input.files.length;
const formData = new FormData(); const formData = new FormData();
for (const file of input.files) { for (const file of input.files) {
formData.append('files', file); formData.append('files', file);
} }
status.textContent = 'Uploading\u2026'; // Disable controls and show progress bar
status.className = 'text-sm text-gray-500'; btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`;
}
});
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
xhr.addEventListener('load', () => {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import Files';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
try { try {
const response = await fetch( const data = JSON.parse(xhr.responseText);
`/api/projects/${projectId}/nrl/${locationId}/upload-data`, if (xhr.status >= 200 && xhr.status < 300) {
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) {
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`]; const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
if (data.leq_files || data.lp_files) { if (data.leq_files || data.lp_files) {
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`); parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
@@ -670,16 +715,30 @@ async function submitUpload() {
status.textContent = parts.join(' '); status.textContent = parts.join(' ');
status.className = 'text-sm text-green-600 dark:text-green-400'; status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = ''; input.value = '';
// Refresh the file list
htmx.trigger(document.getElementById('data-files-list'), 'load'); htmx.trigger(document.getElementById('data-files-list'), 'load');
} else { } else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`; status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} }
} catch (err) { } catch {
status.textContent = `Error: ${err.message}`; status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} }
});
xhr.addEventListener('error', () => {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import Files';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400';
});
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
xhr.send(formData);
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,79 +1,149 @@
<!-- Monitoring Sessions List --> <!-- Monitoring Sessions List -->
{% if sessions %} {% if sessions %}
<div class="space-y-4"> <div class="space-y-3">
{% for item in sessions %} {% for item in sessions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"> {% set s = item.session %}
<div class="flex items-start justify-between gap-3"> {% set loc = item.location %}
{% set unit = item.unit %}
{# Period display maps #}
{% set period_labels = {
'weekday_day': 'Weekday Day',
'weekday_night': 'Weekday Night',
'weekend_day': 'Weekend Day',
'weekend_night': 'Weekend Night',
} %}
{% set period_colors = {
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
} %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
id="session-card-{{ s.id }}">
<div class="flex items-start justify-between gap-3 p-4 pb-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-semibold text-gray-900 dark:text-white"> <!-- Label + badges -->
Session {{ item.session.id[:8] }}... <div class="flex flex-wrap items-center gap-2 mb-2">
</h4> <span id="label-display-{{ s.id }}"
{% if item.session.status == 'recording' %} class="font-semibold text-gray-900 dark:text-white text-sm cursor-pointer hover:text-seismo-orange"
<span class="px-2 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"> title="Click to edit label"
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span> onclick="startEditLabel('{{ s.id }}')">
Recording {{ s.session_label or ('Session ' + s.id[:8] + '…') }}
</span> </span>
{% elif item.session.status == 'completed' %} <input id="label-input-{{ s.id }}"
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full"> class="hidden text-sm font-semibold bg-transparent border-b border-seismo-orange text-gray-900 dark:text-white focus:outline-none min-w-[180px]"
Completed value="{{ s.session_label or '' }}"
</span> onblur="saveLabel('{{ s.id }}')"
{% elif item.session.status == 'paused' %} onkeydown="if(event.key==='Enter'){saveLabel('{{ s.id }}');}if(event.key==='Escape'){cancelEditLabel('{{ s.id }}');}">
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
Paused {% if s.status == 'recording' %}
</span> <span class="px-2 py-0.5 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">
{% elif item.session.status == 'failed' %} <span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
Failed
</span> </span>
{% elif s.status == 'completed' %}
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
{% elif s.status == 'failed' %}
<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) -->
<div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="togglePeriodMenu('{{ s.id }}')"
id="period-badge-{{ s.id }}"
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
title="Click to change period type">
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="period-menu-{{ s.id }}"
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
{{ pt_label }}
</button>
{% endfor %}
</div>
</div>
</div> </div>
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400"> <!-- Info grid -->
{% if item.unit %} <div class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<div> {% if loc %}
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span> <div class="flex items-center gap-1">
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1"> <svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{{ item.unit.id }} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
</a> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ loc.name }}</span>
</div> </div>
{% endif %} {% endif %}
<div> {% if s.started_at %}
<span class="text-xs text-gray-500">Started:</span> <div class="flex items-center gap-1">
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span> <svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{% if item.session.stopped_at %} <span>{{ s.started_at|local_datetime }}</span>
<div>
<span class="text-xs text-gray-500">Ended:</span>
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
</div> </div>
{% endif %} {% endif %}
{% if item.session.duration_seconds %} {% if s.stopped_at %}
<div> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Duration:</span> <svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Ended {{ s.stopped_at|local_datetime }}</span>
</div>
{% endif %}
{% if s.duration_seconds %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" 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>{{ (s.duration_seconds // 3600) }}h {{ ((s.duration_seconds % 3600) // 60) }}m</span>
</div>
{% endif %}
{% if unit %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"></path>
</svg>
<a href="/slm/{{ unit.id }}?from_project={{ project_id }}"
class="text-seismo-orange hover:underline font-medium">{{ unit.id }}</a>
</div>
{% endif %}
{% if s.device_model %}
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
<span>{{ s.device_model }}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if item.session.notes %} {% if s.notes %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> <p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
{{ item.session.notes }}
</p>
{% endif %} {% endif %}
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 shrink-0">
{% if item.session.status == 'recording' %} {% if s.status == 'recording' %}
<button onclick="stopRecording('{{ item.session.id }}')" <button onclick="stopRecording('{{ s.id }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"> class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Stop Stop
</button> </button>
{% endif %} {% endif %}
<button onclick="viewSession('{{ item.session.id }}')" <button onclick="viewSession('{{ s.id }}')"
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"> class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Details Details
</button> </button>
@@ -84,24 +154,107 @@
</div> </div>
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg> </svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p> <p class="text-gray-500 dark:text-gray-400 mb-1">No monitoring sessions yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p> <p class="text-sm text-gray-400 dark:text-gray-500">Upload data to create sessions</p>
</div> </div>
{% endif %} {% endif %}
<script> <script>
const PERIOD_COLORS = {
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
};
const PERIOD_LABELS = {
weekday_day: 'Weekday Day',
weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
const ALL_BADGE_COLORS = [...new Set([
...FALLBACK_COLORS,
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
])];
function togglePeriodMenu(sessionId) {
const menu = document.getElementById('period-menu-' + sessionId);
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
});
menu.classList.toggle('hidden');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="period-wrap-"]')) {
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
}
});
async function setPeriodType(sessionId, periodType) {
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
const badge = document.getElementById('period-badge-' + sessionId);
badge.disabled = true;
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({period_type: periodType}),
});
if (!resp.ok) throw new Error(await resp.text());
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
} catch(err) {
alert('Failed to update period type: ' + err.message);
} finally {
badge.disabled = false;
}
}
function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden');
const input = document.getElementById('label-input-' + sessionId);
input.classList.remove('hidden');
input.focus();
input.select();
}
function cancelEditLabel(sessionId) {
document.getElementById('label-input-' + sessionId).classList.add('hidden');
document.getElementById('label-display-' + sessionId).classList.remove('hidden');
}
async function saveLabel(sessionId) {
const display = document.getElementById('label-display-' + sessionId);
const input = document.getElementById('label-input-' + sessionId);
const newLabel = input.value.trim();
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_label: newLabel}),
});
if (!resp.ok) throw new Error(await resp.text());
display.textContent = newLabel || ('Session ' + sessionId.slice(0, 8) + '…');
} catch(err) {
alert('Failed to save label: ' + err.message);
} finally {
input.classList.add('hidden');
display.classList.remove('hidden');
}
}
function viewSession(sessionId) { function viewSession(sessionId) {
// TODO: Implement session detail modal or page
alert('Session details coming soon: ' + sessionId); alert('Session details coming soon: ' + sessionId);
} }
function stopRecording(sessionId) { function stopRecording(sessionId) {
if (!confirm('Stop this monitoring session?')) return; if (!confirm('Stop this monitoring session?')) return;
// TODO: Implement stop recording API call
alert('Stop recording API coming soon for session: ' + sessionId); alert('Stop recording API coming soon for session: ' + sessionId);
} }
</script> </script>

View File

@@ -264,16 +264,28 @@
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
file:text-sm file:font-medium file:bg-seismo-orange file:text-white file:text-sm file:font-medium file:bg-seismo-orange file:text-white
hover:file:bg-seismo-navy file:cursor-pointer" /> hover:file:bg-seismo-navy file:cursor-pointer" />
<button onclick="submitUploadAll()" <span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
<button id="upload-all-btn" onclick="submitUploadAll()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"> class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import Import
</button> </button>
<button onclick="toggleUploadAll()" <button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"> class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Cancel Cancel
</button> </button>
<span id="upload-all-status" class="text-sm hidden"></span> <span id="upload-all-status" class="text-sm hidden"></span>
</div> </div>
<!-- Progress bar -->
<div id="upload-all-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-all-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-all-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
<!-- Result summary --> <!-- Result summary -->
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div> <div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div> </div>
@@ -1642,45 +1654,110 @@ function toggleUploadAll() {
document.getElementById('upload-all-results').classList.add('hidden'); document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = ''; document.getElementById('upload-all-results').innerHTML = '';
document.getElementById('upload-all-input').value = ''; document.getElementById('upload-all-input').value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
document.getElementById('upload-all-progress-bar').style.width = '0%';
} }
} }
async function submitUploadAll() { // Show file count and filter info when folder is selected
document.getElementById('upload-all-input').addEventListener('change', function() {
const countEl = document.getElementById('upload-all-file-count');
const total = this.files.length;
if (!total) { countEl.classList.add('hidden'); return; }
const wanted = Array.from(this.files).filter(_isWantedFile).length;
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
countEl.classList.remove('hidden');
});
function _isWantedFile(f) {
const n = (f.webkitRelativePath || f.name).toLowerCase();
const base = n.split('/').pop();
if (base.endsWith('.rnh')) return true;
if (base.endsWith('.rnd')) {
if (base.includes('_leq_')) return true; // NL-43 Leq
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
if (!base.includes('_lp')) return true; // unknown format — keep
}
return false;
}
function submitUploadAll() {
const input = document.getElementById('upload-all-input'); const input = document.getElementById('upload-all-input');
const status = document.getElementById('upload-all-status'); const status = document.getElementById('upload-all-status');
const resultsEl = document.getElementById('upload-all-results'); const resultsEl = document.getElementById('upload-all-results');
const btn = document.getElementById('upload-all-btn');
const cancelBtn = document.getElementById('upload-all-cancel-btn');
const progressWrap = document.getElementById('upload-all-progress-wrap');
const progressBar = document.getElementById('upload-all-progress-bar');
const progressLabel = document.getElementById('upload-all-progress-label');
if (!input.files.length) { if (!input.files.length) {
alert('Please select a folder to upload.'); alert('Please select a folder to upload.');
return; return;
} }
// Filter client-side — only send Leq .rnd and .rnh files
const filesToSend = Array.from(input.files).filter(_isWantedFile);
if (!filesToSend.length) {
alert('No Leq .rnd or .rnh files found in selected folder.');
return;
}
const formData = new FormData(); const formData = new FormData();
for (const f of input.files) { for (const f of filesToSend) {
// webkitRelativePath gives the path relative to the selected folder root
formData.append('files', f); formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name); formData.append('paths', f.webkitRelativePath || f.name);
} }
status.textContent = `Uploading ${input.files.length} files\u2026`; // Disable controls and show progress
status.className = 'text-sm text-gray-500'; btn.disabled = true;
btn.textContent = 'Uploading\u2026';
btn.classList.add('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = true;
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
status.className = 'text-sm hidden';
resultsEl.classList.add('hidden'); resultsEl.classList.add('hidden');
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
}
});
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
function _resetControls() {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
}
xhr.addEventListener('load', () => {
_resetControls();
try { try {
const response = await fetch( const data = JSON.parse(xhr.responseText);
`/api/projects/{{ project_id }}/upload-all`, if (xhr.status >= 200 && xhr.status < 300) {
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) {
const s = data.sessions_created; const s = data.sessions_created;
const f = data.files_imported; const f = data.files_imported;
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`; status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
status.className = 'text-sm text-green-600 dark:text-green-400'; status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = ''; input.value = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
// Build results summary
let html = ''; let html = '';
if (data.sessions && data.sessions.length) { if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>'; html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
@@ -1700,17 +1777,25 @@ async function submitUploadAll() {
resultsEl.innerHTML = html; resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden'); resultsEl.classList.remove('hidden');
} }
// Refresh the unified files view
htmx.trigger(document.getElementById('unified-files'), 'refresh'); htmx.trigger(document.getElementById('unified-files'), 'refresh');
} else { } else {
status.textContent = `Error: ${data.detail || 'Upload failed'}`; status.textContent = `Error: ${data.detail || 'Upload failed'}`;
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} }
} catch (err) { } catch {
status.textContent = `Error: ${err.message}`; status.textContent = 'Error: Unexpected server response';
status.className = 'text-sm text-red-600 dark:text-red-400'; status.className = 'text-sm text-red-600 dark:text-red-400';
} }
});
xhr.addEventListener('error', () => {
_resetControls();
status.textContent = 'Error: Network error during upload';
status.className = 'text-sm text-red-600 dark:text-red-400';
});
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
xhr.send(formData);
} }
// Load project details on page load and restore active tab from URL hash // Load project details on page load and restore active tab from URL hash