feat(dashboard, api): integrate BMI thresholds and enhance dashboard with health metrics

This commit is contained in:
Danel Kungla
2025-08-21 22:09:17 +03:00
parent b1b0846234
commit 1fb8df7c89
9 changed files with 269 additions and 101 deletions

View File

@@ -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 (
<>
<DashboardCards />
<PageHeader title={
<>
<Trans i18nKey={'common:welcome'} />
{account.name ? `, ${toTitleCase(account.name)}` : ''}
</>
}
description={
<Trans i18nKey={'dashboard:recentlyCheckedDescription'} />
<PageHeader
title={
<>
<Trans i18nKey={'common:welcome'} />
{account.name ? `, ${toTitleCase(account.name)}` : ''}
</>
}
description={<Trans i18nKey={'dashboard:recentlyCheckedDescription'} />}
/>
<PageBody>
<Dashboard account={account} />
<Dashboard account={account} bmiThresholds={bmiThresholds} />
</PageBody>
</>
);

View File

@@ -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: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bmi',
description: bmi,
icon: <TrendingUp />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bloodPressure',
description: '-',
icon: <Activity />,
iconBg: 'bg-warning',
},
{
title: 'dashboard:cholesterol',
description: '-',
icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive',
},
{
title: 'dashboard:ldlCholesterol',
description: '-',
icon: <Pill />,
iconBg: 'bg-warning',
},
// {
// title: 'Score 2',
// description: 'Normis',
// icon: <LineChart />,
// iconBg: 'bg-success',
// },
// {
// title: 'dashboard:smoking',
// description: 'dashboard:respondToQuestion',
// descriptionColor: 'text-primary',
// icon: (
// <Button size="icon" variant="outline" className="px-2 text-black">
// <ChevronRight className="size-4 stroke-2" />
// </Button>
// ),
// cardVariant: 'gradient-success' as CardProps['variant'],
// },
];
};
bmiStatus: BmiCategory | null;
}) => [
{
title: 'dashboard:gender',
description: gender ?? 'dashboard:male',
icon: <User />,
iconBg: 'bg-success',
},
{
title: 'dashboard:age',
description: age ? `${age}` : '-',
icon: <Clock9 />,
iconBg: 'bg-success',
},
{
title: 'dashboard:height',
description: height ? `${height}cm` : '-',
icon: <RulerHorizontalIcon className="size-4" />,
iconBg: 'bg-success',
},
{
title: 'dashboard:weight',
description: weight ? `${weight}kg` : '-',
icon: <Scale />,
iconBg: 'bg-success',
},
{
title: 'dashboard:bmi',
description: bmiFromMetric(weight || 0, height || 0).toString(),
icon: <TrendingUp />,
iconBg: getBmiBackgroundColor(bmiStatus),
},
{
title: 'dashboard:bloodPressure',
description: '-',
icon: <Activity />,
iconBg: 'bg-warning',
},
{
title: 'dashboard:cholesterol',
description: '-',
icon: <BlendingModeIcon className="size-4" />,
iconBg: 'bg-destructive',
},
{
title: 'dashboard:ldlCholesterol',
description: '-',
icon: <Pill />,
iconBg: 'bg-warning',
},
// {
// title: 'Score 2',
// description: 'Normis',
// icon: <LineChart />,
// iconBg: 'bg-success',
// },
// {
// title: 'dashboard:smoking',
// description: 'dashboard:respondToQuestion',
// descriptionColor: 'text-primary',
// icon: (
// <Button size="icon" variant="outline" className="px-2 text-black">
// <ChevronRight className="size-4 stroke-2" />
// </Button>
// ),
// 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,
}) => (
<Card
key={title}
variant={cardVariant}
// variant={cardVariant}
className="flex flex-col justify-between"
>
<CardHeader className="items-end-safe">
@@ -199,7 +214,9 @@ export default function Dashboard({ account }: { account: AccountWithParams }) {
<h5>
<Trans i18nKey={title} />
</h5>
<CardDescription className={descriptionColor}>
<CardDescription
// className={descriptionColor}
>
<Trans i18nKey={description} />
</CardDescription>
</CardFooter>

View File

@@ -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);

7
lib/types/bmi.ts Normal file
View File

@@ -0,0 +1,7 @@
export enum BmiCategory {
UNDER_WEIGHT = 'under_weight',
NORMAL = 'normal',
OVER_WEIGHT = 'over_weight',
VERY_OVERWEIGHT = 'very_overweight',
OBESE = 'obese',
}

View File

@@ -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<T>(a: T[] | undefined, key: keyof T): T[] | undefined {
export function sortByDate<T>(
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';
}
}

View File

@@ -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<Database>) {

View File

@@ -14,4 +14,4 @@ export enum ApplicationRoleEnum {
User = 'user',
Doctor = 'doctor',
SuperAdmin = 'super_admin',
}
}

View File

@@ -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"]
}[]
}

View File

@@ -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);