feat(dashboard, api): integrate BMI thresholds and enhance dashboard with health metrics
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
7
lib/types/bmi.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum BmiCategory {
|
||||
UNDER_WEIGHT = 'under_weight',
|
||||
NORMAL = 'normal',
|
||||
OVER_WEIGHT = 'over_weight',
|
||||
VERY_OVERWEIGHT = 'very_overweight',
|
||||
OBESE = 'obese',
|
||||
}
|
||||
59
lib/utils.ts
59
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<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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -14,4 +14,4 @@ export enum ApplicationRoleEnum {
|
||||
User = 'user',
|
||||
Doctor = 'doctor',
|
||||
SuperAdmin = 'super_admin',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}[]
|
||||
}
|
||||
|
||||
27
supabase/migrations/20250821213200_bmi_config.sql
Normal file
27
supabase/migrations/20250821213200_bmi_config.sql
Normal 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);
|
||||
Reference in New Issue
Block a user