Skip to content

Commit 4248c83

Browse files
committed
fix(tables): preserve expiration precision during calendar edits
1 parent 1bf421a commit 4248c83

5 files changed

Lines changed: 90 additions & 8 deletions

File tree

apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -441,10 +441,14 @@ describe('organization setup entry points', () => {
441441
}),
442442
expect.any(Object)
443443
)
444-
await click(button('Cancel'))
445-
expect(mocks.urlUpdate).toHaveBeenLastCalledWith(
446-
expect.objectContaining({ queryString: '?search=keep' })
447-
)
444+
await act(async () => {
445+
button('Cancel').click()
446+
await vi.waitFor(() =>
447+
expect(mocks.urlUpdate).toHaveBeenLastCalledWith(
448+
expect.objectContaining({ queryString: '?search=keep' })
449+
)
450+
)
451+
})
448452
expect(document.querySelector('[role="dialog"]')).toBeNull()
449453
expect(mocks.push).not.toHaveBeenCalled()
450454
expect(

docs/testing/expiration-qa.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
This is the acceptance matrix for the current Expiration column. Execute against a disposable local database with this branch's migrations. Never run the destructive fixtures or injected failures against an existing development, staging, or production database.
44

5+
Expiration has not been released, and there are no production tables with Expiration columns. These changes define the initial timestamp contract; numeric-value migration is not a rollout requirement. Malformed stored values in this matrix are deliberately injected fixtures.
6+
57
## Invariants
68

79
- Only rows with a valid explicit expiration at or before the cleanup run's cutoff can be deleted.
@@ -79,6 +81,8 @@ The results below distinguish automated coverage from live browser, HTTP, and Po
7981

8082
## Results — September 9, 2026
8183

84+
**Review follow-up: 22 calendar tests and 108 table/search UI tests pass.** Calendar day selection and Today preserve the existing clock time, seconds, and microseconds, including across the Los Angeles daylight-saving gap. The Expiration editor reattaches the stored numeric offset. The failing search setup test now waits for the queued URL update after Cancel before asserting it; production search behavior is unchanged.
85+
8286
**Offset-preservation follow-up: 2,162 regression tests and 23 non-stress PostgreSQL scenarios pass.** The current contract preserves numeric offsets and spells incoming Z as -00:00. Earlier results below were collected before this formatting change; the follow-up section records the new contract checks. Production-environment verification is still separate.
8387

8488
The environment was Chrome plus this worktree's local Next.js application, PostgreSQL 17, and a freshly migrated database named `expiration_qa`. All accounts, keys, tables, and rows were disposable fixtures. Existing application environments were not used. External provider credentials were cleared in the test process. The queue used the real database backend; Trigger.dev and Redis were not configured.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act, createElement } from 'react'
6+
import { Calendar } from '@sim/emcn'
7+
import { createRoot } from 'react-dom/client'
8+
import { describe, expect, it, vi } from 'vitest'
9+
10+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
11+
12+
describe('Calendar precise date edits', () => {
13+
it.each(['07:30:45.123456', '00:00:00.000001', '02:30:00.999999'])(
14+
'retains %s when selecting a day and Today',
15+
async (time) => {
16+
const container = document.createElement('div')
17+
document.body.appendChild(container)
18+
const root = createRoot(container)
19+
const onChange = vi.fn()
20+
try {
21+
await act(async () =>
22+
root.render(
23+
createElement(Calendar, {
24+
value: `2026-03-07T${time}`,
25+
showTime: true,
26+
today: '2026-03-09',
27+
onChange,
28+
})
29+
)
30+
)
31+
const buttons = Array.from(container.querySelectorAll('button'))
32+
const nextDay = buttons.find((button) => button.textContent?.trim() === '8')
33+
expect(nextDay).toBeDefined()
34+
await act(async () => nextDay!.click())
35+
expect(onChange).toHaveBeenLastCalledWith(`2026-03-08T${time}`)
36+
const today = buttons.find((button) => button.textContent?.trim() === 'Today')
37+
expect(today).toBeDefined()
38+
await act(async () => today!.click())
39+
expect(onChange).toHaveBeenLastCalledWith(`2026-03-09T${time}`)
40+
} finally {
41+
await act(async () => root.unmount())
42+
container.remove()
43+
}
44+
}
45+
)
46+
})

packages/emcn/src/components/calendar/calendar.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,29 @@ describe('parseDateTimeValue', () => {
2020
expect(parseDateTimeValue('2026-07-06T16:04').time).toBe('16:04')
2121
})
2222

23+
it.each(['14:30:00.000001', '14:30:45.123456', '00:00:00.999999'])(
24+
'retains the full wall time %s for subsequent date selections',
25+
(time) => {
26+
expect(parseDateTimeValue(`2026-09-07T${time}`).time).toBe(time)
27+
}
28+
)
29+
30+
it('does not reinterpret a literal wall time through a daylight-saving gap', () => {
31+
expect(parseDateTimeValue('2026-03-08T02:30:45.123456').time).toBe('02:30:45.123456')
32+
})
33+
2334
it('treats a coincidental local midnight as no time for Date instances', () => {
2435
expect(parseDateTimeValue(new Date(2026, 6, 6)).time).toBeNull()
2536
expect(parseDateTimeValue(new Date(2026, 6, 6, 16, 4, 55)).time).toBe('16:04:55')
2637
})
2738

39+
it('retains early years when reading a literal wall time', () => {
40+
expect(parseDateTimeValue('0001-01-01T12:30:00.123456').date?.getFullYear()).toBe(1)
41+
})
42+
2843
it('returns nulls for unparseable input', () => {
2944
expect(parseDateTimeValue('garbage')).toEqual({ date: null, time: null })
45+
expect(parseDateTimeValue('2026-99-99T12:30:00.123456')).toEqual({ date: null, time: null })
3046
})
3147
})
3248

packages/emcn/src/components/calendar/calendar.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,9 @@ function timeOfDayFrom(date: Date): string {
131131

132132
/**
133133
* Parses a date value into its local day plus an optional time-of-day. Bare
134-
* `YYYY-MM-DD` strings are pure days (no time). Datetime strings parse through
135-
* `Date` so an explicit offset (`Z`, `-07:00`) resolves to the **local** day —
134+
* `YYYY-MM-DD` strings are pure days (no time). Offset-free ISO datetimes keep
135+
* their literal clock and fractional precision. Explicit offsets (`Z`, `-07:00`)
136+
* parse through `Date` and resolve to the **local** day —
136137
* unlike {@link parseDateValue}'s date-slice fast path, which would read the
137138
* UTC day.
138139
*
@@ -153,6 +154,17 @@ export function parseDateTimeValue(value: string | Date | undefined): {
153154
}
154155
const parsed = value instanceof Date ? value : new Date(value)
155156
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
157+
if (typeof value === 'string') {
158+
const wallTime =
159+
/^\d{4}-\d{2}-\d{2}T((?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?)$/.exec(value)
160+
if (wallTime) {
161+
const time = wallTime[1]
162+
return {
163+
date: parsed,
164+
time: time.length === 8 && time.endsWith(':00') ? time.slice(0, 5) : time,
165+
}
166+
}
167+
}
156168
if (typeof value === 'string' && value.includes('T')) {
157169
return { date: parsed, time: timeOfDayFrom(parsed) }
158170
}
@@ -206,12 +218,12 @@ interface CalendarSingleProps extends CalendarBaseProps {
206218
value?: string | Date
207219
/**
208220
* Called with the picked date in `YYYY-MM-DD` format — or, with `showTime`
209-
* and a set time, the local wall time `YYYY-MM-DDTHH:mm[:ss]`.
221+
* and a set time, the local wall time `YYYY-MM-DDTHH:mm[:ss[.fraction]]`.
210222
*/
211223
onChange?: (value: string) => void
212224
/**
213225
* Adds a time-of-day input under the grid. Day picks keep the current time
214-
* (seconds included when the seeded value had them); time edits re-emit on
226+
* (seconds and fractional seconds included when supplied); time edits re-emit on
215227
* the selected (or today's) day. Without a time set, day picks emit bare
216228
* `YYYY-MM-DD` days.
217229
*/

0 commit comments

Comments
 (0)