diff --git a/.storybook/seed-fake.js b/.storybook/seed-fake.js index 7834225b6..392111d85 100644 --- a/.storybook/seed-fake.js +++ b/.storybook/seed-fake.js @@ -468,6 +468,42 @@ export const seedFake = (db) => { errors: [], }) + const device7 = db.addDevice({ + workspace_id: ws2.workspace_id, + connected_account_id: ca.connected_account_id, + device_type: 'noiseaware_activity_zone', + created_at: '2023-05-15T15:08:53.000', + name: 'Living Room', + properties: { + online: true, + model: { + manufacturer_display_name: 'NoiseAware', + display_name: 'Indoor Sensor', + }, + }, + errors: [], + }) + + db.addNoiseThreshold({ + device_id: device7.device_id, + workspace_id: ws2.workspace_id, + created_at: '2023-05-17T00:16:12.000', + noise_threshold_decibels: 70, + starts_daily_at: '22:00:00[America/Los_Angeles]', + ends_daily_at: '06:00:00[America/Los_Angeles]', + name: 'Quiet Hours', + }) + + db.addNoiseThreshold({ + device_id: device7.device_id, + workspace_id: ws2.workspace_id, + created_at: '2023-05-17T00:16:12.000', + noise_threshold_decibels: 75, + starts_daily_at: '06:00:00[America/Los_Angeles]', + ends_daily_at: '22:00:00[America/Los_Angeles]', + name: 'Active Hours', + }) + // add climate setting schedules db.addClimateSettingSchedule({ device_id: device5.device_id, @@ -532,5 +568,6 @@ export const seedFake = (db) => { device4, device5, device6, + device7, }) } diff --git a/assets/icons/noise-levels.svg b/assets/icons/noise-levels.svg new file mode 100644 index 000000000..e23bdb5ba --- /dev/null +++ b/assets/icons/noise-levels.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/package-lock.json b/package-lock.json index d40778f5c..99386146b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "queue": "^7.0.0", "react-hook-form": "^7.46.1", "seamapi": "^8.22.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zoned-time": "^1.1.2" }, "devDependencies": { "@emotion/styled": "^11.10.6", @@ -3469,6 +3470,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz", + "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==", + "dependencies": { + "jsbi": "^4.3.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@juggle/resize-observer": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", @@ -16958,6 +16971,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.0.tgz", + "integrity": "sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==" + }, "node_modules/jscodeshift": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.15.2.tgz", @@ -25022,6 +25040,18 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zoned-time": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/zoned-time/-/zoned-time-1.1.2.tgz", + "integrity": "sha512-NF1KVXst8boltFgpeA0Kf8vPm/BQeSYwxegsU2oM3fgyv2361x+Do0aZWs6OWGnZYBwMLUERPWEcoGmAM8DH/Q==", + "dependencies": { + "@js-temporal/polyfill": "^0.4.4" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">= 9.0.0" + } + }, "node_modules/zustand": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz", diff --git a/package.json b/package.json index c04398367..bc8cd96eb 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,8 @@ "queue": "^7.0.0", "react-hook-form": "^7.46.1", "seamapi": "^8.22.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zoned-time": "^1.1.2" }, "devDependencies": { "@emotion/styled": "^11.10.6", diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 723611e13..323e3effc 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -23,6 +23,11 @@ export const formatTimeZone = (timeZone: string): string => { return `${timeZone.replaceAll('_', ' ')} (${offset})` } +export const formatTime = (time: string): string => { + const dateTime = DateTime.fromISO(time) + return dateTime.isValid ? dateTime.toLocaleString(DateTime.TIME_SIMPLE) : '' +} + export const serializeDateTimePickerValue = ( dateTime: DateTime, timeZone: string diff --git a/src/lib/icons/NoiseLevels.tsx b/src/lib/icons/NoiseLevels.tsx new file mode 100644 index 000000000..1766cec61 --- /dev/null +++ b/src/lib/icons/NoiseLevels.tsx @@ -0,0 +1,31 @@ +/* + * Automatically generated by SVGR from assets/icons/*.svg. + * Do not edit this file or add other components to this directory. + */ +import type { SVGProps } from 'react' +export function NoiseLevelsIcon(props: SVGProps): JSX.Element { + return ( + + + + + + ) +} diff --git a/src/lib/seam/components/DeviceDetails/DeviceDetails.stories.tsx b/src/lib/seam/components/DeviceDetails/DeviceDetails.stories.tsx index f8aa9d5f4..5f7ded6c7 100644 --- a/src/lib/seam/components/DeviceDetails/DeviceDetails.stories.tsx +++ b/src/lib/seam/components/DeviceDetails/DeviceDetails.stories.tsx @@ -82,3 +82,7 @@ export const DeviceOffline: Story = { export const ThermostatDevice: Story = { render: (props) => , } + +export const NoiseSensorDevice: Story = { + render: (props) => , +} diff --git a/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx index 9c662cee6..d79510973 100644 --- a/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/DeviceDetails.tsx @@ -1,10 +1,11 @@ -import { isLockDevice, isThermostatDevice } from 'seamapi' +import { isLockDevice, isNoiseSensorDevice, isThermostatDevice } from 'seamapi' import { type CommonProps, withRequiredCommonProps, } from 'lib/seam/components/common-props.js' import { LockDeviceDetails } from 'lib/seam/components/DeviceDetails/LockDeviceDetails.js' +import { NoiseSensorDeviceDetails } from 'lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.js' import { ThermostatDeviceDetails } from 'lib/seam/components/DeviceDetails/ThermostatDeviceDetails.js' import { useDevice } from 'lib/seam/devices/use-device.js' import { useComponentTelemetry } from 'lib/telemetry/index.js' @@ -67,5 +68,9 @@ export function DeviceDetails({ return } + if (isNoiseSensorDevice(device)) { + return + } + return null } diff --git a/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx new file mode 100644 index 000000000..729887d0b --- /dev/null +++ b/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx @@ -0,0 +1,58 @@ +import type { NoiseSensorDevice } from 'seamapi' + +import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js' +import { DeviceInfo } from 'lib/seam/components/DeviceDetails/DeviceInfo.js' +import { DeviceModel } from 'lib/seam/components/DeviceDetails/DeviceModel.js' +import { DeviceImage } from 'lib/ui/device/DeviceImage.js' +import { OnlineStatus } from 'lib/ui/device/OnlineStatus.js' +import { NoiseThresholdsList } from 'lib/ui/noise-sensor/NoiseThresholdsList.js' + +interface NoiseSensorDeviceDetailsProps + extends NestedSpecificDeviceDetailsProps { + device: NoiseSensorDevice +} + +export function NoiseSensorDeviceDetails({ + device, + disableConnectedAccountInformation, + disableResourceIds, +}: NoiseSensorDeviceDetailsProps): JSX.Element | null { + return ( +
+
+
+
+
+ +
+
+ {t.noiseSensor} +

{device.properties.name}

+
+ {t.status}:{' '} + + +
+
+
+
+ + + + +
+
+ ) +} + +const t = { + noiseSensor: 'Noise Sensor', + status: 'Status', + noiseLevel: 'Noise level', +} diff --git a/src/lib/seam/noise-sensors/use-noise-thresholds.ts b/src/lib/seam/noise-sensors/use-noise-thresholds.ts new file mode 100644 index 000000000..c06f0cf15 --- /dev/null +++ b/src/lib/seam/noise-sensors/use-noise-thresholds.ts @@ -0,0 +1,46 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import type { + NoiseThresholds, + NoiseThresholdsListRequest, + NoiseThresholdsListResponse, + SeamError, +} from 'seamapi' + +import { useSeamClient } from 'lib/seam/use-seam-client.js' +import type { UseSeamQueryResult } from 'lib/seam/use-seam-query-result.js' + +export type UseNoiseThresholdsParams = NoiseThresholdsListRequest +export type UseNoiseThresholdsData = NoiseThresholds[] + +export function useNoiseThresholds( + params: UseNoiseThresholdsParams +): UseSeamQueryResult<'noiseThresholds', UseNoiseThresholdsData> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + const { data, ...rest } = useQuery< + NoiseThresholdsListResponse['noise_thresholds'], + SeamError + >({ + enabled: client != null, + queryKey: ['noise_thresholds', 'list', params], + queryFn: async () => { + if (client == null) return [] + return await client.noiseThresholds.list(params) + }, + onSuccess: (noiseThresholds) => { + for (const noiseThreshold of noiseThresholds) { + queryClient.setQueryData( + [ + 'noise_thresholds', + 'get', + { noise_threshold_id: noiseThreshold.noise_threshold_id }, + ], + noiseThreshold + ) + } + }, + }) + + return { ...rest, noiseThresholds: data } +} diff --git a/src/lib/ui/layout/DetailRow.tsx b/src/lib/ui/layout/DetailRow.tsx index a706c3f1a..7bd8a05e0 100644 --- a/src/lib/ui/layout/DetailRow.tsx +++ b/src/lib/ui/layout/DetailRow.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames' import type { PropsWithChildren } from 'react' interface DetailRowProps { - label: string + label: JSX.Element | string sublabel?: string onClick?: () => void } diff --git a/src/lib/ui/layout/DetailSection.tsx b/src/lib/ui/layout/DetailSection.tsx index d66995f11..5bd0677cf 100644 --- a/src/lib/ui/layout/DetailSection.tsx +++ b/src/lib/ui/layout/DetailSection.tsx @@ -4,7 +4,7 @@ import { Tooltip } from 'lib/ui/Tooltip/Tooltip.js' interface DetailSectionProps { label?: string - tooltipContent?: string + tooltipContent?: JSX.Element | string } export function DetailSection({ diff --git a/src/lib/ui/noise-sensor/NoiseThresholdsList.tsx b/src/lib/ui/noise-sensor/NoiseThresholdsList.tsx new file mode 100644 index 000000000..85866be24 --- /dev/null +++ b/src/lib/ui/noise-sensor/NoiseThresholdsList.tsx @@ -0,0 +1,141 @@ +import type { NoiseSensorDevice, NoiseThresholds } from 'seamapi' +import { ZonedTime } from 'zoned-time' + +import { formatTime, formatTimeZone } from 'lib/dates.js' +import { ArrowRightIcon } from 'lib/icons/ArrowRight.js' +import { useNoiseThresholds } from 'lib/seam/noise-sensors/use-noise-thresholds.js' +import { DetailRow } from 'lib/ui/layout/DetailRow.js' +import { DetailSection } from 'lib/ui/layout/DetailSection.js' +import { DetailSectionGroup } from 'lib/ui/layout/DetailSectionGroup.js' + +interface NoiseThresholdsListProps { + device: NoiseSensorDevice +} + +export function NoiseThresholdsList({ + device, +}: NoiseThresholdsListProps): JSX.Element { + const { noiseThresholds, isInitialLoading } = useNoiseThresholds({ + device_id: device.device_id, + }) + + return ( + +
+ + + {t.minutTooltipFirst} + + + {t.minutTooltipSecond} + +
+ ) : ( + t.tooltip + ) + } + > + + + +
+
+
+
+

