From a073b9b06e64fbd86e834eec7523c278df85dbbf Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 18 May 2026 21:45:52 +0000 Subject: [PATCH] fix(deployment-timeline): respect user timezone for display and edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Display side: deployment_timeline.py now converts every emitted timestamp (starts_at, ends_at, event_overlay.peak_pvs_at and last_event) through `utc_to_local()` using the user's configured timezone from UserPreferences before serializing. Frontend slice keeps working — it just slices a local-time string now. Write side (so the new edit / add-historical-assignment modals stay consistent): - PATCH /api/projects/{pid}/assignments/{aid} - POST /api/projects/{pid}/locations/{loc}/assign both now interpret a *naive* assigned_at / assigned_until ISO string as the user's local time and convert to UTC for storage via `local_to_utc()`. Explicit tz-aware strings ("...Z" or "...+00:00") skip the conversion so programmatic callers that already speak UTC keep working. Verified live: BE13121's stored 2026-01-28 18:06:29 UTC now serializes as 2026-01-28 13:06:29 in the timeline endpoint; PATCHing "2026-01-28T13:06:29" round-trips back to the same UTC value. Co-Authored-By: Claude Opus 4.7 --- backend/routers/project_locations.py | 52 +++++++++++++------------ backend/services/deployment_timeline.py | 40 +++++++++++++++---- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 12192c1..8f81abb 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from sqlalchemy import and_, or_ from datetime import datetime +from zoneinfo import ZoneInfo from typing import Optional import uuid import json @@ -34,7 +35,7 @@ from backend.models import ( ScheduledAction, ) from backend.templates_config import templates -from backend.utils.timezone import local_to_utc +from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401 router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) @@ -761,27 +762,22 @@ async def assign_unit_to_location( detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'", ) - # Parse dates. - assigned_at_str = form_data.get("assigned_at") - assigned_until_str = form_data.get("assigned_until") - try: - assigned_at = ( - datetime.fromisoformat(assigned_at_str) - if assigned_at_str else datetime.utcnow() - ) - except (TypeError, ValueError): - raise HTTPException( - status_code=400, detail=f"Invalid assigned_at: {assigned_at_str!r}" - ) - try: - assigned_until = ( - datetime.fromisoformat(assigned_until_str) - if assigned_until_str else None - ) - except (TypeError, ValueError): - raise HTTPException( - status_code=400, detail=f"Invalid assigned_until: {assigned_until_str!r}" - ) + # Parse dates. Naive datetimes from datetime-local inputs are + # interpreted as user-local and converted to UTC for storage; explicit + # tz-aware ISO strings (Z / +00:00) skip the conversion. + def _parse_user_dt(s: str | None, field: str): + if not s: + return None + try: + parsed = datetime.fromisoformat(s) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}") + if parsed.tzinfo is None: + return local_to_utc(parsed) + return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) + + assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow() + assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until") if assigned_until is not None and assigned_until <= assigned_at: raise HTTPException( status_code=400, detail="assigned_until must be after assigned_at.", @@ -924,6 +920,11 @@ async def update_assignment( - assigned_until: ISO datetime, or null/"" to mark indefinite (active) - notes: string + Naive datetimes (no tz suffix) are interpreted as the user's + configured timezone and converted to UTC for storage. Send an + explicit "+00:00" / "Z" suffix to skip the conversion (programmatic + callers that already have UTC). + Sets `status` to "active" when assigned_until is cleared, "completed" when it's set in the past. """ @@ -954,12 +955,14 @@ async def update_assignment( ) try: # Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO. - new_assigned_at = datetime.fromisoformat(raw) + parsed = datetime.fromisoformat(raw) except (TypeError, ValueError): raise HTTPException( status_code=400, detail=f"Invalid assigned_at datetime: {raw!r}", ) + # Naive (no tz) → treat as user's local time and store as UTC. + new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) if "assigned_until" in payload: raw = payload["assigned_until"] @@ -967,12 +970,13 @@ async def update_assignment( new_assigned_until = None else: try: - new_assigned_until = datetime.fromisoformat(raw) + parsed = datetime.fromisoformat(raw) except (TypeError, ValueError): raise HTTPException( status_code=400, detail=f"Invalid assigned_until datetime: {raw!r}", ) + new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) if "notes" in payload: raw = payload["notes"] diff --git a/backend/services/deployment_timeline.py b/backend/services/deployment_timeline.py index 6690b52..dae011b 100644 --- a/backend/services/deployment_timeline.py +++ b/backend/services/deployment_timeline.py @@ -39,9 +39,35 @@ from backend.services.sfm_events import ( _fetch_events_for_serial, _iso_utc, ) +from backend.utils.timezone import utc_to_local log = logging.getLogger("backend.services.deployment_timeline") + +def _iso_local(dt) -> Optional[str]: + """Serialize a datetime / ISO-string in the user's configured timezone. + + The timeline frontend slices these strings to character 19 to produce + "YYYY-MM-DD HH:MM:SS" — no JS-side timezone conversion happens. We + therefore emit *already-local* timestamps here so the displayed time + matches what the operator actually saw on the wall clock. + + Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM + response). Returns ``None`` for ``None`` input. Naive ISO strings + from SFM are interpreted as UTC. + """ + if dt is None: + return None + if isinstance(dt, str): + try: + dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T")) + except ValueError: + return dt # give up gracefully — emit whatever SFM sent + local = utc_to_local(dt) + if local is None: + return None + return local.replace(tzinfo=None).isoformat() + # Don't emit synthetic gap entries shorter than this (seconds). Avoids visual # clutter from a sub-second handoff during a swap workflow. _MIN_GAP_SECONDS = 24 * 3600 # 1 day @@ -185,8 +211,8 @@ async def deployment_timeline_for_unit( overlays[a.id] = { "event_count": len(events), "peak_pvs": peak, - "peak_pvs_at": peak_at, - "last_event": last_ev, + "peak_pvs_at": _iso_local(peak_at), + "last_event": _iso_local(last_ev), } # 4. Build entries. Start by emitting assignment rows + gap rows between @@ -202,8 +228,8 @@ async def deployment_timeline_for_unit( entry = { "kind": "assignment", - "starts_at": _iso_utc(a.assigned_at), - "ends_at": _iso_utc(a.assigned_until), + "starts_at": _iso_local(a.assigned_at), + "ends_at": _iso_local(a.assigned_until), "duration_days": round(duration_days, 1) if duration_days is not None else None, "assignment_id": a.id, "location_id": a.location_id, @@ -227,8 +253,8 @@ async def deployment_timeline_for_unit( if gap_seconds >= _MIN_GAP_SECONDS: entries.append({ "kind": "gap", - "starts_at": _iso_utc(gap_start), - "ends_at": _iso_utc(gap_end), + "starts_at": _iso_local(gap_start), + "ends_at": _iso_local(gap_end), "duration_days": round(gap_seconds / 86400, 1), "context": "between assignments", }) @@ -241,7 +267,7 @@ async def deployment_timeline_for_unit( continue entries.append({ "kind": "state_change", - "starts_at": _iso_utc(h.changed_at), + "starts_at": _iso_local(h.changed_at), "ends_at": None, "duration_days": None, "change_type": h.change_type,