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
+28 -24
View File
@@ -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"]