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"`