From e2792e2e12ae5f6ae1bf04e39f17d14a3c8b3be0 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Thu, 6 Aug 2026 22:25:01 -0400 Subject: [PATCH] fix(polls): stop scheduled polls shifting by the UTC offset A poll scheduled for 9am went live at 5am. The poll form's datetime-local inputs produce a zoneless "2026-08-07T09:00", the page sent that string to the API verbatim, and parsePollTime read zoneless layouts with time.Parse, which labels them UTC. Philadelphia's offset is the whole error. The browser is the only side that knows which zone the editor typed in, so it now says: localInputToISO puts starts_at/ends_at through toISOString on both create and edit, matching what the article editor already does. The server no longer guesses. RFC3339 is the only accepted form; the old zoneless layouts are recognised solely to name the problem in the error, which the call sites now pass through instead of a bare "invalid starts_at" -- a stale browser tab gets a message that explains itself, not a silent 400. Frontend and server ship together, so don't cherry-pick the server half on its own. Polls saved before this are still shifted in the database; re-saving one through the form corrects it. Co-Authored-By: Claude Opus 5 --- frontend/src/pages/pollView.tsx | 18 ++++++-- server/internal/handlers/poll_handlers.go | 27 ++++++----- .../internal/handlers/poll_handlers_test.go | 45 +++++++++++++++++++ server/internal/models/api_responses.go | 4 ++ 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/pollView.tsx b/frontend/src/pages/pollView.tsx index cf0bae8..0abd14e 100644 --- a/frontend/src/pages/pollView.tsx +++ b/frontend/src/pages/pollView.tsx @@ -74,6 +74,16 @@ function toLocalInput(value?: string): string { return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16) } +// The way back out. A zoneless "YYYY-MM-DDTHH:mm" reaching the API is read as +// UTC, so a 9am start saved from Philadelphia comes back as 5am -- the browser +// is the only side that knows which zone the editor typed in, so it has to say. +function localInputToISO(value: string): string { + if (!value) return "" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "" + return date.toISOString() +} + function formatRunDate(value?: string): string { if (!value) return "" const date = new Date(value) @@ -279,8 +289,8 @@ export default function PollView() { // "Now" and "scheduled" are the same published status; the start date // is what decides when readers see it. status: newTiming === "draft" ? "draft" : "active", - starts_at: newTiming === "schedule" ? newStartsAt : null, - ends_at: newTiming === "draft" ? null : newEndsAt || null, + starts_at: newTiming === "schedule" ? localInputToISO(newStartsAt) : null, + ends_at: newTiming === "draft" ? null : localInputToISO(newEndsAt) || null, }), }, "Failed to create poll", @@ -307,8 +317,8 @@ export default function PollView() { body: JSON.stringify({ question, // Explicit null clears the date; omitting it would leave it unchanged. - starts_at: editStartsAt || null, - ends_at: editEndsAt || null, + starts_at: localInputToISO(editStartsAt) || null, + ends_at: localInputToISO(editEndsAt) || null, }), }, "Failed to update poll", diff --git a/server/internal/handlers/poll_handlers.go b/server/internal/handlers/poll_handlers.go index 3dca439..51ec9e5 100644 --- a/server/internal/handlers/poll_handlers.go +++ b/server/internal/handlers/poll_handlers.go @@ -442,12 +442,12 @@ func PostPollRecord(conn *sql.DB) http.Handler { startsAt, _, err := parsePollTime(body.StartsAt) if err != nil { - writeError(w, http.StatusBadRequest, "invalid starts_at") + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid starts_at: %v", err)) return } endsAt, _, err := parsePollTime(body.EndsAt) if err != nil { - writeError(w, http.StatusBadRequest, "invalid ends_at") + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid ends_at: %v", err)) return } if startsAt != nil && endsAt != nil && endsAt.Before(*startsAt) { @@ -530,12 +530,12 @@ func PatchPollRecord(conn *sql.DB) http.Handler { startsAt, clearStarts, err := parsePollTime(body.StartsAt) if err != nil { - writeError(w, http.StatusBadRequest, "invalid starts_at") + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid starts_at: %v", err)) return } endsAt, clearEnds, err := parsePollTime(body.EndsAt) if err != nil { - writeError(w, http.StatusBadRequest, "invalid ends_at") + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid ends_at: %v", err)) return } // Only checkable when the same request supplies both; a PATCH that moves @@ -837,6 +837,8 @@ func pollPageParams(r *http.Request) (int, int) { // unchanged; explicit null or "" (nil, true) means clear the column; a // timestamp (value, false) means set it. Collapsing null and absent would make // every PATCH silently wipe the dates it didn't mention. +// +// Timestamps must carry a UTC offset (RFC3339). See below for why. func parsePollTime(raw *string) (*time.Time, bool, error) { if raw == nil { return nil, false, nil @@ -846,12 +848,17 @@ func parsePollTime(raw *string) (*time.Time, bool, error) { return nil, true, nil } - // datetime-local inputs submit "2006-01-02T15:04" with no zone, which is - // what the CMS poll form sends. - layouts := []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02"} - for _, layout := range layouts { - if parsed, err := time.Parse(layout, trimmed); err == nil { - return &parsed, false, nil + if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil { + return &parsed, false, nil + } + + // A zoneless timestamp -- what hands you + // unhelped -- used to parse as UTC, so a poll scheduled for 9am in + // Philadelphia went live at 5am. There is no zone the server can supply + // that is better than a guess, so make the caller state one. + for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02"} { + if _, err := time.Parse(layout, trimmed); err == nil { + return nil, false, fmt.Errorf("timestamp %q has no UTC offset; send RFC3339 (e.g. 2006-01-02T15:04:05-05:00)", trimmed) } } return nil, false, fmt.Errorf("unrecognised timestamp: %q", trimmed) diff --git a/server/internal/handlers/poll_handlers_test.go b/server/internal/handlers/poll_handlers_test.go index 23c37f0..5d82160 100644 --- a/server/internal/handlers/poll_handlers_test.go +++ b/server/internal/handlers/poll_handlers_test.go @@ -67,3 +67,48 @@ func TestPollView_AlwaysCarriesState(t *testing.T) { }) } } + +// A poll scheduled for 9am in Philadelphia once went live at 5am, because a +// zoneless timestamp parsed as UTC. Silently guessing a zone is the bug, so +// the parse has to fail instead. +func TestParsePollTime(t *testing.T) { + ptr := func(s string) *string { return &s } + + t.Run("absent leaves the column unchanged", func(t *testing.T) { + value, clear, err := parsePollTime(nil) + if value != nil || clear || err != nil { + t.Fatalf("got (%v, %v, %v), want (nil, false, nil)", value, clear, err) + } + }) + + t.Run("empty clears the column", func(t *testing.T) { + value, clear, err := parsePollTime(ptr(" ")) + if value != nil || !clear || err != nil { + t.Fatalf("got (%v, %v, %v), want (nil, true, nil)", value, clear, err) + } + }) + + t.Run("offset is preserved as the instant it names", func(t *testing.T) { + value, clear, err := parsePollTime(ptr("2026-08-07T09:00:00-04:00")) + if err != nil || clear || value == nil { + t.Fatalf("got (%v, %v, %v), want a parsed time", value, clear, err) + } + if want := time.Date(2026, 8, 7, 13, 0, 0, 0, time.UTC); !value.Equal(want) { + t.Fatalf("parsed %s, want %s", value.UTC(), want) + } + }) + + for _, raw := range []string{"2026-08-07T09:00", "2026-08-07T09:00:00", "2026-08-07"} { + t.Run("zoneless "+raw+" is rejected", func(t *testing.T) { + if _, _, err := parsePollTime(ptr(raw)); err == nil { + t.Fatalf("parsePollTime(%q) succeeded; a zoneless timestamp must not be assumed UTC", raw) + } + }) + } + + t.Run("nonsense is rejected", func(t *testing.T) { + if _, _, err := parsePollTime(ptr("next tuesday")); err == nil { + t.Fatal("parsePollTime accepted a non-timestamp") + } + }) +} diff --git a/server/internal/models/api_responses.go b/server/internal/models/api_responses.go index 5b5ddd7..75b0b82 100644 --- a/server/internal/models/api_responses.go +++ b/server/internal/models/api_responses.go @@ -450,6 +450,10 @@ type PollResponse struct { // PollRequest creates or updates a poll. Pointer fields distinguish "not // supplied" from "set to empty", which is how a PATCH clears an end date // (explicit null) without every other PATCH wiping it. +// +// StartsAt/EndsAt must be RFC3339 with a UTC offset. A zoneless timestamp is +// rejected rather than assumed to be UTC, which used to move a scheduled poll +// by the paper's offset. type PollRequest struct { Question *string `json:"question"` Status *string `json:"status"`