Skip to content

Commit

Permalink
fix: Handle edge cases with dates and timezones in form components
Browse files Browse the repository at this point in the history
Co-authored-by: Seam Bot <[email protected]>
  • Loading branch information
razor-x and seambot authored Sep 28, 2023
1 parent 4324f69 commit 83d8c73
Show file tree
Hide file tree
Showing 34 changed files with 1,248 additions and 1,338 deletions.
6 changes: 1 addition & 5 deletions examples/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ export default defineConfig(async ({ command, mode }) => {
return {
base: `/${base}`,
envPrefix: 'SEAM_',
plugins: [
tsconfigPaths(),
// @ts-expect-error https://github.com/vitejs/vite-plugin-react/issues/104
react(),
],
plugins: [tsconfigPaths(), react()],
resolve: {
alias: {
'@seamapi/react/elements.js': fileURLToPath(
Expand Down
1,669 changes: 828 additions & 841 deletions package-lock.json

Large diffs are not rendered by default.

154 changes: 19 additions & 135 deletions src/lib/dates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DateTime, IANAZone } from 'luxon'
import { DateTime } from 'luxon'

export const compareByCreatedAtDesc = (
a: { created_at: string },
Expand All @@ -10,148 +10,32 @@ export const compareByCreatedAtDesc = (
return t1.toMillis() - t2.toMillis()
}

/**
* Get the timezone strings supported by the user's browser.
*
* @returns string[]
*/
export function getTimezones(): string[] {
return Intl.supportedValuesOf('timeZone')
export const getSupportedTimeZones = (): string[] => {
const timeZones = new Set(Intl.supportedValuesOf('timeZone'))
timeZones.add('UTC')
return Array.from(timeZones).sort()
}

/**
* Get the default browser timezone.
*
* @returns string
*/
export function getBrowserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}

/**
* Takes an IANA timezone, like America/Los_Angeles, into a more readable
* string: Los Angeles (America).
* @param timezone
* @returns string
*/
export function getTimezoneLabel(timezone: string): string {
const [region = '', city = ''] = timezone.replace(/_/g, ' ').split('/')
return `${city} (${region})`
}

/**
* Get a timezones offset from UTC in minutes.
*
* @param timezone
* @returns minutes
*/
function getTimezoneOffsetMinutes(timezone: string): number {
return DateTime.local().setZone(timezone).offset
}

/**
* Compares 2 timezones (America/Los_angeles) by their offset
* minutes in ascending order.
*
* @param timezoneA
* @param timezonB
* @returns number
*/
export const compareByTimezoneOffsetAsc = (
timezoneA: string,
timezonB: string
): number =>
getTimezoneOffsetMinutes(timezoneA) - getTimezoneOffsetMinutes(timezonB)

/**
* Get the timezone offset
* America/Los_angeles -> -07:00
*
* eg. America/Los_Angeles -> UTC-07:00
*
* @param timezone
* @returns offset
*/
export function getTimezoneOffset(timezone: string): string {
return IANAZone.create(timezone).formatOffset(Date.now(), 'short')
}

const formatDateReadable = (
date: string,
options: {
showWeekday?: boolean
} = {}
): string => {
const { showWeekday = true } = options
export const getSystemTimeZone = (): string => DateTime.now().zoneName ?? 'UTC'

// '2023-04-17' to 'Mon Apr 17, 2023' / 'Apr 17, 2023'
const format = showWeekday ? 'EEE MMM d, yyyy' : 'MMM d, yyyy'

return DateTime.fromFormat(date, 'yyyy-MM-dd').toFormat(format)
export const formatTimeZone = (timeZone: string): string => {
const offset = DateTime.now().setZone(timeZone).toFormat("'UTC'Z")
return `${timeZone.replaceAll('_', ' ')} (${offset})`
}

const formatTimeReadable = (time: string): string | null => {
const dateTime = DateTime.fromFormat(time, 'HH:mm:ss')
export const serializeDateTimePickerValue = (
dateTime: DateTime,
timeZone: string
): string | null => {
if (!dateTime.isValid) {
return null
}

return dateTime.toFormat('h:mm a')
}

export const formatDateTimeReadable = (dateTime: string): string => {
const [date = '', time = ''] = dateTime.split('T')
return `${formatDateReadable(date, { showWeekday: false })} at ${
formatTimeReadable(time) ?? ''
}`
return dateTime.setZone(timeZone).toFormat("yyyy-MM-dd'T'HH:mm:ss")
}

export const getNow = (): string => getDateTimeOnly(DateTime.now())
export const get24HoursLater = (): string =>
getDateTimeOnly(DateTime.now().plus({ days: 1 }))

function getDateTimeOnly(dateTime: DateTime): string {
const date = dateTime.toFormat('yyyy-MM-dd')
const time = dateTime.toFormat('HH:mm:ss')
return `${date}T${time}`
}

/**
* Takes a date (2023-07-20T00:00:00), and a timezone (America/Los_angeles), and
* returns an ISO8601 Date (2023-07-20T00:00:00.000-07:00).
*
* @param date
* @param timezone
* @returns ISOdate
*/
export const createIsoDate = (date: string, timezone: string): string => {
const offset = getTimezoneOffset(timezone)
return `${date}.000${offset}`
}

/**
* Takes a ISO datetime string (2023-07-20T00:00:00.000-07:00) and returns
* the IANA timezone (America/Los_angeles).
*
* @param date
* @returns string
*/
export const getTimezoneFromIsoDate = (date: string): string | null =>
DateTime.fromISO(date).zoneName

/**
* Takes an ISO datetime string (2023-07-20T00:00:00.000-07:00) and returns a string like
* (Jul 20, 12:00 AM PDT).
*
* @param date
* @returns string
*
*/
export const formatDateAndTime = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
})
export const parseDateTimePickerValue = (
value: string,
timeZone: string
): DateTime =>
DateTime.fromISO(value).setZone(timeZone, { keepLocalTime: true })
15 changes: 6 additions & 9 deletions src/lib/seam/components/AccessCodeDetails/AccessCodeDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,28 +221,25 @@ function Duration(props: { accessCode: AccessCode }): JSX.Element {
)
}

function formatDurationDate(date: string): string {
return DateTime.fromISO(date).toLocaleString({
const formatDurationDate = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
month: 'short',
day: 'numeric',
})
}

function formatTime(date: string): string {
return DateTime.fromISO(date).toLocaleString({
const formatTime = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
hour: 'numeric',
minute: '2-digit',
})
}

function formatDate(date: string): string {
return DateTime.fromISO(date).toLocaleString({
const formatDate = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
weekday: 'short',
month: 'long',
day: 'numeric',
year: 'numeric',
})
}

const errorFilter = (
error: AccessCodeError | DeviceError | ConnectedAccountError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const Content: Story = {
code: '1234',
status: 'setting',
is_backup_access_code_available: false,
is_external_modification_allowed: false,
appearance: {},
errors: [
{
error_code: 'account_disconnected',
Expand All @@ -64,6 +66,8 @@ export const Content: Story = {
code: '1234',
status: 'setting',
is_backup_access_code_available: false,
is_external_modification_allowed: true,
appearance: {},
errors: [
{
error_code: 'account_disconnected',
Expand Down
5 changes: 2 additions & 3 deletions src/lib/seam/components/AccessCodeTable/CodeDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,11 @@ function Duration(props: { accessCode: AccessCode }): JSX.Element {
)
}

function formatDate(date: string): string {
return DateTime.fromISO(date).toLocaleString({
const formatDate = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
month: 'long',
day: 'numeric',
})
}

const t = {
code: 'Code',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { DateTime } from 'luxon'
import type { ClimateSettingSchedule } from 'seamapi'

import { formatDateAndTime } from 'lib/dates.js'
import { ClimateSettingScheduleIcon } from 'lib/icons/ClimateSettingSchedule.js'
import { ClimateSettingScheduleDeviceBar } from 'lib/seam/components/ClimateSettingScheduleDetails/ClimateSettingScheduleDeviceBar.js'
import { useClimateSettingSchedule } from 'lib/seam/thermostats/climate-setting-schedules/use-climate-setting-schedule.js'
import { DotDivider } from 'lib/ui/layout/DotDivider.js'
import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'
import { useCurrentTime } from 'lib/ui/use-current-time.js'
import { useNow } from 'lib/ui/use-now.js'

import { formatDateTime } from './dates.js'

interface ClimateSettingScheduleCardProps {
climateSettingScheduleId: string
Expand Down Expand Up @@ -74,25 +75,24 @@ function ClimateSettingScheduleTiming(props: {
}): JSX.Element | null {
const { climateSettingSchedule } = props

const currentTime = useCurrentTime()
const now = useNow()

if (currentTime === null) return null
if (now === null) return null

const startTime = DateTime.fromISO(climateSettingSchedule.schedule_starts_at)
const endTime = DateTime.fromISO(climateSettingSchedule.schedule_ends_at)

if (currentTime < startTime)
if (now < startTime)
return (
<span>
{t.starts}{' '}
{formatDateAndTime(climateSettingSchedule.schedule_starts_at)}
{t.starts} {formatDateTime(climateSettingSchedule.schedule_starts_at)}
</span>
)

if (startTime <= currentTime && currentTime <= endTime)
if (startTime <= now && now <= endTime)
return (
<span>
{t.ends} {formatDateAndTime(climateSettingSchedule.schedule_starts_at)}
{t.ends} {formatDateTime(climateSettingSchedule.schedule_starts_at)}
</span>
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useState } from 'react'

import { useComponentTelemetry } from 'lib/telemetry/index.js'

import { formatDateAndTime } from 'lib/dates.js'
import { ArrowRightIcon } from 'lib/icons/ArrowRight.js'
import { ClimateSettingScheduleCard } from 'lib/seam/components/ClimateSettingScheduleDetails/ClimateSettingScheduleCard.js'
import {
Expand All @@ -18,6 +17,8 @@ import { DetailSection } from 'lib/ui/layout/DetailSection.js'
import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js'
import { ClimateSettingStatus } from 'lib/ui/thermostat/ClimateSettingStatus.js'

import { formatDateTime } from './dates.js'

export interface ClimateSettingScheduleDetailsProps extends CommonProps {
climateSettingScheduleId: string
}
Expand Down Expand Up @@ -89,13 +90,9 @@ export function ClimateSettingScheduleDetails({
<DetailSection>
<DetailRow label={t.startEndTime}>
<span className='seam-climate-setting-details-value seam-climate-setting-details-schedule-range'>
{`${formatDateAndTime(
climateSettingSchedule.schedule_starts_at
)}`}
{formatDateTime(climateSettingSchedule.schedule_starts_at)}
<ArrowRightIcon />
{`${formatDateAndTime(
climateSettingSchedule.schedule_ends_at
)}`}
{formatDateTime(climateSettingSchedule.schedule_ends_at)}
</span>
</DetailRow>
<DetailRow label={t.climateSetting}>
Expand All @@ -113,7 +110,7 @@ export function ClimateSettingScheduleDetails({
<DetailSection>
<DetailRow label={t.creationDate}>
<div className='seam-creation-date'>
{formatDateAndTime(climateSettingSchedule.created_at)}
{formatDateTime(climateSettingSchedule.created_at)}
</div>
</DetailRow>
</DetailSection>
Expand Down
10 changes: 10 additions & 0 deletions src/lib/seam/components/ClimateSettingScheduleDetails/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DateTime } from 'luxon'

export const formatDateTime = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
})
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,11 @@ function Duration(props: {
)
}

function formatDate(date: string): string {
return DateTime.fromISO(date).toLocaleString({
const formatDate = (date: string): string =>
DateTime.fromISO(date).toLocaleString({
month: 'long',
day: 'numeric',
})
}

const t = {
starts: 'Starts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { SeamError } from 'seamapi'

import { useComponentTelemetry } from 'lib/telemetry/index.js'

import { createIsoDate } from 'lib/dates.js'
import { useCreateAccessCode } from 'lib/seam/access-codes/use-create-access-code.js'
import {
type CommonProps,
Expand Down Expand Up @@ -82,7 +81,7 @@ function useSubmitCreateAccessCode(params: { onSuccess: () => void }): {
const submit = (data: AccessCodeFormSubmitData): void => {
resetResponseErrors()

const { name, code, type, device, startDate, endDate, timezone } = data
const { name, code, type, device, startDate, endDate } = data
if (name === '') {
return
}
Expand All @@ -97,8 +96,8 @@ function useSubmitCreateAccessCode(params: { onSuccess: () => void }): {
name,
code,
device_id: device.device_id,
starts_at: createIsoDate(startDate, timezone),
ends_at: createIsoDate(endDate, timezone),
starts_at: startDate,
ends_at: endDate,
},
{
onSuccess,
Expand Down
Loading

0 comments on commit 83d8c73

Please sign in to comment.