diff --git a/src/components/MyData/LineChart/index.tsx b/src/components/MyData/LineChart/index.tsx index 5e6907cc..9634133e 100644 --- a/src/components/MyData/LineChart/index.tsx +++ b/src/components/MyData/LineChart/index.tsx @@ -17,7 +17,7 @@ import ViewShot from 'react-native-view-shot'; import { Trace, TraceLine } from './TraceLine'; import { useVictoryTheme } from '../useVictoryTheme'; import { scaleTime } from 'd3-scale'; -import { Title } from './Title'; +import { Title } from '../common/Title'; import { View, Dimensions } from 'react-native'; import { createStyles } from '../../BrandConfigProvider'; import { useStyles } from '../../../hooks'; diff --git a/src/components/MyData/SleepChart/DailyChart.tsx b/src/components/MyData/SleepChart/DailyChart.tsx new file mode 100644 index 00000000..22de38ab --- /dev/null +++ b/src/components/MyData/SleepChart/DailyChart.tsx @@ -0,0 +1,284 @@ +import React, { useMemo } from 'react'; +import { VictoryChart, VictoryAxis } from 'victory-native'; +import { VictoryLabelProps, Tuple } from 'victory-core'; +import { + eachHourOfInterval, + format, + endOfMinute, + startOfMinute, + differenceInMinutes, +} from 'date-fns'; +import { View } from 'react-native'; +import { createStyles, useIcons } from '../../BrandConfigProvider'; +import { + ForeignObject, + G, + Circle, + Rect, + Text, + SvgProps, + CircleProps, +} from 'react-native-svg'; +import { useCommonChartProps } from '../useCommonChartProps'; +import uniqBy from 'lodash/unionBy'; +import type { SleepChartData } from './useSleepChartData'; +import ViewShot from 'react-native-view-shot'; +import { t } from 'i18next'; +import { useVictoryTheme } from '../useVictoryTheme'; +import { useStyles } from '../../../hooks'; +import { CodeableConcept } from 'fhir/r3'; +import orderBy from 'lodash/orderBy'; +import { Falsey } from 'lodash'; +import { scaleLinear } from 'd3-scale'; +import { ActivityIndicatorView } from '../../ActivityIndicatorView'; + +type Props = SleepChartData & { + viewShotRef: React.RefObject; +}; + +export const DailyChart = (props: Props) => { + const { viewShotRef } = props; + const { xDomain, sleepData, isFetching } = props; + const common = useCommonChartProps(); + const { sleepAnalysisTheme: theme } = useVictoryTheme(); + const { styles } = useStyles(defaultStyles); + + const yDomain = useMemo( + () => + scaleLinear() + .range([0, common.height - common.padding.bottom - common.padding.top]) + .domain([0, 4]), + [common], + ); + + const data = useMemo( + () => + orderBy( + compact( + sleepData + .flatMap((d) => + d.component?.map((c, i) => ({ id: `${d.id}-${i}`, ...c })), + ) + .map((d) => { + if (!d?.valuePeriod?.start || !d?.valuePeriod?.end) { + return undefined; + } + + const sleepType = codeToNum(d.code); + const start = startOfMinute(new Date(d.valuePeriod.start)); + const end = endOfMinute(new Date(d.valuePeriod.end)); + + return { + ...d, + x: xDomain(start), + width: xDomain(end) - xDomain(start), + y: yDomain(4 - sleepType) + common.padding.top, + height: yDomain(sleepType), + sleepType, + fill: codeToValue({ + default: 'transparent', + ...styles.stageColors, + })(d.code), + sleepTypeName: valToName(sleepType), + startTime: start.toLocaleTimeString(), + durationInMinutes: Math.abs(differenceInMinutes(start, end)), + }; + }), + ), + 'sleepType', + 'asc', + ), + [sleepData, styles, common, xDomain, yDomain], + ); + + const ticks = useMemo(() => { + const [start, end] = xDomain.domain().sort((a, b) => Number(a) - Number(b)); + + if (start === end) { + return []; + } + + return uniqBy([start, ...eachHourOfInterval({ start, end }), end], (v) => + v.toISOString(), + ); + }, [xDomain]); + + return ( + + + , + x: data.length ? (xDomain.domain() as Tuple) : undefined, + }} + > + + + + {data.map((d) => ( + + ))} + + + + i === 0 || i + 1 === a.length ? format(v, 'hh:mm aa') : '*' + } + tickLabelComponent={} + style={theme.independentAxis} + /> + + + + + {} + + + ); +}; + +function compact(arr: T[]): Exclude[] { + return arr.filter((v) => !!v) as Exclude[]; +} + +type CodeMap = Partial> & + Record<'default', T>; + +function codeToValue(map: CodeMap) { + return (coding: CodeableConcept) => { + for (const code of coding.coding ?? []) { + if ( + code.system === 'http://loinc.org' && + code.code === '93830-8' && + map.light + ) { + return map.light; + } else if ( + code.system === 'http://loinc.org' && + code.code === '93831-6' && + map.deep + ) { + return map.deep; + } else if ( + code.system === 'http://loinc.org' && + code.code === '93829-0' && + map.rem + ) { + return map.rem; + } else if ( + code.system === 'http://loinc.org' && + code.code === '93828-2' && + map.awake + ) { + return map.awake; + } + } + + return map.default; + }; +} + +const codeToNum = codeToValue({ + deep: 1, + light: 2, + rem: 3, + awake: 4, + default: 0, +}); + +const valToName = (value: number) => + ({ + 4: t('sleep-analysis-type-awake', 'Awake'), + 3: t('sleep-analysis-type-rem', 'REM'), + 2: t('sleep-analysis-type-light', 'Light'), + 1: t('sleep-analysis-type-deep', 'Deep'), + }[value] ?? ''); + +type TickProps = { + text?: string; + index?: number; + enabled: boolean; +} & VictoryLabelProps; + +const Tick = ({ text, index, enabled, ...props }: TickProps) => { + const { Moon, Sunrise } = useIcons(); + const { styles } = useStyles(defaultStyles); + const Icon = index === 0 ? Moon : Sunrise; + + if (!enabled) { + return null; + } + + if (text === '*') { + return ( + null && ( + + ) + ); + } + + return ( + + + + + + + + {text} + + ); +}; + +const defaultStyles = createStyles('SleepAnalysisSingleDay', (theme) => ({ + tickDot: { + fill: theme.colors.backdrop, + opacity: 0.4, + } as SvgProps, + tickIcon: { + color: theme.colors.onPrimary, + width: '100%', + height: '100%', + } as SvgProps, + tickIconBackground: { + fill: theme.colors.primary, + r: 10, + } as CircleProps, + stageColors: { + awake: 'hotpink', + deep: 'dodgerblue', + light: 'deepskyblue', + rem: 'deeppink', + default: 'transparent', + }, + loadingContainer: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, +})); + +declare module '@styles' { + interface ComponentStyles + extends ComponentNamedStyles {} +} diff --git a/src/components/MyData/SleepChart/MultiDayChart.tsx b/src/components/MyData/SleepChart/MultiDayChart.tsx new file mode 100644 index 00000000..a878492d --- /dev/null +++ b/src/components/MyData/SleepChart/MultiDayChart.tsx @@ -0,0 +1,214 @@ +import React, { useMemo } from 'react'; +import { VictoryChart, VictoryAxis, VictoryBar } from 'victory-native'; +import { VictoryLabelProps } from 'victory-core'; +import { + eachDayOfInterval, + format, + eachMonthOfInterval, + differenceInMinutes, + differenceInDays, + startOfDay, + startOfMonth, + isValid, +} from 'date-fns'; +import groupBy from 'lodash/groupBy'; +import { G, Circle, Text, SvgProps } from 'react-native-svg'; +import { useCommonChartProps } from '../useCommonChartProps'; +import type { SleepChartData } from './useSleepChartData'; +import { useVictoryTheme } from '../useVictoryTheme'; +import ViewShot from 'react-native-view-shot'; +import { t } from 'i18next'; +import { useStyles } from '../../../hooks'; +import { createStyles } from '../../BrandConfigProvider'; +import { View } from 'react-native'; +import { ActivityIndicatorView } from '../../ActivityIndicatorView'; + +type Props = SleepChartData & { + domainPadding?: number; + viewShotRef: React.RefObject; +}; + +export const MultiDayChart = (props: Props) => { + const { viewShotRef } = props; + const { dateRange, sleepData, isFetching } = props; + const common = useCommonChartProps(); + const theme = useVictoryTheme(); + const { styles } = useStyles(defaultStyles); + const { domainPadding: domainPaddingIn = styles.data?.domainPadding } = props; + + const { isYear, ticks, data } = useMemo(() => { + const isYearChart = differenceInDays(...dateRange) < -31; + const ticksFromRange = isYearChart + ? eachMonthOfInterval({ start: dateRange[0], end: dateRange[1] }) + : eachDayOfInterval({ start: dateRange[0], end: dateRange[1] }); + + const groupFn = isYearChart ? startOfMonth : startOfDay; + + const groupedData = groupBy(sleepData, (d) => + groupFn(new Date(d.effectiveDateTime!)), + ); + + return { + isYear: isYearChart, + ticks: ticksFromRange, + data: Object.entries(groupedData) + .filter(([date]) => isValid(new Date(date))) + .map(([date, d]) => { + const duration = + d.reduce( + (total, observation) => + total + + (!observation.valuePeriod?.end || + !observation.valuePeriod?.start + ? 0 + : differenceInMinutes( + new Date(observation.valuePeriod.end), + new Date(observation.valuePeriod.start), + )), + 0, + ) / (isYearChart ? d.length : 1); + + return { + y: duration, + x: new Date(date), + hours: Math.floor(duration / 60), + minutes: Math.round(duration % 60), + isAverage: isYearChart, + period: format(new Date(date), isYearChart ? 'MMMM' : 'MMMM d'), + }; + }), + }; + }, [dateRange, sleepData]); + + const { barWidth, domainPadding } = useMemo(() => { + let padding = domainPaddingIn ?? 0; + const width = (common.plotAreaWidth - padding) / (ticks.length * 2 - 1); + return { + domainPadding: domainPaddingIn ?? width, + barWidth: width, + }; + }, [ticks, common, domainPaddingIn]); + + const dependentTicks = useMemo(() => { + let maxY = 12 * 60; + const maxDataPoint = Math.max(...data.map((d) => d.y)); + if (maxDataPoint > 12 * 60 && maxDataPoint <= 16 * 60) { + maxY = 16 * 60; + } else if (maxDataPoint > 16 * 60) { + maxY = 24 * 60; + } + + return new Array(4).fill(0).map((_, i) => ((i + 1) * maxY) / 4); + }, [data]); + + return ( + + + + `${v / 60}h`} + style={theme.sleepAnalysisTheme.dependentAxis} + /> + + label} + labelComponent={} + style={{ + ...theme.bar?.style, + data: { + ...theme.bar?.style?.data, + fill: styles.data?.color, + }, + }} + /> + + + i === 0 || i + 1 === a.length + ? format(v, isYear ? 'MMM' : 'MM/dd') + : '' + } + tickLabelComponent={} + style={theme.sleepAnalysisTheme.independentAxis} + /> + + + + + + + + ); +}; + +type TickProps = { text?: string } & VictoryLabelProps; + +const Tick = ({ text, ...props }: TickProps) => { + const { styles } = useStyles(defaultStyles); + return ( + + + {text} + + ); +}; + +const A11yBarLabel = (props: VictoryLabelProps & { datum?: any }) => { + return ( + props.datum && ( + + ) + ); +}; + +const defaultStyles = createStyles('SleepAnalysisMultiDay', (theme) => ({ + data: { + domainPadding: undefined as any as number, + color: theme.colors.primary, + }, + tickDot: { + fill: theme.colors.backdrop, + opacity: 0.4, + } as SvgProps, + loadingContainer: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, +})); + +declare module '@styles' { + interface ComponentStyles + extends ComponentNamedStyles {} +} diff --git a/src/components/MyData/SleepChart/index.test.tsx b/src/components/MyData/SleepChart/index.test.tsx new file mode 100644 index 00000000..313b8ae5 --- /dev/null +++ b/src/components/MyData/SleepChart/index.test.tsx @@ -0,0 +1,341 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { SleepChart } from './index'; +import { useSleepChartData } from './useSleepChartData'; +import { + addMinutes, + format, + startOfDay, + addDays, + addMonths, + startOfYear, + endOfYear, +} from 'date-fns'; +import { scaleTime } from 'd3-scale'; + +jest.unmock('@react-navigation/native'); +jest.mock('./useSleepChartData', () => ({ + useSleepChartData: jest.fn(), +})); + +jest.mock( + 'victory-native/src/components/victory-primitives/tspan', + () => + ({ children }: any) => + {children}, +); +jest.mock('react-native-svg', () => { + const actual = jest.requireActual('react-native-svg'); + + return new Proxy(actual, { + get(target, prop) { + if (prop === 'Text') { + return ({ children }: any) => {children}; + } + + return target[prop]; + }, + }); +}); + +const mockUseSleepChartData = useSleepChartData as any as jest.Mock< + ReturnType +>; + +const REM = { + system: 'http://loinc.org', + code: '93829-0', +}; +const Awake = { + system: 'http://loinc.org', + code: '93828-2', +}; +const Light = { + system: 'http://loinc.org', + code: '93830-8', +}; +const Deep = { + system: 'http://loinc.org', + code: '93831-6', +}; + +describe('LineChart', () => { + it('should render daily chart', async () => { + mockUseSleepChartData.mockReturnValue({ + isFetching: false, + sleepData: [ + { + resourceType: 'Observation', + code: {}, + status: 'final', + component: [ + { + code: { + coding: [REM], + }, + valuePeriod: { + start: new Date(0).toISOString(), + end: addMinutes(new Date(0), 2).toISOString(), + }, + }, + { + code: { + coding: [Awake], + }, + valuePeriod: { + start: addMinutes(new Date(0), 2).toISOString(), + end: addMinutes(new Date(0), 3).toISOString(), + }, + }, + { + code: { + coding: [Light], + }, + valuePeriod: { + start: addMinutes(new Date(0), 3).toISOString(), + end: addMinutes(new Date(0), 4).toISOString(), + }, + }, + { + code: { + coding: [Deep], + }, + valuePeriod: { + start: addMinutes(new Date(0), 4).toISOString(), + end: addMinutes(new Date(0), 5).toISOString(), + }, + }, + // malformed components - not rendered: + { + code: { + coding: [], + }, + valuePeriod: { + start: new Date(0).toISOString(), + }, + }, + { + code: { + coding: [REM], + }, + valuePeriod: { + end: addMinutes(new Date(0), 5).toISOString(), + }, + }, + ], + }, + ], + xDomain: scaleTime().domain([new Date(0), addMinutes(new Date(0), 5)]), + dateRange: [new Date(0), new Date(0)], + }); + + const { findByText, findByLabelText } = render( + , + ); + + expect(await findByText('Single Day Test Title')).toBeDefined(); + expect(await findByText(format(new Date(0), 'hh:mm aa'))).toBeDefined(); + expect( + await findByText(format(addMinutes(new Date(0), 5), 'hh:mm aa')), + ).toBeDefined(); + expect(await findByText('Awake')).toBeDefined(); + expect(await findByText('REM')).toBeDefined(); + expect(await findByText('Light')).toBeDefined(); + expect(await findByText('Deep')).toBeDefined(); + expect( + await findByLabelText( + `2 minutes of REM sleep starting at ${new Date( + 0, + ).toLocaleTimeString()}`, + ), + ).toBeDefined(); + expect( + await findByLabelText( + `1 minutes of Awake sleep starting at ${addMinutes( + new Date(0), + 2, + ).toLocaleTimeString()}`, + ), + ).toBeDefined(); + expect( + await findByLabelText( + `1 minutes of Light sleep starting at ${addMinutes( + new Date(0), + 3, + ).toLocaleTimeString()}`, + ), + ).toBeDefined(); + expect( + await findByLabelText( + `1 minutes of Deep sleep starting at ${addMinutes( + new Date(0), + 4, + ).toLocaleTimeString()}`, + ), + ).toBeDefined(); + }); + + it('should render multi day chart', async () => { + mockUseSleepChartData.mockReturnValue({ + isFetching: false, + sleepData: [ + { + resourceType: 'Observation', + code: {}, + status: 'final', + effectiveDateTime: addMinutes(new Date(0), 7 * 60).toISOString(), + valuePeriod: { + start: new Date(0).toISOString(), + end: addMinutes(new Date(0), 7 * 60).toISOString(), + }, + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + effectiveDateTime: addMinutes( + addDays(new Date(0), 1), + 9.5 * 60, + ).toISOString(), + valuePeriod: { + start: addDays(new Date(0), 1).toISOString(), + end: addMinutes(addDays(new Date(0), 1), 9.5 * 60).toISOString(), + }, + }, + ], + xDomain: scaleTime().domain([new Date(0), addDays(new Date(0), 7)]), + dateRange: [new Date(0), addDays(new Date(0), 7)], + }); + + const { findByText, findByLabelText } = render( + , + ); + + expect(await findByText('Multi Day Test Title')).toBeDefined(); + expect(await findByText(format(new Date(0), 'MM/dd'))).toBeDefined(); + expect( + await findByText(format(addDays(new Date(0), 7), 'MM/dd')), + ).toBeDefined(); + expect( + await findByLabelText( + `7 hours and 0 minutes of sleep on ${format( + startOfDay(addMinutes(new Date(0), 7 * 60)), + 'MMMM d', + )}`, + ), + ).toBeDefined(); + expect( + await findByLabelText( + `9 hours and 30 minutes of sleep on ${format( + startOfDay(addMinutes(addDays(new Date(0), 1), 9.5 * 60)), + 'MMMM d', + )}`, + ), + ).toBeDefined(); + }); + + it('should render year chart', async () => { + mockUseSleepChartData.mockReturnValue({ + isFetching: false, + sleepData: [ + { + resourceType: 'Observation', + code: {}, + status: 'final', + effectiveDateTime: addMinutes(new Date(0), 18 * 60).toISOString(), + valuePeriod: { + start: new Date(0).toISOString(), + end: addMinutes(new Date(0), 18 * 60).toISOString(), + }, + }, + { + resourceType: 'Observation', + code: {}, + status: 'final', + effectiveDateTime: addMinutes( + addMonths(new Date(0), 1), + 9.5 * 60, + ).toISOString(), + valuePeriod: { + start: addMonths(new Date(0), 1).toISOString(), + end: addMinutes(addMonths(new Date(0), 1), 9.5 * 60).toISOString(), + }, + }, + ], + xDomain: scaleTime().domain([ + startOfYear(new Date(0)), + endOfYear(new Date(0)), + ]), + dateRange: [startOfYear(new Date(0)), endOfYear(new Date(0))], + }); + + const { findByText, findByLabelText } = render( + , + ); + + expect(await findByText('Year Test Title')).toBeDefined(); + expect( + await findByText(format(startOfYear(new Date(0)), 'MMM')), + ).toBeDefined(); + expect( + await findByText(format(endOfYear(new Date(0)), 'MMM')), + ).toBeDefined(); + expect( + await findByLabelText( + `Average of 18 hours and 0 minutes of sleep for ${format( + startOfDay(addMinutes(new Date(0), 18 * 60)), + 'MMMM', + )}`, + ), + ).toBeDefined(); + expect( + await findByLabelText( + `Average of 9 hours and 30 minutes of sleep for ${format( + startOfDay(addMinutes(addMonths(new Date(0), 1), 9.5 * 60)), + 'MMMM', + )}`, + ), + ).toBeDefined(); + }); + + it('calls share when the share button is pressed', async () => { + mockUseSleepChartData.mockReturnValue({ + isFetching: false, + sleepData: [], + xDomain: scaleTime().domain([new Date(0), addMinutes(new Date(0), 5)]), + dateRange: [new Date(0), new Date(0)], + }); + + const onShare = jest.fn(); + + const { getByTestId } = render( + , + ); + + await act(async () => fireEvent.press(await getByTestId('share-button'))); + + expect(onShare).toHaveBeenCalledWith({ + dataUri: 'mockImageData', + dateRange: [startOfDay(new Date(0)), startOfDay(new Date(0))], + selectedPoints: [], + title: 'Test Title', + }); + }); +}); diff --git a/src/components/MyData/SleepChart/index.tsx b/src/components/MyData/SleepChart/index.tsx new file mode 100644 index 00000000..86681b32 --- /dev/null +++ b/src/components/MyData/SleepChart/index.tsx @@ -0,0 +1,135 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { startOfDay, differenceInDays } from 'date-fns'; +import ViewShot from 'react-native-view-shot'; +import { useVictoryTheme } from '../useVictoryTheme'; +import { Title } from '../common/Title'; +import { View, Dimensions } from 'react-native'; +import { createStyles } from '../../BrandConfigProvider'; +import { useStyles } from '../../../hooks'; +import { CommonChartPropsProvider } from '../useCommonChartProps'; +import { defaultChartStyles } from '../styles'; +import { useSleepChartData } from './useSleepChartData'; +import { DailyChart } from './DailyChart'; +import { MultiDayChart } from './MultiDayChart'; + +type Props = { + title: string; + dateRange: { + start: Date; + end: Date; + }; + onShare?: (config: { + selectedPoints: []; + title: string; + dataUri?: string; + dateRange: [Date, Date]; + }) => void; +}; + +const SleepChart = (props: Props) => { + const { title, dateRange: incomingDateRange, onShare } = props; + const viewShotRef = useRef(null); + const [_showSelection, setShowSelection] = useState(false); + const [isSwitchingChartType, setIsSwitchingChartType] = useState(false); + const dateRange = useMemo<[Date, Date]>( + () => [ + startOfDay(incomingDateRange.start), + startOfDay(incomingDateRange.end), + ], + [incomingDateRange.start, incomingDateRange.end], + ); + const { styles } = useStyles(defaultStyles); + const chartData = useSleepChartData({ dateRange }); + + const handleExport = useCallback(async () => { + setShowSelection(true); + const dataUri = await viewShotRef.current?.capture?.(); + onShare?.({ + selectedPoints: [], + title, + dataUri, + dateRange, + }); + setShowSelection(false); + }, [title, dateRange, onShare]); + + const ChartType = useMemo( + () => (differenceInDays(...dateRange) === 0 ? DailyChart : MultiDayChart), + [dateRange], + ); + + useEffect(() => setIsSwitchingChartType(true), [ChartType]); + useEffect(() => { + // end existing switch once isFetching is false + setIsSwitchingChartType( + (chartTypeChanged) => chartTypeChanged && chartData.isFetching, + ); + }, [chartData.isFetching]); + + return ( + + + + <View style={styles.chartWrapper}> + <ChartType + viewShotRef={viewShotRef} + {...chartData} + // Hide data when switching, multi-day data does not render well on daily + sleepData={isSwitchingChartType ? [] : chartData.sleepData} + // Use incoming date range when switching, multi-day chart shows single day when switching from daily + dateRange={isSwitchingChartType ? dateRange : chartData.dateRange} + /> + </View> + </View> + ); +}; + +const SleepChartWrapper = (props: Props & { padding?: number }) => { + const { padding = 0, ...lineChartProps } = props; + const theme = useVictoryTheme(); + const { styles } = useStyles(defaultChartStyles); + const width = styles.chart?.width ?? Dimensions.get('window').width - padding; + + return ( + <CommonChartPropsProvider + theme={theme} + width={width} + height={styles.chart?.height ?? 300} + padding={{ + left: padding + 5, + right: padding - 10, + top: padding - 30, + bottom: padding - 5, + }} + > + <SleepChart {...lineChartProps} /> + </CommonChartPropsProvider> + ); +}; + +export { SleepChartWrapper as SleepChart }; + +const defaultStyles = createStyles('LineChart', () => ({ + container: {}, + chartWrapper: { + position: 'relative', + }, + dataSelectorContainer: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, +})); + +declare module '@styles' { + interface ComponentStyles + extends ComponentNamedStyles<typeof defaultStyles> {} +} diff --git a/src/components/MyData/SleepChart/useSleepChartData.test.ts b/src/components/MyData/SleepChart/useSleepChartData.test.ts new file mode 100644 index 00000000..35d82a97 --- /dev/null +++ b/src/components/MyData/SleepChart/useSleepChartData.test.ts @@ -0,0 +1,217 @@ +import { renderHook } from '@testing-library/react-native'; +import { startOfDay, endOfDay, addDays } from 'date-fns'; +import { useFhirClient } from '../../../hooks'; +import { useSleepChartData } from './useSleepChartData'; + +jest.mock('../../../hooks/useFhirClient', () => ({ + useFhirClient: jest.fn(), +})); + +const mockUseFhirClient = useFhirClient as any as jest.Mock; +const useSearchResourcesQuery = jest.fn(); + +describe('useSleepChartData', () => { + beforeEach(() => { + useSearchResourcesQuery.mockReset(); + + mockUseFhirClient.mockReturnValue({ + useSearchResourcesQuery, + }); + }); + + it('should handle loading states', () => { + useSearchResourcesQuery + // initially not fetched and not loaded + .mockReturnValueOnce({ + isFetching: false, + isFetched: false, + }) + // transitions to fetching + .mockReturnValueOnce({ + isFetching: true, + isFetched: false, + }) + // fetching complete + .mockReturnValue({ + isFetching: false, + isFetched: true, + }); + + const props = { + dateRange: [new Date(0), new Date(0)] as [Date, Date], + }; + + const { result, rerender } = renderHook(useSleepChartData, { + initialProps: props, + }); + + expect(result.current.isFetching).toBe(true); + + rerender(props); + + expect(result.current.isFetching).toBe(true); + + rerender(props); + + expect(result.current.isFetching).toBe(false); + }); + + it('should fetch the trace data', () => { + const observation: fhir3.Observation = { + resourceType: 'Observation', + status: 'final', + code: {}, + effectiveDateTime: new Date(10).toISOString(), + valuePeriod: { + start: new Date(0).toISOString(), + end: new Date(10).toISOString(), + }, + component: [ + { + code: {}, + valuePeriod: { + start: new Date(0).toISOString(), + end: new Date(10).toISOString(), + }, + }, + ], + }; + useSearchResourcesQuery.mockReturnValue({ + isFetching: false, + isFetched: true, + data: { + entry: [ + { + resource: observation, + }, + ], + }, + }); + + const { result } = renderHook(useSleepChartData, { + initialProps: { + dateRange: [new Date(0), new Date(0)], + }, + }); + + expect(useSearchResourcesQuery).toHaveBeenNthCalledWith(1, { + resourceType: 'Observation', + coding: [{ code: '258158006', system: 'http://snomed.info/sct' }], + dateRange: [startOfDay(new Date(0)), endOfDay(new Date(0))], + pageSize: 50, + }); + + expect(result.current.sleepData).toEqual([observation]); + expect(result.current.xDomain.domain()[0]).toEqual(new Date(0)); + expect(result.current.xDomain.domain()[1]).toEqual(new Date(10)); + }); + + it('should fetch the data with a pageSize based on the dateRange', () => { + useSearchResourcesQuery.mockReturnValue({ + isFetching: false, + isFetched: true, + data: undefined, + }); + + renderHook(useSleepChartData, { + initialProps: { + dateRange: [new Date(0), addDays(new Date(0), 500)], + }, + }); + + expect(useSearchResourcesQuery).toHaveBeenNthCalledWith(1, { + resourceType: 'Observation', + coding: [{ code: '258158006', system: 'http://snomed.info/sct' }], + dateRange: [startOfDay(new Date(0)), endOfDay(addDays(new Date(0), 500))], + pageSize: 500, + }); + }); + + it('when dates change it should return the old data and range until the new data resolves', async () => { + const observation1: fhir3.Observation = { + resourceType: 'Observation', + status: 'final', + id: 'first', + code: {}, + effectiveDateTime: new Date(10).toISOString(), + valuePeriod: { + start: new Date(0).toISOString(), + end: new Date(10).toISOString(), + }, + }; + const observation2: fhir3.Observation = { + ...observation1, + id: 'second', + }; + + const res1 = { + isFetching: false, + isFetched: true, + data: { + entry: [ + { + resource: observation1, + }, + ], + }, + }; + + const res2 = { + isFetching: false, + isFetched: true, + data: { + entry: [ + { + resource: observation2, + }, + ], + }, + }; + + const loadingState = { + isFetching: true, + isFetched: true, + }; + + useSearchResourcesQuery + .mockReturnValueOnce(res1) // initial + .mockReturnValueOnce(res1) // set chart data + .mockReturnValueOnce(loadingState) // rerender - new date range + .mockReturnValueOnce(loadingState) // new date range - setIsLoading + .mockReturnValueOnce(res2) // resolve new data + .mockReturnValueOnce(res2); // set new chart data + + const dateRange1 = [new Date(0), new Date(0)] as [Date, Date]; + const dateRange2 = [new Date(10), new Date(20)] as [Date, Date]; + + const { result, rerender } = renderHook(useSleepChartData, { + initialProps: { + dateRange: dateRange1, + }, + }); + + expect(result.current.dateRange).toEqual(dateRange1); + expect(result.current.isFetching).toBe(false); + expect(result.current.sleepData).toEqual([observation1]); + + expect(useSearchResourcesQuery).toHaveBeenCalledTimes(2); + + const changedProps = { + dateRange: dateRange2, + }; + + rerender(changedProps); + + expect(result.current.isFetching).toBe(true); + + // Returns old date range with old data + expect(result.current.dateRange).toEqual(dateRange1); + expect(result.current.sleepData).toEqual([observation1]); + + rerender(changedProps); + + expect(result.current.isFetching).toBe(false); + expect(result.current.dateRange).toEqual(dateRange2); + expect(result.current.sleepData).toEqual([observation2]); + }); +}); diff --git a/src/components/MyData/SleepChart/useSleepChartData.ts b/src/components/MyData/SleepChart/useSleepChartData.ts new file mode 100644 index 00000000..72f1fb3d --- /dev/null +++ b/src/components/MyData/SleepChart/useSleepChartData.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { useFhirClient } from '../../../hooks'; +import { ScaleTime, scaleTime } from 'd3-scale'; +import { differenceInDays, min, max, startOfDay, endOfDay } from 'date-fns'; +import { useCommonChartProps } from '../useCommonChartProps'; +import { Observation } from 'fhir/r3'; +import compact from 'lodash/compact'; + +type Props = { + dateRange: [Date, Date]; +}; + +export type SleepChartData = ReturnType<typeof useSleepChartData>; + +export const useSleepChartData = (props: Props) => { + const { dateRange } = props; + const { useSearchResourcesQuery } = useFhirClient(); + const common = useCommonChartProps(); + const [chartData, setChartData] = useState<{ + sleepData: Observation[]; + xDomain: ScaleTime<number, number>; + dateRange: [Date, Date]; + }>({ sleepData: [], xDomain: scaleTime(), dateRange }); + const [isLoading, setIsLoading] = useState(true); + + const days = Math.abs(differenceInDays(...dateRange)); + + const { data, isFetching, isFetched } = useSearchResourcesQuery({ + resourceType: 'Observation', + coding: [ + { + system: 'http://snomed.info/sct', + code: '258158006', + }, + ], + dateRange: [startOfDay(dateRange[0]), endOfDay(dateRange[1])], + pageSize: Math.max(50, days), + }); + + useEffect(() => { + if (!isFetching && isFetched) { + const newSleepData = + data?.entry + ?.map((e) => e.resource) + .filter((v): v is Observation => !!v) ?? []; + + const { start, end } = newSleepData.reduce( + (domain, observation) => ({ + start: min( + compact([ + domain.start, + observation.valuePeriod?.start && + new Date(observation.valuePeriod.start), + ...(observation.component?.map( + (c) => c.valuePeriod?.start && new Date(c.valuePeriod?.start), + ) ?? []), + ]), + ), + end: max( + compact([ + domain.end, + observation.valuePeriod?.end && + new Date(observation.valuePeriod.end), + ...(observation.component?.map( + (c) => c.valuePeriod?.end && new Date(c.valuePeriod?.end), + ) ?? []), + ]), + ), + }), + {} as { start?: Date; end?: Date }, + ); + + const newXDomain = scaleTime() + .range([0, common.plotAreaWidth]) + .domain([new Date(start ?? 0), new Date(end ?? 0)]); + + setChartData({ sleepData: newSleepData, xDomain: newXDomain, dateRange }); + setIsLoading(false); + } else { + setIsLoading(true); + } + }, [common.plotAreaWidth, data, isFetching, isFetched, dateRange]); + + return { + ...chartData, + isFetching: isFetching || isLoading, + }; +}; diff --git a/src/components/MyData/LineChart/Title.tsx b/src/components/MyData/common/Title.tsx similarity index 100% rename from src/components/MyData/LineChart/Title.tsx rename to src/components/MyData/common/Title.tsx diff --git a/src/components/MyData/styles.ts b/src/components/MyData/styles.ts index 479378f6..657ad9f3 100644 --- a/src/components/MyData/styles.ts +++ b/src/components/MyData/styles.ts @@ -65,6 +65,30 @@ export const defaultChartStyles = createStyles('ChartStyles', (theme) => ({ height: undefined as number | undefined, width: undefined as number | undefined, }, + bar: { + data: { + fill: theme.colors.primary, + }, + } as VictoryStyleProp<'bar'>, + sleepAnalysis: { + independentAxis: { + grid: { + display: 'none', + }, + } as VictoryStyleProp<'axis'>, + dependentAxis: { + grid: { + stroke: theme.colors.backdrop, + display: 'flex', + strokeWidth: 2, + strokeLinecap: 'round', + opacity: 0.4, + }, + axis: { + display: 'none', + }, + } as VictoryStyleProp<'dependentAxis'>, + } as VictoryThemeDefinition, })); declare module '@styles' { diff --git a/src/components/MyData/useCommonChartProps.tsx b/src/components/MyData/useCommonChartProps.tsx index 1e889d62..0dc1eabb 100644 --- a/src/components/MyData/useCommonChartProps.tsx +++ b/src/components/MyData/useCommonChartProps.tsx @@ -6,11 +6,15 @@ type ChartProps = VictoryCommonProps & { padding: Required<BlockProps>; width: number; height: number; + plotAreaWidth: number; + plotAreaHeight: number; }; const CommonChartPropsContext = React.createContext<ChartProps>({ height: 0, width: 0, + plotAreaWidth: 0, + plotAreaHeight: 0, padding: { top: 0, bottom: 0, @@ -19,10 +23,18 @@ const CommonChartPropsContext = React.createContext<ChartProps>({ }, }); -type Props = ChartProps & { children: React.ReactNode }; +type Props = Omit<ChartProps, 'plotAreaWidth' | 'plotAreaHeight'> & { + children: React.ReactNode; +}; export const CommonChartPropsProvider = ({ children, ...props }: Props) => ( - <CommonChartPropsContext.Provider value={props}> + <CommonChartPropsContext.Provider + value={{ + ...props, + plotAreaWidth: props.width - props.padding.left - props.padding.right, + plotAreaHeight: props.height - props.padding.top - props.padding.bottom, + }} + > {children} </CommonChartPropsContext.Provider> ); diff --git a/src/components/MyData/useVictoryTheme.ts b/src/components/MyData/useVictoryTheme.ts index 20ead2a9..18615a2f 100644 --- a/src/components/MyData/useVictoryTheme.ts +++ b/src/components/MyData/useVictoryTheme.ts @@ -7,6 +7,7 @@ import { merge } from 'lodash'; type VictoryTheme = VictoryThemeDefinition & { trendlineTheme: VictoryThemeDefinition; + sleepAnalysisTheme: VictoryThemeDefinition; }; export const useVictoryTheme = ( @@ -56,6 +57,9 @@ export const useVictoryTheme = ( styles.scatter, ), }, + bar: { + style: merge({}, styles.bar), + }, }; const customTheme: VictoryTheme = { @@ -64,6 +68,7 @@ export const useVictoryTheme = ( ...theme, line: merge({}, theme.line, { style: styles.trendline }), }, + sleepAnalysisTheme: styles.sleepAnalysis ?? {}, }; return customTheme; }, [styles, trace, variant]); diff --git a/src/hooks/useAppConfig.tsx b/src/hooks/useAppConfig.tsx index 1982742a..c0476c4e 100644 --- a/src/hooks/useAppConfig.tsx +++ b/src/hooks/useAppConfig.tsx @@ -53,6 +53,20 @@ export type TabStyle = { svgPropsInactive?: SvgProps; }; +type LineChart = { + type: 'LineChart'; + title: string; + trace1: Trace; + trace2?: Trace; +}; + +type SleepChart = { + type: 'SleepChart'; + title: string; +}; + +type Chart = LineChart | SleepChart; + export interface AppConfig { homeTab?: { appTiles?: AppTile[]; @@ -66,12 +80,7 @@ export interface AppConfig { messageTiles?: MessageTile[]; pillarSettings?: { advancedScreenTrackers: string[] }; myDataSettings?: { - components: { - type: 'LineChart'; - title: string; - trace1: Trace; - trace2?: Trace; - }[]; + components: Chart[]; }; todayTile?: AppTile; todayTileSettings?: { diff --git a/src/screens/MyDataScreen.tsx b/src/screens/MyDataScreen.tsx index dd7333fc..40538db6 100644 --- a/src/screens/MyDataScreen.tsx +++ b/src/screens/MyDataScreen.tsx @@ -4,6 +4,7 @@ import { useAppConfig } from '../hooks/useAppConfig'; import { ActivityIndicatorView } from '../components/ActivityIndicatorView'; import { ScreenSurface } from '../components/ScreenSurface'; import { LineChart } from '../components/MyData/LineChart'; +import { SleepChart } from '../components/MyData/SleepChart'; import { DatePicker } from '../components/TrackTile/TrackerDetails/DatePicker'; import { format, @@ -146,12 +147,23 @@ export const MyDataScreen = () => { {config?.homeTab?.myDataSettings?.components.map((component, index) => ( <React.Fragment key={`${component.type}-${index}`}> {index > 0 && <Divider style={styles.divider} />} - <LineChart - {...component} - dateRange={range} - padding={Number(styles.container?.paddingHorizontal) * 2} - onShare={setExportData} - /> + {component.type === 'LineChart' && ( + <LineChart + {...component} + dateRange={range} + padding={Number(styles.container?.paddingHorizontal) * 2} + onShare={setExportData} + /> + )} + {component.type === 'SleepChart' && ( + <SleepChart + {...component} + title="Sleep Analysis" + dateRange={range} + padding={Number(styles.container?.paddingHorizontal) * 2} + onShare={setExportData} + /> + )} </React.Fragment> ))}