From 69a521f5cbda63dec90b84529ae1b57b3af928a1 Mon Sep 17 00:00:00 2001 From: Fides Date: Wed, 8 Jul 2026 16:08:01 +0200 Subject: [PATCH] chore(frontend): update profile strength calculation --- .../Sidebar/HomeSidebar.stories.tsx | 2 + .../src/i18n/locales/en-GB/translation.json | 7 +- .../src/i18n/locales/en-US/translation.json | 7 +- .../src/i18n/locales/es-AR/translation.json | 7 +- .../src/i18n/locales/es-ES/translation.json | 7 +- .../src/i18n/locales/ny-ZM/translation.json | 7 +- .../src/i18n/locales/pt-MZ/translation.json | 7 +- .../src/i18n/locales/sw-KE/translation.json | 7 +- .../src/navigation/NavBar/NavBar.stories.tsx | 2 + frontend-new/src/profile/Profile.stories.tsx | 5 +- frontend-new/src/profile/Profile.test.tsx | 4 +- frontend-new/src/profile/Profile.tsx | 38 +- frontend-new/src/profile/ProfileContainer.tsx | 5 +- .../src/profile/UserProfileContext.tsx | 2 + .../__snapshots__/Profile.test.tsx.snap | 333 +++++++++++++-- .../ModuleProgressCard.stories.tsx | 67 ++- .../ModuleProgressCard/ModuleProgressCard.tsx | 172 ++++---- .../src/profile/hooks/useUserProfile.test.ts | 77 +++- .../src/profile/hooks/useUserProfile.ts | 25 +- .../src/profile/hooks/utils/fetchSkills.ts | 4 +- .../utils/calculateProfileStrength.test.ts | 386 ++++++++++++++++++ .../profile/utils/calculateProfileStrength.ts | 144 +++++++ 22 files changed, 1114 insertions(+), 201 deletions(-) create mode 100644 frontend-new/src/profile/utils/calculateProfileStrength.test.ts create mode 100644 frontend-new/src/profile/utils/calculateProfileStrength.ts diff --git a/frontend-new/src/home/components/Sidebar/HomeSidebar.stories.tsx b/frontend-new/src/home/components/Sidebar/HomeSidebar.stories.tsx index 7e94308a..79072e9f 100644 --- a/frontend-new/src/home/components/Sidebar/HomeSidebar.stories.tsx +++ b/frontend-new/src/home/components/Sidebar/HomeSidebar.stories.tsx @@ -48,8 +48,10 @@ const makeProfileContext = (programmeSkills: string[]): UseUserProfileResult => programmeSkills, totalExperiences: 0, exploredExperiences: 0, + experiences: [], modules: [], skillsInterestsProgress: 0, + conversationPhase: null, careerExplorerSectors: [], }, refreshProfileData: () => {}, diff --git a/frontend-new/src/i18n/locales/en-GB/translation.json b/frontend-new/src/i18n/locales/en-GB/translation.json index 009e1dbd..23ad6f7a 100644 --- a/frontend-new/src/i18n/locales/en-GB/translation.json +++ b/frontend-new/src/i18n/locales/en-GB/translation.json @@ -1092,8 +1092,11 @@ } }, "profileStrengthProgressAriaLabel": "Profile strength progress", - "educationSkillsProgressAriaLabel": "Education skills progress", - "workSkillsProgressAriaLabel": "Work skills progress" + "profileStrengthExperienceCollection": "Experience Collection", + "profileStrengthSkillsDiscovery": "Skills Discovery", + "profileStrengthPreferences": "Preference Elicitation", + "profileStrengthCareerReadiness": "Career Readiness", + "profileStrengthCareerExplorer": "Career Explorer" }, "modules": { "skillsDiscovery": "Build Your Profile", diff --git a/frontend-new/src/i18n/locales/en-US/translation.json b/frontend-new/src/i18n/locales/en-US/translation.json index 161e6a44..df71c34f 100644 --- a/frontend-new/src/i18n/locales/en-US/translation.json +++ b/frontend-new/src/i18n/locales/en-US/translation.json @@ -1091,8 +1091,11 @@ } }, "profileStrengthProgressAriaLabel": "Profile strength progress", - "educationSkillsProgressAriaLabel": "Education skills progress", - "workSkillsProgressAriaLabel": "Work skills progress", + "profileStrengthExperienceCollection": "Experience Collection", + "profileStrengthSkillsDiscovery": "Skills Discovery", + "profileStrengthPreferences": "Preference Elicitation", + "profileStrengthCareerReadiness": "Career Readiness", + "profileStrengthCareerExplorer": "Career Explorer", "viewMyCvSubtitle": "Download and share your personalized Skills Report" }, "modules": { diff --git a/frontend-new/src/i18n/locales/es-AR/translation.json b/frontend-new/src/i18n/locales/es-AR/translation.json index 1d597241..0cec3093 100644 --- a/frontend-new/src/i18n/locales/es-AR/translation.json +++ b/frontend-new/src/i18n/locales/es-AR/translation.json @@ -1190,8 +1190,11 @@ } }, "profileStrengthProgressAriaLabel": "Progreso de la fortaleza del perfil", - "educationSkillsProgressAriaLabel": "Progreso de habilidades educativas", - "workSkillsProgressAriaLabel": "Progreso de habilidades laborales", + "profileStrengthExperienceCollection": "Recopilación de experiencias", + "profileStrengthSkillsDiscovery": "Descubrimiento de habilidades", + "profileStrengthPreferences": "Obtención de preferencias", + "profileStrengthCareerReadiness": "Preparación profesional", + "profileStrengthCareerExplorer": "Explorador de carreras", "viewMyCvSubtitle": "Descargá y compartí tu Informe de Habilidades personalizado" }, "modules": { diff --git a/frontend-new/src/i18n/locales/es-ES/translation.json b/frontend-new/src/i18n/locales/es-ES/translation.json index 7629b686..aab5219d 100644 --- a/frontend-new/src/i18n/locales/es-ES/translation.json +++ b/frontend-new/src/i18n/locales/es-ES/translation.json @@ -1097,8 +1097,11 @@ } }, "profileStrengthProgressAriaLabel": "Progreso de la fortaleza del perfil", - "educationSkillsProgressAriaLabel": "Progreso de habilidades educativas", - "workSkillsProgressAriaLabel": "Progreso de habilidades laborales", + "profileStrengthExperienceCollection": "Recopilación de experiencias", + "profileStrengthSkillsDiscovery": "Descubrimiento de habilidades", + "profileStrengthPreferences": "Obtención de preferencias", + "profileStrengthCareerReadiness": "Preparación profesional", + "profileStrengthCareerExplorer": "Explorador de carreras", "viewMyCvSubtitle": "Descargá y compartí tu Informe de Habilidades personalizado" }, "modules": { diff --git a/frontend-new/src/i18n/locales/ny-ZM/translation.json b/frontend-new/src/i18n/locales/ny-ZM/translation.json index 4ba01d36..db347716 100644 --- a/frontend-new/src/i18n/locales/ny-ZM/translation.json +++ b/frontend-new/src/i18n/locales/ny-ZM/translation.json @@ -1097,8 +1097,11 @@ } }, "profileStrengthProgressAriaLabel": "Mapambano a mphamvu ya profaili", - "educationSkillsProgressAriaLabel": "Mapambano a luso la maphunziro", - "workSkillsProgressAriaLabel": "Mapambano a luso la ntchito", + "profileStrengthExperienceCollection": "Kusonkhanitsa Zochitika", + "profileStrengthSkillsDiscovery": "Kupeza Luso", + "profileStrengthPreferences": "Kupeza Zokonda", + "profileStrengthCareerReadiness": "Kukonzekera Ntchito", + "profileStrengthCareerExplorer": "Kafukufuku wa Ntchito", "viewMyCvSubtitle": "Tsitsani ndi kugawana Malipoti Anu a Luso" }, "modules": { diff --git a/frontend-new/src/i18n/locales/pt-MZ/translation.json b/frontend-new/src/i18n/locales/pt-MZ/translation.json index bb6cca51..b42837c2 100644 --- a/frontend-new/src/i18n/locales/pt-MZ/translation.json +++ b/frontend-new/src/i18n/locales/pt-MZ/translation.json @@ -1092,8 +1092,11 @@ } }, "profileStrengthProgressAriaLabel": "Progresso da força do perfil", - "educationSkillsProgressAriaLabel": "Progresso das competências de educação", - "workSkillsProgressAriaLabel": "Progresso das competências de trabalho" + "profileStrengthExperienceCollection": "Recolha de Experiências", + "profileStrengthSkillsDiscovery": "Descoberta de Competências", + "profileStrengthPreferences": "Obtenção de Preferências", + "profileStrengthCareerReadiness": "Preparação para a Carreira", + "profileStrengthCareerExplorer": "Explorador de Carreiras" }, "modules": { "skillsDiscovery": "Construa o Seu Perfil", diff --git a/frontend-new/src/i18n/locales/sw-KE/translation.json b/frontend-new/src/i18n/locales/sw-KE/translation.json index bc245f73..88cb4665 100644 --- a/frontend-new/src/i18n/locales/sw-KE/translation.json +++ b/frontend-new/src/i18n/locales/sw-KE/translation.json @@ -1097,8 +1097,11 @@ } }, "profileStrengthProgressAriaLabel": "Maendeleo ya nguvu ya wasifu", - "educationSkillsProgressAriaLabel": "Maendeleo ya ujuzi wa elimu", - "workSkillsProgressAriaLabel": "Maendeleo ya ujuzi wa kazi", + "profileStrengthExperienceCollection": "Ukusanyaji wa Uzoefu", + "profileStrengthSkillsDiscovery": "Ugunduzi wa Ujuzi", + "profileStrengthPreferences": "Kubaini Mapendeleo", + "profileStrengthCareerReadiness": "Kujiandaa kwa Kazi", + "profileStrengthCareerExplorer": "Mchunguzi wa Kazi", "viewMyCvSubtitle": "Pakua na kushiriki Ripoti Yako ya Ujuzi" }, "modules": { diff --git a/frontend-new/src/navigation/NavBar/NavBar.stories.tsx b/frontend-new/src/navigation/NavBar/NavBar.stories.tsx index 87aad26e..2da567cc 100644 --- a/frontend-new/src/navigation/NavBar/NavBar.stories.tsx +++ b/frontend-new/src/navigation/NavBar/NavBar.stories.tsx @@ -20,8 +20,10 @@ const mockProfileContext: UseUserProfileResult = { programmeSkills: [], totalExperiences: 0, exploredExperiences: 0, + experiences: [], modules: [], skillsInterestsProgress: 0, + conversationPhase: null, careerExplorerSectors: [], }, refreshProfileData: () => {}, diff --git a/frontend-new/src/profile/Profile.stories.tsx b/frontend-new/src/profile/Profile.stories.tsx index 8d7c9f4f..c4195fd3 100644 --- a/frontend-new/src/profile/Profile.stories.tsx +++ b/frontend-new/src/profile/Profile.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Profile, ProfileProps } from "./Profile"; import type { ModuleSummary } from "src/careerReadiness/types"; +import { ConversationPhase } from "src/chat/chatProgressbar/types"; const makeModule = (id: string, status: ModuleSummary["status"]): ModuleSummary => ({ id, @@ -79,13 +80,14 @@ const createProfileState = (overrides?: Partial): ProfileProps => ], totalExperiences: 2, exploredExperiences: 1, + experiences: [], modules: [ makeModule("skills_discovery", "COMPLETED"), makeModule("career_discovery", "IN_PROGRESS"), makeModule("job_readiness", "UNLOCKED"), makeModule("career_explorer", "NOT_STARTED"), ], - skillsInterestsProgress: 75, + conversationPhase: { phase: ConversationPhase.DIVE_IN, percentage: 50, current: null, total: null }, careerExplorerSectors: [], // Loading states @@ -93,6 +95,7 @@ const createProfileState = (overrides?: Partial): ProfileProps => isLoadingPreferences: false, isLoadingProfile: false, isLoadingSkills: false, + isLoadingProgress: false, isLoadingCareerExplorer: false, }; diff --git a/frontend-new/src/profile/Profile.test.tsx b/frontend-new/src/profile/Profile.test.tsx index db2c7299..cb3f6d99 100644 --- a/frontend-new/src/profile/Profile.test.tsx +++ b/frontend-new/src/profile/Profile.test.tsx @@ -79,13 +79,15 @@ const getDefaultProps = (overrides?: Partial): ProfileProps => ({ educationSkills: [], totalExperiences: 0, exploredExperiences: 0, + experiences: [], modules: [], - skillsInterestsProgress: 0, + conversationPhase: null, careerExplorerSectors: [], isLoadingSecurity: false, isLoadingPreferences: false, isLoadingProfile: false, isLoadingSkills: false, + isLoadingProgress: false, isLoadingCareerExplorer: false, ...overrides, }); diff --git a/frontend-new/src/profile/Profile.tsx b/frontend-new/src/profile/Profile.tsx index e32f7396..af6caddb 100644 --- a/frontend-new/src/profile/Profile.tsx +++ b/frontend-new/src/profile/Profile.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, useTheme, Stack } from "@mui/material"; -import { Skill } from "src/experiences/experienceService/experiences.types"; +import { Experience, Skill } from "src/experiences/experienceService/experiences.types"; import { SecurityCard } from "./components/SecurityCard/SecurityCard"; import { PreferencesCard } from "./components/PreferencesCard/PreferencesCard"; import { ProfileCard } from "./components/ProfileCard/ProfileCard"; @@ -10,6 +10,8 @@ import { ModuleProgressCard } from "./components/ModuleProgressCard/ModuleProgre import CareerReadinessProgressBanner from "src/careerReadiness/components/CareerReadinessProgressBanner/CareerReadinessProgressBanner"; import type { ModuleSummary } from "src/careerReadiness/types"; import type { UserSectorEngagementItem } from "src/careerExplorer/services/CareerExplorerService"; +import type { CurrentPhase } from "src/chat/chatProgressbar/types"; +import { calculateProfileStrength } from "./utils/calculateProfileStrength"; const uniqueId = "a7f8e4b2-9c3d-4a1e-8f6b-2d3e4a5b6c7d"; @@ -30,13 +32,15 @@ export interface ProfileProps { educationSkills: Skill[]; totalExperiences: number; exploredExperiences: number; + experiences: Experience[]; modules: ModuleSummary[]; - skillsInterestsProgress: number; + conversationPhase: CurrentPhase | null; careerExplorerSectors: UserSectorEngagementItem[]; isLoadingSecurity: boolean; isLoadingPreferences: boolean; isLoadingProfile: boolean; isLoadingSkills: boolean; + isLoadingProgress: boolean; isLoadingCareerExplorer: boolean; } @@ -53,27 +57,29 @@ export const Profile: React.FC = ({ educationSkills, totalExperiences, exploredExperiences, + experiences, modules, - skillsInterestsProgress: _skillsInterestsProgress, + conversationPhase, careerExplorerSectors, isLoadingSecurity, isLoadingPreferences, isLoadingProfile, isLoadingSkills, + isLoadingProgress, isLoadingCareerExplorer, }) => { const theme = useTheme(); - const clampPercentage = (value: number) => Math.max(0, Math.min(100, Math.round(value))); - // Education Skills are programme skills only - const educationProgress = clampPercentage(educationSkills.length > 0 ? 100 : 0); - - // Work progress reflects explored experiences - const workProgress = - totalExperiences > 0 ? clampPercentage((exploredExperiences / totalExperiences) * 100) : clampPercentage(0); - - // Overall Profile Strength equally weights Education Skills and Work & Other Skills - const overallProgress = clampPercentage((educationProgress + workProgress) / 2); + const profileStrength = calculateProfileStrength({ + phase: conversationPhase?.phase ?? null, + phaseCurrent: conversationPhase?.current ?? null, + phaseTotal: conversationPhase?.total ?? null, + totalExperiences, + exploredExperiences, + experiences, + modules, + sectors: careerExplorerSectors, + }); return ( = ({ }} > - + diff --git a/frontend-new/src/profile/ProfileContainer.tsx b/frontend-new/src/profile/ProfileContainer.tsx index 186fa0c7..0b742a24 100644 --- a/frontend-new/src/profile/ProfileContainer.tsx +++ b/frontend-new/src/profile/ProfileContainer.tsx @@ -16,6 +16,7 @@ const ProfileContainer: React.FC = () => { isLoadingPreferences, isLoadingProfile, isLoadingSkills, + isLoadingModules, isLoadingCareerExplorer, refreshModules, } = useUserProfileContext(); @@ -49,13 +50,15 @@ const ProfileContainer: React.FC = () => { educationSkills={profileData.educationSkills} totalExperiences={profileData.totalExperiences} exploredExperiences={profileData.exploredExperiences} + experiences={profileData.experiences} modules={profileData.modules} - skillsInterestsProgress={profileData.skillsInterestsProgress} + conversationPhase={profileData.conversationPhase} careerExplorerSectors={profileData.careerExplorerSectors} isLoadingSecurity={isLoadingSecurity} isLoadingPreferences={isLoadingPreferences} isLoadingProfile={isLoadingProfile} isLoadingSkills={isLoadingSkills} + isLoadingProgress={isLoadingModules} isLoadingCareerExplorer={isLoadingCareerExplorer} /> diff --git a/frontend-new/src/profile/UserProfileContext.tsx b/frontend-new/src/profile/UserProfileContext.tsx index 0515be3d..f0579e61 100644 --- a/frontend-new/src/profile/UserProfileContext.tsx +++ b/frontend-new/src/profile/UserProfileContext.tsx @@ -17,8 +17,10 @@ const defaultUserProfileContext: UseUserProfileResult = { programmeSkills: [], totalExperiences: 0, exploredExperiences: 0, + experiences: [], modules: [], skillsInterestsProgress: 0, + conversationPhase: null, careerExplorerSectors: [], }, refreshProfileData: () => {}, diff --git a/frontend-new/src/profile/__snapshots__/Profile.test.tsx.snap b/frontend-new/src/profile/__snapshots__/Profile.test.tsx.snap index 009e0fee..10fdc7da 100644 --- a/frontend-new/src/profile/__snapshots__/Profile.test.tsx.snap +++ b/frontend-new/src/profile/__snapshots__/Profile.test.tsx.snap @@ -54,21 +54,21 @@ exports[`Profile Component Rendering should render all profile sections with cor
- 0 + 5 %
@@ -76,19 +76,49 @@ exports[`Profile Component Rendering should render all profile sections with cor class="MuiBox-root css-160qnla" >
+

+ Experience Collection +

+ + + +

+ 20 + % +

+
+

- Education Skills + Skills Discovery

+ 25 + % +

+
+
+

+ Preference Elicitation +

+ + + +

+ 20 + % +

+
+
+

+ Career Readiness +

+ + + +

- 0 + 30 %

- Work & Other Skills + Career Explorer

- 0 + 5 %

@@ -549,19 +638,107 @@ exports[`Profile Component Rendering should render correctly with partial data 1 class="MuiBox-root css-160qnla" >
+

+ Experience Collection +

+ + + +

+ 20 + % +

+
+
+

+ Skills Discovery +

+ + + +

+ 25 + % +

+
+
+

+ Preference Elicitation +

+ + + +

+ 20 + % +

+
+

- Education Skills + Career Readiness

- 0 + 30 %

- Work & Other Skills + Career Explorer

- 0 + 5 %

@@ -1022,19 +1200,107 @@ exports[`Profile Component Rendering should render with null data showing 'Not a class="MuiBox-root css-160qnla" >
+

+ Experience Collection +

+ + + +

+ 20 + % +

+
+
+

+ Skills Discovery +

+ + + +

+ 25 + % +

+
+
+

+ Preference Elicitation +

+ + + +

+ 20 + % +

+
+

- Education Skills + Career Readiness

- 0 + 30 %

- Work & Other Skills + Career Explorer

- 0 + 5 %

diff --git a/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.stories.tsx b/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.stories.tsx index 031bdd75..2c2a9fc8 100644 --- a/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.stories.tsx +++ b/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.stories.tsx @@ -1,5 +1,21 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ModuleProgressCard } from "./ModuleProgressCard"; +import type { ProfileStrengthBreakdown } from "src/profile/utils/calculateProfileStrength"; + +const getBreakdown = ( + experienceCollection: number, + skillsDiscovery: number, + preferences: number, + careerReadiness: number, + careerExplorer: number +): ProfileStrengthBreakdown => ({ + experienceCollection: { points: experienceCollection, max: 20 }, + skillsDiscovery: { points: skillsDiscovery, max: 25 }, + preferences: { points: preferences, max: 20 }, + careerReadiness: { points: careerReadiness, max: 30 }, + careerExplorer: { points: careerExplorer, max: 5 }, + overall: Math.round(experienceCollection + skillsDiscovery + preferences + careerReadiness + careerExplorer), +}); const meta: Meta = { title: "Profile/Components/ModuleProgressCard", @@ -13,64 +29,37 @@ type Story = StoryObj; export const Default: Story = { args: { - overallProgress: 78, - educationProgress: 100, - workProgress: 55, - }, -}; - -export const SingleModule: Story = { - args: { - overallProgress: 100, - educationProgress: 100, - workProgress: 100, - }, -}; - -export const AllCompleted: Story = { - args: { - overallProgress: 100, - educationProgress: 100, - workProgress: 100, + profileStrength: getBreakdown(15, 12.5, 0, 10, 5), }, }; -export const AllInProgress: Story = { +export const NotStarted: Story = { args: { - overallProgress: 52, - educationProgress: 67, - workProgress: 38, + profileStrength: getBreakdown(0, 0, 0, 0, 0), }, }; -export const JustStarted: Story = { +export const MidJourney: Story = { args: { - overallProgress: 10, - educationProgress: 0, - workProgress: 20, + profileStrength: getBreakdown(10, 8.33, 0, 5, 0), }, }; -export const ManyModules: Story = { +export const ConversationFinished: Story = { args: { - overallProgress: 85, - educationProgress: 100, - workProgress: 70, + profileStrength: getBreakdown(20, 25, 20, 0, 0), }, }; -export const LongModuleNames: Story = { +export const FullyComplete: Story = { args: { - overallProgress: 63, - educationProgress: 67, - workProgress: 60, + profileStrength: getBreakdown(20, 25, 20, 30, 5), }, }; -export const NoModules: Story = { +export const Loading: Story = { args: { - overallProgress: 0, - educationProgress: 0, - workProgress: 0, + profileStrength: getBreakdown(0, 0, 0, 0, 0), + isLoading: true, }, }; diff --git a/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.tsx b/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.tsx index 398e6906..2ae1f9ad 100644 --- a/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.tsx +++ b/frontend-new/src/profile/components/ModuleProgressCard/ModuleProgressCard.tsx @@ -1,6 +1,8 @@ import React from "react"; -import { Box, Card, CardContent, Typography, LinearProgress, useTheme } from "@mui/material"; +import { Box, Card, CardContent, Typography, LinearProgress, Skeleton, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; +import type { ProfileStrengthBreakdown } from "src/profile/utils/calculateProfileStrength"; +import type { TranslationKey } from "src/react-i18next"; const uniqueId = "module-progress-card-b7e3f4a5-8c9d-1e2f-3a4b-5c6d7e8f9a1b"; @@ -11,21 +13,22 @@ export const DATA_TEST_ID = { }; export interface ModuleProgressCardProps { - overallProgress: number; - educationProgress: number; - workProgress: number; + profileStrength: ProfileStrengthBreakdown; + isLoading?: boolean; } -export const ModuleProgressCard: React.FC = ({ - overallProgress, - educationProgress, - workProgress, -}) => { +const COMPONENT_ROWS: { key: keyof Omit; labelKey: TranslationKey }[] = [ + { key: "experienceCollection", labelKey: "home.profile.profileStrengthExperienceCollection" }, + { key: "skillsDiscovery", labelKey: "home.profile.profileStrengthSkillsDiscovery" }, + { key: "preferences", labelKey: "home.profile.profileStrengthPreferences" }, + { key: "careerReadiness", labelKey: "home.profile.profileStrengthCareerReadiness" }, + { key: "careerExplorer", labelKey: "home.profile.profileStrengthCareerExplorer" }, +]; + +export const ModuleProgressCard: React.FC = ({ profileStrength, isLoading = false }) => { const theme = useTheme(); const { t } = useTranslation(); - const safeOverallProgress = Math.max(0, Math.min(100, overallProgress)); - const safeEducationProgress = Math.max(0, Math.min(100, educationProgress)); - const safeWorkProgress = Math.max(0, Math.min(100, workProgress)); + const safeOverallProgress = Math.max(0, Math.min(100, Math.round(profileStrength.overall))); return ( @@ -53,86 +56,75 @@ export const ModuleProgressCard: React.FC = ({ gap: 4, }} > - {/* Main Giant Progress */} - - - {safeOverallProgress}% - - - - - {/* Breakdowns */} - - - - {t("home.profile.educationSkills")} - - - - {safeEducationProgress}% - + {isLoading ? ( + + + + {COMPONENT_ROWS.map((row) => ( + + ))} + ) : ( + <> + {/* Main Giant Progress */} + + + {safeOverallProgress}% + + + - - - {t("home.profile.workSkills")} - - - - {safeWorkProgress}% - - - + {/* Breakdowns */} + + {COMPONENT_ROWS.map((row, index) => { + const { points, max } = profileStrength[row.key]; + const label = t(row.labelKey); + const safeComponentProgress = Math.max(0, Math.min(100, (points / max) * 100)); + return ( + + + {label} + + + + {max}% + + + ); + })} + + + )} diff --git a/frontend-new/src/profile/hooks/useUserProfile.test.ts b/frontend-new/src/profile/hooks/useUserProfile.test.ts index 669c1567..62e943f5 100644 --- a/frontend-new/src/profile/hooks/useUserProfile.test.ts +++ b/frontend-new/src/profile/hooks/useUserProfile.test.ts @@ -4,10 +4,13 @@ import "src/_test_utilities/envServiceMock"; import { renderHook, waitFor } from "src/_test_utilities/test-utils"; import { useUserProfile } from "./useUserProfile"; import UserMeService from "src/userMe/UserMeService"; +import ChatService from "src/chat/ChatService/ChatService"; import type { ModuleSummary } from "src/careerReadiness/types"; +import { ConversationPhase } from "src/chat/chatProgressbar/types"; // Mock all external dependencies jest.mock("src/userMe/UserMeService"); +jest.mock("src/chat/ChatService/ChatService"); jest.mock("src/auth/services/AuthenticationState.service", () => ({ __esModule: true, default: { getInstance: jest.fn(() => ({ getUser: jest.fn(() => ({ id: "user-1", email: "test@test.com" })) })) }, @@ -17,13 +20,19 @@ jest.mock("src/userPreferences/UserPreferencesStateService", () => ({ default: { getInstance: jest.fn(() => ({ getUserPreferences: jest.fn(() => ({ accepted_tc: null, language: "en" })), - getActiveSessionId: jest.fn(() => null), + getActiveSessionId: jest.fn(() => 123), })), }, })); jest.mock("./utils/fetchSkills", () => ({ fetchSkills: jest.fn(() => - Promise.resolve({ workSkills: [], educationSkills: [], totalExperiences: 0, exploredExperiences: 0 }) + Promise.resolve({ + workSkills: [], + educationSkills: [], + totalExperiences: 3, + exploredExperiences: 1, + experiences: [{ UUID: "exp-1" }, { UUID: "exp-2" }, { UUID: "exp-3" }], + }) ), })); @@ -57,6 +66,21 @@ const mockProfileResponse = { programme_skills: [], }; +const mockConversationPhase = { + percentage: 33, + phase: ConversationPhase.COLLECT_EXPERIENCES, + current: 2, + total: 2, +}; + +const mockChatHistoryResponse = { + messages: [], + conversation_completed: false, + conversation_conducted_at: null, + experiences_explored: 0, + current_phase: mockConversationPhase, +}; + const mockProgressResponse = { skills_interests_progress: 0, career_readiness_modules: mockModules, @@ -66,16 +90,21 @@ const mockProgressResponse = { describe("useUserProfile", () => { let mockGetProfile: jest.Mock; let mockGetProgress: jest.Mock; + let mockGetChatHistory: jest.Mock; beforeEach(() => { jest.clearAllMocks(); mockGetProfile = jest.fn(() => Promise.resolve(mockProfileResponse)); mockGetProgress = jest.fn(() => Promise.resolve(mockProgressResponse)); + mockGetChatHistory = jest.fn(() => Promise.resolve(mockChatHistoryResponse)); (UserMeService.getInstance as jest.Mock).mockReturnValue({ getProfile: mockGetProfile, getProgress: mockGetProgress, }); + (ChatService.getInstance as jest.Mock).mockReturnValue({ + getChatHistory: mockGetChatHistory, + }); }); test("should fetch and return module statuses from UserMeService.getProgress", async () => { @@ -90,6 +119,50 @@ describe("useUserProfile", () => { expect(result.current.profileData.modules).toEqual(mockModules); }); + test("should populate the conversation phase from the chat history", async () => { + // WHEN the hook is rendered + const { result } = renderHook(() => useUserProfile()); + + // THEN eventually the conversation phase is populated from the chat history of the active session + await waitFor(() => { + expect(result.current.isLoadingModules).toBe(false); + }); + + expect(mockGetChatHistory).toHaveBeenCalledWith(123); + expect(result.current.profileData.conversationPhase).toEqual(mockConversationPhase); + }); + + test("should leave the conversation phase null when the chat history fetch fails", async () => { + // GIVEN the chat history fetch fails + mockGetChatHistory.mockRejectedValue(new Error("Network error")); + + // WHEN the hook is rendered + const { result } = renderHook(() => useUserProfile()); + + // THEN the rest of the progress data still loads and the phase stays null + await waitFor(() => { + expect(result.current.isLoadingModules).toBe(false); + }); + + expect(result.current.profileData.conversationPhase).toBeNull(); + expect(result.current.profileData.modules).toEqual(mockModules); + expect(result.current.errors.modules).toBeNull(); + }); + + test("should populate experiences from fetchSkills", async () => { + // WHEN the hook is rendered + const { result } = renderHook(() => useUserProfile()); + + // THEN eventually the experiences are populated from fetchSkills + await waitFor(() => { + expect(result.current.isLoadingSkills).toBe(false); + }); + + expect(result.current.profileData.totalExperiences).toBe(3); + expect(result.current.profileData.exploredExperiences).toBe(1); + expect(result.current.profileData.experiences).toHaveLength(3); + }); + test("should set modules error when progress API call fails", async () => { // GIVEN the service throws (UserMeService.getInstance as jest.Mock).mockReturnValue({ diff --git a/frontend-new/src/profile/hooks/useUserProfile.ts b/frontend-new/src/profile/hooks/useUserProfile.ts index 22a55e85..897f3d5f 100644 --- a/frontend-new/src/profile/hooks/useUserProfile.ts +++ b/frontend-new/src/profile/hooks/useUserProfile.ts @@ -1,12 +1,14 @@ import { useCallback, useContext, useEffect, useState } from "react"; import { ReconnectVersionContext } from "src/app/isOnlineProvider/IsOnlineProvider"; import { fetchSkills } from "./utils/fetchSkills"; -import { Skill } from "src/experiences/experienceService/experiences.types"; +import { Experience, Skill } from "src/experiences/experienceService/experiences.types"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import authenticationStateService from "src/auth/services/AuthenticationState.service"; import UserMeService from "src/userMe/UserMeService"; +import ChatService from "src/chat/ChatService/ChatService"; import type { UserSectorEngagementItem } from "src/careerExplorer/services/CareerExplorerService"; import type { ModuleSummary } from "src/careerReadiness/types"; +import type { CurrentPhase } from "src/chat/chatProgressbar/types"; export interface UserProfileData { name: string | null; @@ -22,8 +24,10 @@ export interface UserProfileData { programmeSkills: string[]; totalExperiences: number; exploredExperiences: number; + experiences: Experience[]; modules: ModuleSummary[]; skillsInterestsProgress: number; + conversationPhase: CurrentPhase | null; careerExplorerSectors: UserSectorEngagementItem[]; } @@ -95,8 +99,10 @@ export const useUserProfile = (): UseUserProfileResult => { const [programmeSkills, setProgrammeSkills] = useState([]); const [totalExperiences, setTotalExperiences] = useState(0); const [exploredExperiences, setExploredExperiences] = useState(0); + const [experiences, setExperiences] = useState([]); const [modules, setModules] = useState([]); const [skillsInterestsProgress, setSkillsInterestsProgress] = useState(0); + const [conversationPhase, setConversationPhase] = useState(null); const [careerExplorerSectors, setCareerExplorerSectors] = useState([]); // Individual error states @@ -218,6 +224,7 @@ export const useUserProfile = (): UseUserProfileResult => { setSkills(skillsResult.value.workSkills); setTotalExperiences(skillsResult.value.totalExperiences ?? 0); setExploredExperiences(skillsResult.value.exploredExperiences ?? 0); + setExperiences(skillsResult.value.experiences ?? []); } } else { const error = profileResult.reason as any; @@ -234,6 +241,7 @@ export const useUserProfile = (): UseUserProfileResult => { setSkills(skillsResult.value.workSkills); setTotalExperiences(skillsResult.value.totalExperiences ?? 0); setExploredExperiences(skillsResult.value.exploredExperiences ?? 0); + setExperiences(skillsResult.value.experiences ?? []); setEducationSkills([]); } } @@ -267,10 +275,23 @@ export const useUserProfile = (): UseUserProfileResult => { } try { const sessionId = UserPreferencesStateService.getInstance().getActiveSessionId(); + const progressResponse = await UserMeService.getInstance().getProgress(sessionId); setSkillsInterestsProgress(progressResponse.skills_interests_progress); setModules(progressResponse.career_readiness_modules); setCareerExplorerSectors(progressResponse.sector_engagement); + + // Fetch the conversation phase from chat history for profile strength (non-fatal if missing). + let currentPhase: CurrentPhase | null = null; + if (sessionId != null) { + try { + const history = await ChatService.getInstance().getChatHistory(sessionId); + currentPhase = history.current_phase ?? null; + } catch (error) { + console.warn("Failed to fetch chat history for profile strength", error); + } + } + setConversationPhase(currentPhase); } catch (error) { console.error("Error fetching user progress:", error); if (opts.setLoading) { @@ -309,8 +330,10 @@ export const useUserProfile = (): UseUserProfileResult => { programmeSkills, totalExperiences, exploredExperiences, + experiences, modules, skillsInterestsProgress, + conversationPhase, careerExplorerSectors, }; diff --git a/frontend-new/src/profile/hooks/utils/fetchSkills.ts b/frontend-new/src/profile/hooks/utils/fetchSkills.ts index 446809f0..093b67ac 100644 --- a/frontend-new/src/profile/hooks/utils/fetchSkills.ts +++ b/frontend-new/src/profile/hooks/utils/fetchSkills.ts @@ -8,6 +8,7 @@ export interface FetchSkillsResult { educationSkills: Skill[]; totalExperiences: number; exploredExperiences: number; + experiences: Experience[]; } const aggregateTopSkills = (experiences: Experience[]): Skill[] => { @@ -29,7 +30,7 @@ export async function fetchSkills(): Promise { const sessions = userPreferences?.sessions || []; if (sessions.length === 0) { - return { workSkills: [], educationSkills: [], totalExperiences: 0, exploredExperiences: 0 }; + return { workSkills: [], educationSkills: [], totalExperiences: 0, exploredExperiences: 0, experiences: [] }; } // Fetch experiences for all sessions in parallel @@ -55,5 +56,6 @@ export async function fetchSkills(): Promise { educationSkills: [], totalExperiences, exploredExperiences, + experiences: allExperiences, }; } diff --git a/frontend-new/src/profile/utils/calculateProfileStrength.test.ts b/frontend-new/src/profile/utils/calculateProfileStrength.test.ts new file mode 100644 index 00000000..283c8e5e --- /dev/null +++ b/frontend-new/src/profile/utils/calculateProfileStrength.test.ts @@ -0,0 +1,386 @@ +import { calculateProfileStrength, ProfileStrengthInput } from "./calculateProfileStrength"; +import { ConversationPhase } from "src/chat/chatProgressbar/types"; +import { DiveInPhase, Experience, WorkType } from "src/experiences/experienceService/experiences.types"; +import type { ModuleSummary, ModuleStatus } from "src/careerReadiness/types"; +import type { UserSectorEngagementItem } from "src/careerExplorer/services/CareerExplorerService"; + +const getModule = (status: ModuleStatus, index: number = 0): ModuleSummary => ({ + id: `module-${index}`, + title: `Module ${index}`, + description: "", + icon: "", + status, + sort_order: index + 1, + input_placeholder: "", + active_conversation_id: null, + topics: [], +}); + +const getExperience = (workType: WorkType | null): Experience => ({ + UUID: `experience-${workType ?? "none"}`, + timeline: { start: "", end: "" }, + experience_title: "", + company: "", + location: "", + work_type: workType, + top_skills: [], + remaining_skills: [], + summary: null, + exploration_phase: DiveInPhase.NOT_STARTED, +}); + +const paidExperience = getExperience(WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT); +const unpaidExperience = getExperience(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK); + +const getSector = (inquiryCount: number): UserSectorEngagementItem => ({ + sector_name: "Agriculture", + is_priority: false, + inquiry_count: inquiryCount, + last_asked_at: null, +}); + +const getDefaultInput = (overrides: Partial = {}): ProfileStrengthInput => ({ + phase: null, + phaseCurrent: null, + phaseTotal: null, + totalExperiences: 0, + exploredExperiences: 0, + experiences: [], + modules: [], + sectors: [], + ...overrides, +}); + +describe("calculateProfileStrength", () => { + describe("experience collection component (max 20)", () => { + test("should give 0 points when there is no conversation and no experiences", () => { + // GIVEN a user with no conversation phase and no experiences + const input = getDefaultInput(); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the experience collection component is 0 + expect(breakdown.experienceCollection).toEqual({ points: 0, max: 20 }); + expect(breakdown.overall).toBe(0); + }); + + test("should give 5 points per work type with at least one collected experience while still collecting the first work type", () => { + // GIVEN a conversation still on the paid work question (current=1) with 1 paid and 1 unpaid experience + const input = getDefaultInput({ + phase: ConversationPhase.COLLECT_EXPERIENCES, + phaseCurrent: 1, + totalExperiences: 2, + experiences: [paidExperience, unpaidExperience], + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN both work types get partial credit (5 + 5) + expect(breakdown.experienceCollection).toEqual({ points: 10, max: 20 }); + }); + + test("should give 10 points for paid once the conversation moved to the second work type", () => { + // GIVEN a conversation on the unpaid work question (current=2) with no experiences collected + const input = getDefaultInput({ + phase: ConversationPhase.COLLECT_EXPERIENCES, + phaseCurrent: 2, + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN paid is done (10) even without paid experiences, unpaid is not started (0) + expect(breakdown.experienceCollection).toEqual({ points: 10, max: 20 }); + }); + + test("should give partial unpaid credit while paid is done and unpaid is being collected", () => { + // GIVEN a conversation on the unpaid work question with 1 unpaid experience collected + const input = getDefaultInput({ + phase: ConversationPhase.COLLECT_EXPERIENCES, + phaseCurrent: 2, + totalExperiences: 1, + experiences: [unpaidExperience], + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN paid is done (10) and unpaid gets partial credit (5) + expect(breakdown.experienceCollection).toEqual({ points: 15, max: 20 }); + }); + + test.each([ + ConversationPhase.DIVE_IN, + ConversationPhase.PREFERENCE_ELICITATION, + ConversationPhase.RECOMMENDATION, + ConversationPhase.ENDED, + ])("should give the full 20 points when the phase is %s, even with no experiences", (phase) => { + // GIVEN a conversation past the collection phase with no experiences (user said no to both) + const input = getDefaultInput({ phase }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the full experience collection points are awarded + expect(breakdown.experienceCollection).toEqual({ points: 20, max: 20 }); + }); + + test.each([ConversationPhase.INTRO, ConversationPhase.INITIALIZING, ConversationPhase.UNKNOWN])( + "should treat phase %s as not past collection", + (phase) => { + // GIVEN a conversation that has not reached the collection phase, with 1 paid experience + const input = getDefaultInput({ phase, totalExperiences: 1, experiences: [paidExperience] }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN only the paid partial credit is awarded + expect(breakdown.experienceCollection).toEqual({ points: 5, max: 20 }); + } + ); + + test("should not treat phaseCurrent as meaningful outside COLLECT_EXPERIENCES", () => { + // GIVEN an INTRO phase with a (stale) phaseCurrent of 2 + const input = getDefaultInput({ phase: ConversationPhase.INTRO, phaseCurrent: 2 }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN no experience collection points are awarded + expect(breakdown.experienceCollection).toEqual({ points: 0, max: 20 }); + }); + }); + + describe("skills discovery component (max 25)", () => { + test("should give 0 points when there are no experiences", () => { + // GIVEN no collected experiences + const input = getDefaultInput({ totalExperiences: 0, exploredExperiences: 0 }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the skills discovery component is 0 + expect(breakdown.skillsDiscovery).toEqual({ points: 0, max: 25 }); + }); + + test("should scale points with the share of explored experiences", () => { + // GIVEN 2 of 4 experiences explored + const input = getDefaultInput({ totalExperiences: 4, exploredExperiences: 2 }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN half of the skills discovery points are awarded + expect(breakdown.skillsDiscovery).toEqual({ points: 12.5, max: 25 }); + }); + + test("should give the full 25 points when all experiences are explored", () => { + // GIVEN all experiences explored + const input = getDefaultInput({ totalExperiences: 3, exploredExperiences: 3 }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the full skills discovery points are awarded + expect(breakdown.skillsDiscovery).toEqual({ points: 25, max: 25 }); + }); + }); + + describe("preferences component (max 20)", () => { + test.each([ConversationPhase.RECOMMENDATION, ConversationPhase.ENDED])( + "should give 20 points when the phase is %s", + (phase) => { + // GIVEN a conversation past preference elicitation + const input = getDefaultInput({ phase }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the full preferences points are awarded + expect(breakdown.preferences).toEqual({ points: 20, max: 20 }); + } + ); + + test.each([null, ConversationPhase.INTRO, ConversationPhase.COLLECT_EXPERIENCES, ConversationPhase.DIVE_IN])( + "should give 0 points when the phase is %s", + (phase) => { + // GIVEN a conversation that has not reached preference elicitation + const input = getDefaultInput({ phase }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN no preferences points are awarded + expect(breakdown.preferences).toEqual({ points: 0, max: 20 }); + } + ); + + test("should give proportional credit while preference elicitation is in progress", () => { + // GIVEN a preference elicitation flow halfway through (3 of 6 steps) + const input = getDefaultInput({ + phase: ConversationPhase.PREFERENCE_ELICITATION, + phaseCurrent: 3, + phaseTotal: 6, + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN half of the preferences points are awarded + expect(breakdown.preferences).toEqual({ points: 10, max: 20 }); + }); + + test("should cap the proportional preferences credit at 20 points", () => { + // GIVEN a preference elicitation sub-progress reporting more steps than the total + const input = getDefaultInput({ + phase: ConversationPhase.PREFERENCE_ELICITATION, + phaseCurrent: 8, + phaseTotal: 6, + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the preferences points are capped at the maximum + expect(breakdown.preferences).toEqual({ points: 20, max: 20 }); + }); + + test("should give 0 points during preference elicitation when the sub-progress is unavailable", () => { + // GIVEN a preference elicitation phase without current/total counters + const input = getDefaultInput({ phase: ConversationPhase.PREFERENCE_ELICITATION }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN no preferences points are awarded + expect(breakdown.preferences).toEqual({ points: 0, max: 20 }); + }); + }); + + describe("career readiness component (max 30)", () => { + test("should give 5 points per completed module", () => { + // GIVEN 2 completed modules out of 6 + const modules = [ + getModule("COMPLETED", 0), + getModule("COMPLETED", 1), + getModule("IN_PROGRESS", 2), + getModule("NOT_STARTED", 3), + getModule("NOT_STARTED", 4), + getModule("NOT_STARTED", 5), + ]; + const input = getDefaultInput({ modules }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN 10 career readiness points are awarded + expect(breakdown.careerReadiness).toEqual({ points: 10, max: 30 }); + }); + + test("should give the full 30 points when all 6 modules are completed", () => { + // GIVEN 6 completed modules + const modules = Array.from({ length: 6 }, (_, i) => getModule("COMPLETED", i)); + const input = getDefaultInput({ modules }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the full career readiness points are awarded + expect(breakdown.careerReadiness).toEqual({ points: 30, max: 30 }); + }); + + test("should cap the points at 30 even with more than 6 completed modules", () => { + // GIVEN 7 completed modules + const modules = Array.from({ length: 7 }, (_, i) => getModule("COMPLETED", i)); + const input = getDefaultInput({ modules }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the career readiness points are capped at 30 + expect(breakdown.careerReadiness).toEqual({ points: 30, max: 30 }); + }); + }); + + describe("career explorer component (max 5)", () => { + test("should give 0 points when the user asked no sector questions", () => { + // GIVEN sectors with no inquiries + const input = getDefaultInput({ sectors: [getSector(0)] }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN no career explorer points are awarded + expect(breakdown.careerExplorer).toEqual({ points: 0, max: 5 }); + }); + + test("should give 5 points when the user asked at least one sector question", () => { + // GIVEN a sector with one inquiry + const input = getDefaultInput({ sectors: [getSector(1)] }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the full career explorer points are awarded + expect(breakdown.careerExplorer).toEqual({ points: 5, max: 5 }); + }); + }); + + describe("overall", () => { + test("should sum to 100 when everything is complete", () => { + // GIVEN a fully complete profile + const input = getDefaultInput({ + phase: ConversationPhase.ENDED, + totalExperiences: 3, + exploredExperiences: 3, + experiences: [paidExperience, unpaidExperience], + modules: Array.from({ length: 6 }, (_, i) => getModule("COMPLETED", i)), + sectors: [getSector(2)], + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the overall strength is 100 + expect(breakdown.overall).toBe(100); + }); + + test("should round the overall to the nearest integer", () => { + // GIVEN a skills discovery ratio producing a fractional score (1/3 of 25 ≈ 8.33) + const input = getDefaultInput({ totalExperiences: 3, exploredExperiences: 1 }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN the overall is rounded + expect(breakdown.overall).toBe(8); + }); + + test("should sum a mid-journey profile correctly", () => { + // GIVEN a user still collecting unpaid experiences (paid done), 1 of 2 experiences explored, + // 1 completed module, and career explorer started + const input = getDefaultInput({ + phase: ConversationPhase.COLLECT_EXPERIENCES, + phaseCurrent: 2, + totalExperiences: 2, + exploredExperiences: 1, + experiences: [paidExperience, unpaidExperience], + modules: [getModule("COMPLETED", 0)], + sectors: [getSector(1)], + }); + + // WHEN the profile strength is calculated + const breakdown = calculateProfileStrength(input); + + // THEN each component contributes as expected: 15 + 12.5 + 0 + 5 + 5 = 37.5 → 38 + expect(breakdown.experienceCollection.points).toBe(15); + expect(breakdown.skillsDiscovery.points).toBe(12.5); + expect(breakdown.preferences.points).toBe(0); + expect(breakdown.careerReadiness.points).toBe(5); + expect(breakdown.careerExplorer.points).toBe(5); + expect(breakdown.overall).toBe(38); + }); + }); +}); diff --git a/frontend-new/src/profile/utils/calculateProfileStrength.ts b/frontend-new/src/profile/utils/calculateProfileStrength.ts new file mode 100644 index 00000000..ee2ff6c0 --- /dev/null +++ b/frontend-new/src/profile/utils/calculateProfileStrength.ts @@ -0,0 +1,144 @@ +import { ConversationPhase } from "src/chat/chatProgressbar/types"; +import { Experience, WorkType } from "src/experiences/experienceService/experiences.types"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import type { UserSectorEngagementItem } from "src/careerExplorer/services/CareerExplorerService"; + +export interface ProfileStrengthInput { + phase: ConversationPhase | null; + phaseCurrent: number | null; + phaseTotal: number | null; + totalExperiences: number; + exploredExperiences: number; + experiences: Experience[]; + modules: ModuleSummary[]; + sectors: UserSectorEngagementItem[]; +} + +export interface ProfileStrengthComponent { + points: number; + max: number; +} + +export interface ProfileStrengthBreakdown { + experienceCollection: ProfileStrengthComponent; + skillsDiscovery: ProfileStrengthComponent; + preferences: ProfileStrengthComponent; + careerReadiness: ProfileStrengthComponent; + careerExplorer: ProfileStrengthComponent; + overall: number; +} + +// Component weights, summing to 100. +export const EXPERIENCE_COLLECTION_MAX = 20; +export const SKILLS_DISCOVERY_MAX = 25; +export const PREFERENCES_MAX = 20; +export const CAREER_READINESS_MAX = 30; +export const CAREER_EXPLORER_MAX = 5; + +const POINTS_PER_COMPLETED_MODULE = 5; // per completed career-readiness module (6 modules -> 30) +const WORK_TYPE_DONE_POINTS = 10; // work-type question finished +const WORK_TYPE_PARTIAL_POINTS = 5; // still collecting, with >= 1 of that type collected +const PAID_WORK_TYPE_POSITION = 1; // paid work is asked first during a collection + +// Conversation order +const PHASE_SEQUENCE = [ + ConversationPhase.INITIALIZING, + ConversationPhase.INTRO, + ConversationPhase.COLLECT_EXPERIENCES, + ConversationPhase.DIVE_IN, + ConversationPhase.PREFERENCE_ELICITATION, + ConversationPhase.RECOMMENDATION, + ConversationPhase.ENDED, +]; + +// True once the conversation has reached `target` or a later phase. +const hasReachedPhase = (phase: ConversationPhase | null, target: ConversationPhase): boolean => { + if (phase === null) { + return false; + } + const current = PHASE_SEQUENCE.indexOf(phase); + return current !== -1 && current >= PHASE_SEQUENCE.indexOf(target); +}; + +// Full points once the work-type question is done, half for >= 1 collected while still collecting. +const getWorkTypePoints = (done: boolean, collectedCount: number): number => { + if (done) { + return WORK_TYPE_DONE_POINTS; + } + if (collectedCount >= 1) { + return WORK_TYPE_PARTIAL_POINTS; + } + return 0; +}; + +// Profile strength (0-100): sum of five weighted components. +export const calculateProfileStrength = (input: ProfileStrengthInput): ProfileStrengthBreakdown => { + const { phase, phaseCurrent, phaseTotal, totalExperiences, exploredExperiences, experiences, modules, sectors } = + input; + + const isPastCollect = hasReachedPhase(phase, ConversationPhase.DIVE_IN); + const isCollecting = phase === ConversationPhase.COLLECT_EXPERIENCES; + + const hasMovedPastPaidQuestion = isCollecting && phaseCurrent !== null && phaseCurrent > PAID_WORK_TYPE_POSITION; + const paidDone = isPastCollect || hasMovedPastPaidQuestion; + const unpaidDone = isPastCollect; + + const paidExperiencesCount = experiences.filter( + (experience) => experience.work_type === WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + ).length; + const unpaidExperiencesCount = experiences.filter( + (experience) => + experience.work_type === WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK || + experience.work_type === WorkType.UNSEEN_UNPAID + ).length; + + const paidPoints = getWorkTypePoints(paidDone, paidExperiencesCount); + const unpaidPoints = getWorkTypePoints(unpaidDone, unpaidExperiencesCount); + + const experienceCollectionPoints = paidPoints + unpaidPoints; + + const skillsDiscoveryPoints = + totalExperiences > 0 ? (exploredExperiences / totalExperiences) * SKILLS_DISCOVERY_MAX : 0; + + // Full once the recommendation is reached; otherwise proportional to elicitation sub-progress. + let preferencesPoints = 0; + if (hasReachedPhase(phase, ConversationPhase.RECOMMENDATION)) { + preferencesPoints = PREFERENCES_MAX; + } else if ( + phase === ConversationPhase.PREFERENCE_ELICITATION && + phaseCurrent !== null && + phaseTotal !== null && + phaseTotal > 0 + ) { + preferencesPoints = Math.min(phaseCurrent / phaseTotal, 1) * PREFERENCES_MAX; + } + + const completedModules = modules.filter((module) => module.status === "COMPLETED").length; + const careerReadinessPoints = Math.min(completedModules * POINTS_PER_COMPLETED_MODULE, CAREER_READINESS_MAX); + + const totalInquiries = sectors.reduce((sum, sector) => sum + sector.inquiry_count, 0); + const careerExplorerPoints = totalInquiries >= 1 ? CAREER_EXPLORER_MAX : 0; + + const overall = Math.max( + 0, + Math.min( + 100, + Math.round( + experienceCollectionPoints + + skillsDiscoveryPoints + + preferencesPoints + + careerReadinessPoints + + careerExplorerPoints + ) + ) + ); + + return { + experienceCollection: { points: experienceCollectionPoints, max: EXPERIENCE_COLLECTION_MAX }, + skillsDiscovery: { points: skillsDiscoveryPoints, max: SKILLS_DISCOVERY_MAX }, + preferences: { points: preferencesPoints, max: PREFERENCES_MAX }, + careerReadiness: { points: careerReadinessPoints, max: CAREER_READINESS_MAX }, + careerExplorer: { points: careerExplorerPoints, max: CAREER_EXPLORER_MAX }, + overall, + }; +};