diff --git a/backend/migrate_add_session_report_date.py b/backend/migrate_add_session_report_date.py new file mode 100644 index 0000000..3b17ac7 --- /dev/null +++ b/backend/migrate_add_session_report_date.py @@ -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() diff --git a/backend/models.py b/backend/models.py index d6e156d..b45f12f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -309,6 +309,11 @@ class MonitoringSession(Base): 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 diff --git a/backend/routers/projects.py b/backend/routers/projects.py index e995293..528305e 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1159,7 +1159,7 @@ async def get_project_sessions( 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 = local_start.date() + start_day = session.report_date if session.report_date else local_start.date() sh = session.period_start_hour eh = session.period_end_hour @@ -1259,10 +1259,11 @@ async def get_sessions_calendar( # Build calendar grid (Mon–Sun weeks) first_day = _date(year, month, 1) last_day = _date(year, month, monthrange(year, month)[1]) - # Start on Monday before first_day - grid_start = first_day - _td(days=first_day.weekday()) - # End on Sunday after last_day - days_after = 6 - last_day.weekday() + # 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 = [] @@ -2061,6 +2062,17 @@ async def patch_session( except (ValueError, TypeError): raise HTTPException(status_code=400, detail=f"{field} must be an integer 0–23 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", @@ -2068,6 +2080,7 @@ async def patch_session( "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, }) @@ -2103,7 +2116,7 @@ async def view_session_detail( 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 = local_start.date() + 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): @@ -2137,6 +2150,7 @@ async def view_session_detail( "files": files, "effective_range": effective_range, "session_meta": session_meta, + "report_date": session.report_date.isoformat() if session.report_date else "", }) @@ -2672,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 ") @@ -3160,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 @@ -3508,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 ") @@ -3751,6 +3765,7 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids "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": [], } @@ -3807,10 +3822,13 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids if is_day_session: # Day-style: start_h <= hour < end_h, restricted to the LAST calendar date in_window = lambda h: sh <= h < eh - daytime_dates = sorted({ - dt.date() for dt, row in parsed if dt and in_window(dt.hour) - }) - target_date = daytime_dates[-1] if daytime_dates else None + if entry.get("report_date"): + target_date = entry["report_date"] + else: + daytime_dates = sorted({ + 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 in_window(dt.hour) @@ -4101,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") diff --git a/templates/partials/projects/sessions_calendar.html b/templates/partials/projects/sessions_calendar.html index feda7d4..e4f0e42 100644 --- a/templates/partials/projects/sessions_calendar.html +++ b/templates/partials/projects/sessions_calendar.html @@ -40,9 +40,9 @@