Skip to content

DatetimeWidget: whole_day events lose one day due to date-only value discarding safe noon time before serialization #8371

Description

@wesleybl

Describe the bug
When creating or editing an Event content type with the whole_day option enabled, the date selected in the calendar can be persisted as the previous day, depending on the user's timezone (specifically, timezones behind UTC, such as Brazil - UTC-3).

This happens because DatetimeWidget's onDateChange handler builds an internal moment object with a safe default time of noon (defaultTimeDateOnly = { hour: 12, minute: 0, second: 0 }) specifically to avoid timezone rollover issues, but then discards that time when isDateOnly is true, serializing only base.format('YYYY-MM-DD') (e.g. "2026-07-08") instead of a full ISO string.

Since this date-only string has no timezone information, the backend normalizes it assuming UTC midnight (2026-07-08T00:00:00Z). When the frontend later reads this value back and converts it to the user's local timezone (e.g. UTC-3), it rolls back to the previous day (2026-07-07T21:00:00-03:00), so the UI displays the wrong date.

To Reproduce
Steps to reproduce the behavior:

  1. Set your browser/OS timezone to one behind UTC (e.g. America/Sao_Paulo, UTC-3).
  2. Go to an Event content type add/edit form in Volto.
  3. Enable the "Whole day" option.
  4. Set the Start (or End) date to any date, e.g. July 8, 2026.
  5. Save the item.
  6. See that the date field now shows July 7, 2026 instead of July 8, 2026.

Expected behavior
The date selected by the user for a whole-day event should be preserved regardless of the browser's timezone, and should not shift by one day when reloaded.

Screenshots
N/A

Software (please complete the following information):

  • OS: any
  • Browser: any
  • Volto Version: 19
  • Plone Version: 6.2
  • Plone REST API Version: 10

Additional context
The relevant code is in DatetimeWidget's onDateChange handler:

const onDateChange = (date) => {
    if (date) {
      const isDateOnly = getDateOnly();
      const base = (getInternalValue() || moment.default()).set({
        year: date.year(),
        month: date.month(),
        date: date.date(),
        ...(isDateOnly ? defaultTimeDateOnly : {}),
      });
      const dateValue = isDateOnly
        ? base.format('YYYY-MM-DD')   // <- discards the safe noon time set above
        : base.toISOString();
      onChange(id, dateValue);
    }
    setIsDefault(false);
  };

const dateValue = isDateOnly
? base.format('YYYY-MM-DD')
: base.toISOString();
onChange(id, dateValue);

The defaultTimeDateOnly object (noon local time) appears to have been introduced precisely to avoid date rollover when converting to/from UTC, but the isDateOnly branch bypasses it by formatting only the date portion. A possible fix is to always serialize with base.toISOString(), keeping the whole_day flag purely as a UI concern (hiding the time picker) rather than changing what is sent to the backend:

const dateValue = base.toISOString();
onChange(id, dateValue);

This preserves the calendar day across virtually all timezones, since noon ± any realistic UTC offset (up to ±12h) does not cross a day boundary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions