Skip to content

Plain Date, Not Instant: the event_date Contract at KotaPortal

Adityo Guni Waluyo

The admin event_date once left the browser as an instant T00:00:00Z. The contract now requires a plain YYYY-MM-DD string, with Go validation rejecting instants outright.

TL;DR

A calendar-date field was leaking through the API as a UTC midnight instant, silently accepted by lenient MariaDB and patched over client-side with toLocalDateInput. The fix changes the contract itself: format date, a string DTO, validation at the service edge with a 400 on bad input. Regenerated frontend types now reject instants at compile time, killing the timezone-drift bug class.

While inspecting the network tab for the agenda form in the KotaPortal project, the saved payload always carried a timestamp: event_date: "2026-03-05T00:00:00Z". The form had collected a plain calendar date, March 5, 2026, but the outgoing request contained an instant at UTC midnight. The client-side helper toLocalDateInput then sliced the value back with slice(0, 10) so the display would not drift by one day. That was masking the leak, not fixing the pipe.

The earlier fix had recommended exactly that approach: keep the date-time contract, anchor the value at UTC midnight. It looked sufficient at the time.

The Wrong Type, Not the Wrong Time

This field never had a time component at all. It is a calendar date. In the OpenAPI contract, format: date follows full-date from RFC3339, which is year, month, day [1][2], while format: date-time is a date plus time and an offset. Two different wire formats, separated by one token in the format column.

The incoming change states the rule plainly: event_date: { type: string, format: date, nullable: true } in the spec, the Go DTO switched from EventDate *time.Time to EventDate *string, then validation runs at the service boundary.

if req.EventDate != nil && *req.EventDate != "" {
        t, err := time.Parse("2006-01-02", *req.EventDate)
        if err != nil {
                return nil, validationError("tanggal acara tidak valid (format YYYY-MM-DD)")
        }
        a.EventDate = sql.NullTime{Time: t.UTC(), Valid: true}
}

A string that cannot be parsed by the 2006-01-02 layout is rejected outright with a 400 that names the field, and time.Parse does require the input to be parseable with the supplied layout [7]. The type choice matters for another reason: time.Time in Go requires a quoted RFC3339 string on decode, so a plain date would never reach service validation at all [8]. With *string, the value arrives as-is and the format decision belongs to one place: the 2006-01-02 layout at the service edge.

New regression tests lock two behaviors at once: 2026-03-05T00:00:00Z now fails with a validation error, while null and "" both clear the column. That is the PUT semantics I wanted: an absent key is not a delete command, an empty key is.

Why the Wrong Payload Stored Silently

The browser was already speaking plain dates from the start. The <input type="date"> value is always normalized to yyyy-mm-dd, and only the display format follows the browser locale [4]. The damage happened in the next step on the client: Date.parse on a date-only string implies UTC [5], and toISOString always returns a suffix of Z [6]. A correct date on screen became a wrong payload on the wire.

The database layer offered no protection either. The MariaDB DATE column displays YYYY-MM-DD but accepts much looser input, including numbers and arbitrary-delimiter strings, as long as they make sense [3]. An RFC3339 instant entered without complaint, stored as a date, produced no red log lines. That leniency is exactly why a malformed payload was swallowed silently, and from that fact I infer that the column is the last place to enforce a contract.

Whether every remaining consumer of the admin API has migrated to the new format is unknown from public sources. The old shape now returns a 400, so any surviving client will fail hard rather than store the wrong date. A visible failure is healthier than a silent agreement between teams.

Regenerated Types, Not Verbal Agreements

This change also closes a class of bug that never appeared in logs: dates drifting because of the client's timezone. As long as the field carried an instant, every client was free to pick its own midnight point, and that choice leaked into the table.

On the client, toLocalDateInput was deleted outright, not patched. The read path became d.event_date ? d.event_date.slice(0, 10) : "", and the write path simply sends form.event_date || null. The regenerated frontend types carried the contract forward, so consumers cannot accidentally send an instant anymore: the compiler rejects it first.

Pinning midnight was a workaround; removing the instant from the contract was the fix. As long as a field stores a calendar date, its wire type must be a calendar date too. The story of the one-day drift bug I wrote in the earlier article still holds as diagnostics, but the contract it recommended has now been retired.

## Sources [1] https://spec.openapis.org/oas/v3.0.4.html [2] https://www.rfc-editor.org/rfc/rfc3339.txt [3] https://mariadb.com/kb/en/date [4] https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date [5] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse [6] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString [7] https://github.com/golang/go/blob/master/src/time/format.go [8] https://github.com/golang/go/blob/master/src/time/time.go

Related articles