Skip to content

fix(tables): canonicalize date cells, render times in effective timezone#5465

Open
TheodoreSpeaks wants to merge 1 commit into
stagingfrom
fix/table-date-rendering
Open

fix(tables): canonicalize date cells, render times in effective timezone#5465
TheodoreSpeaks wants to merge 1 commit into
stagingfrom
fix/table-date-rendering

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • date cells now store exactly two canonical shapes: YYYY-MM-DD calendar dates (timezone-free) and UTC ISO instants — server coercion and CSV import normalize on write, legacy rows self-heal
  • grid renders instants with time (07/06/2026 4:04 PM, seconds when present) in the user's effective timezone (settings timezone, falling back to browser); switching the setting re-renders live
  • emcn Calendar single mode gains showTime (mirrors range mode) — inline cell editor gets a time field, day picks preserve the existing time instead of silently dropping it; row modal uses ChipDatePicker + ChipTimePicker
  • imports interpret naive datetimes (Excel/Sheets exports) in the user's timezone: request field → saved setting → UTC; threaded through both sync and async import paths + job payload
  • fixed Calendar highlighting the UTC day for ISO instant values, and displayToStorage accepting rolled-over dates like 02/30

Type of Change

  • Bug fix

Testing

Unit tests for the canonical date module (zone math, DST, exporter wire formats) and the display/parse round-trips; full lint, type-check, check:api-validation:strict pass. Inline editor focus/blur behavior needs a manual browser pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 7, 2026 3:18am

Request Review

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches shared coercion/validation and import paths where incorrect timezone or parsing could shift stored datetimes; UI/editor blur/focus behavior is complex but covered partly by tests.

Overview
Table date cells now normalize to either a timezone-free YYYY-MM-DD calendar day or a UTC ISO instant on write (validation, CSV coercion, and UI cleanCellValue / parse helpers), with legacy UTC-midnight rows treated as calendar days.

UI renders instants with time in the user’s effective timezone (useTimezone from general settings). Date editing gains separate date + time controls (row modal, inline grid editor with Calendar showTime, expanded popover), and paste/column sizing use the same zone-aware parsing and display.

CSV imports accept an optional IANA timezone (validated on sync multipart routes; optional on async contracts), defaulting to the user’s saved timezone then UTC, and pass it through async job payloads into coerceRowsForTable.

New shared module lib/table/dates centralizes normalize/display/DST logic; emcn Calendar single mode adds showTime and fixes day highlighting for offset datetimes. Unit tests cover the date module and table utils round-trips.

Reviewed by Cursor Bugbot for commit 10060c6. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 10060c6. Configure here.

