From 76c2382e115c482a3cc5cb80ec8082b63b504b5f Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 21 Oct 2025 16:04:01 +0300 Subject: [PATCH] lifestyle development --- .../(user)/(dashboard)/life-style/page.tsx | 84 ++++++++++++++ app/home/(user)/_components/ai/ai-blocks.tsx | 8 +- .../(user)/_components/ai/life-style-card.tsx | 26 ++++- .../ai/recommendations-skeleton.tsx | 91 +++++++-------- app/home/(user)/_components/ai/types.ts | 18 +++ .../_components/orders/order-items-table.tsx | 90 +++++++-------- app/home/(user)/_lib/server/ai-actions.ts | 108 ++++++++++++++++++ .../(user)/_lib/server/load-life-style.ts | 54 +++++---- .../_lib/server/load-recommendations.ts | 3 +- lib/services/audit/pageView.service.ts | 1 + packages/shared/src/config/paths.config.ts | 2 + .../personal-account-navigation.config.tsx | 7 ++ public/locales/et/common.json | 8 +- public/locales/et/dashboard.json | 3 + 14 files changed, 374 insertions(+), 129 deletions(-) create mode 100644 app/home/(user)/(dashboard)/life-style/page.tsx create mode 100644 app/home/(user)/_components/ai/types.ts create mode 100644 app/home/(user)/_lib/server/ai-actions.ts diff --git a/app/home/(user)/(dashboard)/life-style/page.tsx b/app/home/(user)/(dashboard)/life-style/page.tsx new file mode 100644 index 0000000..e359c73 --- /dev/null +++ b/app/home/(user)/(dashboard)/life-style/page.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; +import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; +import { Circle } from 'lucide-react'; + +import { cn } from '@kit/ui/lib/utils'; +import { PageBody } from '@kit/ui/makerkit/page'; +import { Trans } from '@kit/ui/makerkit/trans'; +import { Skeleton } from '@kit/ui/shadcn/skeleton'; + +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { withI18n } from '~/lib/i18n/with-i18n'; +import { + PageViewAction, + createPageViewLog, +} from '~/lib/services/audit/pageView.service'; + +import { HomeLayoutPageHeader } from '../../_components/home-page-header'; +import { loadLifeStyle } from '../../_lib/server/load-life-style'; +import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; + +export async function generateMetadata() { + const { t } = await createI18nServerInstance(); + + return { + title: t('common:lifeStyle.title'), + }; +} + +async function LifeStylePage() { + const supabaseClient = getSupabaseServerClient(); + const userAnalysesApi = createUserAnalysesApi(supabaseClient); + + const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses(); + console.log('analysisResponses', analysisResponses); + const { account } = await loadCurrentUserAccount(); + if (!account) { + return null; + } + const data = await loadLifeStyle(account); + + await createPageViewLog({ + accountId: account.id, + action: PageViewAction.VIEW_LIFE_STYLE, + }); + + if (!data.lifestyle) { + return ; + } + + return ( + <> + } + description="" + /> + + +
+ {data.lifestyle.map(({ title, description, score }, index) => ( + +
+

{title}

+ +
+

{description}

+
+ ))} +
+
+ + ); +} + +export default withI18n(LifeStylePage); diff --git a/app/home/(user)/_components/ai/ai-blocks.tsx b/app/home/(user)/_components/ai/ai-blocks.tsx index fe1ff9d..7c44026 100644 --- a/app/home/(user)/_components/ai/ai-blocks.tsx +++ b/app/home/(user)/_components/ai/ai-blocks.tsx @@ -18,14 +18,14 @@ const AIBlocks = async ({ account }: { account: AccountWithParams }) => { return ; } - const { analyses, countryCode } = await loadAnalyses(); + const { analyses } = await loadAnalyses(); if (analyses.length === 0) { return ( <> - }> - + }> + ); @@ -33,7 +33,7 @@ const AIBlocks = async ({ account }: { account: AccountWithParams }) => { return ( }> - + ); diff --git a/app/home/(user)/_components/ai/life-style-card.tsx b/app/home/(user)/_components/ai/life-style-card.tsx index e2fffd0..14da00d 100644 --- a/app/home/(user)/_components/ai/life-style-card.tsx +++ b/app/home/(user)/_components/ai/life-style-card.tsx @@ -2,16 +2,34 @@ import React from 'react'; -import { Card } from '@kit/ui/shadcn/card'; +import Link from 'next/link'; + +import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts'; +import { pathsConfig } from '@/packages/shared/src/config'; +import { ChevronRight } from 'lucide-react'; + +import { Trans } from '@kit/ui/makerkit/trans'; +import { Button } from '@kit/ui/shadcn/button'; +import { Card, CardHeader } from '@kit/ui/shadcn/card'; import { loadLifeStyle } from '../../_lib/server/load-life-style'; -const LifeStyleCard = async () => { - const data = await loadLifeStyle(); +const LifeStyleCard = async ({ account }: { account: AccountWithParams }) => { + const data = await loadLifeStyle(account); return ( - Test + +
+ +
+ + + +
+ {data.summary}
); }; diff --git a/app/home/(user)/_components/ai/recommendations-skeleton.tsx b/app/home/(user)/_components/ai/recommendations-skeleton.tsx index fad1225..2964784 100644 --- a/app/home/(user)/_components/ai/recommendations-skeleton.tsx +++ b/app/home/(user)/_components/ai/recommendations-skeleton.tsx @@ -11,7 +11,7 @@ import { } from '@kit/ui/shadcn/card'; import { Skeleton } from '@kit/ui/skeleton'; -const RecommendationsSkeleton = () => { +const RecommendationsSkeleton = ({ amount = 2 }: { amount?: number }) => { const emptyData = [ { title: '1', @@ -20,55 +20,48 @@ const RecommendationsSkeleton = () => { variant: { id: '' }, price: 1, }, - { - title: '2', - description: '', - subtitle: '', - variant: { id: '' }, - price: 1, - }, ]; - return ( -
- {emptyData.map(({ title, description, subtitle }) => ( - - - -
-
-
- - -
-
- {title} - {description && ( - <> - {' '} - - {description} -
- } - /> - - )} - - {subtitle && {subtitle}} -
-
- -
-
- ))} -
- ); + return Array.from({ length: amount }, (_, index) => { + const { title, description, subtitle } = emptyData[0]!; + + return ( + + + +
+
+
+ + +
+
+ {title} + {description && ( + <> + {' '} + + {description} +
+ } + /> + + )} + + {subtitle && {subtitle}} +
+
+ +
+
+ ); + }); }; export default RecommendationsSkeleton; diff --git a/app/home/(user)/_components/ai/types.ts b/app/home/(user)/_components/ai/types.ts new file mode 100644 index 0000000..2026cce --- /dev/null +++ b/app/home/(user)/_components/ai/types.ts @@ -0,0 +1,18 @@ +import { Database } from '@/packages/supabase/src/database.types'; + +export interface ILifeStyleResponse { + lifestyle: { + title: string; + description: string; + score: 0 | 1 | 2; + }[]; + summary: string | null; +} + +export enum PROMPT_NAME { + LIFE_STYLE = 'Life Style', + ANALYSIS_RECOMMENDATIONS = 'Analysis Recommendations', +} + +export type AnalysisResponses = + Database['medreport']['Functions']['get_latest_analysis_response_elements_for_current_user']['Returns']; diff --git a/app/home/(user)/_components/orders/order-items-table.tsx b/app/home/(user)/_components/orders/order-items-table.tsx index ec9bbef..3eb3840 100644 --- a/app/home/(user)/_components/orders/order-items-table.tsx +++ b/app/home/(user)/_components/orders/order-items-table.tsx @@ -63,55 +63,53 @@ export default function OrderItemsTable({ return ( <> - - {items - .sort((a, b) => - (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1, - ) - .map((orderItem) => ( -
+ {items + .sort((a, b) => + (a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1, + ) + .map((orderItem) => ( + + + + {order.location && ( - - {order.location && ( - - )} - - - - - + {isTtoservice && order.bookingCode && ( + - {isTtoservice && order.bookingCode && ( - - )} - - -
- ))} -
+ )} + + + + ))}
diff --git a/app/home/(user)/_lib/server/ai-actions.ts b/app/home/(user)/_lib/server/ai-actions.ts new file mode 100644 index 0000000..5031e50 --- /dev/null +++ b/app/home/(user)/_lib/server/ai-actions.ts @@ -0,0 +1,108 @@ +'use server'; + +import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts'; +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; +import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; +import OpenAI from 'openai'; + +import PersonalCode from '~/lib/utils'; + +import { + AnalysisResponses, + ILifeStyleResponse, + PROMPT_NAME, +} from '../../_components/ai/types'; + +async function getLatestResponseTime(items: AnalysisResponses) { + if (!items?.length) return null; + + let latest = null; + for (const it of items) { + const d = new Date(it.response_time); + const t = d.getTime(); + if (!Number.isNaN(t) && (latest === null || t > latest.getTime())) { + latest = d; + } + } + return latest; +} + +export async function updateLifeStyle({ + account, +}: { + account: AccountWithParams; +}): Promise { + const LIFE_STYLE_PROMPT_ID = process.env.PROMPT_ID_LIFE_STYLE; + if (!LIFE_STYLE_PROMPT_ID || !account?.personal_code) { + return { + lifestyle: [], + summary: null, + }; + } + + const openAIClient = new OpenAI(); + const supabaseClient = getSupabaseServerClient(); + const userAnalysesApi = createUserAnalysesApi(supabaseClient); + + const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses(); + const { gender, age } = PersonalCode.parsePersonalCode(account.personal_code); + const weight = account.accountParams?.weight || 'unknown'; + const height = account.accountParams?.height || 'unknown'; + const isSmoker = !!account.accountParams?.isSmoker; + + const latestResponseTime = await getLatestResponseTime(analysisResponses); + const latestISO = latestResponseTime + ? new Date(latestResponseTime).toISOString() + : new Date('2025').toISOString(); + + try { + const response = await openAIClient.responses.create({ + store: false, + prompt: { + id: LIFE_STYLE_PROMPT_ID, + variables: { + gender: gender.value, + age: age.toString(), + weight: weight.toString(), + height: height.toString(), + cholesterol: '', + ldl: '', + hdl: '', + vitamind: '', + is_smoker: isSmoker.toString(), + }, + }, + }); + + await supabaseClient + .schema('medreport') + .from('ai_responses') + .insert({ + account_id: account.id, + prompt_name: PROMPT_NAME.LIFE_STYLE, + prompt_id: LIFE_STYLE_PROMPT_ID, + input: JSON.stringify({ + gender: gender.value, + age: age.toString(), + weight: weight.toString(), + cholesterol: '', + ldl: '', + hdl: '', + vitamind: '', + is_smoker: isSmoker.toString(), + }), + latest_data_change: latestISO, + response: response.output_text, + }); + + const json = JSON.parse(response.output_text); + + return json; + } catch (error) { + console.error('Error calling OpenAI: ', error); + return { + lifestyle: [], + summary: null, + }; + } +} diff --git a/app/home/(user)/_lib/server/load-life-style.ts b/app/home/(user)/_lib/server/load-life-style.ts index 631ffd4..889bddd 100644 --- a/app/home/(user)/_lib/server/load-life-style.ts +++ b/app/home/(user)/_lib/server/load-life-style.ts @@ -1,41 +1,49 @@ import { cache } from 'react'; import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts'; -import OpenAI from 'openai'; +import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; -import PersonalCode from '~/lib/utils'; +import { ILifeStyleResponse, PROMPT_NAME } from '../../_components/ai/types'; +import { updateLifeStyle } from './ai-actions'; const failedResponse = { - lifeStyle: null, + lifestyle: [], summary: null, }; -async function lifeStyleLoader(account: AccountWithParams) { +async function lifeStyleLoader( + account: AccountWithParams, +): Promise { if (!account?.personal_code) { return failedResponse; } - const openAIClient = new OpenAI(); - const { gender, age } = PersonalCode.parsePersonalCode(account.personal_code); - try { - const response = await openAIClient.responses.create({ - store: false, - prompt: { - id: analysesRecommendationsPromptId, - variables: { - analyses: JSON.stringify(formattedAnalyses), - results: JSON.stringify(formattedAnalysisResponses), - gender: gender.value, - age: age.toString(), - weight: weight.toString(), - }, - }, - }); + const lifeStylePromptId = process.env.PROMPT_ID_LIFE_STYLE; - return response; - } catch (error) { - console.error('Error calling OpenAI: ', error); + if (!lifeStylePromptId) { return failedResponse; } + + const supabaseClient = getSupabaseServerClient(); + const { data, error } = await supabaseClient + .schema('medreport') + .from('ai_responses') + .select('response') + .eq('account_id', account.id) + .eq('prompt_name', PROMPT_NAME.LIFE_STYLE) + .order('latest_data_change', { ascending: false, nullsFirst: false }) + .limit(1) + .maybeSingle(); + + if (error) { + console.error('Error fetching AI response from DB: ', error); + return failedResponse; + } + + if (data?.response) { + return JSON.parse(data.response as string); + } else { + return await updateLifeStyle({ account }); + } } export const loadLifeStyle = cache(lifeStyleLoader); diff --git a/app/home/(user)/_lib/server/load-recommendations.ts b/app/home/(user)/_lib/server/load-recommendations.ts index 8763c87..4c89ae3 100644 --- a/app/home/(user)/_lib/server/load-recommendations.ts +++ b/app/home/(user)/_lib/server/load-recommendations.ts @@ -8,6 +8,7 @@ import OpenAI from 'openai'; import PersonalCode from '~/lib/utils'; +import { PROMPT_NAME } from '../../_components/ai/types'; import { OrderAnalysisCard } from '../../_components/order-analyses-cards'; export const loadRecommendations = cache(recommendationsLoader); @@ -120,7 +121,7 @@ async function recommendationsLoader( .from('ai_responses') .insert({ account_id: account.id, - prompt_name: 'Analysis Recommendations', + prompt_name: PROMPT_NAME.ANALYSIS_RECOMMENDATIONS, prompt_id: analysesRecommendationsPromptId, input: JSON.stringify({ analyses: formattedAnalyses, diff --git a/lib/services/audit/pageView.service.ts b/lib/services/audit/pageView.service.ts index c107cf1..2f45566 100644 --- a/lib/services/audit/pageView.service.ts +++ b/lib/services/audit/pageView.service.ts @@ -7,6 +7,7 @@ export enum PageViewAction { VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS', VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD', VIEW_TTO_SERVICE_BOOKING = 'VIEW_TTO_SERVICE_BOOKING', + VIEW_LIFE_STYLE = 'VIEW_LIFE_STYLE', } export const createPageViewLog = async ({ diff --git a/packages/shared/src/config/paths.config.ts b/packages/shared/src/config/paths.config.ts index b3f4965..383fdf3 100644 --- a/packages/shared/src/config/paths.config.ts +++ b/packages/shared/src/config/paths.config.ts @@ -40,6 +40,7 @@ const PathsSchema = z.object({ completedJobs: z.string().min(1), openJobs: z.string().min(1), analysisDetails: z.string().min(1), + lifeStyle: z.string().min(1), }), }); @@ -83,6 +84,7 @@ const pathsConfig = PathsSchema.parse({ completedJobs: '/doctor/completed-jobs', openJobs: '/doctor/open-jobs', analysisDetails: 'doctor/analysis', + lifeStyle: '/home/life-style', }, } satisfies z.infer); diff --git a/packages/shared/src/config/personal-account-navigation.config.tsx b/packages/shared/src/config/personal-account-navigation.config.tsx index d2763d7..c4bde8f 100644 --- a/packages/shared/src/config/personal-account-navigation.config.tsx +++ b/packages/shared/src/config/personal-account-navigation.config.tsx @@ -1,4 +1,5 @@ import { + Bike, FileLineChart, HeartPulse, LineChart, @@ -53,6 +54,12 @@ const routes = [ Icon: , end: true, }, + { + label: 'common:routes.lifeStyle', + path: pathsConfig.app.lifeStyle, + Icon: , + end: true, + }, ], }, ] satisfies z.infer['routes']; diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 8615a16..1c9f38a 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -84,7 +84,8 @@ "preferences": "Eelistused", "security": "Turvalisus", "admin": "Admin", - "accounts": "Kontod" + "accounts": "Kontod", + "lifeStyle": "Elustiil" }, "roles": { "owner": { @@ -150,5 +151,8 @@ "no": "Ei", "preferNotToAnswer": "Eelistan mitte vastata", "book": "Broneeri", - "change": "Muuda" + "change": "Muuda", + "lifeStyle": { + "title": "Elustiili soovitused" + } } diff --git a/public/locales/et/dashboard.json b/public/locales/et/dashboard.json index ffc7aa7..acf7f42 100644 --- a/public/locales/et/dashboard.json +++ b/public/locales/et/dashboard.json @@ -25,6 +25,9 @@ "orderPackage": { "title": "Telli analüüside pakett", "description": "Võrdle erinevate pakettide vahel ja vali endale sobiv" + }, + "lifeStyle": { + "title": "Elustiili soovitused" } }, "recommendations": {