From 49bc625c1a5ad54efe645ca72f6d7bfa0b80c71c Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 27 Mar 2026 22:18:50 +0000 Subject: [PATCH] feat: add report_date to monitoring sessions and update related functionality fix: chart properly renders centered --- backend/migrate_add_session_report_date.py | 41 +++++ backend/models.py | 5 + backend/routers/projects.py | 46 +++-- .../partials/projects/sessions_calendar.html | 4 +- templates/session_detail.html | 158 +++++++++++++----- 5 files changed, 192 insertions(+), 62 deletions(-) create mode 100644 backend/migrate_add_session_report_date.py 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 @@
- {% for day_name in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] %} + {% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
+ {% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% endif %}"> {{ day_name }}
{% endfor %} diff --git a/templates/session_detail.html b/templates/session_detail.html index c3bece0..23d64b4 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -77,6 +77,12 @@
{% endif %} +
+
Report Date
+
+ {{ report_date or '— (auto)' }} +
+
{% if session.started_at %}
Started
@@ -118,66 +124,107 @@
-

Edit Session

-
-
+

Edit Session

+ + + +
-
- - -
-
-
- - -
-
- - + + +
+

Required Recording Window

+

The hours that count for reports. Only data within this window is included.

+ +
+
+ + +
+ +
+
+ +
+ +
+
+
+ + +
+
+ + +
+ {% 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 %} +
+ +
+ + +

Leave blank to auto-select the last day with data in the window.

+
-
- - + + +
+

Device On/Off Times

+

When the meter was actually running. Usually set automatically from the data file.

+ +
+
+ + +
+
+ + +
+
-
- - -
-
+ +
-
- +
@@ -307,8 +354,23 @@ function fillPeriodDefaults() { 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'); @@ -330,6 +392,9 @@ async function saveSession(e) { 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; @@ -354,6 +419,7 @@ async function saveSession(e) { 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!';