Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions frontend/src/pages/pollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
27 changes: 17 additions & 10 deletions server/internal/handlers/poll_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <input type="datetime-local"> 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)
Expand Down
45 changes: 45 additions & 0 deletions server/internal/handlers/poll_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
4 changes: 4 additions & 0 deletions server/internal/models/api_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading