Skip to content

Commit 6bc3e70

Browse files
committed
fix(calendar): validate date and time with Zod
1 parent 1113be3 commit 6bc3e70

4 files changed

Lines changed: 23 additions & 7 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/emcn/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"dependencies": {
3838
"@sim/utils": "workspace:*",
3939
"clsx": "^2.1.1",
40-
"tailwind-merge": "3.6.0"
40+
"tailwind-merge": "3.6.0",
41+
"zod": "4.3.6"
4142
},
4243
"peerDependencies": {
4344
"@radix-ui/react-avatar": "^1.1.10",

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { describe, expect, it, vi } from 'vitest'
55
import {
66
buildRangeBounds,
77
formatDateRangeLabel,
@@ -20,13 +20,26 @@ 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'])(
23+
it.each(['14:30:00.000001', '14:30:45.123456', '00:00:00.999999', '14:30:45.123456789'])(
2424
'retains the full wall time %s for subsequent date selections',
2525
(time) => {
2626
expect(parseDateTimeValue(`2026-09-07T${time}`).time).toBe(time)
2727
}
2828
)
2929

30+
it.each([
31+
['2026-09-07T07:30:45.123456Z', '07:30:45'],
32+
['2026-09-07T07:30:45.123456-07:00', '14:30:45'],
33+
['2026-09-07T07:30:45.123456+05:45', '01:45:45'],
34+
])('keeps explicit-offset input %s on the instant conversion path', (value, expectedTime) => {
35+
vi.stubEnv('TZ', 'UTC')
36+
try {
37+
expect(parseDateTimeValue(value).time).toBe(expectedTime)
38+
} finally {
39+
vi.unstubAllEnvs()
40+
}
41+
})
42+
3043
it('does not reinterpret a literal wall time through a daylight-saving gap', () => {
3144
expect(parseDateTimeValue('2026-03-08T02:30:45.123456').time).toBe('02:30:45.123456')
3245
})

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useMemo, useState } from 'react'
4+
import { z } from 'zod'
45
import { ChevronLeft, ChevronRight } from '../../icons'
56
import { cn } from '../../lib/cn'
67
import { Chip, chipVariants } from '../chip/chip'
@@ -27,6 +28,7 @@ const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] as const
2728

2829
const DEFAULT_RANGE_START_TIME = '00:00'
2930
const DEFAULT_RANGE_END_TIME = '23:59'
31+
const localDateTimePartsSchema = z.tuple([z.iso.date(), z.iso.time()])
3032

3133
function getDaysInMonth(year: number, month: number): number {
3234
return new Date(year, month + 1, 0).getDate()
@@ -155,10 +157,9 @@ export function parseDateTimeValue(value: string | Date | undefined): {
155157
const parsed = value instanceof Date ? value : new Date(value)
156158
if (Number.isNaN(parsed.getTime())) return { date: null, time: null }
157159
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]
160+
const wallTime = localDateTimePartsSchema.safeParse(value.split('T'))
161+
if (wallTime.success) {
162+
const [, time] = wallTime.data
162163
return {
163164
date: parsed,
164165
time: time.length === 8 && time.endsWith(':00') ? time.slice(0, 5) : time,

0 commit comments

Comments
 (0)