const handleTimeChange = (time: string) => {
setTimeOfDay(time)
const base = selected ?? today
emit(base.getFullYear(), base.getMonth(), base.getDate(), time)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time picker drops seconds

Medium Severity

With showTime, the single-mode Calendar wires ChipTimePicker to minute precision only: displayed time is truncated to HH:mm, and handleTimeChange stores that value and emits YYYY-MM-DDTHH:mm without seconds. Editing the time on a cell that already had non-zero seconds re-saves the instant with seconds zeroed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10060c6. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces canonical two-shape date storage (YYYY-MM-DD calendar dates and UTC ISO instants), timezone-aware rendering in the grid and editors, a showTime prop on the single-mode Calendar, and timezone threading through all sync/async CSV import paths.

  • lib/table/dates.ts (new): core canonicalization — normalizeDateCellValue, formatDateCellDisplay, DST-safe wallTimeInZoneToUtc, and getWallClockParts via Intl.DateTimeFormat.
  • Inline editor (inline-editors.tsx): adds a time field, a draftRef to avoid stale-closure saves, and popover pointer/blur tracking; emits bare YYYY-MM-DD on date-only picks and a wall-time string on datetime picks.
  • Import routes (4 files): timezone resolved from the request body → user settings → UTC and forwarded into coerceRowsForTable / the async job payload so naive CSV datetimes are interpreted in the importing user's zone.

Confidence Score: 3/5

Two defects in new code: the Calendar's midnight guard converts stored instants to bare calendar dates on the next edit, and date-only MM/DD/YYYY inputs skip the day-in-month check that the datetime path applies — both should be fixed before merging.

The core canonicalization module (dates.ts) and the import timezone threading are well-designed and well-tested. The two regressions are both in newly written code paths: parseDateTimeValue's midnight guard silently discards the time of any instant whose zone-local hour is midnight (data loss on the next edit), and displayToStorage's date-only branches omit the day-in-month validation that the datetime branch already applies (inconsistent client rejection). The rest of the change — server-side normalization, row modal, cell rendering, async import routes — looks correct.

packages/emcn/src/components/calendar/calendar.tsx (parseDateTimeValue midnight guard) and apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts (displayToStorage full/partial branches) need fixes before the PR is safe to merge.

Important Files Changed

Filename Overview
packages/emcn/src/components/calendar/calendar.tsx Added showTime prop and parseDateTimeValue helper for single mode; the midnight guard silently drops the time when a zone-local midnight instant is passed as the picker value, converting instants to bare calendar dates on next day pick.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts Rewrote displayToStorage, storageToDisplay, and cleanCellValue for timezone-aware date handling; the full/partial MM/DD/YYYY-only branches lack the day-in-month validation that the withTime branch applies, causing "02/30/2026" to produce a non-null but invalid calendar string.
apps/sim/lib/table/dates.ts New canonical date module: normalizeDateCellValue, formatDateCellDisplay, getWallClockParts, and wallTimeInZoneToUtc; well-tested with DST two-pass logic and explicit-offset detection.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx Inline date editor extended with time field, draftRef for stale-closure safety, and popover blur/pointer handling; logic is correct but relies on parseDateTimeValue in Calendar not dropping midnight times.
apps/sim/lib/table/import.ts CSV import coercion now delegates date normalization to normalizeDateCellValue with an optional timezone, threaded through coerceValue and coerceRowsForTable.
apps/sim/lib/table/validation.ts Server-side coerceValueToColumnType for date columns now normalizes via normalizeDateCellValue, catching invalid strings like "2026-02-30" that slip through client-side validation.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts Round-trip tests for display/parse utilities; missing a test for displayToStorage('02/30/2026') which would have caught the day-in-month gap in the date-only path.
apps/sim/lib/table/dates.test.ts Comprehensive unit tests for all exported functions including DST, explicit offsets, and IANA zone math; good coverage of the canonicalization contract.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Raw date string\n(UI input / CSV cell)"] --> B{Has explicit\noffset?}
    B -- Yes --> C["Parse as UTC exactly\n(Date.parse → .toISOString)"]
    B -- No --> D{Has time\ncomponent?}
    D -- No --> E{ISO reduced\nform?}
    E -- Yes --> F["toUtcCalendarDate\nYYYY-MM-DD via UTC"]
    E -- No --> G["toLocalCalendarDate\nYYYY-MM-DD via local"]
    D -- Yes --> H["wallTimeInZoneToUtc\n(2-pass DST-safe)"]
    H --> I["UTC ISO instant\n2026-07-06T20:04:55.000Z"]
    C --> I
    F --> J["Calendar date\n2026-07-06"]
    G --> J
    I --> K["formatDateCellDisplay\n→ MM/DD/YYYY h:mm AM/PM\n(viewer effective timezone)"]
    J --> L["formatDateCellDisplay\n→ MM/DD/YYYY\n(zone-independent)"]
    subgraph ImportPath["CSV Import path"]
        M["CSV naive datetime\n(Excel/Sheets export)"] --> N["coerceValue\n+ NormalizeDateCellOptions\n{timezone: user IANA zone}"]
        N --> H
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["Raw date string\n(UI input / CSV cell)"] --> B{Has explicit\noffset?}
    B -- Yes --> C["Parse as UTC exactly\n(Date.parse → .toISOString)"]
    B -- No --> D{Has time\ncomponent?}
    D -- No --> E{ISO reduced\nform?}
    E -- Yes --> F["toUtcCalendarDate\nYYYY-MM-DD via UTC"]
    E -- No --> G["toLocalCalendarDate\nYYYY-MM-DD via local"]
    D -- Yes --> H["wallTimeInZoneToUtc\n(2-pass DST-safe)"]
    H --> I["UTC ISO instant\n2026-07-06T20:04:55.000Z"]
    C --> I
    F --> J["Calendar date\n2026-07-06"]
    G --> J
    I --> K["formatDateCellDisplay\n→ MM/DD/YYYY h:mm AM/PM\n(viewer effective timezone)"]
    J --> L["formatDateCellDisplay\n→ MM/DD/YYYY\n(zone-independent)"]
    subgraph ImportPath["CSV Import path"]
        M["CSV naive datetime\n(Excel/Sheets export)"] --> N["coerceValue\n+ NormalizeDateCellOptions\n{timezone: user IANA zone}"]
        N --> H
    end
Loading

Reviews (1): Last reviewed commit: "fix(tables): canonicalize date cells, re..." | Re-trigger Greptile

Comment on lines +148 to +152
const parsed = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
const isMidnight =
parsed.getHours() === 0 && parsed.getMinutes() === 0 && parsed.getSeconds() === 0
return { date: parsed, time: isMidnight ? null : timeOfDayFrom(parsed) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Midnight guard drops time for zone-local midnight instants

When the stored instant's wall time in the user's effective timezone is exactly 00:00:00, pickerValue is built as "2026-07-07T00:00:00" (a naive datetime string). parseDateTimeValue parses this and finds getHours() === 0 && getMinutes() === 0 && getSeconds() === 0isMidnight = truetime = null. timeOfDay is then null, so any day-click calls emit(y, m, d, null)onChange?.("YYYY-MM-DD"). Back in handlePickerChange, isCalendarDateString is true, so doSave('enter', "YYYY-MM-DD") fires immediately, converting the stored instant (e.g. "2026-07-07T07:00:00.000Z" for a PDT user) into a bare calendar date and silently discarding the UTC time offset.

The fix is to skip the midnight guard when the input is an explicit datetime string (contains T), as it was intentionally supplied with a time component.

Suggested change
const parsed = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
const isMidnight =
parsed.getHours() === 0 && parsed.getMinutes() === 0 && parsed.getSeconds() === 0
return { date: parsed, time: isMidnight ? null : timeOfDayFrom(parsed) }
// For explicit datetime strings (containing 'T'), always preserve the time —
// even midnight — because the string was intentionally built from a wall-clock
// reading. The midnight guard exists only for Date objects and bare-date strings
// that coincidentally land at local midnight (e.g. parseDateValue("YYYY-MM-DD")
// → UTC midnight → getHours() = 0 in a UTC runtime).
if (typeof value === 'string' && value.includes('T')) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
return { date: parsed, time: timeOfDayFrom(parsed) }
}
const parsed = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
const isMidnight =
parsed.getHours() === 0 && parsed.getMinutes() === 0 && parsed.getSeconds() === 0
return { date: parsed, time: isMidnight ? null : timeOfDayFrom(parsed) }

Comment on lines +185 to 199
const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (full) {
const month = Number(full[1])
const day = Number(full[2])
if (month < 1 || month > 12 || day < 1 || day > 31) return null
return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}`
}
const partial = display.match(/^(\d{1,2})\/(\d{1,2})$/)
const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/)
if (partial) {
const month = Number(partial[1])
const day = Number(partial[2])
if (month < 1 || month > 12 || day < 1 || day > 31) return null
return `${new Date().getFullYear()}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
const year = todayLocalCalendarDate(timeZone).slice(0, 4)
return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Date-only branches skip the day-in-month validation that the withTime branch applies

The withTime branch (line 175-176) correctly rejects "02/30/2026 5:00 PM" with an explicit dayCheck. However, the full branch only verifies day < 1 || day > 31, so "02/30/2026" returns the string "2026-02-30" instead of null. In the inline-editor blur path Date.parse("2026-02-30") happens to return NaN in V8 and the toast fires, but in the paste-into-cell path cleanCellValue forwards "2026-02-30" to the server, which then rejects it silently. The same gap exists in the partial branch.

Suggested change
const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (full) {
const month = Number(full[1])
const day = Number(full[2])
if (month < 1 || month > 12 || day < 1 || day > 31) return null
return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}`
}
const partial = display.match(/^(\d{1,2})\/(\d{1,2})$/)
const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/)
if (partial) {
const month = Number(partial[1])
const day = Number(partial[2])
if (month < 1 || month > 12 || day < 1 || day > 31) return null
return `${new Date().getFullYear()}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
const year = todayLocalCalendarDate(timeZone).slice(0, 4)
return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
}
const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (full) {
const month = Number(full[1])
const day = Number(full[2])
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const dayCheck = new Date(Number(full[3]), month - 1, day)
if (dayCheck.getMonth() !== month - 1 || dayCheck.getDate() !== day) return null
return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}`
}
const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/)
if (partial) {
const month = Number(partial[1])
const day = Number(partial[2])
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const year = Number(todayLocalCalendarDate(timeZone).slice(0, 4))
const dayCheck = new Date(year, month - 1, day)
if (dayCheck.getMonth() !== month - 1 || dayCheck.getDate() !== day) return null
return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
}

Comment on lines 354 to 363
const goToToday = () => {
setView({ month: today.getMonth(), year: today.getFullYear() })
onChange?.(toDateString(today.getFullYear(), today.getMonth(), today.getDate()))
emit(today.getFullYear(), today.getMonth(), today.getDate(), timeOfDay)
}

const handleTimeChange = (time: string) => {
setTimeOfDay(time)
const base = selected ?? today
emit(base.getFullYear(), base.getMonth(), base.getDate(), time)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "Today" and time-only edits anchor to the browser's local date, not the effective timezone's date

today is new Date() memoized at mount (browser local time). goToToday navigates the view and emits using today.get*() getters, and handleTimeChange falls back to selected ?? today. When the browser's timezone differs from the user's effective settings timezone and they straddle midnight (e.g., UTC browser at 23:30 but America/Los_Angeles effective timezone at 15:30), the "Today" button navigates to tomorrow's date in the effective timezone. Since the Calendar is intentionally timezone-agnostic, the fix would be to accept a todayStr?: string prop (a YYYY-MM-DD precomputed in the effective zone) for the single+showTime mode, falling back to new Date() when absent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant