diff --git a/.storybook/seed-fake.js b/.storybook/seed-fake.js index 975309194..9e7f05f5b 100644 --- a/.storybook/seed-fake.js +++ b/.storybook/seed-fake.js @@ -513,6 +513,26 @@ export const seedFake = (db) => { name: 'Active Hours', }) + db.addEvent({ + device_id: device7.device_id, + workspace_id: ws2.workspace_id, + created_at: '2024-05-16T00:16:12.000', + event_type: 'noise_sensor.noise_threshold_triggered', + noise_level_decibels: 75, + noise_threshold_id: 2, + noise_threshold_name: 'Active Hours', + }) + + db.addEvent({ + device_id: device7.device_id, + workspace_id: ws2.workspace_id, + created_at: '2024-05-16T00:16:12.000', + event_type: 'noise_sensor.noise_threshold_triggered', + noise_level_decibels: 75, + noise_threshold_id: 2, + noise_threshold_name: 'Active Hours', + }) + // add climate setting schedules db.addClimateSettingSchedule({ device_id: device5.device_id, diff --git a/assets/icons/clock.svg b/assets/icons/clock.svg new file mode 100644 index 000000000..408589bfa --- /dev/null +++ b/assets/icons/clock.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/lib/icons/Clock.tsx b/src/lib/icons/Clock.tsx new file mode 100644 index 000000000..01830fcb9 --- /dev/null +++ b/src/lib/icons/Clock.tsx @@ -0,0 +1,36 @@ +/* + * 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 ClockIcon(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 5f7ded6c7..f4994b528 100644 --- a/src/lib/seam/components/DeviceDetails/DeviceDetails.stories.tsx +++ b/src/lib/seam/components/DeviceDetails/DeviceDetails.stories.tsx @@ -18,6 +18,7 @@ const meta: Meta = { type: 'figma', url: 'https://www.figma.com/file/Su3VO6yupz4yxe88fv0Uqa/Seam-Components?type=design&node-id=358-39439&mode=design&t=4OQwfRB8Mw8kT1rw-4', }, + layout: 'fullscreen', }, } diff --git a/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx b/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx index ff25447e0..0798ad06d 100644 --- a/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx +++ b/src/lib/seam/components/DeviceDetails/NoiseSensorDeviceDetails.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames' +import { useState } from 'react' import type { NoiseSensorDevice } from 'seamapi' import type { NestedSpecificDeviceDetailsProps } from 'lib/seam/components/DeviceDetails/DeviceDetails.js' @@ -8,7 +9,11 @@ import { DeviceImage } from 'lib/ui/device/DeviceImage.js' import { NoiseLevelStatus } from 'lib/ui/device/NoiseLevelStatus.js' import { OnlineStatus } from 'lib/ui/device/OnlineStatus.js' import { ContentHeader } from 'lib/ui/layout/ContentHeader.js' +import { NoiseSensorActivityList } from 'lib/ui/noise-sensor/NoiseSensorActivityList.js' import { NoiseThresholdsList } from 'lib/ui/noise-sensor/NoiseThresholdsList.js' +import { TabSet } from 'lib/ui/TabSet.js' + +type TabType = 'details' | 'activity' interface NoiseSensorDeviceDetailsProps extends NestedSpecificDeviceDetailsProps { @@ -22,38 +27,63 @@ export function NoiseSensorDeviceDetails({ onBack, className, }: NoiseSensorDeviceDetailsProps): JSX.Element | null { + const [tab, setTab] = useState('details') + return (
- - -
-
-
-
- -
-
- {t.noiseSensor} -

{device.properties.name}

-
- {t.status}:{' '} - - - +
+
+ +
+
+
+ +
+
+ {t.noiseSensor} +

{device.properties.name}

+
+ {t.status}:{' '} + + + +
+ + + tabs={['details', 'activity']} + tabTitles={{ + details: t.details, + activity: t.activity, + }} + activeTab={tab} + onTabChange={(tab) => { + setTab(tab) + }} + />
- + {tab === 'details' && ( +
+ + + +
+ )} - + {tab === 'activity' && }
) @@ -63,4 +93,6 @@ const t = { noiseSensor: 'Noise Sensor', status: 'Status', noiseLevel: 'Noise level', + details: 'Details', + activity: 'Activity', } diff --git a/src/lib/seam/events/use-events.ts b/src/lib/seam/events/use-events.ts new file mode 100644 index 000000000..b04b4ca9e --- /dev/null +++ b/src/lib/seam/events/use-events.ts @@ -0,0 +1,44 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import type { + Event, + EventsListRequest, + EventsListResponse, + 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 UseEventsParams = EventsListRequest +export type UseEventsData = Event[] +export interface UseEventsOptions { + refetchInterval?: number +} + +export function useEvents( + params?: UseEventsParams, + options?: UseEventsOptions +): UseSeamQueryResult<'events', UseEventsData> { + const { client } = useSeamClient() + const queryClient = useQueryClient() + + const { data, ...rest } = useQuery({ + enabled: client != null, + queryKey: ['events', 'list', params], + queryFn: async () => { + if (client == null) return [] + return await client.events.list(params) + }, + onSuccess: (events) => { + for (const event of events) { + queryClient.setQueryData( + ['events', 'get', { event_id: event.event_id }], + event + ) + } + }, + refetchInterval: options?.refetchInterval ?? 30_000, + }) + + return { ...rest, events: data } +} diff --git a/src/lib/ui/TabSet.tsx b/src/lib/ui/TabSet.tsx new file mode 100644 index 000000000..9e8e360bc --- /dev/null +++ b/src/lib/ui/TabSet.tsx @@ -0,0 +1,110 @@ +import classNames from 'classnames' +import { + type MouseEventHandler, + useCallback, + useEffect, + useLayoutEffect, + useState, +} from 'react' + +interface TabSetProps { + tabs: TabType[] + tabTitles: Record + activeTab: TabType + onTabChange: (tab: TabType) => void +} + +interface HighlightStyle { + left: number + width: number +} + +export function TabSet({ + tabs, + tabTitles, + activeTab, + onTabChange, +}: TabSetProps): JSX.Element { + const [highlightStyle, setHighlightStyle] = useState({ + left: 0, + width: 140, + }) + + const calculateHighlightStyle = useCallback(() => { + const tabButton: HTMLButtonElement | null = + globalThis.document?.querySelector( + `.seam-tab-button:nth-of-type(${tabs.indexOf(activeTab) + 1})` + ) + + setHighlightStyle({ + left: tabButton?.offsetLeft ?? 0, + width: tabButton?.offsetWidth ?? 140, + }) + }, [activeTab, tabs]) + + useLayoutEffect(() => { + calculateHighlightStyle() + }, [activeTab, calculateHighlightStyle]) + + useEffect(() => { + globalThis.addEventListener?.('resize', calculateHighlightStyle) + return () => { + globalThis.removeEventListener?.('resize', calculateHighlightStyle) + } + }, [calculateHighlightStyle]) + + return ( +
+
+
+ + {tabs.map((tab) => ( + + key={tab} + tab={tab} + title={tabTitles[tab]} + isActive={activeTab === tab} + onTabChange={onTabChange} + setHighlightStyle={setHighlightStyle} + /> + ))} +
+
+ ) +} + +interface TabButtonProps { + tab: TabType + title: string + isActive: boolean + onTabChange: (tab: TabType) => void + setHighlightStyle: (style: HighlightStyle) => void +} + +function TabButton({ + tab, + title, + isActive, + onTabChange, + setHighlightStyle, +}: TabButtonProps): JSX.Element { + const handleClick: MouseEventHandler = (ev) => { + onTabChange(tab) + setHighlightStyle({ + left: ev.currentTarget.offsetLeft, + width: ev.currentTarget.offsetWidth, + }) + } + + return ( + + ) +} diff --git a/src/lib/ui/layout/ContentHeader.tsx b/src/lib/ui/layout/ContentHeader.tsx index 26622d856..a63b569af 100644 --- a/src/lib/ui/layout/ContentHeader.tsx +++ b/src/lib/ui/layout/ContentHeader.tsx @@ -1,19 +1,22 @@ +import classNames from 'classnames' + import { ArrowBackIcon } from 'lib/icons/ArrowBack.js' interface ContentHeaderProps { onBack: (() => void) | undefined title?: string subheading?: string + className?: string } export function ContentHeader(props: ContentHeaderProps): JSX.Element | null { - const { title, onBack, subheading } = props + const { title, onBack, subheading, className } = props if (title == null && onBack == null) { return null } return ( -
+
{title} diff --git a/src/lib/ui/noise-sensor/NoiseSensorActivityList.tsx b/src/lib/ui/noise-sensor/NoiseSensorActivityList.tsx new file mode 100644 index 000000000..8d4c109d1 --- /dev/null +++ b/src/lib/ui/noise-sensor/NoiseSensorActivityList.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react' +import type { NoiseSensorDevice } from 'seamapi' + +import { useEvents } from 'lib/seam/events/use-events.js' +import { NoiseSensorEventItem } from 'lib/ui/noise-sensor/NoiseSensorEventItem.js' +import { useNow } from 'lib/ui/use-now.js' + +interface NoiseSensorActivityListProps { + device: NoiseSensorDevice +} + +export function NoiseSensorActivityList({ + device, +}: NoiseSensorActivityListProps): JSX.Element { + const now = useNow() + const [mountedAt] = useState(now) + + const { events } = useEvents({ + device_id: device.device_id, + event_type: 'noise_sensor.noise_threshold_triggered', + since: mountedAt.minus({ months: 1 }).toString(), + }) + + return ( +
+ {events?.map((event) => ( + + ))} +
+ ) +} diff --git a/src/lib/ui/noise-sensor/NoiseSensorEventItem.tsx b/src/lib/ui/noise-sensor/NoiseSensorEventItem.tsx new file mode 100644 index 000000000..3881f7ce4 --- /dev/null +++ b/src/lib/ui/noise-sensor/NoiseSensorEventItem.tsx @@ -0,0 +1,67 @@ +import { DateTime } from 'luxon' +import type { Event } from 'seamapi' + +import { ClockIcon } from 'lib/icons/Clock.js' + +interface NoiseSensorEventItemProps { + event: Event +} + +export function NoiseSensorEventItem({ + event, +}: NoiseSensorEventItemProps): JSX.Element { + const date = formatDate(event.created_at) + const time = formatTime(event.created_at) + + return ( +
+
+ + +
+

{date}

+

{time}

+
+
+ +
+

+ {t.noiseThresholdTriggered} +

+ {getContextSublabel(event) != null && ( +

+ {getContextSublabel(event)} +

+ )} +
+ +
+
+ ) +} + +function getContextSublabel(event: Event): string | null { + if ('noise_threshold_name' in event) { + // @ts-expect-error UPSTREAM: Shallow event type + // https://github.com/seamapi/react/issues/611 + return event.noise_threshold_name + } + + if ('noise_level_decibels' in event) { + // UPSTREAM: Shallow event types. + return `${event.noise_level_decibels as string} ${t.decibel}` + } + + return null +} + +const formatDate = (dateTime: string): string => + DateTime.fromISO(dateTime).toLocaleString(DateTime.DATE_FULL) + +const formatTime = (dateTime: string): string => + DateTime.fromISO(dateTime).toLocaleString(DateTime.TIME_SIMPLE) + +const t = { + decibel: 'dB', + noiseThresholdTriggered: 'Noise threshold triggered', +} diff --git a/src/styles/_device-details.scss b/src/styles/_device-details.scss index d8efaffcd..5faec845c 100644 --- a/src/styles/_device-details.scss +++ b/src/styles/_device-details.scss @@ -8,6 +8,11 @@ gap: 16px; margin: 0 24px 24px; + &.seam-body-no-margin { + margin: 0; + gap: 0; + } + .seam-box { border: 1px solid colors.$text-gray-3; border-radius: 8px; @@ -56,6 +61,13 @@ } } + .seam-contained-summary { + padding: 0 24px; + box-shadow: + 0 2px 8px 0 rgb(0 0 0 / 8%), + 0 1px 0 0 rgb(0 0 0 / 10%); + } + .seam-summary { padding: 24px 16px; border-radius: 16px; diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index 402ead389..a0b573e4e 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -9,6 +9,10 @@ justify-content: center; margin: 0 16px; + &.seam-content-header-contained { + margin: 0; + } + .seam-back-icon { position: absolute; top: 50%; @@ -34,6 +38,10 @@ } @mixin detail-section { + .seam-padded-container { + padding: 0 16px; + } + .seam-detail-sections { display: flex; flex-direction: column; diff --git a/src/styles/_main.scss b/src/styles/_main.scss index f630cd245..d2fa99a6c 100644 --- a/src/styles/_main.scss +++ b/src/styles/_main.scss @@ -29,6 +29,8 @@ @use './climate-setting-schedule-form'; @use './climate-setting-schedule-details'; @use './time-zone-picker'; +@use './tab-set'; +@use './noise-sensor'; .seam-components { // Reset @@ -53,6 +55,7 @@ @include spinner.all; @include switch.all; @include time-zone-picker.all; + @include tab-set.all; // Components @include device-details.all; @@ -66,4 +69,5 @@ @include thermostat.all; @include seam-table.all; @include climate-setting-schedule-details.all; + @include noise-sensor.all; } diff --git a/src/styles/_noise-sensor.scss b/src/styles/_noise-sensor.scss new file mode 100644 index 000000000..d90a5be23 --- /dev/null +++ b/src/styles/_noise-sensor.scss @@ -0,0 +1,72 @@ +@use './colors'; + +@mixin all { + .seam-noise-sensor-activity-list { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + } + + .seam-noise-sensor-event-item { + width: 100%; + min-height: 64px; + padding: 12px 8px 12px 16px; + border-bottom: 1px solid colors.$divider-stroke-light; + display: flex; + gap: 40px; + } + + .seam-noise-sensor-event-item-column-wrap { + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: row; + gap: 18px; + flex: 0.5; + } + + .seam-noise-sensor-event-item-datetime-wrap { + color: colors.$text-gray-1; + font-size: 14px; + line-height: 134%; + } + + .seam-noise-sensor-event-item-date, + .seam-noise-sensor-event-item-time, + .seam-noise-sensor-event-item-context-label, + .seam-noise-sensor-event-item-context-sublabel { + white-space: nowrap; + } + + .seam-noise-sensor-event-item-context-wrap { + display: flex; + justify-content: center; + align-items: flex-start; + flex-direction: column; + flex: 1; + } + + .seam-noise-sensor-event-item-context-label { + color: colors.$text-default; + font-size: 14px; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + + .seam-noise-sensor-event-item-context-sublabel { + color: colors.$text-gray-2; + font-size: 14px; + } + + .seam-noise-sensor-event-item-right-block { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 18px; + flex: 0.5; + } +} diff --git a/src/styles/_tab-set.scss b/src/styles/_tab-set.scss new file mode 100644 index 000000000..ae1fd0e06 --- /dev/null +++ b/src/styles/_tab-set.scss @@ -0,0 +1,52 @@ +@use './colors'; + +@mixin all { + .seam-tab-set { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + + .seam-tab-set-buttons { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + position: relative; + padding-top: 8px; + } + + .seam-tab-set-highlight { + height: 4px; + border-radius: 6px 6px 0 0; + background-color: colors.$primary; + position: absolute; + bottom: 0; + transition: 240ms cubic-bezier(0.2, 0, 0.38, 0.9); + } + + .seam-tab-button { + appearance: none; + width: 140px; + padding-top: 4px; + padding-bottom: 12px; + display: flex; + justify-content: center; + align-items: center; + font-size: 14px; + cursor: pointer; + background-color: transparent; + box-shadow: none; + border: none; + color: colors.$text-gray-2; + transition: 240ms ease-in-out; + + &:hover, + &.seam-tab-button-active { + color: colors.$text-default; + } + } + } +}