From 1fb8df7c898441297dc106944373d3653c250063 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Thu, 21 Aug 2025 22:09:17 +0300 Subject: [PATCH] feat(dashboard, api): integrate BMI thresholds and enhance dashboard with health metrics --- app/home/(user)/(dashboard)/page.tsx | 34 ++-- app/home/(user)/_components/dashboard.tsx | 181 ++++++++++-------- .../load-team-account-health-details.ts | 6 +- lib/types/bmi.ts | 7 + lib/utils.ts | 59 +++++- packages/features/accounts/src/server/api.ts | 17 ++ .../features/accounts/src/types/accounts.ts | 2 +- packages/supabase/src/database.types.ts | 37 ++++ .../migrations/20250821213200_bmi_config.sql | 27 +++ 9 files changed, 269 insertions(+), 101 deletions(-) create mode 100644 lib/types/bmi.ts create mode 100644 supabase/migrations/20250821213200_bmi_config.sql diff --git a/app/home/(user)/(dashboard)/page.tsx b/app/home/(user)/(dashboard)/page.tsx index 14d8bff..7eba847 100644 --- a/app/home/(user)/(dashboard)/page.tsx +++ b/app/home/(user)/(dashboard)/page.tsx @@ -1,14 +1,18 @@ import { redirect } from 'next/navigation'; +import { toTitleCase } from '@/lib/utils'; +import { createAccountsApi } from '@/packages/features/accounts/src/server/api'; +import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; + +import { PageBody, PageHeader } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import { PageBody, PageHeader } from '@kit/ui/page'; -import { Trans } from '@kit/ui/trans'; -import { toTitleCase } from '@/lib/utils'; import Dashboard from '../_components/dashboard'; -import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; import DashboardCards from '../_components/dashboard-cards'; +import { loadCurrentUserAccount } from '../_lib/server/load-user-account'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); @@ -20,7 +24,12 @@ export const generateMetadata = async () => { }; async function UserHomePage() { + const client = getSupabaseServerClient(); + const account = await loadCurrentUserAccount(); + const api = await createAccountsApi(client); + const bmiThresholds = await api.fetchBmiThresholds(); + if (!account) { redirect('/'); } @@ -28,18 +37,17 @@ async function UserHomePage() { return ( <> - - - {account.name ? `, ${toTitleCase(account.name)}` : ''} - - } - description={ - + + + {account.name ? `, ${toTitleCase(account.name)}` : ''} + } + description={} /> - + ); diff --git a/app/home/(user)/_components/dashboard.tsx b/app/home/(user)/_components/dashboard.tsx index f223b1c..181129d 100644 --- a/app/home/(user)/_components/dashboard.tsx +++ b/app/home/(user)/_components/dashboard.tsx @@ -4,14 +4,13 @@ import Link from 'next/link'; import { InfoTooltip } from '@/components/ui/info-tooltip'; import type { AccountWithParams } from '@/packages/features/accounts/src/server/api'; +import { Database } from '@/packages/supabase/src/database.types'; import { BlendingModeIcon, RulerHorizontalIcon } from '@radix-ui/react-icons'; import Isikukood from 'isikukood'; import { Activity, - ChevronRight, Clock9, Droplets, - LineChart, Pill, Scale, TrendingUp, @@ -25,95 +24,96 @@ import { CardDescription, CardFooter, CardHeader, - CardProps, } from '@kit/ui/card'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; +import { BmiCategory } from '~/lib/types/bmi'; +import { + bmiFromMetric, + getBmiBackgroundColor, + getBmiStatus, +} from '~/lib/utils'; + const cards = ({ gender, age, height, weight, + bmiStatus, }: { gender?: string; age?: number; height?: number | null; weight?: number | null; -}) => { - const heightInMeters = height ? height / 100 : null; - const bmi = - heightInMeters && weight - ? (weight / (heightInMeters * heightInMeters)).toFixed(1) - : null; - return [ - { - title: 'dashboard:gender', - description: gender ?? 'dashboard:male', - icon: , - iconBg: 'bg-success', - }, - { - title: 'dashboard:age', - description: age ? `${age}` : '-', - icon: , - iconBg: 'bg-success', - }, - { - title: 'dashboard:height', - description: height ? `${height}cm` : '-', - icon: , - iconBg: 'bg-success', - }, - { - title: 'dashboard:weight', - description: weight ? `${weight}kg` : '-', - icon: , - iconBg: 'bg-success', - }, - { - title: 'dashboard:bmi', - description: bmi, - icon: , - iconBg: 'bg-success', - }, - { - title: 'dashboard:bloodPressure', - description: '-', - icon: , - iconBg: 'bg-warning', - }, - { - title: 'dashboard:cholesterol', - description: '-', - icon: , - iconBg: 'bg-destructive', - }, - { - title: 'dashboard:ldlCholesterol', - description: '-', - icon: , - iconBg: 'bg-warning', - }, - // { - // title: 'Score 2', - // description: 'Normis', - // icon: , - // iconBg: 'bg-success', - // }, - // { - // title: 'dashboard:smoking', - // description: 'dashboard:respondToQuestion', - // descriptionColor: 'text-primary', - // icon: ( - // - // ), - // cardVariant: 'gradient-success' as CardProps['variant'], - // }, - ]; -}; + bmiStatus: BmiCategory | null; +}) => [ + { + title: 'dashboard:gender', + description: gender ?? 'dashboard:male', + icon: , + iconBg: 'bg-success', + }, + { + title: 'dashboard:age', + description: age ? `${age}` : '-', + icon: , + iconBg: 'bg-success', + }, + { + title: 'dashboard:height', + description: height ? `${height}cm` : '-', + icon: , + iconBg: 'bg-success', + }, + { + title: 'dashboard:weight', + description: weight ? `${weight}kg` : '-', + icon: , + iconBg: 'bg-success', + }, + { + title: 'dashboard:bmi', + description: bmiFromMetric(weight || 0, height || 0).toString(), + icon: , + iconBg: getBmiBackgroundColor(bmiStatus), + }, + { + title: 'dashboard:bloodPressure', + description: '-', + icon: , + iconBg: 'bg-warning', + }, + { + title: 'dashboard:cholesterol', + description: '-', + icon: , + iconBg: 'bg-destructive', + }, + { + title: 'dashboard:ldlCholesterol', + description: '-', + icon: , + iconBg: 'bg-warning', + }, + // { + // title: 'Score 2', + // description: 'Normis', + // icon: , + // iconBg: 'bg-success', + // }, + // { + // title: 'dashboard:smoking', + // description: 'dashboard:respondToQuestion', + // descriptionColor: 'text-primary', + // icon: ( + // + // ), + // cardVariant: 'gradient-success' as CardProps['variant'], + // }, +]; const dummyRecommendations = [ { @@ -160,8 +160,22 @@ const getPersonParameters = (personalCode: string) => { } }; -export default function Dashboard({ account }: { account: AccountWithParams }) { +export default function Dashboard({ + account, + bmiThresholds, +}: { + account: AccountWithParams; + bmiThresholds: Omit< + Database['medreport']['Tables']['bmi_thresholds']['Row'], + 'id' + >[]; +}) { const params = getPersonParameters(account.personal_code!); + const bmiStatus = getBmiStatus(bmiThresholds, { + age: params?.age || 0, + height: account.account_params?.[0]?.height || 0, + weight: account.account_params?.[0]?.weight || 0, + }); return ( <> @@ -171,18 +185,19 @@ export default function Dashboard({ account }: { account: AccountWithParams }) { age: params?.age, height: account.account_params?.[0]?.height, weight: account.account_params?.[0]?.weight, + bmiStatus, }).map( ({ title, description, icon, iconBg, - cardVariant, - descriptionColor, + // cardVariant, + // descriptionColor, }) => ( @@ -199,7 +214,9 @@ export default function Dashboard({ account }: { account: AccountWithParams }) {
- + diff --git a/app/home/[account]/_lib/server/load-team-account-health-details.ts b/app/home/[account]/_lib/server/load-team-account-health-details.ts index 6aeaa3c..e723846 100644 --- a/app/home/[account]/_lib/server/load-team-account-health-details.ts +++ b/app/home/[account]/_lib/server/load-team-account-health-details.ts @@ -2,6 +2,8 @@ import React from 'react'; import { Clock, TrendingUp, User } from 'lucide-react'; +import { bmiFromMetric } from '~/lib/utils'; + import { TeamAccountStatisticsProps } from '../../_components/team-account-statistics'; interface AccountHealthDetailsField { @@ -26,9 +28,7 @@ export const getAccountHealthDetailsFields = ( ): AccountHealthDetailsField[] => { const averageBMI = ( memberParams.reduce((sum, { height, weight }) => { - const hMeters = height! / 100; - const bmi = weight! / (hMeters * hMeters); - return sum + bmi; + return bmiFromMetric(weight ?? 0, height ?? 0) + sum; }, 0) / memberParams.length ).toFixed(0); diff --git a/lib/types/bmi.ts b/lib/types/bmi.ts new file mode 100644 index 0000000..f5080b2 --- /dev/null +++ b/lib/types/bmi.ts @@ -0,0 +1,7 @@ +export enum BmiCategory { + UNDER_WEIGHT = 'under_weight', + NORMAL = 'normal', + OVER_WEIGHT = 'over_weight', + VERY_OVERWEIGHT = 'very_overweight', + OBESE = 'obese', +} diff --git a/lib/utils.ts b/lib/utils.ts index a9c34c4..29bf93e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,9 @@ +import { Database } from '@/packages/supabase/src/database.types'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { BmiCategory } from './types/bmi'; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } @@ -19,11 +22,63 @@ export function toTitleCase(str?: string) { ); } -export function sortByDate(a: T[] | undefined, key: keyof T): T[] | undefined { +export function sortByDate( + a: T[] | undefined, + key: keyof T, +): T[] | undefined { return a?.sort((a, b) => { if (!a[key] || !b[key]) { return 0; } - return new Date(b[key] as string).getTime() - new Date(a[key] as string).getTime(); + return ( + new Date(b[key] as string).getTime() - + new Date(a[key] as string).getTime() + ); }); } + +export const bmiFromMetric = (kg: number, cm: number) => { + const m = cm / 100; + return kg / (m * m); +}; + +export function getBmiStatus( + thresholds: Omit< + Database['medreport']['Tables']['bmi_thresholds']['Row'], + 'id' + >[], + params: { age: number; height: number; weight: number }, +): BmiCategory | null { + const age = params.age; + const thresholdByAge = + thresholds.find( + (b) => age >= b.age_min && (b.age_max == null || age <= b.age_max), + ) || null; + const bmi = bmiFromMetric(params.weight, params.height); + + if (!thresholdByAge || Number.isNaN(bmi)) return null; + + if (bmi > thresholdByAge.obesity_min) return BmiCategory.OBESE; + if (bmi > thresholdByAge.strong_min) return BmiCategory.VERY_OVERWEIGHT; + if (bmi > thresholdByAge.overweight_min) return BmiCategory.OVER_WEIGHT; + if (bmi >= thresholdByAge.normal_min && bmi <= thresholdByAge.normal_max) + return BmiCategory.NORMAL; + return BmiCategory.UNDER_WEIGHT; +} + +export function getBmiBackgroundColor(bmiStatus: BmiCategory | null): string { + switch (bmiStatus) { + case BmiCategory.UNDER_WEIGHT: + return 'bg-warning'; + case BmiCategory.NORMAL: + return 'bg-success'; + case BmiCategory.OVER_WEIGHT: + return 'bg-warning'; + case BmiCategory.VERY_OVERWEIGHT: + return 'bg-destructive'; + case BmiCategory.OBESE: + return 'bg-error'; + default: + return 'bg-success'; + } +} diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index 7bebe76..0844138 100644 --- a/packages/features/accounts/src/server/api.ts +++ b/packages/features/accounts/src/server/api.ts @@ -242,6 +242,23 @@ class AccountsApi { return (count ?? 0) > 0; } + + async fetchBmiThresholds() { + // Fetch BMI + const { data, error } = await this.client + .schema('medreport') + .from('bmi_thresholds') + .select( + 'age_min,age_max,underweight_max,normal_min,normal_max,overweight_min,strong_min,obesity_min', + ) + .order('age_min', { ascending: true }); + + if (error) { + console.error('Error fetching BMI thresholds:', error); + throw error; + } + return data; + } } export function createAccountsApi(client: SupabaseClient) { diff --git a/packages/features/accounts/src/types/accounts.ts b/packages/features/accounts/src/types/accounts.ts index eb9cf8e..2d54306 100644 --- a/packages/features/accounts/src/types/accounts.ts +++ b/packages/features/accounts/src/types/accounts.ts @@ -14,4 +14,4 @@ export enum ApplicationRoleEnum { User = 'user', Doctor = 'doctor', SuperAdmin = 'super_admin', -} \ No newline at end of file +} diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 8142777..9093fe4 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -690,6 +690,42 @@ export type Database = { }, ] } + bmi_thresholds: { + Row: { + age_max: number | null + age_min: number + id: number + normal_max: number + normal_min: number + obesity_min: number + overweight_min: number + strong_min: number + underweight_max: number + } + Insert: { + age_max?: number | null + age_min: number + id?: number + normal_max: number + normal_min: number + obesity_min: number + overweight_min: number + strong_min: number + underweight_max: number + } + Update: { + age_max?: number | null + age_min?: number + id?: number + normal_max?: number + normal_min?: number + obesity_min?: number + overweight_min?: number + strong_min?: number + underweight_max?: number + } + Relationships: [] + } codes: { Row: { analysis_element_id: number | null @@ -1807,6 +1843,7 @@ export type Database = { primary_owner_user_id: string subscription_status: Database["medreport"]["Enums"]["subscription_status"] permissions: Database["medreport"]["Enums"]["app_permissions"][] + account_role: string application_role: Database["medreport"]["Enums"]["application_role"] }[] } diff --git a/supabase/migrations/20250821213200_bmi_config.sql b/supabase/migrations/20250821213200_bmi_config.sql new file mode 100644 index 0000000..72fb723 --- /dev/null +++ b/supabase/migrations/20250821213200_bmi_config.sql @@ -0,0 +1,27 @@ +create table medreport.bmi_thresholds ( + id bigserial primary key, + age_min int not null, + age_max int, + underweight_max numeric not null, + normal_min numeric not null, + normal_max numeric not null, + overweight_min numeric not null, + strong_min numeric not null, + obesity_min numeric not null +); + +insert into medreport.bmi_thresholds +(age_min, age_max, underweight_max, normal_min, normal_max, overweight_min, strong_min, obesity_min) +values +(19, 24, 19, 19, 24, 24, 30, 35), +(25, 34, 20, 20, 25, 25, 30, 35), +(35, 44, 21, 21, 26, 26, 30, 35), +(45, 54, 22, 22, 27, 27, 30, 35), +(55, 64, 23, 23, 28, 28, 30, 35), +(65, null, 24, 24, 29, 29, 30, 35); + +alter table medreport.bmi_thresholds enable row level security; + +grant select, insert, update, delete on table medreport.bmi_thresholds to authenticated; + +create policy "bmi_thresholds_select" on "medreport"."bmi_thresholds" for select to service_role using (true); \ No newline at end of file