From 47c65268e3841737c3d6cb6578212479a88a5de8 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 15 May 2026 06:33:00 +0000 Subject: [PATCH 01/12] feat(tools): fleet-wide deployment history calendar (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-unit Gantt chart on /unit/{id} (Phase 1, v0.11.0) was scoped to one unit's deployment timeline. This adds the fleet-wide view as a new entry under /tools. What it shows - 12-month calendar grid styled like the Job Planner (4 months per row, responsive down to single column on mobile). - Each day cell shows up to 4 colored mini-bars — one per project that had ≥1 active UnitAssignment that day, color deterministically hashed from project_id. Days with >4 active projects show "+N". - KPI strip at the top: project count, distinct unit count, total assignment count in the window. - Collapsible project legend: ordered by first-active date (which matches the deployment-history reading order), each row links to the project page, shows the assignment count. Click-a-day side panel - Click any populated day cell → slide-over panel from the right - Groups by project, lists every (unit, location) active that day - Per-deployment: unit link, location link, window dates, active / closed badge, "auto-backfilled" tag for metadata_backfill source - Sources from a new GET /api/admin/deployment-history/day endpoint Navigation - Prev / Next month buttons shift the 12-month window by one month - "Recent" button jumps back to default (12 months ending now) - Default window is 11 months back from current month — operator sees the recent past on first load, not future emptiness Files - backend/services/deployment_history.py — data builder + day-detail helper. Walks UnitAssignment windows, intersects with the 12-month range, computes per-project active-day sets. - backend/routers/deployment_history.py — page route + day-detail JSON endpoint. Wired into main.py. - templates/admin/deployment_history.html — page + side-panel - templates/tools.html — new card linking to the page Phase 3 (deferred): drag-to-resize bars to retroactively adjust assignment windows from inside the calendar; per-unit row view (complement to the project-row view) for "where has unit X been across all jobs"; horizontal scroll for >12-month windows. Co-Authored-By: Claude Opus 4.7 --- backend/main.py | 3 + backend/routers/deployment_history.py | 99 +++++++ backend/services/deployment_history.py | 316 ++++++++++++++++++++++ templates/admin/deployment_history.html | 346 ++++++++++++++++++++++++ templates/tools.html | 18 ++ 5 files changed, 782 insertions(+) create mode 100644 backend/routers/deployment_history.py create mode 100644 backend/services/deployment_history.py create mode 100644 templates/admin/deployment_history.html diff --git a/backend/main.py b/backend/main.py index f88e18c..aca1b1f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -109,6 +109,9 @@ app.include_router(watcher_manager.router) from backend.routers import admin_modules app.include_router(admin_modules.router) +from backend.routers import deployment_history +app.include_router(deployment_history.router) + # Projects system routers app.include_router(projects.router) app.include_router(project_locations.router) diff --git a/backend/routers/deployment_history.py b/backend/routers/deployment_history.py new file mode 100644 index 0000000..2beece5 --- /dev/null +++ b/backend/routers/deployment_history.py @@ -0,0 +1,99 @@ +""" +Fleet-wide deployment-history calendar — Phase 2 of the +deployment-history visualisation work (Phase 1 is the per-unit Gantt +on /unit/{id}). + +Renders all UnitAssignment windows across all projects on a 12-month +calendar grid styled like the Job Planner. Each day cell shows one +mini-bar per project that had ≥1 active assignment that day. Click a +day → side panel with the (unit, location) pairs active. + +Routes: + GET /tools/deployment-history — HTML page + GET /api/admin/deployment-history/day — JSON list of deployments + on a specific date (used + by the day-detail panel) +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.services.deployment_history import ( + get_deployment_history_data, + get_deployments_on_day, +) +from backend.templates_config import templates + +router = APIRouter() + + +@router.get("/tools/deployment-history", response_class=HTMLResponse) +def deployment_history_page( + request: Request, + year: Optional[int] = Query(None), + month: Optional[int] = Query(None), + db: Session = Depends(get_db), +): + """Fleet-wide deployment history calendar. + + Defaults to a 12-month window ending in the current month (so the + operator sees the recent past, not the future). ?year=&month= can + override the START of the window to scroll backward or forward. + """ + today = date.today() + # Default: 12-month window ending this month → start = 11 months back. + if year is None or month is None: + # 11 months back from current month. + m = today.month - 11 + y = today.year + while m < 1: + m += 12 + y -= 1 + start_year, start_month = y, m + else: + start_year, start_month = year, month + + calendar = get_deployment_history_data(db, start_year, start_month) + + # Build prev/next navigation values. + prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1) + next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1) + + return templates.TemplateResponse("admin/deployment_history.html", { + "request": request, + "calendar": calendar, + "today": today.isoformat(), + "prev_year": prev_y, + "prev_month": prev_m, + "next_year": next_y, + "next_month": next_m, + }) + + +@router.get("/api/admin/deployment-history/day") +def deployment_history_day( + target_date: str = Query(..., description="YYYY-MM-DD"), + db: Session = Depends(get_db), +): + """Return assignments active on a specific calendar day.""" + try: + d = date.fromisoformat(target_date) + except ValueError: + return JSONResponse( + {"error": f"Invalid date: {target_date!r}"}, + status_code=400, + ) + + deployments = get_deployments_on_day(db, d) + return JSONResponse({ + "date": target_date, + "count": len(deployments), + "deployments": deployments, + }) diff --git a/backend/services/deployment_history.py b/backend/services/deployment_history.py new file mode 100644 index 0000000..0a5bd27 --- /dev/null +++ b/backend/services/deployment_history.py @@ -0,0 +1,316 @@ +""" +Deployment-history calendar service — builds the data structure for the +fleet-wide deployment-history grid (`/tools/deployment-history`). + +For each calendar day in a 12-month window, computes which projects had +at least one unit assigned to a location on that day. Renders as +multi-month grid (job-planner style) with project-colored bars per day. + +Distinct from `services/fleet_calendar_service.py` which renders +forward-looking RESERVATIONS for the planner. This one is purely +historical / current — it walks `unit_assignments` instead of +`job_reservations`. +""" + +from __future__ import annotations + +import hashlib +from datetime import date, datetime, timedelta +from calendar import monthrange +from typing import Optional + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from backend.models import Project, UnitAssignment + + +# Color palette for projects without an explicit color attribute. Chosen +# to have decent contrast on both light and dark backgrounds; cycles +# deterministically by SHA1(project_id). +_PROJECT_COLOR_PALETTE = [ + "#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d", + "#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8", + "#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c", +] + + +def _color_for_project(project_id: str) -> str: + """Deterministic color assignment from a fixed palette.""" + h = hashlib.sha1(project_id.encode("utf-8")).digest()[0] + return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)] + + +def _month_short(m: int) -> str: + return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1] + + +def _month_full(m: int) -> str: + return ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"][m - 1] + + +def get_deployment_history_data( + db: Session, + start_year: int, + start_month: int, +) -> dict: + """ + Build the calendar data structure for a 12-month window starting at + (start_year, start_month). + + Returns: + { + "months": [ + { + "year": int, + "month": int, # 1-12 + "name": "January", + "short_name": "Jan", + "year_short": "26", + "num_days": int, + "first_weekday": int, # 0=Mon..6=Sun (datetime.weekday()) + "active_days": { + day_num: [project_id, project_id, ...] # projects with + # ≥1 active assignment + # on that day + }, + }, + ... # 12 entries + ], + "projects": [ + { + "id": str, + "name": str, + "color": str, + "status": str, + "client_name": str | None, + "assignment_count": int, # total assignments contributing to + # this 12-month window + "first_active": "YYYY-MM-DD" | None, + "last_active": "YYYY-MM-DD" | None, + }, + ... # only projects with + # ≥1 assignment in the + # window, sorted by + # first_active ASC + ], + "total_assignments": int, + "total_active_units": int, # distinct unit_ids across the window + "window": { + "start_year": int, + "start_month": int, + "end_year": int, + "end_month": int, + "first_date": "YYYY-MM-DD", + "last_date": "YYYY-MM-DD", + }, + } + """ + # Compute window edges. + first_date = date(start_year, start_month, 1) + # 12 months → end on day-1 of (start + 12) + end_year = start_year + ((start_month + 10) // 12) + end_month = ((start_month + 10) % 12) + 1 + last_date = date(end_year, end_month, monthrange(end_year, end_month)[1]) + + now = datetime.utcnow() + + # Fetch every assignment that overlaps the window. An assignment + # overlaps if assigned_at <= last_date AND (assigned_until is NULL + # OR assigned_until >= first_date). + assignments = ( + db.query(UnitAssignment) + .filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time())) + .filter( + or_( + UnitAssignment.assigned_until == None, # noqa: E711 — active + UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()), + ) + ) + .all() + ) + + # Resolve referenced projects in one query. + proj_ids = {a.project_id for a in assignments} + proj_map = { + p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all() + } if proj_ids else {} + + # Compute "active days per project" by walking each assignment and + # adding every day in its [start, end] ∩ [first_date, last_date]. + # O(N_assignments × avg_window_days); for a typical fleet this is + # bounded (hundreds of assignments × hundreds of days = manageable). + project_active_days: dict[str, set[date]] = {} + project_first_active: dict[str, date] = {} + project_last_active: dict[str, date] = {} + project_assignment_count: dict[str, int] = {} + distinct_units: set[str] = set() + + for a in assignments: + start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date) + end_dt = a.assigned_until or now + end = min(end_dt.date(), last_date) + if end < start: + continue + days = project_active_days.setdefault(a.project_id, set()) + d = start + while d <= end: + days.add(d) + d += timedelta(days=1) + project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1 + distinct_units.add(a.unit_id) + # Track first/last active dates in the window. + prev_first = project_first_active.get(a.project_id) + if prev_first is None or start < prev_first: + project_first_active[a.project_id] = start + prev_last = project_last_active.get(a.project_id) + if prev_last is None or end > prev_last: + project_last_active[a.project_id] = end + + # Build the projects array (sorted by first_active ascending so the + # legend reads in deployment-order). + projects_data = [] + for pid, days in project_active_days.items(): + p = proj_map.get(pid) + if not p: + # Assignment references a deleted project — surface it anyway + # with a placeholder name, since the bars still need a label. + projects_data.append({ + "id": pid, + "name": "(deleted project)", + "color": _color_for_project(pid), + "status": "deleted", + "client_name": None, + "assignment_count": project_assignment_count.get(pid, 0), + "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, + "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + }) + continue + projects_data.append({ + "id": pid, + "name": p.name, + "color": _color_for_project(pid), + "status": p.status or "active", + "client_name": p.client_name, + "assignment_count": project_assignment_count.get(pid, 0), + "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, + "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + }) + + projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"])) + + # Now build the months array. + months_data = [] + cur_year, cur_month = start_year, start_month + for _ in range(12): + num_days = monthrange(cur_year, cur_month)[1] + first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun + active_days: dict[int, list[str]] = {} + for day_num in range(1, num_days + 1): + d = date(cur_year, cur_month, day_num) + day_projects = [ + pid for pid, days in project_active_days.items() + if d in days + ] + if day_projects: + # Sort by the project's color-stable order so bars don't + # jitter between days. + day_projects.sort() + active_days[day_num] = day_projects + months_data.append({ + "year": cur_year, + "month": cur_month, + "name": _month_full(cur_month), + "short_name": _month_short(cur_month), + "year_short": f"{cur_year % 100:02d}", + "num_days": num_days, + "first_weekday": first_weekday, + "active_days": active_days, + }) + # Advance one month. + if cur_month == 12: + cur_year += 1 + cur_month = 1 + else: + cur_month += 1 + + return { + "months": months_data, + "projects": projects_data, + "total_assignments": len(assignments), + "total_active_units": len(distinct_units), + "window": { + "start_year": start_year, + "start_month": start_month, + "end_year": end_year, + "end_month": end_month, + "first_date": first_date.isoformat(), + "last_date": last_date.isoformat(), + }, + } + + +def get_deployments_on_day( + db: Session, + target_date: date, +) -> list[dict]: + """ + Return the list of (unit, location, project) tuples that were + actively assigned on a specific calendar date. Used for the + day-detail side panel when an operator clicks a day cell. + """ + from backend.models import MonitoringLocation, RosterUnit + + day_start = datetime.combine(target_date, datetime.min.time()) + day_end = datetime.combine(target_date, datetime.max.time()) + + rows = ( + db.query(UnitAssignment) + .filter(UnitAssignment.assigned_at <= day_end) + .filter( + or_( + UnitAssignment.assigned_until == None, # noqa: E711 + UnitAssignment.assigned_until >= day_start, + ) + ) + .order_by(UnitAssignment.project_id, UnitAssignment.unit_id) + .all() + ) + + if not rows: + return [] + + loc_ids = {a.location_id for a in rows} + proj_ids = {a.project_id for a in rows} + loc_map = { + l.id: l for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } + proj_map = { + p.id: p for p in db.query(Project).filter( + Project.id.in_(proj_ids) + ).all() + } + + results = [] + for a in rows: + loc = loc_map.get(a.location_id) + proj = proj_map.get(a.project_id) + results.append({ + "assignment_id": a.id, + "unit_id": a.unit_id, + "location_id": a.location_id, + "location_name": loc.name if loc else "(unknown location)", + "project_id": a.project_id, + "project_name": proj.name if proj else "(deleted project)", + "project_color": _color_for_project(a.project_id), + "assigned_at": a.assigned_at.isoformat() if a.assigned_at else None, + "assigned_until": a.assigned_until.isoformat() if a.assigned_until else None, + "is_active": a.assigned_until is None, + "source": a.source, + }) + + return results diff --git a/templates/admin/deployment_history.html b/templates/admin/deployment_history.html new file mode 100644 index 0000000..b3fdf48 --- /dev/null +++ b/templates/admin/deployment_history.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} + +{% block title %}Deployment History - Seismo Fleet Manager{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+ ← Back to Tools +

Deployment History

+

+ Where every unit has been — actual assignment windows, color-coded by project. + For future / planned deployments use the Job Planner. +

+
+
+ + +
+
+ {{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }} + | + {{ calendar.total_active_units }} unique units + | + {{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window +
+ {% if calendar.projects %} +
+ Project legend +
+ {% for p in calendar.projects %} + + + {{ p.name }} + · + {{ p.assignment_count }} + + {% endfor %} +
+
+ {% endif %} +
+ + +{% if calendar.projects %} +
+ {% for month_data in calendar.months %} +
+

+ {{ month_data.short_name }} '{{ month_data.year_short }} +

+
+
S
+
M
+
T
+
W
+
T
+
F
+
S
+ + {# Sunday-first alignment: shift Monday=0 → Sunday=0 #} + {% set first_offset = (month_data.first_weekday + 1) % 7 %} + {% for i in range(first_offset) %} +
+ {% endfor %} + + {% for day_num in range(1, month_data.num_days + 1) %} + {% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %} + {% set is_today = date_str == today %} + {% set day_proj_ids = month_data.active_days.get(day_num, []) %} +
+ {{ day_num }} + {% if day_proj_ids %} + + {% for pid in day_proj_ids[:4] %} + {% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %} + {% if p %} + + {% endif %} + {% endfor %} + {% if day_proj_ids | length > 4 %} + +{{ day_proj_ids | length - 4 }} + {% endif %} + + {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ + +
+ + + + + + + {{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }} + + + + + + + + Recent + +
+ +{% else %} +
+ + + +

No deployments in this window.

+

Try the navigation buttons below to look at a different range.

+
+{% endif %} + + + + + + +{% endblock %} diff --git a/templates/tools.html b/templates/tools.html index 4b20006..91c8613 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -68,6 +68,24 @@ + + +
+
+ + + +
+
+

Deployment History

+

+ 12-month calendar of every unit assignment across every project. Visual bars per project per day; click a day for the full active-deployments list. +

+
+
+
+ -- 2.52.0 From 825c7370b8b5130fe7eaa98a99e586bc89c1f8a9 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 15 May 2026 06:34:19 +0000 Subject: [PATCH 02/12] feat(project-overview): hover location card to highlight its map pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse direction of the existing pin→card flash on the project overview map. Hovering a location card now enlarges + reddens the matching pin on the map and opens its tooltip. Mouse-out reverts. Why hover instead of click: clicking the card title navigates to the location detail page, so any flash effect would never be visible. Hover is the right interaction here. Event delegation on document means cards that appear after htmx swaps (e.g. after a reorder, remove/restore, or assign-modal close) still get the behavior without rewiring. Co-Authored-By: Claude Opus 4.7 --- .../partials/projects/project_dashboard.html | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index 5b859e2..d8daf49 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -161,7 +161,9 @@ maxZoom: 18, }).addTo(map); - const markers = []; + // Marker store keyed by location id so card-click can find + flash + // the matching pin (bidirectional highlight). + const markersById = {}; const bounds = []; withCoords.forEach(loc => { const marker = L.circleMarker(loc.latlon, { @@ -174,10 +176,53 @@ }).addTo(map); marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] }); marker.on('click', () => _flashLocationCard(loc.id)); - markers.push(marker); + markersById[loc.id] = marker; bounds.push(loc.latlon); }); + // Wire up the reverse direction: hovering a card highlights its + // pin on the map. Hover-based instead of click-based because + // clicking the card navigates to the location detail page, so the + // flash would never be visible. Event delegation so cards that + // appear later (htmx swap or reorder) still work. + let _hoverPinId = null; + document.addEventListener('mouseover', (e) => { + const card = e.target.closest('.location-card'); + if (!card) return; + const locId = card.dataset.locationId; + if (locId === _hoverPinId) return; + if (_hoverPinId) _unhighlightPin(_hoverPinId); + _highlightPin(locId); + _hoverPinId = locId; + }); + document.addEventListener('mouseout', (e) => { + const card = e.target.closest('.location-card'); + if (!card) return; + // Only un-highlight if the mouse actually left the card, + // not just moved to a child element. + const related = e.relatedTarget && e.relatedTarget.closest('.location-card'); + if (related === card) return; + if (_hoverPinId) { + _unhighlightPin(_hoverPinId); + _hoverPinId = null; + } + }); + + function _highlightPin(locId) { + const marker = markersById[locId]; + if (!marker) return; + marker.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 }); + marker.openTooltip(); + marker.bringToFront(); + } + + function _unhighlightPin(locId) { + const marker = markersById[locId]; + if (!marker) return; + marker.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 }); + marker.closeTooltip(); + } + if (bounds.length === 1) { map.setView(bounds[0], 14); } else { -- 2.52.0 From 4dcfcbdc45f57d0e89e5fa76e409f32c027291be Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 15 May 2026 06:36:55 +0000 Subject: [PATCH 03/12] feat(projects): reusable location-map partial + add map to Vibration tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map sidebar that replaced Upcoming Actions on the project overview is now also on the deeper Vibration tab — operators get the same spatial context when they drill into vibration monitoring locations. Refactor - New partial templates/partials/projects/location_map.html. Self-contained: includes the map div + a self-fetch script that pulls coords from /api/projects/{p}/locations-json on load. Accepts: - project_id (required) - map_height (default "320px") - location_type ('vibration' | 'sound' | none = all) - project_dashboard.html: ~150 lines of inline map JS deleted, replaced with {% include 'partials/projects/location_map.html' %}. Identical behavior, less duplication. - projects/detail.html Vibration tab: locations list converted to a 2/3 + 1/3 grid; right column hosts the same map partial filtered to location_type=vibration with a taller 450px viewport. Bidirectional hover-highlight (card ↔ pin) works on both surfaces since the partial registers its own document-level mouseover/mouseout handlers. Co-Authored-By: Claude Opus 4.7 --- templates/partials/projects/location_map.html | 159 +++++++++++++++ .../partials/projects/project_dashboard.html | 182 ++---------------- templates/projects/detail.html | 41 ++-- 3 files changed, 199 insertions(+), 183 deletions(-) create mode 100644 templates/partials/projects/location_map.html diff --git a/templates/partials/projects/location_map.html b/templates/partials/projects/location_map.html new file mode 100644 index 0000000..d9c9fde --- /dev/null +++ b/templates/partials/projects/location_map.html @@ -0,0 +1,159 @@ + +
+
+

Location Map

+ +
+
+
+ + +
+ + diff --git a/templates/partials/projects/project_dashboard.html b/templates/partials/projects/project_dashboard.html index d8daf49..c4ff551 100644 --- a/templates/partials/projects/project_dashboard.html +++ b/templates/partials/projects/project_dashboard.html @@ -78,173 +78,19 @@ - -
- - -
- - -
+ {# Location map — uses the reusable partial that fetches from + /api/projects/{p}/locations-json. Same render is reused on the + deeper Vibration tab so both surfaces stay in sync. #} + {% with project_id=project.id %} + {% include 'partials/projects/location_map.html' %} + {% endwith %} - +{% if upcoming_actions %} + +{% endif %} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 4f75550..ba971f3 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -101,22 +101,33 @@ -
-
-

Monitoring Locations

- +
+
+
+

Monitoring Locations

+ +
+
+
Loading locations...
+
-
-
Loading locations...
+ + {# Reusable location map — fetches from /locations-json + on its own. Hovering any of the location cards on the + left highlights the matching pin on this map. #} +
+ {% with project_id=project_id, location_type='vibration', map_height='450px' %} + {% include 'partials/projects/location_map.html' %} + {% endwith %}
-- 2.52.0 From 75597ec1c408765ae9e33164e3c7114305e9f59f Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 15 May 2026 06:40:18 +0000 Subject: [PATCH 04/12] =?UTF-8?q?feat(mobile):=20bottom-nav=20swap=20Setti?= =?UTF-8?q?ngs=20=E2=86=92=20Events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile bottom navigation had Menu / Dashboard / Devices / Settings, which dated back to before the SFM integration. Settings is rarely needed in the field — Events is the more useful day-to-day mobile destination now that the SFM event firehose lives there. New mobile nav: Menu / Dashboard / Devices / Events. Settings, Projects, Job Planner, Tools, and SFM/SLMM admin pages all remain accessible via the Menu hamburger which opens the full sidebar drawer, exactly as they were before. Co-Authored-By: Claude Opus 4.7 --- templates/base.html | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/templates/base.html b/templates/base.html index d4b9ccf..cb35af3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -210,7 +210,9 @@
- + -- 2.52.0 From 2b8e9168c3bb66b148e9b2a91cb7c7f9a311f312 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 15 May 2026 22:55:21 +0000 Subject: [PATCH 05/12] feat(tools): add Gantt view tab to deployment-history page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Calendar grid (day-cells with project bars) is great for seeing which projects had activity on a given day, but bad for seeing how long any single deployment lasted. The Gantt view inverts that — one row per project, horizontal bars per assignment window — so an operator can read durations at a glance. Service layer - backend/services/deployment_history.py extends each project's payload with `bars`: a list of {unit_id, location_id, location_name, start, end, is_active, source} for every UnitAssignment clipped to the visible 12-month window. Location names are batch-resolved. Same cost as before since the underlying assignment scan is the same; just additional data in the response. Template - Tab switcher at the top of /tools/deployment-history toggles between Calendar and Gantt views. URL hash (#gantt) preserves the active view across month-nav (Prev / Next / Recent buttons within the Gantt view link to ?...#gantt to stay on the same tab). - Gantt view is a plain SVG with: - Left 220px label gutter: project color dot + truncated name, whole row clickable → opens the project page - Right area: horizontal time axis with month gridlines + labels, "today" dashed orange line, one row per project - One bar per assignment in that row, colored by project, reduced opacity for closed assignments, blue outline for metadata- backfilled assignments, white tip on the right edge of active bars - Hover any bar → tooltip with unit + location + window - Alternating row backgrounds for readability. Co-Authored-By: Claude Opus 4.7 --- backend/services/deployment_history.py | 28 ++++ templates/admin/deployment_history.html | 204 ++++++++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/backend/services/deployment_history.py b/backend/services/deployment_history.py index 0a5bd27..d9002fc 100644 --- a/backend/services/deployment_history.py +++ b/backend/services/deployment_history.py @@ -138,14 +138,26 @@ def get_deployment_history_data( p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all() } if proj_ids else {} + # Resolve location names in one batch query (used by the Gantt view + # for per-bar tooltips). + from backend.models import MonitoringLocation + loc_ids = {a.location_id for a in assignments} + loc_name_map = { + l.id: l.name for l in db.query(MonitoringLocation).filter( + MonitoringLocation.id.in_(loc_ids) + ).all() + } if loc_ids else {} + # Compute "active days per project" by walking each assignment and # adding every day in its [start, end] ∩ [first_date, last_date]. # O(N_assignments × avg_window_days); for a typical fleet this is # bounded (hundreds of assignments × hundreds of days = manageable). + # Also collect raw per-assignment bar data for the Gantt view. project_active_days: dict[str, set[date]] = {} project_first_active: dict[str, date] = {} project_last_active: dict[str, date] = {} project_assignment_count: dict[str, int] = {} + project_bars: dict[str, list[dict]] = {} distinct_units: set[str] = set() for a in assignments: @@ -169,6 +181,20 @@ def get_deployment_history_data( if prev_last is None or end > prev_last: project_last_active[a.project_id] = end + # Per-assignment bar data — used by the Gantt view's renderer. + # `is_active` reflects whether the assignment_until was still NULL + # at fetch time (open-ended deployment); the clipped `end` here + # is just for visual bar drawing. + project_bars.setdefault(a.project_id, []).append({ + "unit_id": a.unit_id, + "location_id": a.location_id, + "location_name": loc_name_map.get(a.location_id, "(unknown location)"), + "start": start.isoformat(), + "end": end.isoformat(), + "is_active": a.assigned_until is None, + "source": a.source, + }) + # Build the projects array (sorted by first_active ascending so the # legend reads in deployment-order). projects_data = [] @@ -186,6 +212,7 @@ def get_deployment_history_data( "assignment_count": project_assignment_count.get(pid, 0), "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + "bars": project_bars.get(pid, []), }) continue projects_data.append({ @@ -197,6 +224,7 @@ def get_deployment_history_data( "assignment_count": project_assignment_count.get(pid, 0), "first_active": project_first_active[pid].isoformat() if pid in project_first_active else None, "last_active": project_last_active[pid].isoformat() if pid in project_last_active else None, + "bars": project_bars.get(pid, []), }) projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"])) diff --git a/templates/admin/deployment_history.html b/templates/admin/deployment_history.html index b3fdf48..e77d0b0 100644 --- a/templates/admin/deployment_history.html +++ b/templates/admin/deployment_history.html @@ -97,6 +97,18 @@
+ +
+ + +
+
@@ -124,6 +136,9 @@ {% endif %}
+ +
+ {% if calendar.projects %}
@@ -211,6 +226,52 @@
{% endif %} +
{# /#dh-view-calendar #} + + + {# /#dh-view-gantt #} +
- +
+
@@ -272,6 +276,50 @@ {% endif %} {# /#dh-view-gantt #} + + {# /#dh-view-byunit #} + + + + + +
+
+

Field Deploy

+

+ Capture an install while you're still on site. Project + location can be picked later at a desk. +

+
+ + +
+
+ 1 + Unit +
+
+
+ 2 + Photo +
+
+
+ 3 + Confirm +
+
+ + +
+ +
+
+ + + + + + + + + +
+ + +{% endblock %} diff --git a/templates/tools.html b/templates/tools.html index 91c8613..d9649f6 100644 --- a/templates/tools.html +++ b/templates/tools.html @@ -14,6 +14,43 @@ + Deploy is here because the whole point of the workflow is "I'm + on site, capture this install in 90s before I leave." Devices, + Settings, Projects, Job Planner reachable via the hamburger + Menu (slot 1) which opens the full sidebar drawer. -->