From 033d62f78e718750e97a03c9a6299e499c8d4014 Mon Sep 17 00:00:00 2001 From: Garrett Reinard Date: Mon, 2 Feb 2026 15:04:12 -0500 Subject: [PATCH 1/4] Add new Insights components. --- .../InsightsDataCoordinator.tsx | 97 ++++++ .../InsightsDataCoordinator/index.ts | 1 + .../SurveyAnswerChart.previewdata.ts | 22 +- .../SurveyAnswerChart.stories.tsx | 6 +- src/components/container/index.ts | 1 + .../InsightsBadge/InsightsBadge.css | 20 ++ .../InsightsBadge/InsightsBadge.tsx | 61 ++++ .../presentational/InsightsBadge/index.ts | 1 + .../InsightsCalendar/InsightsCalendar.css | 91 ++++++ .../InsightsCalendar.stories.tsx | 278 ++++++++++++++++++ .../InsightsCalendar/InsightsCalendar.tsx | 122 ++++++++ .../presentational/InsightsCalendar/index.ts | 1 + .../InsightsCalendarDayNavigator.css | 7 + .../InsightsCalendarDayNavigator.stories.tsx | 271 +++++++++++++++++ .../InsightsCalendarDayNavigator.tsx | 44 +++ .../InsightsCalendarDayNavigator/index.ts | 1 + .../InsightsList/InsightsList.stories.tsx | 155 ++++++++++ .../InsightsList/InsightsList.tsx | 39 +++ .../presentational/InsightsList/index.ts | 1 + .../InsightsPreview.stories.tsx | 148 ++++++++++ .../InsightsPreview/InsightsPreview.tsx | 59 ++++ .../presentational/InsightsPreview/index.ts | 1 + .../InsightsRenderer/InsightsRenderer.css | 49 +++ .../InsightsRenderer.stories.tsx | 181 ++++++++++++ .../InsightsRenderer/InsightsRenderer.tsx | 51 ++++ .../presentational/InsightsRenderer/index.ts | 1 + .../InsightsRenderingCoordinator.tsx | 29 ++ .../InsightsRenderingCoordinator/index.ts | 1 + .../InsightsStateCoordinator.tsx | 32 ++ .../InsightsStateCoordinator/index.ts | 1 + src/components/presentational/index.ts | 10 +- src/helpers/daily-data-types/combined.tsx | 4 +- src/helpers/index.ts | 3 +- src/helpers/insights/functions.ts | 75 +++++ src/helpers/insights/index.ts | 2 + src/helpers/insights/types.ts | 12 + src/helpers/survey-answer/index.ts | 1 + src/helpers/survey-answer/sample-data.ts | 24 ++ 38 files changed, 1876 insertions(+), 27 deletions(-) create mode 100644 src/components/container/InsightsDataCoordinator/InsightsDataCoordinator.tsx create mode 100644 src/components/container/InsightsDataCoordinator/index.ts create mode 100644 src/components/presentational/InsightsBadge/InsightsBadge.css create mode 100644 src/components/presentational/InsightsBadge/InsightsBadge.tsx create mode 100644 src/components/presentational/InsightsBadge/index.ts create mode 100644 src/components/presentational/InsightsCalendar/InsightsCalendar.css create mode 100644 src/components/presentational/InsightsCalendar/InsightsCalendar.stories.tsx create mode 100644 src/components/presentational/InsightsCalendar/InsightsCalendar.tsx create mode 100644 src/components/presentational/InsightsCalendar/index.ts create mode 100644 src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.css create mode 100644 src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.stories.tsx create mode 100644 src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.tsx create mode 100644 src/components/presentational/InsightsCalendarDayNavigator/index.ts create mode 100644 src/components/presentational/InsightsList/InsightsList.stories.tsx create mode 100644 src/components/presentational/InsightsList/InsightsList.tsx create mode 100644 src/components/presentational/InsightsList/index.ts create mode 100644 src/components/presentational/InsightsPreview/InsightsPreview.stories.tsx create mode 100644 src/components/presentational/InsightsPreview/InsightsPreview.tsx create mode 100644 src/components/presentational/InsightsPreview/index.ts create mode 100644 src/components/presentational/InsightsRenderer/InsightsRenderer.css create mode 100644 src/components/presentational/InsightsRenderer/InsightsRenderer.stories.tsx create mode 100644 src/components/presentational/InsightsRenderer/InsightsRenderer.tsx create mode 100644 src/components/presentational/InsightsRenderer/index.ts create mode 100644 src/components/presentational/InsightsRenderingCoordinator/InsightsRenderingCoordinator.tsx create mode 100644 src/components/presentational/InsightsRenderingCoordinator/index.ts create mode 100644 src/components/presentational/InsightsStateCoordinator/InsightsStateCoordinator.tsx create mode 100644 src/components/presentational/InsightsStateCoordinator/index.ts create mode 100644 src/helpers/insights/functions.ts create mode 100644 src/helpers/insights/index.ts create mode 100644 src/helpers/insights/types.ts create mode 100644 src/helpers/survey-answer/index.ts create mode 100644 src/helpers/survey-answer/sample-data.ts diff --git a/src/components/container/InsightsDataCoordinator/InsightsDataCoordinator.tsx b/src/components/container/InsightsDataCoordinator/InsightsDataCoordinator.tsx new file mode 100644 index 000000000..22bc76cdb --- /dev/null +++ b/src/components/container/InsightsDataCoordinator/InsightsDataCoordinator.tsx @@ -0,0 +1,97 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { getDayKey, InsightsData, InsightsDataPreviewState, loadInsightsData, useInitializeView } from '../../../helpers'; +import { DateRangeContext } from '../../presentational'; +import { add, startOfToday } from 'date-fns'; +import MyDataHelps from '@careevolution/mydatahelps-js'; + +export interface InsightsDataContext { + logSurveyName?: string; + loading: boolean; + firstTimeLoading: boolean; + insightsData: Partial>; + enterSurveyLog: (date: Date) => void; +} + +export const InsightsDataContext = createContext(null); + +export interface InsightsDataCoordinatorProps { + previewState?: 'loading' | InsightsDataPreviewState; + logSurveyName?: string; + otherSurveyNames?: string[]; + dailyDataTypes?: string[]; + children: React.ReactNode; + innerRef?: React.Ref; +} + +export default function InsightsDataCoordinator(props: InsightsDataCoordinatorProps) { + const dateRangeContext = useContext(DateRangeContext); + + const [loading, setLoading] = useState(true); + const [firstTimeLoading, setFirstTimeLoading] = useState(true); + const [insightsData, setInsightsData] = useState>>({}); + + const latestRequestId = useRef(0); + + const intervalStart = useMemo( + () => dateRangeContext?.intervalStart ?? startOfToday(), + [dateRangeContext?.intervalStart, getDayKey(new Date())] + ); + + const intervalEnd = useMemo( + () => { + if (dateRangeContext?.intervalType === '6Month') { + return add(intervalStart, { months: 6 }); + } + if (dateRangeContext?.intervalType === 'Month') { + return add(intervalStart, { months: 1 }); + } + if (dateRangeContext?.intervalType === 'Week') { + return add(intervalStart, { weeks: 1 }); + } + return add(intervalStart, { days: 1 }); + }, + [intervalStart, dateRangeContext?.intervalType] + ); + + useEffect(() => { + setInsightsData({}); + }, [intervalStart]); + + useInitializeView(() => { + setLoading(true); + if (props.previewState) { + setFirstTimeLoading(true); + setInsightsData({}); + if (props.previewState === 'loading') return; + } + + const surveyNames: string[] = [...new Set(props.otherSurveyNames)]; + if (props.logSurveyName && !surveyNames.includes(props.logSurveyName)) { + surveyNames.push(props.logSurveyName); + } + const dailyDataTypes = [...new Set(props.dailyDataTypes)]; + + const requestId = ++latestRequestId.current; + loadInsightsData(surveyNames, dailyDataTypes, intervalStart, intervalEnd, props.previewState).then(insightData => { + if (requestId !== latestRequestId.current) return; + setInsightsData(insightData); + setFirstTimeLoading(false); + if (!props.previewState?.startsWith('reloading')) { + setLoading(false); + } + }); + }, [], [props.previewState, props.logSurveyName, props.otherSurveyNames, props.dailyDataTypes, intervalStart, intervalEnd]); + + const enterSurveyLog = (date: Date) => { + if (props.previewState || loading || !props.logSurveyName) return; + setLoading(true); + MyDataHelps.startSurvey(props.logSurveyName, { event: getDayKey(date) }); + }; + + return
+ +
; +} \ No newline at end of file diff --git a/src/components/container/InsightsDataCoordinator/index.ts b/src/components/container/InsightsDataCoordinator/index.ts new file mode 100644 index 000000000..8dc05d4b7 --- /dev/null +++ b/src/components/container/InsightsDataCoordinator/index.ts @@ -0,0 +1 @@ +export { default, InsightsDataContext } from './InsightsDataCoordinator'; \ No newline at end of file diff --git a/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts b/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts index 0147f3d7b..6ab47a48f 100644 --- a/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts +++ b/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts @@ -1,11 +1,11 @@ import { add, Duration, formatISO, min, startOfToday } from 'date-fns'; import { SurveyAnswer } from '@careevolution/mydatahelps-js'; -import { getDayKey, predictableRandomNumber } from '../../../helpers'; +import { generateSurveyAnswers } from '../../../helpers/survey-answer'; export type SurveyAnswerChartPreviewState = 'default' | 'no data' | 'with one data point' | 'with one data point in first series' | 'with two data points' | 'with two data points in first series' | 'with two data points in first series with gap' | 'with gap'; export async function generatePreviewData(previewState: SurveyAnswerChartPreviewState | undefined, startDate: Date, endDate: Date, seriesCount: number, dataCadence: Duration): Promise { - const previewDataProvider = (startDate: Date, endDate: Date) => { + const previewDataProvider = async (startDate: Date, endDate: Date) => { const resultIdentifiers = Array.from({ length: seriesCount }, (_, i) => 'Result ' + i); return generateSurveyAnswers(startDate, endDate, resultIdentifiers, 10, 100, dataCadence); }; @@ -45,22 +45,4 @@ export async function getPreviewDataFromProvider(previewState: SurveyAnswerChart } return surveyAnswers; -} - -export async function generateSurveyAnswers(startDate: Date, endDate: Date, resultIdentifiers: string[], minValue: number, maxValue: number, dataCadence: Duration): Promise { - const data = Array.from({ length: resultIdentifiers.length }, (): SurveyAnswer[] => []); - - let currentDate = startDate; - while (currentDate < endDate) { - for (let i = 0; i < data.length; i++) { - const answer = await predictableRandomNumber(getDayKey(currentDate) + resultIdentifiers[i]); - data[i].push({ - date: formatISO(currentDate), - answers: [(answer % (maxValue - minValue) + minValue).toString()] - } as SurveyAnswer); - } - currentDate = add(currentDate, dataCadence); - } - - return data; } \ No newline at end of file diff --git a/src/components/container/SurveyAnswerChart/SurveyAnswerChart.stories.tsx b/src/components/container/SurveyAnswerChart/SurveyAnswerChart.stories.tsx index 7f3cb4478..39111eccb 100644 --- a/src/components/container/SurveyAnswerChart/SurveyAnswerChart.stories.tsx +++ b/src/components/container/SurveyAnswerChart/SurveyAnswerChart.stories.tsx @@ -3,7 +3,7 @@ import { Card, DateRangeCoordinator, Layout } from '../../presentational'; import SurveyAnswerChart, { SurveyAnswerChartProps } from './SurveyAnswerChart'; import { Meta, StoryObj } from '@storybook/react'; import { argTypesToHide } from '../../../../.storybook/helpers'; -import { generateSurveyAnswers } from './SurveyAnswerChart.previewdata'; +import { generateSurveyAnswers } from '../../../helpers/survey-answer'; type SurveyAnswerChartStoryArgs = ComponentProps & { colorScheme: 'auto' | 'light' | 'dark'; @@ -189,7 +189,7 @@ export const FFWEL: Story = { }] : undefined }, expectedDataInterval: { months: 1 }, - previewDataProvider: args.previewState ? (startDate: Date, endDate: Date) => { + previewDataProvider: args.previewState ? async (startDate: Date, endDate: Date) => { return generateSurveyAnswers(startDate, endDate, ['CreativeSelf', 'SocialSelf', 'CopingSelf'], 10, 100, { months: 1 }); } : undefined }; @@ -273,7 +273,7 @@ export const DailyPain: Story = { }] : undefined }, expectedDataInterval: { days: 1 }, - previewDataProvider: args.previewState ? (startDate: Date, endDate: Date) => { + previewDataProvider: args.previewState ? async (startDate: Date, endDate: Date) => { return generateSurveyAnswers(startDate, endDate, ['PainToday'], 0, 10, { days: 1 }); } : undefined }; diff --git a/src/components/container/index.ts b/src/components/container/index.ts index 96502e306..852904978 100644 --- a/src/components/container/index.ts +++ b/src/components/container/index.ts @@ -34,6 +34,7 @@ export { default as HealthConnectPhrSync } from "./HealthConnectPhrSync" export { default as HealthPreviewSection } from "./HealthPreviewSection" export { default as InboxItemList, InboxItemListPreviewState } from "./InboxItemList" export { default as InboxItemListCoordinator } from "./InboxItemListCoordinator" +export { default as InsightsDataCoordinator, InsightsDataContext } from "./InsightsDataCoordinator" export { default as InsightMatrix } from "./InsightMatrix" export { IntradayHeartRateChart } from "./IntradayHeartRateChart" export { default as LabResultsBloodType } from "./LabResultsBloodType" diff --git a/src/components/presentational/InsightsBadge/InsightsBadge.css b/src/components/presentational/InsightsBadge/InsightsBadge.css new file mode 100644 index 000000000..8a6d610d5 --- /dev/null +++ b/src/components/presentational/InsightsBadge/InsightsBadge.css @@ -0,0 +1,20 @@ +.mdhui-insights-badge > .mdhui-progress-ring { + margin: 0; +} + +.mdhui-insights-badge .mdhui-insights-badge-icon { + width: 42px; + height: 42px; + border: 1px solid var(--mdhui-border-color-1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + box-sizing: border-box; +} + +.mdhui-insights-badge .mdhui-insights-badge-icon-label { + line-height: 40px; + font-weight: bold; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsBadge/InsightsBadge.tsx b/src/components/presentational/InsightsBadge/InsightsBadge.tsx new file mode 100644 index 000000000..9db549023 --- /dev/null +++ b/src/components/presentational/InsightsBadge/InsightsBadge.tsx @@ -0,0 +1,61 @@ +import { ColorDefinition, InsightsData, resolveColor } from '../../../helpers'; +import React, { CSSProperties, Ref, useContext } from 'react'; +import { FontAwesomeSvgIcon } from 'react-fontawesome-svg-icon'; +import { LayoutContext, ProgressRing } from '../index'; +import './InsightsBadge.css'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export interface InsightsBadgeConfiguration { + identifier: string; + shouldRender?: (insightsData: InsightsData) => boolean; + getPercentComplete: (insightsData: InsightsData) => number; + customHighlightStyling?: CSSProperties; + icon?: IconDefinition; + iconColor?: ColorDefinition; +} + +export interface InsightsBadgeProps { + configuration: InsightsBadgeConfiguration; + data: InsightsData; + innerRef?: Ref; +} + +export default function InsightsBadge(props: InsightsBadgeProps) { + const layoutContext = useContext(LayoutContext); + + const percentComplete = props.configuration.getPercentComplete(props.data); + const shouldHighlight = percentComplete === 100; + + const iconColor = shouldHighlight ? props.configuration.iconColor ?? 'var(--mdhui-color-primary)' : { lightMode: '#acacb8', darkMode: '#8f8f9f' }; + const progressColor = props.configuration.iconColor ?? 'var(--mdhui-color-primary)'; + + const resolvedBackgroundColor = resolveColor(layoutContext.colorScheme, { lightMode: '#fdfdfd', darkMode: '#43424f' }); + const resolvedIconColor = resolveColor(layoutContext.colorScheme, iconColor); + const resolvedProgressColor = resolveColor(layoutContext.colorScheme, progressColor); + const resolvedIncompleteProgressColor = resolveColor(layoutContext.colorScheme, { lightMode: '#dedfe3', darkMode: '#43424f' }); + + const iconStyle: CSSProperties = { + background: resolvedBackgroundColor, + borderColor: resolvedBackgroundColor, + color: resolvedIconColor, + ...props.configuration.customHighlightStyling + }; + + return
+ +
+ {props.configuration.icon + ? + :
{props.configuration.identifier.substring(0, 1).toUpperCase()}
+ } +
+
+
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsBadge/index.ts b/src/components/presentational/InsightsBadge/index.ts new file mode 100644 index 000000000..2c53d4631 --- /dev/null +++ b/src/components/presentational/InsightsBadge/index.ts @@ -0,0 +1 @@ +export { default, InsightsBadgeConfiguration } from './InsightsBadge'; \ No newline at end of file diff --git a/src/components/presentational/InsightsCalendar/InsightsCalendar.css b/src/components/presentational/InsightsCalendar/InsightsCalendar.css new file mode 100644 index 000000000..835c556f9 --- /dev/null +++ b/src/components/presentational/InsightsCalendar/InsightsCalendar.css @@ -0,0 +1,91 @@ +.mdhui-insights-calendar .mdhui-calendar { + border-top: solid 1px var(--mdhui-border-color-1); + padding-bottom: 8px; +} + +.mdhui-insights-calendar .mdhui-week-calendar { + overflow-x: hidden; + border-bottom: none; +} + +.mdhui-insights-calendar .mdhui-insights-week-calendar-day { + margin: 8px 0 0; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; +} + +.mdhui-insights-calendar .mdhui-insights-week-calendar-day-of-week { + font-size: 14px; + font-weight: bold; + color: var(--mdhui-text-color-2); + text-align: center; +} + +.mdhui-insights-week-calendar-day-of-month { + font-size: 14px; + text-align: center; + margin-top: 8px; +} + +.mdhui-insights-calendar .mdhui-insigts-week-calendar-day-rendered { + width: 100%; + margin: 16px 0 8px; +} + +.mdhui-insights-calendar .mdhui-insights-week-calendar-day-footer { + margin: 16px 2px 0; +} + +.mdhui-insights-calendar .mdhui-insights-week-calendar-day-badges { + margin-bottom: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; +} + +.mdhui-insights-calendar .mdhui-insights-calendar-footer { + border-top: 1px solid var(--mdhui-border-color-1); + position: relative; + min-height: 38px; +} + +.mdhui-insights-calendar .mdhui-insights-calendar-legend { + padding: 8px 40px 8px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + box-sizing: border-box; +} + +.mdhui-insights-calendar .mdhui-insights-calendar-legend-entry { + display: flex; + align-items: center; + gap: 4px; +} + +.mdhui-insights-calendar .mdhui-insights-calendar-legend-entry-color { + height: 16px; + width: 16px; + border-radius: 50%; +} + +.mdhui-insights-calendar .mdhui-insights-calendar-legend-entry-label { + font-size: 0.7em; + color: var(--mdhui-text-color-2); + font-weight: bold; +} + +.mdhui-insights-calendar .mdhui-insights-calendar-loading-indicator.mdhui-loading-indicator { + position: absolute; + bottom: 6px; + right: 16px; + padding: 0; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsCalendar/InsightsCalendar.stories.tsx b/src/components/presentational/InsightsCalendar/InsightsCalendar.stories.tsx new file mode 100644 index 000000000..3c5a8966f --- /dev/null +++ b/src/components/presentational/InsightsCalendar/InsightsCalendar.stories.tsx @@ -0,0 +1,278 @@ +import React, { CSSProperties, ReactNode } from 'react'; +import { CalendarDayState, CalendarDayStates, DateRangeCoordinator, InsightsBadgeConfiguration, InsightsRenderingCoordinator, InsightsStateCoordinator, Layout, Section } from '../index'; +import { StoryObj } from '@storybook/react'; +import { argTypesToHide } from '../../../../.storybook/helpers'; +import InsightsCalendar from './InsightsCalendar'; +import { fnvPredictableRandomNumber, getDayKey, InsightsData, InsightsDataPreviewState } from '../../../helpers'; +import { isAfter, isBefore, isToday, startOfToday } from 'date-fns'; +import { InsightsDataCoordinator } from '../../container'; +import { faBed, faBicycle, faBurn, faSwimmer, faWalking } from '@fortawesome/free-solid-svg-icons'; + +type InsightsCalendarStoryArgs = React.ComponentProps & { + colorScheme: 'auto' | 'light' | 'dark'; + intervalType: 'Month' | 'Week'; + previewState: 'loading' | InsightsDataPreviewState; + withStates: boolean; + withDisplayValues: boolean; + withNotes: boolean; + withBadges: boolean; + multiStateStartAngle: number; + hasLegend: boolean; + customizeToday: boolean; + customizeFuture: boolean; + customizeNoData: boolean; + customizeStatesNote: boolean; + customStyling: boolean; +}; + +export default { + title: 'Presentational/InsightsCalendar', + component: InsightsCalendar, + parameters: { + layout: 'fullscreen' + }, + render: (args: InsightsCalendarStoryArgs) => { + const customHighlightStyling: CSSProperties | undefined = args.customStyling ? { + boxShadow: 'inset -5px -5px 10px rgba(255, 255, 255, 0.3), inset 5px 5px 10px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.3)', + transition: 'border-radius: 0.5s ease, transform 0.2s ease, box-shadow 0.2s ease' + } : undefined; + + const states: CalendarDayState[] = [ + { + label: 'Sleep', + backgroundColor: '#664cda', + borderColor: '#664cda', + textColor: '#f4d6ff', + style: customHighlightStyling, + streakIdentifier: 'purple-state', + streakColor: '#664cda', + combineWhenSolo: true + }, + { + label: 'Activity', + backgroundColor: '#3c973c', + borderColor: '#3c973c', + textColor: '#bdead7', + style: customHighlightStyling, + streakIdentifier: 'green-state', + streakColor: '#3c973c', + combineWhenSolo: true + }, + { + label: 'Swimming', + backgroundColor: '#0877b8', + borderColor: '#0877b8', + textColor: '#abe0ff', + style: customHighlightStyling, + streakIdentifier: 'blue-state', + streakColor: '#0877b8', + combineWhenSolo: true + } + ]; + + const computePreviewStatesForDay = (date: Date, insightsData?: InsightsData): CalendarDayStates => { + const statesForDay: CalendarDayStates = []; + + const surveyAnswers = insightsData?.surveyAnswers ?? []; + const dayKey = getDayKey(date); + + if (surveyAnswers.some(surveyAnswer => parseInt(surveyAnswer.answers[0]) > 0)) { + const statesCount = (fnvPredictableRandomNumber(dayKey + '-states-count') % states.length) + 1; + let currentStateIndex = fnvPredictableRandomNumber(dayKey + '-states-start-index') % states.length; + while (statesForDay.length < statesCount) { + statesForDay.push(states[currentStateIndex]); + currentStateIndex = (currentStateIndex + 1) % states.length; + } + statesForDay.sort((a, b) => states.indexOf(a) - states.indexOf(b)); + } else if (isToday(date) && args.customizeToday) { + statesForDay.push({ borderColor: { lightMode: '#000', darkMode: '#fff' } }); + } else if (isAfter(date, new Date()) && args.customizeFuture) { + statesForDay.push({ borderColor: { lightMode: '#000', darkMode: '#fff' } }); + } else if (isBefore(date, startOfToday()) && args.customizeNoData) { + statesForDay.push({ borderColor: { lightMode: '#000', darkMode: '#fff' } }); + } + + if (args.withDisplayValues) { + statesForDay.displayValue = surveyAnswers[0]?.answers[0] ?? '-'; + } + + if (args.withNotes && fnvPredictableRandomNumber(dayKey + '-states-note-include') % 2 === 0) { + statesForDay.note = 'note'; + if (args.customizeStatesNote) { + statesForDay.noteBorderColor = { lightMode: '#000', darkMode: '#fff' }; + } + } + + return statesForDay; + }; + + const getPercentComplete = (insightsData: InsightsData, resultIdentifier: string): number => { + const surveyAnswer = insightsData.surveyAnswers.find(surveyAnswer => surveyAnswer.resultIdentifier === resultIdentifier); + return !!surveyAnswer && surveyAnswer.answers[0] !== '0' ? 100 : fnvPredictableRandomNumber(`${resultIdentifier}-${getDayKey(insightsData.date)}`) % 90; + }; + + const badgeConfigurations: InsightsBadgeConfiguration[] = [ + { + identifier: 'activity', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result1'), + customHighlightStyling: customHighlightStyling, + icon: faWalking, + iconColor: '#3c973c' + }, + { + identifier: 'sleep', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result2'), + customHighlightStyling: customHighlightStyling, + icon: faBed, + iconColor: '#664cda' + }, + { + identifier: 'swimming', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result3'), + customHighlightStyling: customHighlightStyling, + icon: faSwimmer, + iconColor: '#0877b8' + }, + { + identifier: 'cycling', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result4'), + customHighlightStyling: customHighlightStyling, + icon: faBicycle, + iconColor: '#976d1e' + }, + { + identifier: 'other', + shouldRender: () => false, + getPercentComplete: insightsData => getPercentComplete(insightsData, 'other'), + customHighlightStyling: customHighlightStyling, + icon: faBurn, + iconColor: '#d81442' + } + ]; + + const calendar =
+ +
; + + const wrapWithStateCoordinatorIfNecessary = (children: ReactNode): ReactNode => { + return args.withStates + ? + : children; + }; + + const wrapWithRenderingCoordinatorIfNecessary = (children: ReactNode): ReactNode => { + return args.withBadges + ? + : children; + }; + + return + + + {wrapWithStateCoordinatorIfNecessary( + wrapWithRenderingCoordinatorIfNecessary(calendar) + )} + + + ; + } +}; + +export const Default: StoryObj = { + args: { + colorScheme: 'auto', + intervalType: 'Month', + previewState: 'loaded', + withStates: true, + withDisplayValues: false, + withNotes: false, + withBadges: false, + multiStateStartAngle: 270, + hasLegend: true, + showLegend: true, + customizeToday: false, + customizeFuture: false, + customizeNoData: false, + customizeStatesNote: false, + customStyling: false + }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + intervalType: { + name: 'interval type', + control: 'radio', + options: ['Month', 'Week'] + }, + previewState: { + name: 'state', + control: 'radio', + options: ['loading', 'loaded', 'reloading'] + }, + withStates: { + name: 'with states', + control: 'boolean' + }, + withDisplayValues: { + name: 'with display values', + control: 'boolean' + }, + withNotes: { + name: 'with notes', + control: 'boolean' + }, + withBadges: { + name: 'with badges', + control: 'boolean' + }, + multiStateStartAngle: { + name: 'multi-state start angle', + control: { + type: 'range', + min: 0, + max: 360, + step: 1 + } + }, + hasLegend: { + name: 'has legend', + control: 'boolean' + }, + showLegend: { + name: 'show legend', + control: 'boolean' + }, + customizeToday: { + name: 'customize today', + control: 'boolean' + }, + customizeFuture: { + name: 'customize future', + control: 'boolean' + }, + customizeNoData: { + name: 'customize no-data', + control: 'boolean' + }, + customizeStatesNote: { + name: 'customize states note', + control: 'boolean' + }, + customStyling: { + name: 'custom styling', + control: 'boolean' + }, + ...argTypesToHide(['legend', 'innerRef']) + } +}; diff --git a/src/components/presentational/InsightsCalendar/InsightsCalendar.tsx b/src/components/presentational/InsightsCalendar/InsightsCalendar.tsx new file mode 100644 index 000000000..b2b20ecd4 --- /dev/null +++ b/src/components/presentational/InsightsCalendar/InsightsCalendar.tsx @@ -0,0 +1,122 @@ +import { Calendar, CalendarDay, CalendarDayStates, DateRangeContext, InsightsBadge, InsightsRenderingContext, InsightsStateContext, LayoutContext, LoadingIndicator, TextBlock, WeekCalendar } from '../index'; +import React, { Ref, useContext, useMemo } from 'react'; +import { isAfter, isBefore, isToday, startOfMonth, startOfToday } from 'date-fns'; +import { getDayKey, resolveColor } from '../../../helpers'; +import './InsightsCalendar.css'; +import { InsightsDataContext } from '../../container'; +import { getDayOfMonth, getDayOfWeekLetter } from '../../../helpers/date-helpers'; + +export interface InsightsCalendarProps { + showLegend?: boolean; + onDayClicked?: (date: Date) => void; + selectedDate?: Date; + innerRef?: Ref; +} + +export default function InsightsCalendar(props: InsightsCalendarProps) { + const layoutContext = useContext(LayoutContext); + const dateRangeContext = useContext(DateRangeContext); + const insightsDataContext = useContext(InsightsDataContext); + const insightsStateContext = useContext(InsightsStateContext); + const insightsRenderingContext = useContext(InsightsRenderingContext); + + if (!insightsDataContext) { + return Error: InsightsCalendar must be used within an InsightsDataCoordinator.; + } + + const intervalStart = useMemo( + () => dateRangeContext?.intervalStart ?? startOfMonth(new Date()), + [dateRangeContext?.intervalStart, getDayKey(new Date())] + ); + + const computeStatesForDay = (date: Date): CalendarDayStates => { + const insightsData = insightsDataContext.insightsData[getDayKey(date)]; + const calendarDayStates = insightsStateContext?.computeStatesForDay(date, insightsData) ?? []; + if (calendarDayStates.length === 0) { + if (isToday(date)) { + calendarDayStates.push({ borderColor: '#369CFF' }); + } else if (isAfter(date, new Date())) { + calendarDayStates.push({ style: { cursor: 'default' } }); + } else if (isBefore(date, startOfToday())) { + calendarDayStates.push({ borderColor: 'var(--mdhui-border-color-2)' }); + } + } + return calendarDayStates; + }; + + const onDayClicked = (date: Date): void => { + if (isAfter(date, new Date())) return; + props.onDayClicked ? props.onDayClicked(date) : insightsDataContext.enterSurveyLog(date); + }; + + const renderDay = (year: number, month: number, day?: number, suppressClick: boolean = false): React.JSX.Element => { + return ; + }; + + return
+ {dateRangeContext?.intervalType === 'Week' + ? { + const date = new Date(year, month, day); + const insightsData = insightsDataContext.insightsData[getDayKey(date)]; + + const badges = insightsData + ? insightsRenderingContext?.badgeConfigurations + ?.filter(configuration => !configuration.shouldRender || configuration.shouldRender(insightsData)) + .map((configuration, index) => { + return ; + }) + : undefined; + + return
+
{getDayOfWeekLetter(date)}
+ {computeStatesForDay(date).displayValue !== undefined && +
{getDayOfMonth(date)}
+ } +
+ {renderDay(year, month, day, true)} +
+
+ {!!badges?.length &&
{badges}
} +
+
; + }} + hideDateLabel + loading={insightsDataContext.loading} + /> + : + } + {(insightsDataContext.loading || (props.showLegend && !!insightsStateContext?.legend?.length)) && +
+ {(props.showLegend && !!insightsStateContext?.legend?.length) && +
+ {insightsStateContext.legend.map(state => { + const backgroundColor = resolveColor(layoutContext.colorScheme, state.backgroundColor) ?? 'var(--mdhui-border-color-2)'; + const borderColor = resolveColor(layoutContext.colorScheme, state.borderColor) ?? backgroundColor; + return
+
 
+
{state.label}
+
; + })} +
+ } + {insightsDataContext.loading && } +
+ } +
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsCalendar/index.ts b/src/components/presentational/InsightsCalendar/index.ts new file mode 100644 index 000000000..c808752c8 --- /dev/null +++ b/src/components/presentational/InsightsCalendar/index.ts @@ -0,0 +1 @@ +export { default } from './InsightsCalendar'; \ No newline at end of file diff --git a/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.css b/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.css new file mode 100644 index 000000000..726906deb --- /dev/null +++ b/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.css @@ -0,0 +1,7 @@ +.mdhui-insights-calendar-day-navigator { + --mdhui-insights-calendar-day-navigator-selected-day-bg-color: var(--mdhui-background-color-1); +} + +.mdhui-insights-calendar-day-navigator .mdhui-week-calendar .mdhui-week-calendar-day-selected { + background: var(--mdhui-insights-calendar-day-navigator-selected-day-bg-color); +} \ No newline at end of file diff --git a/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.stories.tsx b/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.stories.tsx new file mode 100644 index 000000000..54c22603d --- /dev/null +++ b/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.stories.tsx @@ -0,0 +1,271 @@ +import React, { CSSProperties, ReactNode } from 'react'; +import { CalendarDayState, CalendarDayStates, DateRangeCoordinator, DateRangeTitle, InsightsBadgeConfiguration, InsightsRenderingCoordinator, InsightsStateCoordinator, Layout } from '../index'; +import { StoryObj } from '@storybook/react'; +import { argTypesToHide } from '../../../../.storybook/helpers'; +import { fnvPredictableRandomNumber, getDayKey, InsightsData, InsightsDataPreviewState } from '../../../helpers'; +import { isAfter, isBefore, isToday, startOfToday } from 'date-fns'; +import { InsightsDataCoordinator } from '../../container'; +import { faBed, faBicycle, faBurn, faSwimmer, faWalking } from '@fortawesome/free-solid-svg-icons'; +import InsightsCalendarDayNavigator from './InsightsCalendarDayNavigator'; + +type InsightsCalendarDayNavigatorStoryArgs = React.ComponentProps & { + colorScheme: 'auto' | 'light' | 'dark'; + intervalType: 'Month' | 'Week'; + previewState: 'loading' | InsightsDataPreviewState; + withStates: boolean; + withDisplayValues: boolean; + withNotes: boolean; + withBadges: boolean; + multiStateStartAngle: number; + hasLegend: boolean; + customizeToday: boolean; + customizeFuture: boolean; + customizeNoData: boolean; + customizeStatesNote: boolean; + customStyling: boolean; +}; + +export default { + title: 'Presentational/InsightsCalendarDayNavigator', + component: InsightsCalendarDayNavigator, + parameters: { + layout: 'fullscreen' + }, + render: (args: InsightsCalendarDayNavigatorStoryArgs) => { + const customHighlightStyling: CSSProperties | undefined = args.customStyling ? { + boxShadow: 'inset -5px -5px 10px rgba(255, 255, 255, 0.3), inset 5px 5px 10px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.3)', + transition: 'border-radius: 0.5s ease, transform 0.2s ease, box-shadow 0.2s ease' + } : undefined; + + const states: CalendarDayState[] = [ + { + label: 'Sleep', + backgroundColor: '#664cda', + borderColor: '#664cda', + textColor: '#f4d6ff', + style: customHighlightStyling, + streakIdentifier: 'purple-state', + streakColor: '#664cda', + combineWhenSolo: true + }, + { + label: 'Activity', + backgroundColor: '#3c973c', + borderColor: '#3c973c', + textColor: '#bdead7', + style: customHighlightStyling, + streakIdentifier: 'green-state', + streakColor: '#3c973c', + combineWhenSolo: true + }, + { + label: 'Swimming', + backgroundColor: '#0877b8', + borderColor: '#0877b8', + textColor: '#abe0ff', + style: customHighlightStyling, + streakIdentifier: 'blue-state', + streakColor: '#0877b8', + combineWhenSolo: true + } + ]; + + const computePreviewStatesForDay = (date: Date, insightsData?: InsightsData): CalendarDayStates => { + const statesForDay: CalendarDayStates = []; + + const surveyAnswers = insightsData?.surveyAnswers ?? []; + const dayKey = getDayKey(date); + + if (surveyAnswers.some(surveyAnswer => parseInt(surveyAnswer.answers[0]) > 0)) { + const statesCount = (fnvPredictableRandomNumber(dayKey + '-states-count') % states.length) + 1; + let currentStateIndex = fnvPredictableRandomNumber(dayKey + '-states-start-index') % states.length; + while (statesForDay.length < statesCount) { + statesForDay.push(states[currentStateIndex]); + currentStateIndex = (currentStateIndex + 1) % states.length; + } + statesForDay.sort((a, b) => states.indexOf(a) - states.indexOf(b)); + } else if (isToday(date) && args.customizeToday) { + statesForDay.push({ borderColor: { lightMode: '#000', darkMode: '#fff' } }); + } else if (isAfter(date, new Date()) && args.customizeFuture) { + statesForDay.push({ borderColor: { lightMode: '#000', darkMode: '#fff' } }); + } else if (isBefore(date, startOfToday()) && args.customizeNoData) { + statesForDay.push({ borderColor: { lightMode: '#000', darkMode: '#fff' } }); + } + + if (args.withDisplayValues) { + statesForDay.displayValue = surveyAnswers[0]?.answers[0] ?? '-'; + } + + if (args.withNotes && fnvPredictableRandomNumber(dayKey + '-states-note-include') % 2 === 0) { + statesForDay.note = 'note'; + if (args.customizeStatesNote) { + statesForDay.noteBorderColor = { lightMode: '#000', darkMode: '#fff' }; + } + } + + return statesForDay; + }; + + const getPercentComplete = (insightsData: InsightsData, resultIdentifier: string): number => { + const surveyAnswer = insightsData.surveyAnswers.find(surveyAnswer => surveyAnswer.resultIdentifier === resultIdentifier); + return !!surveyAnswer && surveyAnswer.answers[0] !== '0' ? 100 : fnvPredictableRandomNumber(`${resultIdentifier}-${getDayKey(insightsData.date)}`) % 90; + }; + + const badgeConfigurations: InsightsBadgeConfiguration[] = [ + { + identifier: 'activity', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result1'), + customHighlightStyling: customHighlightStyling, + icon: faWalking, + iconColor: '#3c973c' + }, + { + identifier: 'sleep', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result2'), + customHighlightStyling: customHighlightStyling, + icon: faBed, + iconColor: '#664cda' + }, + { + identifier: 'swimming', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result3'), + customHighlightStyling: customHighlightStyling, + icon: faSwimmer, + iconColor: '#0877b8' + }, + { + identifier: 'cycling', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result4'), + customHighlightStyling: customHighlightStyling, + icon: faBicycle, + iconColor: '#976d1e' + }, + { + identifier: 'other', + shouldRender: () => false, + getPercentComplete: insightsData => getPercentComplete(insightsData, 'other'), + customHighlightStyling: customHighlightStyling, + icon: faBurn, + iconColor: '#d81442' + } + ]; + + const navigator = + + ; + + const wrapWithRenderingCoordinatorIfNecessary = (children: ReactNode): ReactNode => { + return args.withBadges + ? + : children; + }; + + return + + + + {wrapWithRenderingCoordinatorIfNecessary(navigator)} + + + + ; + } +}; + +export const Default: StoryObj = { + args: { + colorScheme: 'auto', + intervalType: 'Month', + previewState: 'loaded', + withStates: true, + withDisplayValues: false, + withNotes: false, + withBadges: false, + multiStateStartAngle: 270, + hasLegend: true, + showLegend: true, + customizeToday: false, + customizeFuture: false, + customizeNoData: false, + customizeStatesNote: false, + customStyling: false + }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + intervalType: { + name: 'interval type', + control: 'radio', + options: ['Month', 'Week'] + }, + previewState: { + name: 'state', + control: 'radio', + options: ['loading', 'loaded', 'reloading'] + }, + withStates: { + name: 'with states', + control: 'boolean' + }, + withDisplayValues: { + name: 'with display values', + control: 'boolean' + }, + withNotes: { + name: 'with notes', + control: 'boolean' + }, + withBadges: { + name: 'with badges', + control: 'boolean' + }, + multiStateStartAngle: { + name: 'multi-state start angle', + control: { + type: 'range', + min: 0, + max: 360, + step: 1 + } + }, + hasLegend: { + name: 'has legend', + control: 'boolean' + }, + showLegend: { + name: 'show legend', + control: 'boolean' + }, + customizeToday: { + name: 'customize today', + control: 'boolean' + }, + customizeFuture: { + name: 'customize future', + control: 'boolean' + }, + customizeNoData: { + name: 'customize no-data', + control: 'boolean' + }, + customizeStatesNote: { + name: 'customize states note', + control: 'boolean' + }, + customStyling: { + name: 'custom styling', + control: 'boolean' + }, + ...argTypesToHide(['legend', 'innerRef']) + } +}; diff --git a/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.tsx b/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.tsx new file mode 100644 index 000000000..41ec9bffc --- /dev/null +++ b/src/components/presentational/InsightsCalendarDayNavigator/InsightsCalendarDayNavigator.tsx @@ -0,0 +1,44 @@ +import React, { CSSProperties, ReactNode, Ref, useContext, useEffect, useState } from 'react'; +import { add, min, startOfToday } from 'date-fns'; +import { DateRangeContext, DateRangeCoordinator, InsightsCalendar, LayoutContext, Section } from '../../presentational'; +import { getDayKey, resolveColor } from '../../../helpers'; +import './InsightsCalendarDayNavigator.css'; + +export interface InsightsCalendarDayNavigatorProps { + showLegend?: boolean; + children?: ReactNode; + innerRef?: Ref; +} + +export default function InsightsCalendarDayNavigator(props: InsightsCalendarDayNavigatorProps) { + const layoutContext = useContext(LayoutContext); + const dateRangeContext = useContext(DateRangeContext); + + const [selectedDate, setSelectedDate] = useState(startOfToday()); + + useEffect(() => { + const today = startOfToday(); + + const intervalStart = dateRangeContext?.intervalStart ?? today; + if (dateRangeContext?.intervalType === '6Month' || dateRangeContext?.intervalType === 'Month') { + setSelectedDate(min([today, add(intervalStart, { months: 1, days: -1 })])); + } else if (dateRangeContext?.intervalType === 'Week') { + setSelectedDate(min([today, add(intervalStart, { weeks: 1, days: -1 })])); + } else { + setSelectedDate(today); + } + }, [dateRangeContext?.intervalStart]); + + const colorStyles = { + '--mdhui-insights-calendar-day-navigator-selected-day-bg-color': resolveColor(layoutContext.colorScheme, { lightMode: '#dedfe3', darkMode: '#5a596a' }) + } as CSSProperties; + + return
+
+ +
+ + {props.children} + +
; +} diff --git a/src/components/presentational/InsightsCalendarDayNavigator/index.ts b/src/components/presentational/InsightsCalendarDayNavigator/index.ts new file mode 100644 index 000000000..e3585046c --- /dev/null +++ b/src/components/presentational/InsightsCalendarDayNavigator/index.ts @@ -0,0 +1 @@ +export { default } from './InsightsCalendarDayNavigator'; \ No newline at end of file diff --git a/src/components/presentational/InsightsList/InsightsList.stories.tsx b/src/components/presentational/InsightsList/InsightsList.stories.tsx new file mode 100644 index 000000000..2b9f5143d --- /dev/null +++ b/src/components/presentational/InsightsList/InsightsList.stories.tsx @@ -0,0 +1,155 @@ +import React, { ComponentProps, CSSProperties } from 'react'; +import { DateRangeCoordinator, InsightsBadgeConfiguration, InsightsRenderingCoordinator, Layout } from '../index'; +import { StoryObj } from '@storybook/react'; +import { argTypesToHide } from '../../../../.storybook/helpers'; +import InsightsList from './InsightsList'; +import { faBed, faBicycle, faBurn, faSwimmer, faWalking } from '@fortawesome/free-solid-svg-icons'; +import { DailyDataType, fnvPredictableRandomNumber, getDayKey, InsightsData, InsightsDataPreviewState } from '../../../helpers'; +import { InsightsDataCoordinator } from '../../container'; + +type InsightListStoryArgs = ComponentProps & { + colorScheme: 'auto' | 'light' | 'dark'; + previewState: 'loading' | InsightsDataPreviewState; + canLog: boolean; + withBadges: boolean; + withDetails: boolean; + customFiltering: boolean; + customStyling: boolean; +}; + +export default { + title: 'Presentational/InsightsList', + component: InsightsList, + parameters: { + layout: 'fullscreen' + }, + render: (args: InsightListStoryArgs) => { + const customHighlightStyling: CSSProperties | undefined = args.customStyling ? { + boxShadow: 'inset -5px -5px 10px rgba(255, 255, 255, 0.3), inset 5px 5px 10px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.3)', + transition: 'border-radius: 0.5s ease, transform 0.2s ease, box-shadow 0.2s ease' + } : undefined; + + const getPercentComplete = (insightsData: InsightsData, resultIdentifier: string): number => { + const surveyAnswer = insightsData.surveyAnswers.find(surveyAnswer => surveyAnswer.resultIdentifier === resultIdentifier); + return !!surveyAnswer && surveyAnswer.answers[0] !== '0' ? 100 : fnvPredictableRandomNumber(`${resultIdentifier}-${getDayKey(insightsData.date)}`) % 90; + }; + + const badgeConfigurations: InsightsBadgeConfiguration[] = [ + { + identifier: 'activity', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result1'), + customHighlightStyling: customHighlightStyling, + icon: faWalking, + iconColor: '#3c973c' + }, + { + identifier: 'sleep', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result2'), + customHighlightStyling: customHighlightStyling, + icon: faBed, + iconColor: '#664cda' + }, + { + identifier: 'swimming', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result3'), + customHighlightStyling: customHighlightStyling, + icon: faSwimmer, + iconColor: '#0877b8' + }, + { + identifier: 'cycling', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result4'), + customHighlightStyling: customHighlightStyling, + icon: faBicycle, + iconColor: '#976d1e' + }, + { + identifier: 'other', + shouldRender: () => false, + getPercentComplete: insightsData => getPercentComplete(insightsData, 'other'), + customHighlightStyling: customHighlightStyling, + icon: faBurn, + iconColor: '#d81442' + } + ]; + + const surveyLogList = surveyLog.surveyAnswers.length > 0 : undefined} + />; + + return + + + {(args.withBadges || args.withDetails) && + { + return <> +
Details
+
+
Some details about the day.
+
+ ; + } : undefined} + children={surveyLogList} + />} + {!(args.withBadges || args.withDetails) && surveyLogList} +
+
+
; + } +}; + +export const Default: StoryObj = { + args: { + colorScheme: 'auto', + previewState: 'loaded', + canLog: true, + withBadges: true, + withDetails: true, + customFiltering: false, + customStyling: false + }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + previewState: { + name: 'state', + control: 'radio', + options: ['loading', 'loaded', 'reloading'], + mapping: { + 'loaded': 'loaded with today', + 'reloading': 'reloading with today' + } + }, + canLog: { + name: 'can log', + control: 'boolean' + }, + withBadges: { + name: 'with badges', + control: 'boolean' + }, + withDetails: { + name: 'with details', + control: 'boolean' + }, + customFiltering: { + name: 'custom filtering', + control: 'boolean' + }, + customStyling: { + name: 'custom styling', + control: 'boolean' + }, + ...argTypesToHide(['shouldRender', 'innerRef']) + } +}; diff --git a/src/components/presentational/InsightsList/InsightsList.tsx b/src/components/presentational/InsightsList/InsightsList.tsx new file mode 100644 index 000000000..eb12221eb --- /dev/null +++ b/src/components/presentational/InsightsList/InsightsList.tsx @@ -0,0 +1,39 @@ +import { Card, InsightsRenderer, InsightsRenderingContext, TextBlock } from '../index'; +import React, { Ref, useContext } from 'react'; +import { compareDesc, isAfter } from 'date-fns'; +import { InsightsData } from '../../../helpers'; +import { InsightsDataContext } from '../../container'; + +export interface InsightsListProps { + shouldRender?: (insightsData: InsightsData) => boolean; + innerRef?: Ref; +} + +export default function InsightsList(props: InsightsListProps) { + const insightsDataContext = useContext(InsightsDataContext); + const insightsRenderingContext = useContext(InsightsRenderingContext); + + if (!insightsDataContext) { + return Error: InsightsList must be used within an InsightsDataCoordinator.; + } + + const allInsightsData = Object.values(insightsDataContext.insightsData as Record); + + return
+ {allInsightsData + .filter(insightsData => props.shouldRender ? props.shouldRender(insightsData) : !isAfter(insightsData.date, new Date())) + .sort((insightsData1, insightsData2) => compareDesc(insightsData1.date, insightsData2.date)) + .map((insightsData, index) => { + return + + ; + })} +
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsList/index.ts b/src/components/presentational/InsightsList/index.ts new file mode 100644 index 000000000..b9718e2e3 --- /dev/null +++ b/src/components/presentational/InsightsList/index.ts @@ -0,0 +1 @@ +export { default } from './InsightsList'; \ No newline at end of file diff --git a/src/components/presentational/InsightsPreview/InsightsPreview.stories.tsx b/src/components/presentational/InsightsPreview/InsightsPreview.stories.tsx new file mode 100644 index 000000000..a432992ed --- /dev/null +++ b/src/components/presentational/InsightsPreview/InsightsPreview.stories.tsx @@ -0,0 +1,148 @@ +import React, { ComponentProps, CSSProperties } from 'react'; +import { DateRangeCoordinator, InsightsBadgeConfiguration, InsightsRenderingCoordinator, Layout } from '../index'; +import { StoryObj } from '@storybook/react'; +import { argTypesToHide } from '../../../../.storybook/helpers'; +import InsightsPreview from './InsightsPreview'; +import { faBed, faBicycle, faBurn, faSwimmer, faWalking } from '@fortawesome/free-solid-svg-icons'; +import { DailyDataType, fnvPredictableRandomNumber, getDayKey, InsightsData, InsightsDataPreviewState } from '../../../helpers'; +import { InsightsDataCoordinator } from '../../container'; + +type InsightsPreviewStoryArgs = ComponentProps & { + colorScheme: 'auto' | 'light' | 'dark'; + previewState: 'loading' | InsightsDataPreviewState; + canLog: boolean; + withBadges: boolean; + withDetails: boolean; + customStyling: boolean; +}; + +export default { + title: 'Presentational/InsightsPreview', + component: InsightsPreview, + parameters: { + layout: 'fullscreen' + }, + render: (args: InsightsPreviewStoryArgs) => { + const customHighlightStyling: CSSProperties | undefined = args.customStyling ? { + boxShadow: 'inset -5px -5px 10px rgba(255, 255, 255, 0.3), inset 5px 5px 10px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.3)', + transition: 'border-radius 0.5s, transform 0.2s ease, box-shadow 0.2s ease' + } : undefined; + + const getPercentComplete = (insightsData: InsightsData, resultIdentifier: string): number => { + const surveyAnswer = insightsData.surveyAnswers.find(surveyAnswer => surveyAnswer.resultIdentifier === resultIdentifier); + return !!surveyAnswer && surveyAnswer.answers[0] !== '0' ? 100 : fnvPredictableRandomNumber(`${resultIdentifier}-${getDayKey(insightsData.date)}`) % 90; + }; + + const badgeConfigurations: InsightsBadgeConfiguration[] = [ + { + identifier: 'activity', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result1'), + customHighlightStyling: customHighlightStyling, + icon: faWalking, + iconColor: '#3c973c' + }, + { + identifier: 'sleep', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result2'), + customHighlightStyling: customHighlightStyling, + icon: faBed, + iconColor: '#664cda' + }, + { + identifier: 'swimming', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result3'), + customHighlightStyling: customHighlightStyling, + icon: faSwimmer, + iconColor: '#0877b8' + }, + { + identifier: 'cycling', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'result4'), + customHighlightStyling: customHighlightStyling, + icon: faBicycle, + iconColor: '#976d1e' + }, + { + identifier: 'other', + shouldRender: () => false, + getPercentComplete: insightsData => getPercentComplete(insightsData, 'other'), + customHighlightStyling: customHighlightStyling, + icon: faBurn, + iconColor: '#d81442' + } + ]; + + const preview = ; + + return + + + {(args.withBadges || args.withDetails) && + { + return <> +
Details
+
+
Some details about the day.
+
+ ; + } : undefined} + children={preview} + />} + {!(args.withBadges || args.withDetails) && preview} +
+
+
; + } +}; + +export const Default: StoryObj = { + args: { + colorScheme: 'auto', + previewState: 'loaded', + canLog: true, + noLogTitle: '', + withBadges: true, + withDetails: true, + customStyling: false + }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + previewState: { + name: 'state', + control: 'radio', + options: ['loading', 'loaded', 'reloading', 'loaded with today', 'reloading with today'] + }, + canLog: { + name: 'can log', + control: 'boolean' + }, + noLogTitle: { + name: 'no log title', + control: 'text' + }, + withBadges: { + name: 'with badges', + control: 'boolean' + }, + withDetails: { + name: 'with details', + control: 'boolean' + }, + customStyling: { + name: 'custom styling', + control: 'boolean' + }, + ...argTypesToHide(['innerRef']) + } +}; diff --git a/src/components/presentational/InsightsPreview/InsightsPreview.tsx b/src/components/presentational/InsightsPreview/InsightsPreview.tsx new file mode 100644 index 000000000..f2191bd36 --- /dev/null +++ b/src/components/presentational/InsightsPreview/InsightsPreview.tsx @@ -0,0 +1,59 @@ +import React, { Ref, useContext, useMemo } from 'react'; +import { Action, Card, DateRangeContext, InsightsRenderer, InsightsRenderingContext, ShinyOverlay, TextBlock } from '../index'; +import { isToday, startOfToday } from 'date-fns'; +import { getDayKey } from '../../../helpers'; +import { InsightsDataContext } from '../../container'; +import { faPlus, faRefresh } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeSvgIcon } from 'react-fontawesome-svg-icon'; + +export interface InsightsPreviewProps { + noLogTitle?: string; + innerRef?: Ref; +} + +export default function InsightsPreview(props: InsightsPreviewProps) { + const dateRangeContext = useContext(DateRangeContext); + const insightsDataContext = useContext(InsightsDataContext); + const insightsRenderingContext = useContext(InsightsRenderingContext); + + if (!insightsDataContext) { + return Error: InsightsPreview must be used within an InsightsDataCoordinator.; + } + + const currentDate = useMemo( + () => dateRangeContext?.intervalStart ?? startOfToday(), + [dateRangeContext?.intervalStart, getDayKey(new Date())] + ); + + if (insightsDataContext.loading && insightsDataContext.firstTimeLoading) return null; + + const insightsData = insightsDataContext.insightsData[getDayKey(currentDate)]; + if (!insightsData) return null; + + const canLog = !!insightsDataContext.logSurveyName; + const hasLogged = canLog && insightsData.surveyAnswers.some(surveyAnswer => surveyAnswer.surveyName === insightsDataContext.logSurveyName); + + return
+ {canLog && !hasLogged + ? + + } + onClick={!insightsDataContext.loading ? () => insightsDataContext.enterSurveyLog(currentDate) : undefined} + /> + + : + + + } +
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsPreview/index.ts b/src/components/presentational/InsightsPreview/index.ts new file mode 100644 index 000000000..ccaed1743 --- /dev/null +++ b/src/components/presentational/InsightsPreview/index.ts @@ -0,0 +1 @@ +export { default } from './InsightsPreview'; \ No newline at end of file diff --git a/src/components/presentational/InsightsRenderer/InsightsRenderer.css b/src/components/presentational/InsightsRenderer/InsightsRenderer.css new file mode 100644 index 000000000..33fe49dbf --- /dev/null +++ b/src/components/presentational/InsightsRenderer/InsightsRenderer.css @@ -0,0 +1,49 @@ +.mdhui-insights-renderer { + padding: 16px; +} + +.mdhui-insights-renderer .mdhui-insights-renderer-log-button { + color: var(--mdhui-color-primary); + font-weight: bold; + text-decoration: underline; +} + +.mdhui-insights-renderer .mdhui-insights-renderer-badges { + margin-top: 16px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.mdhui-insights-renderer .mdhui-insights-renderer-badge { + width: 28px; + height: 28px; + border: 1px solid var(--mdhui-border-color-1); + border-radius: 50%; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2em; +} + +.mdhui-insights-renderer .mdhui-insights-renderer-badge-icon { + color: var(--mdhui-background-color-0); +} + +.mdhui-insights-renderer .mdhui-insights-renderer-badge-label { + color: var(--mdhui-text-color-2); + line-height: 40px; +} + +.mdhui-insights-renderer .mdhui-insights-renderer-badge-highlighted .mdhui-insights-renderer-badge-label { + color: var(--mdhui-text-color-1); +} + +.mdhui-insights-renderer .mdhui-insights-renderer-details { + background: var(--mdhui-background-color-1); + border-radius: var(--mdhui-card-border-radius); + padding: 16px; + margin-top: 16px; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsRenderer/InsightsRenderer.stories.tsx b/src/components/presentational/InsightsRenderer/InsightsRenderer.stories.tsx new file mode 100644 index 000000000..a98c625d6 --- /dev/null +++ b/src/components/presentational/InsightsRenderer/InsightsRenderer.stories.tsx @@ -0,0 +1,181 @@ +import React, { ComponentProps, CSSProperties } from 'react'; +import { Card, InsightsBadgeConfiguration, Layout } from '../../presentational'; +import { StoryObj } from '@storybook/react'; +import { argTypesToHide } from '../../../../.storybook/helpers'; +import InsightsRenderer from './InsightsRenderer'; +import { noop } from '../../../helpers/functions'; +import { faBed, faBicycle, faBurn, faSwimmer, faWalking } from '@fortawesome/free-solid-svg-icons'; +import { startOfToday } from 'date-fns'; +import { fnvPredictableRandomNumber, getDayKey, InsightsData } from '../../../helpers'; +import { SurveyAnswer } from '@careevolution/mydatahelps-js'; + +type InsightsRendererStoryArgs = ComponentProps & { + colorScheme: 'auto' | 'light' | 'dark'; + customTitle: boolean; + canLog: boolean; + hasLogged: boolean; + withBadges: boolean; + withDetails: boolean; + withEmptyDetails: boolean; + customBadgeIcons: boolean; + customBadgeIconColors: boolean; + customBadgeIconTextColors: boolean; + customStyling: boolean; +}; + +export default { + title: 'Presentational/InsightsRenderer', + component: InsightsRenderer, + parameters: { + layout: 'fullscreen' + }, + render: (args: InsightsRendererStoryArgs) => { + const customHighlightStyling: CSSProperties | undefined = args.customStyling ? { + boxShadow: 'inset -5px -5px 10px rgba(255, 255, 255, 0.3), inset 5px 5px 10px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.3)', + transition: 'border-radius 0.5s, transform 0.2s ease, box-shadow 0.2s ease' + } : undefined; + + const getPercentComplete = (insightsData: InsightsData, resultIdentifier: string): number => { + const surveyAnswer = insightsData.surveyAnswers.find(surveyAnswer => surveyAnswer.resultIdentifier === resultIdentifier); + return !!surveyAnswer && surveyAnswer.answers[0] !== '0' ? 100 : fnvPredictableRandomNumber(`${resultIdentifier}-${getDayKey(insightsData.date)}`) % 90; + }; + + const badgeConfigurations: InsightsBadgeConfiguration[] = [ + { + identifier: 'activity', + shouldRender: () => true, + getPercentComplete: insightsData => getPercentComplete(insightsData, 'activity'), + customHighlightStyling: args.customStyling ? customHighlightStyling : undefined, + icon: args.customBadgeIcons ? faWalking : undefined, + iconColor: args.customBadgeIconColors ? '#3c973c' : undefined + }, + { + identifier: 'sleep', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'sleep'), + customHighlightStyling: args.customStyling ? customHighlightStyling : undefined, + icon: args.customBadgeIcons ? faBed : undefined, + iconColor: args.customBadgeIconColors ? '#664cda' : undefined + }, + { + identifier: 'swimming', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'swimming'), + customHighlightStyling: args.customStyling ? customHighlightStyling : undefined, + icon: args.customBadgeIcons ? faSwimmer : undefined, + iconColor: args.customBadgeIconColors ? '#0877b8' : undefined + }, + { + identifier: 'cycling', + getPercentComplete: insightsData => getPercentComplete(insightsData, 'cycling'), + customHighlightStyling: args.customStyling ? customHighlightStyling : undefined, + icon: args.customBadgeIcons ? faBicycle : undefined, + iconColor: args.customBadgeIconColors ? '#976d1e' : undefined + }, + { + identifier: 'other', + shouldRender: () => false, + getPercentComplete: insightsData => getPercentComplete(insightsData, 'other'), + customHighlightStyling: args.customStyling ? customHighlightStyling : undefined, + icon: args.customBadgeIcons ? faBurn : undefined, + iconColor: args.customBadgeIconColors ? '#d81442' : undefined + } + ]; + + return + + { + return !args.withEmptyDetails ? <> +
Details
+
+
Some details about the day.
+
+ : undefined; + } : undefined} + insightsData={{ + date: startOfToday(), + surveyAnswers: [ + { surveyName: args.hasLogged ? 'Log Survey' : undefined, resultIdentifier: 'activity', answers: ['5'] }, + { resultIdentifier: 'sleep', answers: ['0'] }, + { resultIdentifier: 'swimming', answers: ['3'] } + ] as SurveyAnswer[], + dataPoints: [] + }} + loading={args.loading} + /> +
+
; + } +}; + +export const Default: StoryObj = { + args: { + colorScheme: 'auto', + customTitle: false, + canLog: true, + hasLogged: false, + withBadges: true, + withDetails: true, + withEmptyDetails: false, + customBadgeIcons: true, + customBadgeIconColors: true, + customBadgeIconTextColors: true, + customStyling: false, + loading: false + }, + argTypes: { + colorScheme: { + name: 'color scheme', + control: 'radio', + options: ['auto', 'light', 'dark'] + }, + customTitle: { + name: 'custom title', + control: 'boolean' + }, + canLog: { + name: 'can log', + control: 'boolean' + }, + hasLogged: { + name: 'has logged', + control: 'boolean' + }, + withBadges: { + name: 'with badges', + control: 'boolean' + }, + withDetails: { + name: 'with details', + control: 'boolean' + }, + withEmptyDetails: { + name: 'with empty details', + control: 'boolean' + }, + customBadgeIcons: { + name: 'custom icons', + control: 'boolean' + }, + customBadgeIconColors: { + name: 'custom icon colors', + control: 'boolean' + }, + customBadgeIconTextColors: { + name: 'custom icon text colors', + control: 'boolean' + }, + customStyling: { + name: 'custom styling', + control: 'boolean' + }, + loading: { + name: 'loading', + control: 'boolean' + }, + ...argTypesToHide(['title', 'logSurveyName', 'onEnterSurveyLog', 'badgeConfigurations', 'getDetails', 'insightsData', 'innerRef']) + } +}; \ No newline at end of file diff --git a/src/components/presentational/InsightsRenderer/InsightsRenderer.tsx b/src/components/presentational/InsightsRenderer/InsightsRenderer.tsx new file mode 100644 index 000000000..3b8a5d3af --- /dev/null +++ b/src/components/presentational/InsightsRenderer/InsightsRenderer.tsx @@ -0,0 +1,51 @@ +import React, { ReactNode, Ref } from 'react'; +import { InsightsBadge, InsightsBadgeConfiguration, LoadingIndicator, Title, UnstyledButton } from '../index'; +import { formatDateForLocale, InsightsData } from '../../../helpers'; +import './InsightsRenderer.css'; +import { FontAwesomeSvgIcon } from 'react-fontawesome-svg-icon'; +import { faEdit } from '@fortawesome/free-solid-svg-icons'; + +export interface InsightsRendererProps { + title?: string; + logSurveyName?: string; + onEnterSurveyLog?: (date: Date) => void; + badgeConfigurations?: InsightsBadgeConfiguration[]; + getDetails?: (insightsData: InsightsData) => ReactNode; + insightsData: InsightsData; + loading?: boolean; + innerRef?: Ref; +} + +export default function InsightsRenderer(props: InsightsRendererProps) { + + const canLog = !!props.logSurveyName && !!props.onEnterSurveyLog; + const hasLogged = canLog && props.insightsData.surveyAnswers.some(surveyAnswer => surveyAnswer.surveyName === props.logSurveyName); + + const title = props.title ?? formatDateForLocale(props.insightsData.date, 'PPP'); + const titleAccessory = props.loading + ? + : canLog + ? props.onEnterSurveyLog!(props.insightsData.date)}> + {hasLogged ? + : +
Add Log
+ } +
+ : undefined; + + const badges = props.insightsData + ? props.badgeConfigurations + ?.filter(configuration => !configuration.shouldRender || configuration.shouldRender(props.insightsData)) + .map((configuration, index) => { + return ; + }) + : undefined; + + const details = props.getDetails?.(props.insightsData); + + return
+ {title} + {!!badges?.length &&
{badges}
} + {details &&
{details}
} +
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsRenderer/index.ts b/src/components/presentational/InsightsRenderer/index.ts new file mode 100644 index 000000000..8fd32b970 --- /dev/null +++ b/src/components/presentational/InsightsRenderer/index.ts @@ -0,0 +1 @@ +export { default } from './InsightsRenderer'; \ No newline at end of file diff --git a/src/components/presentational/InsightsRenderingCoordinator/InsightsRenderingCoordinator.tsx b/src/components/presentational/InsightsRenderingCoordinator/InsightsRenderingCoordinator.tsx new file mode 100644 index 000000000..7e84771e9 --- /dev/null +++ b/src/components/presentational/InsightsRenderingCoordinator/InsightsRenderingCoordinator.tsx @@ -0,0 +1,29 @@ +import React, { createContext, ReactNode, Ref } from 'react'; +import { InsightsBadgeConfiguration } from '../index'; +import { InsightsData } from '../../../helpers'; + +export interface InsightsRenderingContext { + badgeConfigurations?: InsightsBadgeConfiguration[]; + getDetails?: (insightsData: InsightsData) => ReactNode; +} + +export const InsightsRenderingContext = createContext(null); + +export interface InsightsRenderingCoordinatorProps { + badgeConfigurations?: InsightsBadgeConfiguration[]; + getDetails?: (insightsData: InsightsData) => ReactNode; + children: ReactNode; + innerRef?: Ref; +} + +export default function InsightsRenderingCoordinator(props: InsightsRenderingCoordinatorProps) { + return
+ +
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsRenderingCoordinator/index.ts b/src/components/presentational/InsightsRenderingCoordinator/index.ts new file mode 100644 index 000000000..ca74a9ae3 --- /dev/null +++ b/src/components/presentational/InsightsRenderingCoordinator/index.ts @@ -0,0 +1 @@ +export { default, InsightsRenderingContext } from './InsightsRenderingCoordinator'; \ No newline at end of file diff --git a/src/components/presentational/InsightsStateCoordinator/InsightsStateCoordinator.tsx b/src/components/presentational/InsightsStateCoordinator/InsightsStateCoordinator.tsx new file mode 100644 index 000000000..d9c29703e --- /dev/null +++ b/src/components/presentational/InsightsStateCoordinator/InsightsStateCoordinator.tsx @@ -0,0 +1,32 @@ +import React, { createContext, ReactNode, Ref } from 'react'; +import { InsightsData } from '../../../helpers'; +import { CalendarDayState, CalendarDayStates } from '../index'; + +export interface InsightsStateContext { + computeStatesForDay: (date: Date, insightsData: InsightsData | undefined) => CalendarDayStates; + multiStateStartAngle?: number; + legend?: CalendarDayState[]; +} + +export const InsightsStateContext = createContext(null); + +export interface InsightStateCoordinatorProps { + computeStatesForDay: (date: Date, insightsData: InsightsData | undefined) => CalendarDayStates; + multiStateStartAngle?: number; + legend?: CalendarDayState[]; + children: ReactNode; + innerRef?: Ref; +} + +export default function InsightsStateCoordinator(props: InsightStateCoordinatorProps) { + return
+ +
; +} \ No newline at end of file diff --git a/src/components/presentational/InsightsStateCoordinator/index.ts b/src/components/presentational/InsightsStateCoordinator/index.ts new file mode 100644 index 000000000..6f7d13371 --- /dev/null +++ b/src/components/presentational/InsightsStateCoordinator/index.ts @@ -0,0 +1 @@ +export { default, InsightsStateContext } from './InsightsStateCoordinator'; \ No newline at end of file diff --git a/src/components/presentational/index.ts b/src/components/presentational/index.ts index 038b9f12c..8555cbef9 100644 --- a/src/components/presentational/index.ts +++ b/src/components/presentational/index.ts @@ -4,7 +4,7 @@ export { default as BasicBadge } from "./BasicBadge" export { default as BloodPressureReading, BloodPressureClassification } from "./BloodPressureReading" export { default as Button } from "./Button" export { default as Calendar } from "./Calendar" -export { default as CalendarDay, CalendarDayStateConfiguration } from "./CalendarDay" +export { default as CalendarDay, CalendarDayState, CalendarDayStates, CalendarDayStateConfiguration } from "./CalendarDay" export { default as Card } from "./Card" export { default as CardTitle } from "./CardTitle" export { default as Chat } from "./Chat" @@ -19,6 +19,14 @@ export { default as Face } from "./Face" export { default as GlucoseStats } from "./GlucoseStats" export { Grid, GridColumnProps, GridProps } from "./Grid" export { default as Histogram } from "./Histogram" +export { default as InsightsBadge, InsightsBadgeConfiguration } from "./InsightsBadge" +export { default as InsightsCalendar } from "./InsightsCalendar" +export { default as InsightsCalendarDayNavigator } from "./InsightsCalendarDayNavigator" +export { default as InsightsList } from "./InsightsList" +export { default as InsightsPreview } from "./InsightsPreview" +export { default as InsightsRenderer } from "./InsightsRenderer" +export { default as InsightsRenderingCoordinator, InsightsRenderingContext } from "./InsightsRenderingCoordinator"; +export { default as InsightsStateCoordinator, InsightsStateContext } from "./InsightsStateCoordinator" export { default as Layout, LayoutContext } from "./Layout" export { default as LoadingIndicator } from "./LoadingIndicator" export { default as MealAnalysis } from "./MealAnalysis" diff --git a/src/helpers/daily-data-types/combined.tsx b/src/helpers/daily-data-types/combined.tsx index a519940ac..012d7be95 100644 --- a/src/helpers/daily-data-types/combined.tsx +++ b/src/helpers/daily-data-types/combined.tsx @@ -1,6 +1,6 @@ import { FontAwesomeSvgIcon } from "react-fontawesome-svg-icon"; import { DailyDataType, DailyDataTypeDefinition } from "../daily-data-types"; -import { faBed, faFireFlameCurved, faHeartbeat, faHourglassHalf, faShoePrints } from "@fortawesome/free-solid-svg-icons"; +import { faBed, faBrain, faFireFlameCurved, faHeartbeat, faHourglassHalf, faShoePrints } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { defaultFormatter, heartRateFormatter, minutesFormatter, minutesToHoursYAxisConverter } from "./formatters"; import { combinedActiveCaloriesBurnedDataProvider, combinedMindfulMinutesDataProvider, combinedRestingHeartRateDataProvider, combinedSleepDataProvider, combinedStepsDataProvider, combinedTherapyMinutesDataProvider } from "../daily-data-providers"; @@ -108,7 +108,7 @@ const combinedTypeDefinitions: DailyDataTypeDefinition[] = [ dataProvider: combinedMindfulMinutesDataProvider, availabilityCheck: combinedAvailabilityCheck(MINDFUL_MINUTES_SOURCES), labelKey: "mindful-minutes", - icon: , + icon: , formatter: value => formatNumberForLocale(value), previewDataRange: [0, 120] }, diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 533a541bb..33b2ef126 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -6,7 +6,7 @@ export * from './BasicPointsAndBadges'; export { default as getDayKey } from './get-day-key'; export * from './get-interval-start'; export * from './colors'; -export * from './blood-pressure-data-providers' +export * from './blood-pressure-data-providers'; export * from './query-all-survey-answers'; export * from './query-latest-survey-answers-by-date'; export * from './Initialization'; @@ -28,3 +28,4 @@ export * from './image'; export * from './news-feed'; export * from './regex'; export * from './document-library'; +export * from './insights'; \ No newline at end of file diff --git a/src/helpers/insights/functions.ts b/src/helpers/insights/functions.ts new file mode 100644 index 000000000..f0c6a8608 --- /dev/null +++ b/src/helpers/insights/functions.ts @@ -0,0 +1,75 @@ +import { add, isToday, min, parseISO, startOfToday } from 'date-fns'; +import { fnvPredictableRandomNumber, getDayKey, queryDailyData } from '../index'; +import { SurveyAnswer } from '@careevolution/mydatahelps-js'; +import { InsightsData, InsightsDataPoint } from './types'; +import { generateSurveyAnswers } from '../survey-answer'; +import queryLatestSurveyAnswersByDate from '../query-latest-survey-answers-by-date'; + +export type InsightsDataPreviewState = 'loaded' | 'reloading' | 'loaded with today' | 'reloading with today'; + +export async function loadInsightsData(surveyNames: string[], dailyDataTypes: string[], startDate: Date, endDate: Date, previewState: InsightsDataPreviewState | undefined): Promise>> { + const [surveyAnswers, dataPoints] = await Promise.all([ + loadSurveyAnswers(surveyNames, startDate, endDate, previewState), + loadDataPoints(dailyDataTypes, startDate, endDate, !!previewState) + ]); + + const insightsDataByDate: Partial> = {}; + + let currentDate = startDate; + while (currentDate < endDate) { + const dayKey = getDayKey(currentDate); + insightsDataByDate[dayKey] = { date: parseISO(dayKey), surveyAnswers: surveyAnswers[dayKey] ?? [], dataPoints: dataPoints[dayKey] ?? [] }; + currentDate = add(currentDate, { days: 1 }); + } + + return insightsDataByDate; +} + +async function loadSurveyAnswers(surveyNames: string[], startDate: Date, endDate: Date, previewState: InsightsDataPreviewState | undefined): Promise>> { + if (surveyNames.length === 0) return {}; + return previewState + ? generatePreviewSurveyAnswers(startDate, endDate, surveyNames, previewState.endsWith('with today')) + : queryLatestSurveyAnswersByDate(startDate, endDate, surveyNames, undefined, undefined, true); +} + +function generatePreviewSurveyAnswers(startDate: Date, endDate: Date, surveyNames: string[], ensureTodayHasLog: boolean): Partial> { + const resultIdentifiers = Array.from({ length: 10 }, (_, index) => `result${index + 1}`); + const surveyAnswers = surveyNames.reduce((surveyAnswers, surveyName) => { + const clampedEndDate = min([add(startOfToday(), { days: ensureTodayHasLog ? 1 : 0 }), endDate]); + generateSurveyAnswers(startDate, clampedEndDate, resultIdentifiers, 0, 5, { days: 1 }).flat().forEach(surveyAnswer => { + surveyAnswers.push({ ...surveyAnswer, surveyName }); + }); + return surveyAnswers; + }, [] as SurveyAnswer[]); + return surveyAnswers.reduce((sparseSurveyAnswers, surveyAnswer) => { + const dayKey = getDayKey(surveyAnswer.date); + const forceAdd = ensureTodayHasLog && isToday(surveyAnswer.date); + const shouldIncludeDay = fnvPredictableRandomNumber(dayKey) % 3 !== 0; + const shouldIncludeSurveyOnDay = fnvPredictableRandomNumber(dayKey + '_' + surveyAnswer.surveyName) % 3 !== 0; + const shouldIncludeResultFromSurveyOnDay = fnvPredictableRandomNumber(dayKey + '_' + surveyAnswer.surveyName + '_' + surveyAnswer.resultIdentifier) % 3 !== 0; + if (forceAdd || (shouldIncludeDay && shouldIncludeSurveyOnDay && shouldIncludeResultFromSurveyOnDay)) { + sparseSurveyAnswers[dayKey] ??= []; + sparseSurveyAnswers[dayKey].push({ + ...surveyAnswer, + answers: forceAdd && surveyAnswer.answers[0] === '0' ? ['1'] : surveyAnswer.answers + } as SurveyAnswer); + } + return sparseSurveyAnswers; + }, {} as Record); +} + +async function loadDataPoints(dailyDataTypes: string[], startDate: Date, endDate: Date, preview: boolean): Promise>> { + if (dailyDataTypes.length === 0) return {}; + + const results = await Promise.all(dailyDataTypes.map(dailyDataType => queryDailyData(dailyDataType, startDate, endDate, preview))); + return results.reduce((dataPointsByDate, result, index) => { + Object.entries(result).forEach(([dayKey, value]) => { + dataPointsByDate[dayKey] ??= []; + dataPointsByDate[dayKey].push({ + type: dailyDataTypes[index], + value: value + }); + }); + return dataPointsByDate; + }, {} as Record); +} \ No newline at end of file diff --git a/src/helpers/insights/index.ts b/src/helpers/insights/index.ts new file mode 100644 index 000000000..a8bfa0963 --- /dev/null +++ b/src/helpers/insights/index.ts @@ -0,0 +1,2 @@ +export * from './functions'; +export * from './types'; diff --git a/src/helpers/insights/types.ts b/src/helpers/insights/types.ts new file mode 100644 index 000000000..1325b8c8c --- /dev/null +++ b/src/helpers/insights/types.ts @@ -0,0 +1,12 @@ +import { SurveyAnswer } from '@careevolution/mydatahelps-js'; + +export interface InsightsDataPoint { + type: string; + value: number; +} + +export interface InsightsData { + date: Date; + surveyAnswers: SurveyAnswer[]; + dataPoints: InsightsDataPoint[]; +} diff --git a/src/helpers/survey-answer/index.ts b/src/helpers/survey-answer/index.ts new file mode 100644 index 000000000..40ac25f08 --- /dev/null +++ b/src/helpers/survey-answer/index.ts @@ -0,0 +1 @@ +export * from './sample-data'; diff --git a/src/helpers/survey-answer/sample-data.ts b/src/helpers/survey-answer/sample-data.ts new file mode 100644 index 000000000..4d6a19cb0 --- /dev/null +++ b/src/helpers/survey-answer/sample-data.ts @@ -0,0 +1,24 @@ +import { add, Duration, formatISO } from 'date-fns'; +import { SurveyAnswer } from '@careevolution/mydatahelps-js'; +import { fnvPredictableRandomNumber } from '../predictableRandomNumber'; +import { getDayKey } from '../index'; + +export function generateSurveyAnswers(startDate: Date, endDate: Date, resultIdentifiers: string[], minValue: number, maxValue: number, dataCadence: Duration): SurveyAnswer[][] { + const data = Array.from({ length: resultIdentifiers.length }, (): SurveyAnswer[] => []); + + let currentDate = startDate; + while (currentDate < endDate) { + for (let i = 0; i < data.length; i++) { + const answer = fnvPredictableRandomNumber(getDayKey(currentDate) + resultIdentifiers[i]); + data[i].push({ + stepIdentifier: resultIdentifiers[i], + resultIdentifier: resultIdentifiers[i], + date: formatISO(add(currentDate, { hours: 12 })), + answers: [(answer % (maxValue - minValue) + minValue).toString()] + } as SurveyAnswer); + } + currentDate = add(currentDate, dataCadence); + } + + return data; +} \ No newline at end of file From 307699531807b7d49152335084d5b178ed1331c8 Mon Sep 17 00:00:00 2001 From: Garrett Reinard Date: Wed, 4 Mar 2026 14:14:43 -0500 Subject: [PATCH 2/4] 3.12.1-Insights.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed2ec53bd..f635fbf92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@careevolution/mydatahelps-ui", - "version": "3.12.0", + "version": "3.12.1-Insights.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@careevolution/mydatahelps-ui", - "version": "3.12.0", + "version": "3.12.1-Insights.0", "license": "MIT", "dependencies": { "@emotion/react": "11.11.3", diff --git a/package.json b/package.json index 1fb248d76..b7c595743 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@careevolution/mydatahelps-ui", - "version": "3.12.0", + "version": "3.12.1-Insights.0", "description": "MyDataHelps UI Library", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", From 4e389a2a7bcb7b152d18b44285b7ff2e9ccf3c63 Mon Sep 17 00:00:00 2001 From: Garrett Reinard Date: Fri, 6 Mar 2026 12:05:14 -0500 Subject: [PATCH 3/4] Merge fix --- .../container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts b/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts index 773c10588..368894778 100644 --- a/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts +++ b/src/components/container/SurveyAnswerChart/SurveyAnswerChart.previewdata.ts @@ -45,3 +45,4 @@ export async function getPreviewDataFromProvider(previewState: SurveyAnswerChart } return surveyAnswers; +} From 197f7cb57e418d69b7655a42fd9fc44caa20e879 Mon Sep 17 00:00:00 2001 From: Garrett Reinard Date: Tue, 24 Mar 2026 11:34:08 -0400 Subject: [PATCH 4/4] 3.14.1-Insights.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ded7f164..cf2196e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@careevolution/mydatahelps-ui", - "version": "3.14.0", + "version": "3.14.1-Insights.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@careevolution/mydatahelps-ui", - "version": "3.14.0", + "version": "3.14.1-Insights.0", "license": "MIT", "dependencies": { "@emotion/react": "11.11.3", diff --git a/package.json b/package.json index 8d8872db7..0b3abd9b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@careevolution/mydatahelps-ui", - "version": "3.14.0", + "version": "3.14.1-Insights.0", "description": "MyDataHelps UI Library", "main": "dist/cjs/index.js", "module": "dist/esm/index.js",