{getTimeZoneCaption(device, noiseThresholds)}

+
+
+
+
+
+ ) +} + +function Content({ + isInitialLoading, + noiseThresholds, +}: { + isInitialLoading: boolean + noiseThresholds: NoiseThresholds[] | undefined +}): JSX.Element | JSX.Element[] { + if (isInitialLoading) { + return ( + {t.loading}} + /> + ) + } + + if (noiseThresholds == null || noiseThresholds.length === 0) { + return ( + {t.none}} + /> + ) + } + + return noiseThresholds?.map((noiseThreshold) => ( + + {noiseThreshold.name !== '' && ( + {noiseThreshold.name} + )} +
+ + {formatTime(noiseThreshold.starts_daily_at)} + + + + {formatTime(noiseThreshold.ends_daily_at)} + +
+ + } + > +

+ {noiseThreshold.noise_threshold_decibels} {t.decibel} +

+
+ )) +} + +const getTimeZoneCaption = ( + device: NoiseSensorDevice, + thresholds: NoiseThresholds[] | undefined +): string | null => { + if (device.location?.timezone != null) { + return `${t.allTimesIn} ${formatTimeZone(device.location.timezone)}` + } + + const firstThreshold = thresholds?.[0] + + if (firstThreshold != null) { + const zonedTime = ZonedTime.from(firstThreshold.starts_daily_at) + return `${t.allTimesIn} ${formatTimeZone(zonedTime.timeZone)}` + } + + return null +} + +const t = { + noiseThresholds: 'Noise thresholds', + tooltip: + 'A noise threshold is the highest noise level (in dB) you want to allow for a given time range in the day.', + minutTooltipFirst: + 'A noise threshold is the highest noise level (in dB) you want to allow.', + minutTooltipSecond: + 'Quiet hours is a separate threshold that takes effect only for a specified time range.', + none: 'None', + loading: 'Loading...', + decibel: 'dB', + allTimesIn: 'All times in', +} diff --git a/src/styles/_device-details.scss b/src/styles/_device-details.scss index 3bffe834a..d8efaffcd 100644 --- a/src/styles/_device-details.scss +++ b/src/styles/_device-details.scss @@ -103,6 +103,7 @@ .seam-label { color: colors.$text-gray-2; + white-space: nowrap; } .seam-status-text { diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index 82441583b..402ead389 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -40,6 +40,12 @@ gap: 32px; } + .seam-detail-section-wrap { + display: flex; + flex-direction: column; + gap: 6px; + } + .seam-detail-section { width: 100%; } @@ -66,6 +72,27 @@ border: 1px solid colors.$text-gray-3; overflow: hidden; } + + .seam-detail-section-footer { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row; + } + + .seam-detail-section-footer-content-text { + font-size: 12px; + color: colors.$text-gray-2-5; + } + + .seam-detail-section-tooltip-inner-content { + display: flex; + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + gap: 8px; + } } @mixin detail-row-common { @@ -105,17 +132,45 @@ gap: 4px; } + .seam-detail-row-label-column { + display: flex; + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .seam-detail-row-label-block { + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: row; + gap: 4px; + } + .seam-row-label { font-size: 16px; font-weight: 600; line-height: 118%; } + .seam-detail-row-empty-label { + font-size: 16px; + font-style: italic; + font-weight: 400; + line-height: 118%; + color: colors.$text-gray-2; + } + .seam-row-sublabel { color: colors.$text-gray-1; font-size: 14px; font-weight: 400; line-height: 118%; + + &.seam-row-sublabel-text-default { + color: colors.$text-default; + } } .seam-detail-row-hstack {