4 Commits

Author SHA1 Message Date
192e15f238 Merge pull request 'cleanup/project-locations-active-assignment' (#42) from cleanup/project-locations-active-assignment into dev
Reviewed-on: #42
2026-03-27 18:20:07 -04:00
49bc625c1a feat: add report_date to monitoring sessions and update related functionality
fix: chart properly renders centered
2026-03-27 22:18:50 +00:00
95fedca8c9 feat: monitoring session improvements — UTC fix, period hours, calendar, session detail
- Fix UTC display bug: upload_nrl_data now wraps RNH datetimes with
  local_to_utc() before storing, matching patch_session behavior.
  Period type and label are derived from local time before conversion.

- Add period_start_hour / period_end_hour to MonitoringSession model
  (nullable integers 0–23). Migration: migrate_add_session_period_hours.py

- Update patch_session to accept and store period_start_hour / period_end_hour.
  Response now includes both fields.

- Update get_project_sessions to compute "Effective: M/D H:MM AM → M/D H:MM AM"
  string from period hours and pass it to session_list.html.

- Rework period edit UI in session_list.html: clicking the period badge now
  opens an inline editor with period type selector + start/end hour inputs.
  Selecting a period type pre-fills default hours (Day: 7–19, Night: 19–7).

- Wire period hours into _build_location_data_from_sessions: uses
  period_start/end_hour when set, falls back to hardcoded defaults.

- RND viewer: inject SESSION_PERIOD_START/END_HOUR from template context.
  renderTable() dims rows outside the period window (opacity-40) with a
  tooltip; shows "(N in period window)" in the row count.

- New session detail page at /api/projects/{id}/sessions/{id}/detail:
  shows breadcrumb, files list with View/Download/Report actions,
  editable session info form (label, period type, hours, times).

- Add local_datetime_input Jinja filter for datetime-local input values.

- Monthly calendar view: new get_sessions_calendar endpoint returns
  sessions_calendar.html partial; added below sessions list in detail.html.
  Color-coded per NRL with legend, HTMX prev/next navigation, session dots
  link to detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:52:52 +00:00
e8e155556a refactor: unify active assignment checks and add project-type guards
- Replace all UnitAssignment "active" checks from `status == "active"` to
  `assigned_until == None` in both project_locations.py and projects.py.
  This aligns with the canonical definition: active = no end date set.
  (status field is still set in sync, but is no longer the query criterion)

- Add `_require_sound_project()` helper to both routers and call it at the
  top of every sound-monitoring-specific endpoint (FTP browser, FTP downloads,
  RND file viewer, all Excel report endpoints, combined report wizard,
  upload-all, NRL live status, NRL data upload). Vibration projects hitting
  these endpoints now receive a clear 400 instead of silently failing or
  returning empty results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 21:12:38 +00:00
11 changed files with 1274 additions and 108 deletions

View File

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

View File

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

View File

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

View File

@@ -31,10 +31,24 @@ from backend.models import (
DataFile,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
# ============================================================================
# Shared helpers
# ============================================================================
def _require_sound_project(project) -> None:
"""Raise 400 if the project is not a sound_monitoring project."""
if not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
)
# ============================================================================
# Session period helpers
# ============================================================================
@@ -98,11 +112,11 @@ async def get_project_locations(
# Enrich with assignment info
locations_data = []
for location in locations:
# Get active assignment
# Get active assignment (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location.id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).first()
@@ -258,11 +272,11 @@ async def delete_location(
if not location:
raise HTTPException(status_code=404, detail="Location not found")
# Check if location has active assignments
# Check if location has active assignments (active = assigned_until IS NULL)
active_assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).count()
@@ -569,9 +583,9 @@ async def get_available_units(
)
).all()
# Filter out units that already have active assignments
# Filter out units that already have active assignments (active = assigned_until IS NULL)
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
UnitAssignment.status == "active"
UnitAssignment.assigned_until == None
).distinct().all()
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
@@ -747,6 +761,9 @@ async def upload_nrl_data(
from datetime import datetime
# Verify project and location exist
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(
id=location_id, project_id=project_id
).first()
@@ -808,8 +825,15 @@ async def upload_nrl_data(
rnh_meta = _parse_rnh(fbytes)
break
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
# RNH files store local time (no UTC offset). Use local values for period
# classification / label generation, then convert to UTC for DB storage so
# the local_datetime Jinja filter displays the correct time.
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
started_at = local_to_utc(started_at_local)
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
duration_seconds = None
if started_at and stopped_at:
duration_seconds = int((stopped_at - started_at).total_seconds())
@@ -819,8 +843,9 @@ async def upload_nrl_data(
index_number = rnh_meta.get("index_number", "")
# --- 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
# Use local times for period/label so classification reflects the clock at the site.
period_type = _derive_period_type(started_at_local) if started_at_local else None
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
session_id = str(uuid.uuid4())
monitoring_session = MonitoringSession(
@@ -925,15 +950,18 @@ async def get_nrl_live_status(
Fetch cached status from SLMM for the unit assigned to this NRL and
return a compact HTML status card. Used in the NRL overview tab for
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
Sound Monitoring projects only.
"""
import os
import httpx
# Find the assigned unit
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
# Find the assigned unit (active = assigned_until IS NULL)
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).first()

View File

@@ -45,6 +45,21 @@ router = APIRouter(prefix="/api/projects", tags=["projects"])
logger = logging.getLogger(__name__)
# ============================================================================
# Shared helpers
# ============================================================================
def _require_sound_project(project: Project) -> None:
"""Raise 400 if the project is not a sound_monitoring project.
Call this at the top of any endpoint that only makes sense for sound projects
(report generation, FTP browser, RND file viewer, etc.)."""
if not project or project.project_type_id != "sound_monitoring":
raise HTTPException(
status_code=400,
detail="This feature is only available for Sound Monitoring projects.",
)
# ============================================================================
# RND file normalization — maps AU2 (older Rion) column names to the NL-43
# equivalents so report generation and the web viewer work for both formats.
@@ -398,11 +413,11 @@ async def get_projects_list(
project_id=project.id
).scalar()
# Count assigned units
# Count assigned units (active = assigned_until IS NULL)
unit_count = db.query(func.count(UnitAssignment.id)).filter(
and_(
UnitAssignment.project_id == project.id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).scalar()
@@ -806,11 +821,11 @@ async def get_project_dashboard(
# Get locations
locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()
# Get assigned units with details
# Get assigned units with details (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).all()
@@ -899,11 +914,11 @@ async def get_project_units(
"""
from backend.models import DataFile
# Get all assignments for this project
# Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).all()
@@ -1128,7 +1143,7 @@ async def get_project_sessions(
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit and location details
# Enrich with unit, location, and effective time window details
sessions_data = []
for session in sessions:
unit = None
@@ -1139,10 +1154,34 @@ async def get_project_sessions(
if session.location_id:
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
# Compute "Effective: date time → date time" string when period hours are set
effective_range = None
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
from datetime import date as _date
local_start = utc_to_local(session.started_at)
start_day = session.report_date if session.report_date else local_start.date()
sh = session.period_start_hour
eh = session.period_end_hour
def _fmt_h(h):
ampm = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:00 {ampm}"
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
if eh > sh: # same calendar day
end_day = start_day
else: # crosses midnight
from datetime import timedelta as _td
end_day = start_day + _td(days=1)
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
effective_range = f"{start_str}{end_str}"
sessions_data.append({
"session": session,
"unit": unit,
"location": location,
"effective_range": effective_range,
})
return templates.TemplateResponse("partials/projects/session_list.html", {
@@ -1152,6 +1191,126 @@ async def get_project_sessions(
})
@router.get("/{project_id}/sessions-calendar", response_class=HTMLResponse)
async def get_sessions_calendar(
project_id: str,
request: Request,
db: Session = Depends(get_db),
month: Optional[int] = Query(None),
year: Optional[int] = Query(None),
):
"""
Monthly calendar view of monitoring sessions.
Color-coded by NRL location. Returns HTML partial.
"""
from calendar import monthrange
from datetime import date as _date, timedelta as _td
# Default to current month
now_local = utc_to_local(datetime.utcnow())
if not year:
year = now_local.year
if not month:
month = now_local.month
# Clamp month to valid range
month = max(1, min(12, month))
# Load all sessions for this project
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
# Build location -> color map (deterministic)
PALETTE = [
"#f97316", "#3b82f6", "#10b981", "#8b5cf6",
"#ec4899", "#14b8a6", "#f59e0b", "#6366f1",
"#ef4444", "#84cc16",
]
loc_ids = sorted({s.location_id for s in sessions if s.location_id})
loc_color = {lid: PALETTE[i % len(PALETTE)] for i, lid in enumerate(loc_ids)}
# Load location names
loc_names = {}
for lid in loc_ids:
loc = db.query(MonitoringLocation).filter_by(id=lid).first()
if loc:
loc_names[lid] = loc.name
# Build day -> list of session dots
# day key: date object
day_sessions: dict = {}
for s in sessions:
if not s.started_at:
continue
local_start = utc_to_local(s.started_at)
d = local_start.date()
if d.year == year and d.month == month:
if d not in day_sessions:
day_sessions[d] = []
day_sessions[d].append({
"session_id": s.id,
"label": s.session_label or f"Session {s.id[:8]}",
"location_id": s.location_id,
"location_name": loc_names.get(s.location_id, "Unknown"),
"color": loc_color.get(s.location_id, "#9ca3af"),
"status": s.status,
"period_type": s.period_type,
})
# Build calendar grid (MonSun weeks)
first_day = _date(year, month, 1)
last_day = _date(year, month, monthrange(year, month)[1])
# Start on Sunday before first_day (isoweekday: Mon=1 … Sun=7; weekday: Mon=0 … Sun=6)
days_before = (first_day.isoweekday() % 7) # Sun=0, Mon=1, …, Sat=6
grid_start = first_day - _td(days=days_before)
# End on Saturday after last_day
days_after = 6 - (last_day.isoweekday() % 7)
grid_end = last_day + _td(days=days_after)
weeks = []
cur = grid_start
while cur <= grid_end:
week = []
for _ in range(7):
week.append({
"date": cur,
"in_month": cur.month == month,
"is_today": cur == now_local.date(),
"sessions": day_sessions.get(cur, []),
})
cur += _td(days=1)
weeks.append(week)
# Prev/next month navigation
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
next_month = month + 1 if month < 12 else 1
next_year = year if month < 12 else year + 1
import calendar as _cal
month_name = _cal.month_name[month]
# Legend: only locations that have sessions this month
used_lids = {s["location_id"] for day in day_sessions.values() for s in day}
legend = [
{"location_id": lid, "name": loc_names.get(lid, lid[:8]), "color": loc_color[lid]}
for lid in loc_ids if lid in used_lids
]
return templates.TemplateResponse("partials/projects/sessions_calendar.html", {
"request": request,
"project_id": project_id,
"weeks": weeks,
"month": month,
"year": year,
"month_name": month_name,
"prev_month": prev_month,
"prev_year": prev_year,
"next_month": next_month,
"next_year": next_year,
"legend": legend,
})
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
async def get_ftp_browser(
project_id: str,
@@ -1160,15 +1319,18 @@ async def get_ftp_browser(
):
"""
Get FTP browser interface for downloading files from assigned SLMs.
Returns HTML partial with FTP browser.
Returns HTML partial with FTP browser. Sound Monitoring projects only.
"""
from backend.models import DataFile
# Get all assignments for this project
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
# Get all assignments for this project (active = assigned_until IS NULL)
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
UnitAssignment.assigned_until == None,
)
).all()
@@ -1202,6 +1364,7 @@ async def ftp_download_to_server(
"""
Download a file from an SLM to the server via FTP.
Creates a DataFile record and stores the file in data/Projects/{project_id}/
Sound Monitoring projects only.
"""
import httpx
import os
@@ -1209,6 +1372,8 @@ async def ftp_download_to_server(
from pathlib import Path
from backend.models import DataFile
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
data = await request.json()
unit_id = data.get("unit_id")
remote_path = data.get("remote_path")
@@ -1367,12 +1532,15 @@ async def ftp_download_folder_to_server(
Download an entire folder from an SLM to the server via FTP.
Extracts all files from the ZIP and preserves folder structure.
Creates individual DataFile records for each file.
Sound Monitoring projects only.
"""
import httpx
import os
import hashlib
import zipfile
import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
from pathlib import Path
from backend.models import DataFile
@@ -1879,8 +2047,111 @@ async def patch_session(
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
# Configurable period window (023 integers; null = no filter)
for field in ("period_start_hour", "period_end_hour"):
if field in data:
val = data[field]
if val is None or val == "":
setattr(session, field, None)
else:
try:
h = int(val)
if not (0 <= h <= 23):
raise ValueError
setattr(session, field, h)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"{field} must be an integer 023 or null")
if "report_date" in data:
val = data["report_date"]
if val is None or val == "":
session.report_date = None
else:
try:
from datetime import date as _date
session.report_date = _date.fromisoformat(str(val))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid report_date format. Use YYYY-MM-DD.")
db.commit()
return JSONResponse({"status": "success", "session_label": session.session_label, "period_type": session.period_type})
return JSONResponse({
"status": "success",
"session_label": session.session_label,
"period_type": session.period_type,
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
"report_date": session.report_date.isoformat() if session.report_date else None,
})
@router.get("/{project_id}/sessions/{session_id}/detail", response_class=HTMLResponse)
async def view_session_detail(
request: Request,
project_id: str,
session_id: str,
db: Session = Depends(get_db),
):
"""
Session detail page: shows files, editable session info, data preview, and report actions.
"""
from backend.models import DataFile
from pathlib import Path
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
session = db.query(MonitoringSession).filter_by(id=session_id, project_id=project_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.unit_id else None
# Load all data files for this session
files = db.query(DataFile).filter_by(session_id=session_id).order_by(DataFile.created_at).all()
# Compute effective time range string for display
effective_range = None
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
local_start = utc_to_local(session.started_at)
start_day = session.report_date if session.report_date else local_start.date()
sh = session.period_start_hour
eh = session.period_end_hour
def _fmt_h(h):
ampm = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:00 {ampm}"
start_str = f"{start_day.month}/{start_day.day} {_fmt_h(sh)}"
if eh > sh:
end_day = start_day
else:
from datetime import timedelta as _td
end_day = start_day + _td(days=1)
end_str = f"{end_day.month}/{end_day.day} {_fmt_h(eh)}"
effective_range = f"{start_str}{end_str}"
# Parse session_metadata if present
session_meta = {}
if session.session_metadata:
try:
session_meta = json.loads(session.session_metadata)
except Exception:
pass
return templates.TemplateResponse("session_detail.html", {
"request": request,
"project": project,
"project_id": project_id,
"session": session,
"location": location,
"unit": unit,
"files": files,
"effective_range": effective_range,
"session_meta": session_meta,
"report_date": session.report_date.isoformat() if session.report_date else "",
})
@router.get("/{project_id}/files/{file_id}/view-rnd", response_class=HTMLResponse)
@@ -1915,6 +2186,7 @@ async def view_rnd_file(
# Get project info
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
# Get location info if available
location = None
@@ -1946,6 +2218,8 @@ async def view_rnd_file(
"metadata": metadata,
"filename": file_path.name,
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
})
@@ -1958,12 +2232,15 @@ async def get_rnd_data(
"""
Get parsed RND file data as JSON.
Returns the measurement data for charts and tables.
Sound Monitoring projects only.
"""
from backend.models import DataFile
from pathlib import Path
import csv
import io
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
# Get the file record
file_record = db.query(DataFile).filter_by(id=file_id).first()
if not file_record:
@@ -2052,6 +2329,8 @@ async def get_rnd_data(
"summary": summary,
"headers": summary["headers"],
"data": rows,
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
}
except Exception as e:
@@ -2120,6 +2399,7 @@ async def generate_excel_report(
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
@@ -2406,7 +2686,7 @@ async def generate_excel_report(
_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, "I4")
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
@@ -2550,6 +2830,7 @@ async def preview_report_data(
# Get related data for report context
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Build full file path
@@ -2761,6 +3042,7 @@ async def generate_report_from_preview(
raise HTTPException(status_code=403, detail="File does not belong to this project")
project = db.query(Project).filter_by(id=project_id).first()
_require_sound_project(project)
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() if session.location_id else None
# Extract data from request
@@ -2892,7 +3174,7 @@ async def generate_report_from_preview(
_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, "I4")
# --- Stats block starting at I28 ---
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
@@ -3041,6 +3323,7 @@ async def generate_combined_excel_report(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
# Get all sessions with measurement files
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
@@ -3239,7 +3522,7 @@ async def generate_combined_excel_report(
_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, "I4")
# 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 ")
@@ -3386,6 +3669,7 @@ async def combined_report_wizard(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).order_by(MonitoringSession.started_at).all()
@@ -3478,7 +3762,10 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
"loc_name": loc_name,
"session_label": session.session_label or "",
"period_type": session.period_type or "",
"period_start_hour": session.period_start_hour,
"period_end_hour": session.period_end_hour,
"started_at": session.started_at,
"report_date": session.report_date,
"rows": [],
}
@@ -3519,25 +3806,39 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
pass
parsed.append((dt, row))
# Determine which rows to keep based on period_type
# Determine effective hour window.
# Prefer per-session period_start/end_hour; fall back to hardcoded defaults.
sh = entry.get("period_start_hour") # e.g. 7 for Day, 19 for Night
eh = entry.get("period_end_hour") # e.g. 19 for Day, 7 for Night
if sh is None or eh is None:
# Legacy defaults based on period_type
is_day_session = period_type in ('weekday_day', 'weekend_day')
sh = 7 if is_day_session else 19
eh = 19 if is_day_session else 7
else:
is_day_session = eh > sh # crosses midnight when end < start
target_date = None
if is_day_session:
# Day: 07:0018:59 only, restricted to the LAST calendar date that has daytime rows
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
in_window = lambda h: sh <= h < eh
if entry.get("report_date"):
target_date = entry["report_date"]
else:
daytime_dates = sorted({
dt.date() for dt, row in parsed
if dt and 7 <= dt.hour < 19
dt.date() for dt, row in parsed if dt and in_window(dt.hour)
})
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
if dt and dt.date() == target_date and in_window(dt.hour)
]
else:
# Night: 19:0006:59, spanning both calendar days — no date restriction
# Night-style: hour >= start_h OR hour < end_h (crosses midnight)
in_window = lambda h: h >= sh or h < eh
filtered = [
(dt, row) for dt, row in parsed
if dt and (dt.hour >= 19 or dt.hour < 7)
if dt and in_window(dt.hour)
]
# Fall back to all rows if filtering removed everything
@@ -3655,6 +3956,7 @@ async def generate_combined_from_preview(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
report_title = data.get("report_title", "Background Noise Study")
project_name = data.get("project_name", project.name)
@@ -3817,7 +4119,7 @@ async def generate_combined_from_preview(
_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, "I4")
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
@@ -4130,6 +4432,7 @@ async def upload_all_project_data(
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
_require_sound_project(project)
# Load all sound monitoring locations for this project
locations = db.query(MonitoringLocation).filter_by(

View File

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

View File

@@ -5,6 +5,7 @@
{% set s = item.session %}
{% set loc = item.location %}
{% set unit = item.unit %}
{% set effective_range = item.effective_range %}
{# Period display maps #}
{% set period_labels = {
@@ -49,27 +50,76 @@
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
{% endif %}
<!-- Period type badge (click to change) -->
<!-- Period type badge (click to open hour editor) -->
<div class="relative" id="period-wrap-{{ s.id }}">
<button onclick="togglePeriodMenu('{{ s.id }}')"
<button onclick="openPeriodEditor('{{ s.id }}')"
id="period-badge-{{ s.id }}"
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
title="Click to change period type">
title="Click to edit period type and hours">
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="period-menu-{{ s.id }}"
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
<button onclick="setPeriodType('{{ s.id }}', '{{ pt }}')"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
<!-- Period editor panel -->
<div id="period-editor-{{ s.id }}"
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg w-64 p-3 space-y-3">
<!-- Period type selector -->
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
<div class="grid grid-cols-2 gap-1">
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
id="pt-btn-{{ s.id }}-{{ pt }}"
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
{{ pt_label }}
</button>
{% endfor %}
</div>
</div>
<!-- Hour inputs -->
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (023)</label>
<input type="number" min="0" max="23"
id="period-start-hr-{{ s.id }}"
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
placeholder="e.g. 19"
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (023)</label>
<input type="number" min="0" max="23"
id="period-end-hr-{{ s.id }}"
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
placeholder="e.g. 7"
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
<!-- Actions -->
<div class="flex gap-2 pt-1">
<button onclick="savePeriodEditor('{{ s.id }}')"
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
Save
</button>
<button onclick="closePeriodEditor('{{ s.id }}')"
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
Cancel
</button>
<button onclick="clearPeriodEditor('{{ s.id }}')"
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
title="Clear period type and hours">
Clear
</button>
</div>
</div>
</div>
</div>
<!-- Info grid -->
@@ -131,8 +181,17 @@
{% endif %}
</div>
{% if s.notes %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">{{ s.notes }}</p>
<!-- Effective window (when period hours are set) -->
{% if effective_range %}
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
</div>
{% else %}
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
id="effective-range-{{ s.id }}"></div>
{% endif %}
</div>
@@ -163,6 +222,8 @@
{% endif %}
<script>
const PROJECT_ID = '{{ project_id }}';
const PERIOD_COLORS = {
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
@@ -175,46 +236,145 @@ const PERIOD_LABELS = {
weekend_day: 'Weekend Day',
weekend_night: 'Weekend Night',
};
// Default hours for each period type
const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7},
weekend_day: {start: 7, end: 19},
weekend_night: {start: 19, end: 7},
};
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
const ALL_BADGE_COLORS = [...new Set([
...FALLBACK_COLORS,
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
])];
function togglePeriodMenu(sessionId) {
const menu = document.getElementById('period-menu-' + sessionId);
document.querySelectorAll('[id^="period-menu-"]').forEach(m => {
if (m.id !== 'period-menu-' + sessionId) m.classList.add('hidden');
// Track which period type is selected in the editor before saving
const _editorState = {};
// ---- Period editor ----
function openPeriodEditor(sessionId) {
// Close all other editors first
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
});
menu.classList.toggle('hidden');
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
}
function closePeriodEditor(sessionId) {
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
delete _editorState[sessionId];
}
function selectPeriodType(sessionId, pt) {
_editorState[sessionId] = pt;
// Highlight selected button
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
btn.classList.toggle('border-seismo-orange', isSelected);
btn.classList.toggle('bg-orange-50', isSelected);
btn.classList.toggle('text-seismo-orange', isSelected);
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
btn.classList.toggle('border-gray-200', !isSelected);
btn.classList.toggle('dark:border-gray-600', !isSelected);
btn.classList.toggle('text-gray-600', !isSelected);
btn.classList.toggle('dark:text-gray-400', !isSelected);
});
// Fill default hours
const defaults = PERIOD_DEFAULT_HOURS[pt];
if (defaults) {
const sh = document.getElementById('period-start-hr-' + sessionId);
const eh = document.getElementById('period-end-hr-' + sessionId);
if (sh && !sh.value) sh.value = defaults.start;
if (eh && !eh.value) eh.value = defaults.end;
}
}
async function savePeriodEditor(sessionId) {
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
?.dataset?.currentPeriod || null;
const shInput = document.getElementById('period-start-hr-' + sessionId);
const ehInput = document.getElementById('period-end-hr-' + sessionId);
const payload = {};
if (pt !== undefined) payload.period_type = pt || null;
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
// Update badge
const badge = document.getElementById('period-badge-' + sessionId);
const label = document.getElementById('period-label-' + sessionId);
const newPt = result.period_type;
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
if (newPt && PERIOD_COLORS[newPt]) {
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
if (label) label.textContent = PERIOD_LABELS[newPt];
} else {
badge.classList.add(...FALLBACK_COLORS);
if (label) label.textContent = 'Set period';
}
// Update effective range display
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
closePeriodEditor(sessionId);
} catch (err) {
alert('Failed to save period: ' + err.message);
}
}
async function clearPeriodEditor(sessionId) {
const shInput = document.getElementById('period-start-hr-' + sessionId);
const ehInput = document.getElementById('period-end-hr-' + sessionId);
if (shInput) shInput.value = '';
if (ehInput) ehInput.value = '';
_editorState[sessionId] = null;
// Reset period type button highlights
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
});
}
// ---- Effective range helper ----
function _updateEffectiveRange(sessionId, startHour, endHour) {
const el = document.getElementById('effective-range-' + sessionId);
if (!el) return;
if (startHour == null || endHour == null) {
el.textContent = '';
el.classList.add('hidden');
return;
}
function _fmt(h) {
const ampm = h < 12 ? 'AM' : 'PM';
const h12 = h % 12 || 12;
return `${h12}:00 ${ampm}`;
}
// We don't have the session start date in JS so just show the hours pattern
el.textContent = `Effective window: ${_fmt(startHour)}${_fmt(endHour)}`;
el.classList.remove('hidden');
}
// ---- Close editors on outside click ----
document.addEventListener('click', function(e) {
if (!e.target.closest('[id^="period-wrap-"]')) {
document.querySelectorAll('[id^="period-menu-"]').forEach(m => m.classList.add('hidden'));
document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
}
});
async function setPeriodType(sessionId, periodType) {
document.getElementById('period-menu-' + sessionId).classList.add('hidden');
const badge = document.getElementById('period-badge-' + sessionId);
badge.disabled = true;
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({period_type: periodType}),
});
if (!resp.ok) throw new Error(await resp.text());
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
badge.classList.add(...(PERIOD_COLORS[periodType] || FALLBACK_COLORS.join(' ')).split(' ').filter(Boolean));
document.getElementById('period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
} catch(err) {
alert('Failed to update period type: ' + err.message);
} finally {
badge.disabled = false;
}
}
// ---- Label editing ----
function startEditLabel(sessionId) {
document.getElementById('label-display-' + sessionId).classList.add('hidden');
@@ -234,7 +394,7 @@ async function saveLabel(sessionId) {
const input = document.getElementById('label-input-' + sessionId);
const newLabel = input.value.trim();
try {
const resp = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_label: newLabel}),
@@ -249,8 +409,10 @@ async function saveLabel(sessionId) {
}
}
// ---- Session details ----
function viewSession(sessionId) {
alert('Session details coming soon: ' + sessionId);
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
}
function stopRecording(sessionId) {

View File

@@ -0,0 +1,91 @@
<!-- Monthly Sessions Calendar -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Calendar header: month navigation -->
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
hx-target="#sessions-calendar"
hx-swap="innerHTML"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
title="Previous month">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
hx-target="#sessions-calendar"
hx-swap="innerHTML"
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
title="Next month">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- Legend -->
{% if legend %}
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap gap-3">
{% for loc in legend %}
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
<span>{{ loc.name }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
<div class="py-2 text-center text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% endif %}">
{{ day_name }}
</div>
{% endfor %}
</div>
<!-- Calendar grid -->
{% for week in weeks %}
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
{% for day in week %}
<div class="min-h-[72px] p-1.5 {% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
<!-- Date number -->
<div class="text-right mb-1">
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
{{ day.date.day }}
</span>
</div>
<!-- Session dots/chips -->
{% if day.sessions %}
<div class="space-y-0.5">
{% for s in day.sessions[:4] %}
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail"
class="flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate hover:opacity-80 transition-opacity"
style="background-color: {{ s.color }}22; border-left: 2px solid {{ s.color }};"
title="{{ s.label }}">
<span class="truncate text-gray-700 dark:text-gray-300" style="color: {{ s.color }}; font-size: 0.65rem; line-height: 1.2;">
{{ s.location_name }}
{% if s.period_type %}
<span class="opacity-60">{{ '☀' if 'day' in s.period_type else '☾' }}</span>
{% endif %}
</span>
</a>
{% endfor %}
{% if day.sessions|length > 4 %}
<div class="text-center text-xs text-gray-400 dark:text-gray-500">+{{ day.sessions|length - 4 }} more</div>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>

View File

@@ -208,6 +208,19 @@
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
</div>
</div>
<!-- Monthly Calendar -->
<div class="mt-6 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Calendar View</h3>
</div>
<div id="sessions-calendar"
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
</div>
</div>
</div>
<!-- Data Files Tab -->

View File

@@ -181,6 +181,27 @@ let chartInstance = null;
let allData = [];
let allHeaders = [];
// Session period window (null = no filtering)
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
/**
* Returns true if the given hour integer is within the session's period window.
* Always returns true when no period window is configured.
*/
function _isInPeriodWindow(hour) {
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
const sh = SESSION_PERIOD_START_HOUR;
const eh = SESSION_PERIOD_END_HOUR;
if (eh > sh) {
// Same-day window, e.g. 719
return hour >= sh && hour < eh;
} else {
// Crosses midnight, e.g. 197
return hour >= sh || hour < eh;
}
}
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadRndData();
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
});
}
function renderTable(headers, data) {
const headerRow = document.getElementById('table-header');
const tbody = document.getElementById('table-body');
function _rowHour(row) {
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
const t = row['Start Time'];
if (!t) return null;
const parts = t.split(' ');
if (parts.length < 2) return null;
return parseInt(parts[1].split(':')[0], 10);
}
// Render headers
headerRow.innerHTML = '<tr>' + headers.map(h =>
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
).join('') + '</tr>';
// Render rows (limit to first 500 for performance)
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
function _buildRow(headers, row) {
const hour = _rowHour(row);
const inWindow = hour === null || _isInPeriodWindow(hour);
const dimClass = inWindow ? '' : 'opacity-40';
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
headers.map(h => {
const val = row[h];
let displayVal = val;
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).join('');
'</tr>';
}
function renderTable(headers, data) {
const headerRow = document.getElementById('table-header');
const tbody = document.getElementById('table-body');
// Render headers — add period window indicator if configured
let periodNote = '';
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)}${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
}
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
).join('') + '</tr>';
// Render rows (limit to first 500 for performance)
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
// Update row count
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
? ` (${inWindowCount} in period window)`
: '';
document.getElementById('row-count').textContent =
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
// Search functionality
document.getElementById('table-search').addEventListener('input', function(e) {
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
);
const displayFiltered = filtered.slice(0, 500);
tbody.innerHTML = displayFiltered.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
headers.map(h => {
const val = row[h];
let displayVal = val;
if (val === null || val === undefined) {
displayVal = '-';
} else if (typeof val === 'number') {
displayVal = val.toFixed(1);
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).join('');
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
document.getElementById('row-count').textContent =
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;

View File

@@ -0,0 +1,437 @@
{% extends "base.html" %}
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto">
<!-- Breadcrumb -->
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name }}</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="text-gray-900 dark:text-white truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
</nav>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<svg class="w-7 h-7 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
</h1>
{% if location %}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if session.status == 'completed' %}
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
{% elif session.status == 'recording' %}
<span class="px-3 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
</span>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- LEFT COLUMN: Info + Edit -->
<div class="lg:col-span-1 space-y-4">
<!-- Session Info Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
id="info-label">{{ session.session_label or '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
{{ PLABELS.get(session.period_type, '—') }}
</dd>
</div>
{% if effective_range %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
</div>
{% else %}
<div class="flex justify-between hidden" id="info-effective-row">
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
</div>
{% endif %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
{{ report_date or '— (auto)' }}
</dd>
</div>
{% if session.started_at %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
</div>
{% endif %}
{% if session.stopped_at %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
</div>
{% endif %}
{% if session.duration_seconds %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
</div>
{% endif %}
{% if session.device_model %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
</div>
{% endif %}
{% if session_meta.get('store_name') %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
</div>
{% endif %}
{% if session_meta.get('serial_number') %}
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Edit Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
<form id="edit-form" onsubmit="saveSession(event)">
<!-- Label -->
<div class="mb-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
<input type="text" id="edit-label" name="session_label"
value="{{ session.session_label or '' }}"
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<!-- Section: Required Recording Window -->
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
<div class="space-y-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
<select id="edit-period-type" name="period_type"
onchange="fillPeriodDefaults()"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
<option value="">— Not Set —</option>
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
<div class="relative">
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
placeholder="e.g. 19"
oninput="updateWindowPreview()"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
placeholder="e.g. 7"
oninput="updateWindowPreview()"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
<!-- Live preview -->
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
{% set sh = session.period_start_hour %}
{% set eh = session.period_end_hour %}
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
{% endif %}
</div>
<div class="mt-2">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
</label>
<input type="date" id="edit-report-date" name="report_date"
value="{{ report_date or '' }}"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
</div>
</div>
</div>
<!-- Section: Device On/Off Times -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
<div class="space-y-2">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
<input type="datetime-local" id="edit-started-at" name="started_at"
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
</div>
</div>
</div>
<div class="flex gap-2">
<button type="submit"
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
Save Changes
</button>
</div>
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
</form>
</div>
</div>
<!-- RIGHT COLUMN: Files + Report Actions -->
<div class="lg:col-span-2 space-y-5">
<!-- Files List -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Data Files
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
</h2>
</div>
{% if files %}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{% for f in files %}
{% set fname = f.file_path.split('/')[-1] %}
{% set is_rnd = fname.lower().endswith('.rnd') %}
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<!-- Icon -->
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
{% if is_rnd %}
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
{% endif %}
</div>
<!-- Name + meta -->
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
<span>{{ f.file_type | upper }}</span>
{% if f.file_size_bytes %}
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
{% endif %}
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
{% if is_rnd %}
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
View
</a>
{% if is_leq %}
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
Report
</button>
{% endif %}
{% endif %}
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
Download
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="px-5 py-10 text-center text-gray-400">
<p>No files found for this session.</p>
</div>
{% endif %}
</div>
<!-- Report Actions -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
{% if session.status == 'completed' %}
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
{% if has_rnd %}
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
<p class="text-xs text-gray-600 dark:text-gray-400">
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
{% if session.period_start_hour is not none %}
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00{{ session.period_end_hour }}:00 will be applied.</span>
{% endif %}
</p>
<a href="/projects/{{ project_id }}?tab=data"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Go to Combined Report Wizard
</a>
</div>
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
{% endif %}
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
{% endif %}
</div>
</div>
</div>
</div>
<script>
const PROJECT_ID = '{{ project_id }}';
const SESSION_ID = '{{ session.id }}';
const PERIOD_DEFAULT_HOURS = {
weekday_day: {start: 7, end: 19},
weekday_night: {start: 19, end: 7},
weekend_day: {start: 7, end: 19},
weekend_night: {start: 19, end: 7},
};
function fillPeriodDefaults() {
const pt = document.getElementById('edit-period-type').value;
const defaults = PERIOD_DEFAULT_HOURS[pt];
if (defaults) {
document.getElementById('edit-start-hour').value = defaults.start;
document.getElementById('edit-end-hour').value = defaults.end;
}
updateWindowPreview();
}
function updateWindowPreview() {
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
const el = document.getElementById('window-preview');
if (!el) return;
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
const crosses = eh <= sh;
el.textContent = `Window: ${fmt(sh)}${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
}
// Run once on load to populate preview if values already set
document.addEventListener('DOMContentLoaded', updateWindowPreview);
async function saveSession(e) {
e.preventDefault();
const status = document.getElementById('save-status');
status.className = 'text-xs text-center pt-1 text-gray-400';
status.textContent = 'Saving…';
status.classList.remove('hidden');
const form = document.getElementById('edit-form');
const payload = {};
const label = form.session_label.value.trim();
payload.session_label = label || null;
const pt = form.period_type.value;
payload.period_type = pt || null;
const sh = form.period_start_hour.value;
const eh = form.period_end_hour.value;
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
const rd = form.report_date.value;
payload.report_date = rd || null;
const sa = form.started_at.value;
if (sa) payload.started_at = sa;
const st = form.stopped_at.value;
if (st) payload.stopped_at = st;
else if (form.stopped_at.value === '') payload.stopped_at = null;
try {
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
// Update displayed label
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
document.getElementById('header-label').textContent = newLabel;
document.getElementById('info-label').textContent = result.session_label || '—';
document.getElementById('info-period').textContent = {
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
}[result.period_type] || '—';
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
status.textContent = 'Saved!';
setTimeout(() => status.classList.add('hidden'), 2500);
} catch(err) {
status.className = 'text-xs text-center pt-1 text-red-500';
status.textContent = 'Error: ' + err.message;
}
}
function openSingleFileReport(fileId, filename) {
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
}
</script>
{% endblock %}