fix(deployment-timeline): respect user timezone for display and edits

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:45:52 +00:00
parent 502bf5bbeb
commit a073b9b06e
2 changed files with 61 additions and 31 deletions
+33 -7
View File
@@ -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,