diff --git a/wearables-dashboard/bun.lockb b/wearables-dashboard/bun.lockb index 867d462..46897f2 100644 Binary files a/wearables-dashboard/bun.lockb and b/wearables-dashboard/bun.lockb differ diff --git a/wearables-dashboard/index.html b/wearables-dashboard/index.html index 341cd59..f3304aa 100644 --- a/wearables-dashboard/index.html +++ b/wearables-dashboard/index.html @@ -8,7 +8,7 @@ rel="stylesheet" /> - Flex showcase + Healthosia - Luzmo Flex SDK sample application
diff --git a/wearables-dashboard/package-lock.json b/wearables-dashboard/package-lock.json index 5a6b459..261fea2 100644 --- a/wearables-dashboard/package-lock.json +++ b/wearables-dashboard/package-lock.json @@ -13,6 +13,7 @@ "@luzmo/react-embed": "next", "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.172", + "axios": "^1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", "userflow.js": "^2.12.1" @@ -2060,6 +2061,23 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -2195,6 +2213,18 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2263,6 +2293,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2768,6 +2807,40 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3221,6 +3294,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3472,6 +3566,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/wearables-dashboard/package.json b/wearables-dashboard/package.json index 458a146..af280d3 100644 --- a/wearables-dashboard/package.json +++ b/wearables-dashboard/package.json @@ -17,6 +17,7 @@ "@luzmo/react-embed": "next", "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.172", + "axios": "^1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", "userflow.js": "^2.12.1" diff --git a/wearables-dashboard/src/App.tsx b/wearables-dashboard/src/App.tsx index 65010b4..b326b8e 100644 --- a/wearables-dashboard/src/App.tsx +++ b/wearables-dashboard/src/App.tsx @@ -13,6 +13,27 @@ const theme = createTheme({ typography: { fontFamily: "Lato", }, + palette: { + primary: { + main: "#6440EB", + }, + secondary: { + main: "#fefefe", + }, + // background color for the entire app + background: { + default: "#f4f5fd", + }, + }, + breakpoints: { + values: { + xs: 0, + sm: 900, + md: 1200, + lg: 1536, + xl: 1920, + }, + }, }); import "./App.css"; @@ -33,23 +54,23 @@ function App() { <> - - - {selectedTab === "analytics" && } - {selectedTab === "settings" && ( - - - - - - )} - + + + {selectedTab === "analytics" && } + {selectedTab === "settings" && ( + + + + + + )} + + diff --git a/wearables-dashboard/src/components/Analytics/Analytics.tsx b/wearables-dashboard/src/components/Analytics/Analytics.tsx index cc355df..6835db5 100644 --- a/wearables-dashboard/src/components/Analytics/Analytics.tsx +++ b/wearables-dashboard/src/components/Analytics/Analytics.tsx @@ -1,4 +1,5 @@ -import { Grid, Box, Tabs, Tab } from "@mui/material"; +import { useState, SyntheticEvent, useEffect } from "react"; +import { Grid, Box, Tabs, Tab, Typography, Stack, Paper } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { @@ -7,6 +8,8 @@ import { VizItemOptions, } from "@luzmo/react-embed"; +import { useUser } from "../../services/UserService"; + import { SleepTimeline } from "../SleepTimeline/SleepTimeline"; import { heattableOptions, heatTableSlots } from "./sleep/heatTableConsts"; @@ -28,19 +31,27 @@ import { areaChartOptions as stepsAreaChartOptions, areaChartSlots as stepsAreaChartSlots, } from "./steps/areaChartConsts"; +import { + circleGaugeOptions, + circleGaugeSlots, +} from "./sleep/circleGaugeConsts"; import "./Analytics.css"; -import { useState, SyntheticEvent } from "react"; import { - numberWidgetOptions, - numberWidgetSlots, -} from "./sleep/numberWidgetConsts"; -import { useUser } from "../../services/UserService"; + getSleepScoreLastNight, + getStepsPerDay, +} from "../../services/DataService"; const LUZMO_VIZ_ITEM_SMALL_STYLE = { width: "100%", height: "10rem" }; const LUZMO_VIZ_ITEM_MEDIUM_STYLE = { width: "100%", height: "20rem" }; const LUZMO_VIZ_ITEM_LARGE_STYLE = { width: "100%", height: "40rem" }; +const TEXTUAL_INSIGHTS_STYLE = { + textAlign: "center", + paddingY: "0.5rem", + paddingX: "0.2rem", +}; + interface TabPanelProps { children?: React.ReactNode; index: number; @@ -64,11 +75,67 @@ function CustomTabPanel(props: TabPanelProps) { } export default function Analytics() { + const { user } = useUser(); + const theme = useTheme(); const [value, setValue] = useState(0); + const [sleepScoreYesterday, setSleepScoreYesterday] = useState< + number | undefined + >(undefined); + const [sleepScoreDayBefore, setSleepScoreDayBefore] = useState< + number | undefined + >(undefined); + const [averageKcalBurned, setAverageKcalBurned] = useState< + number | undefined + >(undefined); + const [stepsToday, setStepsToday] = useState(undefined); - const theme = useTheme(); + useEffect(() => { + if (!user) return; - const { user } = useUser(); + setSleepScoreYesterday(undefined); + + // Fetch sleep score from yesterday + getSleepScoreLastNight(user.id).then((sleepScores) => { + setSleepScoreYesterday( + sleepScores.lastNightSleepScore > -1 + ? Math.round(sleepScores.lastNightSleepScore * 100) + : undefined + ); + + setSleepScoreDayBefore( + sleepScores.previousNightSleepScore > -1 + ? Math.round(sleepScores.previousNightSleepScore * 100) + : undefined + ); + }); + }, [user]); + + useEffect(() => { + if (!user) return; + + setStepsToday(undefined); + + // Fetch sleep score from yesterday + getStepsPerDay(user.id).then((stepsPerDay) => { + // Check if the first entry is from today & set the steps + setStepsToday( + stepsPerDay[0].date === + new Date(new Date().toISOString().split("T")[0]).toISOString() + ? stepsPerDay[0].steps + : 0 + ); + + // Calculate the average steps per day to estimate the kcal burned + const totalSteps = stepsPerDay.reduce( + (acc, current) => acc + current.steps, + 0 + ); + + setAverageKcalBurned( + Math.round((totalSteps / stepsPerDay.length) * 0.04) + ); + }); + }, [user]); const stepDevice = user ? user.devices.find((d) => d.type === "steps") @@ -81,60 +148,67 @@ export default function Analytics() { const hasSleepData = sleepDevice !== undefined; let stepDatetimeLevel = "day"; - let sleepDatetimeLevel = "day"; if (stepDevice) { stepDatetimeLevel = stepDevice.intervalInSeconds > 1800 - ? "day" + ? "week" : stepDevice.intervalInSeconds > 60 - ? "hour" - : "minute"; - } - if (sleepDevice) { - sleepDatetimeLevel = - sleepDevice.intervalInSeconds > 1800 ? "day" - : sleepDevice.intervalInSeconds > 60 - ? "hour" - : "minute"; + : "hour"; } + const hourOfDay = new Date().getHours(); + const timeOfDay = + hourOfDay > 6 && hourOfDay < 12 + ? "morning" + : hourOfDay >= 12 && hourOfDay < 18 + ? "afternoon" + : hourOfDay < 22 + ? "evening" + : "night"; + + const [defaultTab, setDefaultTab] = useState( + hasSleepData ? (hourOfDay < 15 ? 1 : 0) : 0 + ); + // Create the necessary filters to filter to the user's data /* * NOTE: * This is a mock implementation, which performs the filtering on the client side. * In a real-world scenario, the filtering must be done server side in the Authorization request for security purposes! */ - const filtersToApply: FilterGroup[] = [ - { - condition: "and", - filters: [ - // Steps filter on Patient ID column + const filtersToApply: FilterGroup[] = user + ? [ { - expression: "? = ?", - parameters: [ + condition: "and", + filters: [ + // Steps filter on Patient ID column { - column_id: "572740ed-aeed-4aeb-b318-d059e8be35a6", - dataset_id: "1c759996-74fd-438d-bcba-eb58838a5b03", + expression: "? = ?", + parameters: [ + { + column_id: "572740ed-aeed-4aeb-b318-d059e8be35a6", + dataset_id: "1c759996-74fd-438d-bcba-eb58838a5b03", + }, + user.id, + ], }, - user.id, - ], - }, - // Sleep filter on Patient ID column - { - expression: "? = ?", - parameters: [ + // Sleep filter on Patient ID column { - column_id: "695b3aee-6772-4aa6-bb50-a67a88bfc26b", - dataset_id: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + expression: "? = ?", + parameters: [ + { + column_id: "695b3aee-6772-4aa6-bb50-a67a88bfc26b", + dataset_id: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + }, + user.id, + ], }, - user.id, ], }, - ], - }, - ]; + ] + : []; const handleSleepTabChange = (_: SyntheticEvent, newValue: number) => { setValue(newValue); @@ -145,7 +219,7 @@ export default function Analytics() { ...widgetOptions, theme: { font: { - fontFamily: "Lato", + fontFamily: theme.typography.fontFamily, }, mainColor: theme.palette.primary.main, }, @@ -166,68 +240,158 @@ export default function Analytics() { <> {/* Create Luzmo number evolution widget with steps info */} {hasStepsData && ( - - - - )} - {hasSleepData && ( - <> - {/* Create Luzmo number evolution widget with sleep info */} - + + + + Good {timeOfDay} {user?.name}! + + + {hasSleepData && sleepScoreYesterday && sleepScoreDayBefore && ( + + Your sleep score was {sleepScoreYesterday}%{" "} + last night, which is{" "} + {sleepScoreDayBefore > sleepScoreYesterday + ? "less than" + : sleepScoreDayBefore < sleepScoreYesterday + ? "more than" + : "equal to"}{" "} + the night before ({sleepScoreDayBefore}%). + + )} + {hasSleepData && !sleepScoreYesterday && ( + + Loading sleep insights... + + )} + {hasStepsData && stepsToday && ( + + Based on your walking history, you should have used about{" "} + {averageKcalBurned} kcal by the end of today + (currently at {stepsToday} steps today). + + )} + {hasStepsData && !stepsToday && ( + + Loading step insights... + + )} + + + + + + )} + {hasSleepData && ( + <> + {/* Create Luzmo circular gauge widget with sleep info */} + + + + {/* Create sleep timeline */} - - + + + + {/* Create Luzmo sleep insights with custom tabs element */} + - - - - - - - - - - - - + + {defaultTab === 0 && ( + + )} + {defaultTab === 1 && ( + <> + + + + + + + + + + + + + + + + + + )} + )} @@ -235,37 +399,44 @@ export default function Analytics() { {hasStepsData && ( <> - + + + - - - - {/* Create Luzmo line chart with steps info */} - + + + + {!hasSleepData && ( + + + + + + )} )} diff --git a/wearables-dashboard/src/components/Analytics/sleep/areaChartConsts.ts b/wearables-dashboard/src/components/Analytics/sleep/areaChartConsts.ts index 88b2063..cd2ed08 100644 --- a/wearables-dashboard/src/components/Analytics/sleep/areaChartConsts.ts +++ b/wearables-dashboard/src/components/Analytics/sleep/areaChartConsts.ts @@ -2,7 +2,7 @@ import { VizItemOptions } from "@luzmo/react-embed"; import { Slots } from "@luzmo/react-embed"; export const areaChartOptions: VizItemOptions = { - interpolation: "step-after", + interpolation: "step-before", interactivity: { brush: true, urlConfig: { diff --git a/wearables-dashboard/src/components/Analytics/sleep/circleGaugeConsts.ts b/wearables-dashboard/src/components/Analytics/sleep/circleGaugeConsts.ts new file mode 100644 index 0000000..aa4b549 --- /dev/null +++ b/wearables-dashboard/src/components/Analytics/sleep/circleGaugeConsts.ts @@ -0,0 +1,28 @@ +import { VizItemOptions } from "@luzmo/react-embed"; +import { Slots } from "@luzmo/react-embed"; + +export const circleGaugeOptions: VizItemOptions = { + circle: { + degrees: 360, + flip: false, + }, +}; + +export const circleGaugeSlots: Slots = [ + { + name: "measure", + content: [ + { + column: "d0ff1497-fa36-4dce-9480-a03e2f65c5ee", + set: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + label: { + en: "Average sleep score", + }, + type: "numeric", + + format: ",.0a%", + aggregationFunc: "average", + }, + ], + }, +]; diff --git a/wearables-dashboard/src/components/Analytics/sleep/heatTableConsts.ts b/wearables-dashboard/src/components/Analytics/sleep/heatTableConsts.ts index bf0c8e8..e65cd27 100644 --- a/wearables-dashboard/src/components/Analytics/sleep/heatTableConsts.ts +++ b/wearables-dashboard/src/components/Analytics/sleep/heatTableConsts.ts @@ -92,7 +92,7 @@ export const heattableOptions: VizItemOptions = { spacing: null, }, interactivity: { - select: true, + select: false, urlConfig: { target: "_blank", url: null, diff --git a/wearables-dashboard/src/components/Analytics/steps/heightNumberWidgetConsts.ts b/wearables-dashboard/src/components/Analytics/steps/heightNumberWidgetConsts.ts index 2208198..0ed34c6 100644 --- a/wearables-dashboard/src/components/Analytics/steps/heightNumberWidgetConsts.ts +++ b/wearables-dashboard/src/components/Analytics/steps/heightNumberWidgetConsts.ts @@ -56,7 +56,7 @@ export function getHeightNumberWidgetSlots(datetimeLevel: string): Slot[] { column: "24bf0c70-5464-4131-81af-9281e6d3c4f0", set: "1c759996-74fd-438d-bcba-eb58838a5b03", label: { - en: `Height meters per ${datetimeLevel}`, + en: `Height meters climbed per ${datetimeLevel}`, }, type: "numeric", format: ",.3as", diff --git a/wearables-dashboard/src/components/Analytics/steps/stepsNumberWidgetConsts.ts b/wearables-dashboard/src/components/Analytics/steps/stepsNumberWidgetConsts.ts index d4ab0b0..ff4d5bb 100644 --- a/wearables-dashboard/src/components/Analytics/steps/stepsNumberWidgetConsts.ts +++ b/wearables-dashboard/src/components/Analytics/steps/stepsNumberWidgetConsts.ts @@ -12,7 +12,7 @@ export function getStepNumberWidgetSlots(datetimeLevel: string): Slot[] { column: "27978dfd-7218-40a4-88cf-59de2df91935", set: "1c759996-74fd-438d-bcba-eb58838a5b03", label: { - en: `Number of steps by ${datetimeLevel}`, + en: `Number of steps per ${datetimeLevel}`, }, type: "numeric", format: ",.3as", @@ -81,7 +81,7 @@ export const stepNumberWidgetSlots: Slot[] = [ column: "27978dfd-7218-40a4-88cf-59de2df91935", set: "1c759996-74fd-438d-bcba-eb58838a5b03", label: { - en: "Number of steps by day", + en: "Total steps per day", }, type: "numeric", format: ",.3as", diff --git a/wearables-dashboard/src/components/Settings/UserDevices.tsx b/wearables-dashboard/src/components/Settings/UserDevices.tsx index c9b930f..7f021f4 100644 --- a/wearables-dashboard/src/components/Settings/UserDevices.tsx +++ b/wearables-dashboard/src/components/Settings/UserDevices.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Card, CardContent, @@ -6,9 +5,12 @@ import { List, ListItem, ListItemText, - ListSubheader, + ListItemIcon, Divider, } from "@mui/material"; +import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk"; +import NightsStayIcon from "@mui/icons-material/NightsStay"; +import DeviceUnknownIcon from "@mui/icons-material/DeviceUnknown"; import { User } from "../../types/types"; @@ -38,18 +40,19 @@ export const UserDevices = ({ user }: { user: User }) => { - {user.name}'s Devices + Your devices - - Devices - - } - > + {user.devices.map((device, index) => ( + + {device.type === "sleep" && } + {device.type === "steps" && } + {!["steps", "sleep"].includes(device.type) && ( + + )} + void; +}; + +export function SleepTimeline({ + selected, + onSelectedChange, +}: SLEEP_TIMELINE_PROPS) { return ( @@ -41,11 +41,20 @@ export function SleepTimeline() { 9:30 am - - + + onSelectedChange(0)} + sx={{ cursor: selected === 0 ? "default" : "pointer" }} + > - + @@ -64,7 +73,7 @@ export function SleepTimeline() { - + @@ -78,11 +87,20 @@ export function SleepTimeline() { - - + + onSelectedChange(1)} + sx={{ cursor: selected === 1 ? "default" : "pointer" }} + > - + @@ -94,7 +112,7 @@ export function SleepTimeline() { - + diff --git a/wearables-dashboard/src/components/Topnav/Topnav.css b/wearables-dashboard/src/components/Topnav/Topnav.css index e69de29..f1f88ec 100644 --- a/wearables-dashboard/src/components/Topnav/Topnav.css +++ b/wearables-dashboard/src/components/Topnav/Topnav.css @@ -0,0 +1,3 @@ +.topnav { + padding-right: 0 !important; +} diff --git a/wearables-dashboard/src/components/Topnav/Topnav.tsx b/wearables-dashboard/src/components/Topnav/Topnav.tsx index 1850e00..cff4ab1 100644 --- a/wearables-dashboard/src/components/Topnav/Topnav.tsx +++ b/wearables-dashboard/src/components/Topnav/Topnav.tsx @@ -10,23 +10,25 @@ import { Menu, MenuItem, Tooltip, + Typography, } from "@mui/material"; import { AccountCircle } from "@mui/icons-material"; +import DirectionsRunIcon from "@mui/icons-material/DirectionsRun"; +import AnalyticsIcon from "@mui/icons-material/Analytics"; +import DevicesIcon from "@mui/icons-material/Devices"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import SettingsIcon from "@mui/icons-material/Settings"; import "./Topnav.css"; import { mockUsers, useUser } from "../../services/UserService"; +import { User } from "../../types/types"; +import { useTheme } from "@mui/material/styles"; const TAB_STYLING = { - "&:hover": { - color: "#fff", - opacity: 0.6, - }, - "&.Mui-selected": { - color: "#fff", - }, "&.Mui-focusVisible": { backgroundColor: "rgba(100, 95, 228, 0.32)", }, + minWidth: 60, }; export default function Topnav({ @@ -37,11 +39,10 @@ export default function Topnav({ onTabChange: (tab: string) => void; }) { const { user, switchUser } = useUser(); - // const [value, setValue] = useState(selectedTab); + const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const handleChange = (_: SyntheticEvent, newValue: string) => { - // setValue(newValue); onTabChange(newValue); }; @@ -53,18 +54,45 @@ export default function Topnav({ setAnchorEl(null); }; + const handleUserChange = (user: User) => { + handleClose(); + switchUser(user); + }; + return ( - - + + + + + + + Healthosia + + + - - + + Analytics + + } + sx={TAB_STYLING} + icon={} + /> + + Connect + + } + sx={TAB_STYLING} + icon={} + disabled + /> + + Upload + + } + sx={TAB_STYLING} + icon={} + disabled + /> + + Settings + + } + sx={TAB_STYLING} + icon={} + /> @@ -117,7 +201,7 @@ export default function Topnav({ {mockUsers.map((u) => ( switchUser(u)} + onClick={() => handleUserChange(u)} sx={{ backgroundColor: u.id === user?.id ? "#e5e5e5" : "inherit", }} diff --git a/wearables-dashboard/src/services/DataService.ts b/wearables-dashboard/src/services/DataService.ts new file mode 100644 index 0000000..6c6264f --- /dev/null +++ b/wearables-dashboard/src/services/DataService.ts @@ -0,0 +1,165 @@ +import axios from "axios"; + +const API_BASE_URL = "https://api.luzmo.com/0.1.0"; + +export type SleepScoreData = { + lastNightSleepScore: number; + previousNightSleepScore: number; +}; + +export type StepsPerDayData = { + date: string; + steps: number; +}[]; + +export async function getSleepScoreLastNight( + userId: string +): Promise { + const sleepScoreQuery = { + dimensions: [ + // Group on day level + { + dataset_id: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + column_id: "3fc71210-6362-4bf6-ba1b-d27d16b1c36d", + level: 5, + }, + ], + measures: [ + // Calculate average sleep score + { + dataset_id: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + column_id: "d0ff1497-fa36-4dce-9480-a03e2f65c5ee", + aggregation: { + type: "average", + }, + }, + ], + where: [ + // Filter by user ID + /* IMPORTANT NOTE: + * This is a simplified example that doesn't use authorization tokens. In a real-world application, you should never pass the user ID directly to the API client-side, as it can be easily manipulated. + * Instead, always use a user-specific Authorization token that handles the multi-tenancy filtering, and use this key-token pair to retrieve the data. + */ + { + expression: "? = ?", + parameters: [ + { + column_id: "695b3aee-6772-4aa6-bb50-a67a88bfc26b", + dataset_id: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + }, + userId, + ], + }, + ], + order: [ + // Order by day in descending order + { + dataset_id: "f0e0df8c-87fc-4bdc-ab7f-8cd744146284", + column_id: "3fc71210-6362-4bf6-ba1b-d27d16b1c36d", + level: 5, + order: "desc", + }, + ], + limit: { + by: 2, + }, + }; + try { + const response = await axios.post(`${API_BASE_URL}/data`, { + action: "get", + version: "0.1.0", + find: { queries: [sleepScoreQuery] }, + }); + const results = response.data.data; + const output = { + lastNightSleepScore: -1, + previousNightSleepScore: -1, + }; + const currentDate = new Date().toISOString().split("T")[0]; + + const previousDate = new Date(currentDate); + previousDate.setDate(new Date(currentDate).getDate() - 1); + + const dateBeforePreviousDate = new Date(previousDate); + dateBeforePreviousDate.setDate(new Date(previousDate).getDate() - 1); + + results.forEach((result: any) => { + const date = result[0].split("T")[0]; + if ( + new Date(previousDate).toISOString() === new Date(date).toISOString() + ) { + output.lastNightSleepScore = result[1]; + } else if ( + new Date(dateBeforePreviousDate).toISOString() === + new Date(date).toISOString() + ) { + output.previousNightSleepScore = result[1]; + } else { + console.log("Date received is not yesterday or the day before:", date); + } + }); + + return output; + } catch (error) { + console.error("Error fetching sleep score:", error); + throw error; + } +} + +export async function getStepsPerDay(userId: string): Promise { + const stepsPerDayQuery = { + dimensions: [ + // Group on day level + { + dataset_id: "1c759996-74fd-438d-bcba-eb58838a5b03", + column_id: "87680fa2-96d2-42cf-ae76-557c3996cdf2", + level: 5, + }, + ], + measures: [ + // Calculate total steps (default aggregation is sum) + { + dataset_id: "1c759996-74fd-438d-bcba-eb58838a5b03", + column_id: "27978dfd-7218-40a4-88cf-59de2df91935", + }, + ], + where: [ + { + expression: "? = ?", + parameters: [ + { + column_id: "572740ed-aeed-4aeb-b318-d059e8be35a6", + dataset_id: "1c759996-74fd-438d-bcba-eb58838a5b03", + }, + userId, + ], + }, + ], + order: [ + // Order by day in descending order + { + dataset_id: "1c759996-74fd-438d-bcba-eb58838a5b03", + column_id: "87680fa2-96d2-42cf-ae76-557c3996cdf2", + level: 5, + order: "desc", + }, + ], + }; + try { + const response = await axios.post(`${API_BASE_URL}/data`, { + action: "get", + version: "0.1.0", + find: { queries: [stepsPerDayQuery] }, + }); + + return response.data.data.map((result: any) => { + return { + date: result[0], + steps: result[1], + }; + }); + } catch (error) { + console.error("Error fetching steps per day:", error); + throw error; + } +}