From a00274118471b65ab297ef3297d10b6dcb9b288f Mon Sep 17 00:00:00 2001 From: Jed556 <84989546+Jed556@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:00:07 +0800 Subject: [PATCH 1/6] fix: Fix problems (#33) --- src/pages/Student/PanelComments.tsx | 29 +++++++++++++++++------------ src/utils/emailUtils.ts | 1 - 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/pages/Student/PanelComments.tsx b/src/pages/Student/PanelComments.tsx index a0ad2e1..5d99518 100644 --- a/src/pages/Student/PanelComments.tsx +++ b/src/pages/Student/PanelComments.tsx @@ -1,12 +1,11 @@ import * as React from 'react'; import { - Alert, Box, Button, CircularProgress, Dialog, DialogContent, DialogTitle, IconButton, - Paper, Skeleton, Stack, Tab, Tabs, Tooltip, Typography, + Alert, Box, Button, CircularProgress, Dialog, DialogContent, DialogTitle, + IconButton, Paper, Skeleton, Stack, Tab, Tabs, Tooltip, Typography, } from '@mui/material'; import { CommentBank as CommentBankIcon, CloudUpload as CloudUploadIcon, - RateReview as RequestReviewIcon, Delete as DeleteIcon, - Close as CloseIcon, Download as DownloadIcon, + RateReview as RequestReviewIcon, Close as CloseIcon, Download as DownloadIcon, } from '@mui/icons-material'; import { useSession } from '@toolpad/core'; import type { Session } from '../../types/session'; @@ -23,10 +22,8 @@ import { PanelCommentTable } from '../../components/PanelComments'; import { UnauthorizedNotice } from '../../layouts/UnauthorizedNotice'; import { useSnackbar } from '../../components/Snackbar'; import { - listenPanelCommentEntries, listenPanelCommentRelease, - updatePanelCommentStudentFields, listenPanelManuscript, - uploadPanelManuscript, requestManuscriptReview, deletePanelManuscript, - type PanelCommentContext, + listenPanelCommentEntries, listenPanelCommentRelease, updatePanelCommentStudentFields, listenPanelManuscript, + uploadPanelManuscript, requestManuscriptReview, deletePanelManuscript, type PanelCommentContext, } from '../../utils/firebase/firestore/panelComments'; import { findGroupById, getGroupsByLeader, getGroupsByMember } from '../../utils/firebase/firestore/groups'; import { findThesisByGroupId } from '../../utils/firebase/firestore/thesis'; @@ -466,7 +463,8 @@ export default function StudentPanelCommentsPage() { const auditCtx = buildAuditContextFromGroup(group); await createAuditEntry(auditCtx, { name: 'Manuscript Deleted', - description: `Manuscript "${manuscript.fileName}" deleted for ${getPanelCommentStageLabel(activeStage)} panel review`, + description: + `Manuscript "${manuscript.fileName}" deleted for ${getPanelCommentStageLabel(activeStage)} panel review`, userId: userUid, category: 'thesis', action: 'file_deleted', @@ -651,9 +649,12 @@ export default function StudentPanelCommentsPage() { setFileViewerOpen(false)} maxWidth="lg" fullWidth - PaperProps={{ sx: { height: '80vh' } }} + slotProps={{ paper: { sx: { height: '80vh' } } }} > @@ -807,9 +809,12 @@ export default function StudentPanelCommentsPage() { Date: Sun, 14 Dec 2025 19:38:28 +0800 Subject: [PATCH 2/6] fix: Fix uid fetching error --- src/pages/Admin/Management/Groups/GroupView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Admin/Management/Groups/GroupView.tsx b/src/pages/Admin/Management/Groups/GroupView.tsx index 6db7beb..328b91e 100644 --- a/src/pages/Admin/Management/Groups/GroupView.tsx +++ b/src/pages/Admin/Management/Groups/GroupView.tsx @@ -68,7 +68,7 @@ interface PanelAssignmentManagerProps { function PanelAssignmentManager({ groupId, onAssignmentsUpdated }: PanelAssignmentManagerProps) { const { showNotification } = useSnackbar(); const session = useSession(); - const adminUid = session?.user?.id; + const adminUid = session?.user?.uid; const [panelOptions, setPanelOptions] = React.useState([]); const [selectedPanelUids, setSelectedPanelUids] = React.useState([]); const [initialPanelUids, setInitialPanelUids] = React.useState([]); From 767d2871951e2c6a799dacc54ebe76dca8cbac33 Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 14 Dec 2025 20:35:38 +0800 Subject: [PATCH 3/6] fix: Update profile view to show the scores on the cards --- package.json | 2 +- src/components/Profile/ProfileView.tsx | 2 +- src/config/skills.json | 4 +- .../Student/Recommendations/ProfileView.tsx | 46 +++++++++++- src/utils/recommendUtils.ts | 70 ++++++++----------- 5 files changed, 77 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index f6560a3..c58859f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thesisflow", - "version": "5.7.1", + "version": "5.7.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/Profile/ProfileView.tsx b/src/components/Profile/ProfileView.tsx index 0875bcb..144aa05 100644 --- a/src/components/Profile/ProfileView.tsx +++ b/src/components/Profile/ProfileView.tsx @@ -144,7 +144,7 @@ export default function ProfileView({ } return skillRatings.map((entry) => ({ ...entry, - rating: Math.max(0, Math.min(typeof entry.rating === 'number' ? entry.rating : 0, 5)), + rating: Math.max(0, Math.min(typeof entry.rating === 'number' ? entry.rating : 0, 10)), })); }, [skillRatings]); diff --git a/src/config/skills.json b/src/config/skills.json index ddf69be..23f310a 100644 --- a/src/config/skills.json +++ b/src/config/skills.json @@ -230,7 +230,9 @@ "intelligent", "cognitive", "robotics", - "autonomous" + "autonomous", + "tf", + "idf" ] }, { diff --git a/src/pages/Student/Recommendations/ProfileView.tsx b/src/pages/Student/Recommendations/ProfileView.tsx index 685fee1..2acc931 100644 --- a/src/pages/Student/Recommendations/ProfileView.tsx +++ b/src/pages/Student/Recommendations/ProfileView.tsx @@ -9,7 +9,10 @@ import AnimatedPage from '../../../components/Animate/AnimatedPage/AnimatedPage' import ProfileView from '../../../components/Profile/ProfileView'; import GroupCard, { GroupCardSkeleton } from '../../../components/Group/GroupCard'; import { onUserProfile, findUsersByIds } from '../../../utils/firebase/firestore/user'; -import { listenGroupsByLeader, listenGroupsByExpertRole } from '../../../utils/firebase/firestore/groups'; +import { + listenGroupsByLeader, listenGroupsByExpertRole, getGroupsByLeader, getGroupsByMember +} from '../../../utils/firebase/firestore/groups'; +import { findThesisByGroupId } from '../../../utils/firebase/firestore/thesis'; import { createExpertRequestByGroup, listenExpertRequestsByGroup, } from '../../../utils/firebase/firestore/expertRequests'; @@ -55,6 +58,45 @@ export default function ExpertProfileViewPage() { const [requestMessage, setRequestMessage] = React.useState(''); const [requestSubmitting, setRequestSubmitting] = React.useState(false); const [groupRequests, setGroupRequests] = React.useState>(new Map()); + const [viewerThesisTitle, setViewerThesisTitle] = React.useState(null); + + // Fetch viewer's thesis title for compatibility calculation + React.useEffect(() => { + if (!viewerUid) { + setViewerThesisTitle(null); + return () => { /* no-op */ }; + } + + let cancelled = false; + void (async () => { + try { + const [leaderGroups, memberGroups] = await Promise.all([ + getGroupsByLeader(viewerUid), + getGroupsByMember(viewerUid), + ]); + if (cancelled) return; + + const combined = [...leaderGroups, ...memberGroups]; + if (combined.length === 0) return; + + combined.sort((a, b) => ( + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + )); + const primaryGroup = combined.find((g) => g.status !== 'archived') ?? combined[0]; + + const thesis = await findThesisByGroupId(primaryGroup.id); + if (!cancelled && thesis?.title) { + setViewerThesisTitle(thesis.title); + } + } catch (err) { + console.error('Failed to load viewer thesis title:', err); + } + })(); + + return () => { + cancelled = true; + }; + }, [viewerUid]); React.useEffect(() => { if (!uid) { @@ -208,7 +250,7 @@ export default function ExpertProfileViewPage() { ? `${openSlots}/${normalizedCapacity}` : `${openSlots}/0`; const compatibility = profile && expertRole - ? evaluateExpertCompatibility(profile, roleStats, expertRole) + ? evaluateExpertCompatibility(profile, roleStats, expertRole, viewerThesisTitle) : null; const sortedGroups = React.useMemo(() => { diff --git a/src/utils/recommendUtils.ts b/src/utils/recommendUtils.ts index 63a810f..89c3b88 100644 --- a/src/utils/recommendUtils.ts +++ b/src/utils/recommendUtils.ts @@ -1,5 +1,4 @@ import type { UserProfile } from '../types/profile'; -import { normalizeDateInput } from './dateUtils'; import { isCompletedGroupStatus } from './expertProfileUtils'; import type { ThesisGroup } from '../types/group'; import { devLog } from './devUtils'; @@ -242,10 +241,20 @@ export function computeExpertCards( scored.sort((a, b) => b.score - a.score); + // Normalize compatibility scores relative to the highest score + // So the best match is 100% and others are proportional + const maxCompatibility = scored.length > 0 + ? Math.max(...scored.map((s) => s.compatibility)) + : 1; + const normalizeCompatibility = (raw: number): number => { + if (maxCompatibility === 0) return 0; + return Math.round((raw / maxCompatibility) * 100); + }; + return scored.map((entry, index) => ({ profile: entry.profile, stats: entry.stats, - compatibility: entry.compatibility, + compatibility: normalizeCompatibility(entry.compatibility), capacity: entry.capacity, activeCount: entry.activeCount, openSlots: entry.openSlots, @@ -256,53 +265,30 @@ export function computeExpertCards( /** * Reusable compatibility evaluator for expert detail views. + * Uses TF-IDF based skill matching when thesis title is provided. + * @param profile Expert's user profile + * @param stats Thesis role statistics for the expert + * @param role Expert role being evaluated + * @param thesisTitle Optional thesis title for skill matching + * @returns Compatibility score (0-100) */ export function evaluateExpertCompatibility( profile: UserProfile, stats: ThesisRoleStats, - role: 'adviser' | 'editor' | 'statistician' + role: 'adviser' | 'editor' | 'statistician', + thesisTitle?: string | null ): number { - return computeCompatibility(profile, stats, role); -} + // Compute matched skills using TF-IDF + const matchedSkills = computeSkillMatches(thesisTitle, profile); -/** - * Generates a recency score that weights experts who were active recently. - */ -function computeRecencyScore(date: Date | null): number { - if (!date) return 0; - const diffMs = Date.now() - date.getTime(); - if (Number.isNaN(diffMs) || diffMs < 0) { - return 20; - } - const diffDays = diffMs / (1000 * 60 * 60 * 24); - if (diffDays <= 1) return 20; - if (diffDays >= 30) return 0; - return Math.round((30 - diffDays) * (20 / 30)); -} + // Calculate skill match score: weighted average of (similarity * rating/10) for top 3 skills + const topSkills = matchedSkills.slice(0, 3); + const skillMatchScore = topSkills.length > 0 + ? topSkills.reduce((sum, s) => sum + s.similarity * (s.rating / 10), 0) / topSkills.length + : 0; -/** - * Calculates expert compatibility using availability, skills coverage, and activity recency. - */ -function computeCompatibility( - profile: UserProfile, - stats: ThesisRoleStats, - role: 'adviser' | 'editor' | 'statistician' -): number { - const capacity = profile.slots ?? 0; - const active = role === 'adviser' - ? stats.adviserCount - : role === 'editor' - ? stats.editorCount - : stats.statisticianCount; - const openSlots = capacity > 0 ? Math.max(capacity - active, 0) : 0; - const availabilityRatio = capacity > 0 ? openSlots / capacity : 0; - const availabilityScore = Math.round(availabilityRatio * 40); - const skillsScore = Math.min((profile.skillRatings?.length ?? 0) * 5, 20); - const recencyScore = computeRecencyScore(normalizeDateInput(profile.lastActive)); - const penalty = Math.min(active * 3, 15); - const baseScore = 40; - const total = baseScore + availabilityScore + skillsScore + recencyScore - penalty; - return Math.max(0, Math.min(100, Math.round(total))); + // Return skill-based compatibility (scaled 0-100) + return computeCompatibilityWithSkills(profile, stats, role, skillMatchScore); } /** From 3a3854d8c850242dd6d6e57081821110d609d148 Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 14 Dec 2025 21:08:08 +0800 Subject: [PATCH 4/6] fix: Fix comptibility scoring --- .../ExpertRequests/ExpertRequestsPage.tsx | 236 ++++++++++-------- .../Student/Recommendations/ProfileView.tsx | 71 ++++-- src/utils/recommendUtils.ts | 117 +++++++-- 3 files changed, 282 insertions(+), 142 deletions(-) diff --git a/src/components/ExpertRequests/ExpertRequestsPage.tsx b/src/components/ExpertRequests/ExpertRequestsPage.tsx index 30d67ce..df083da 100644 --- a/src/components/ExpertRequests/ExpertRequestsPage.tsx +++ b/src/components/ExpertRequests/ExpertRequestsPage.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Alert, Box, Button, Card, CardActions, CardContent, Chip, - Divider, LinearProgress, Paper, Skeleton, Stack, TextField, Typography + Divider, LinearProgress, Paper, Skeleton, Stack, TextField, Tooltip, Typography } from '@mui/material'; import { Edit as EditIcon } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; @@ -438,6 +438,26 @@ export default function ExpertRequestsPage({ role, roleLabel, allowedRoles }: Ex navigate(`${basePath}/${requestToOpen.groupId}`, { state: { expertRequest: requestToOpen } }); }, [navigate, role]); + // Check if adviser has all skills properly rated (rating > 0) + // Advisers must rate their skills before they can edit slots + const allSkillsRated = React.useMemo(() => { + // Only advisers need to rate skills before editing slots + if (role !== 'adviser') return true; + // If no department skills exist, allow editing + if (departmentSkills.length === 0) return true; + // Check if all department skills have a rating > 0 + return departmentSkills.every((skill) => { + const userRating = skillRatings.find((r) => r.skillId === skill.id); + return userRating && userRating.rating > 0; + }); + }, [role, departmentSkills, skillRatings]); + + const editSlotsDisabledReason = !expertProfile + ? 'Loading profile...' + : !allSkillsRated + ? 'Rate all your skills first before editing slots' + : undefined; + if (!viewerRole && session?.loading) { return ( @@ -657,14 +677,21 @@ export default function ExpertRequestsPage({ role, roleLabel, allowedRoles }: Ex ) : ( - + + + + {expertUid && ( - {/* Skill ratings card */} - - - - - Your Skills - - {departmentSkills.length > 0 && ( - r.rating > 0).length}/${departmentSkills.length}`} - size="small" - color={ - skillRatings.filter((r) => r.rating > 0).length === departmentSkills.length - ? 'success' - : 'warning' - } - variant="outlined" - /> - )} - - {skillsLoading ? ( - - - - - - ) : departmentSkills.length === 0 ? ( - - No skill templates defined for your department yet. - - ) : ( - - {/* Show skill overview with ratings */} - {departmentSkills.slice(0, 5).map((skill) => { - const userRating = skillRatings.find((r) => r.skillId === skill.id); - const rating = userRating?.rating ?? 0; - return ( - - - - {skill.name} - - 0 ? 'text.primary' : 'text.disabled'} - > - {rating > 0 ? `${rating}/10` : '—'} - - - - - ); - })} - {departmentSkills.length > 5 && ( - - +{departmentSkills.length - 5} more skills - + {/* Skill ratings card - only for advisers */} + {role === 'adviser' && ( + + + + + Your Skills + + {departmentSkills.length > 0 && ( + r.rating > 0).length}/${departmentSkills.length}`} + size="small" + color={ + skillRatings.filter((r) => r.rating > 0).length === departmentSkills.length + ? 'success' + : 'warning' + } + variant="outlined" + /> )} + {skillsLoading ? ( + + + + + + ) : departmentSkills.length === 0 ? ( + + No skill templates defined for your department yet. + + ) : ( + + {/* Show skill overview with ratings */} + {departmentSkills.slice(0, 5).map((skill) => { + const userRating = skillRatings.find((r) => r.skillId === skill.id); + // Show 0 if rating is <= 0 (initial rating is -1) + const rawRating = userRating?.rating ?? -1; + const displayRating = rawRating <= 0 ? 0 : rawRating; + const hasValidRating = rawRating > 0; + return ( + + + + {skill.name} + + + {hasValidRating ? `${displayRating}/10` : `${displayRating}/10`} + + + + + ); + })} + {departmentSkills.length > 5 && ( + + +{departmentSkills.length - 5} more skills + + )} + + )} + + {departmentSkills.length > 0 && ( + + + )} - - {departmentSkills.length > 0 && ( - - - - )} - + + )} diff --git a/src/pages/Student/Recommendations/ProfileView.tsx b/src/pages/Student/Recommendations/ProfileView.tsx index 2acc931..059614f 100644 --- a/src/pages/Student/Recommendations/ProfileView.tsx +++ b/src/pages/Student/Recommendations/ProfileView.tsx @@ -8,16 +8,21 @@ import { useSession } from '@toolpad/core'; import AnimatedPage from '../../../components/Animate/AnimatedPage/AnimatedPage'; import ProfileView from '../../../components/Profile/ProfileView'; import GroupCard, { GroupCardSkeleton } from '../../../components/Group/GroupCard'; -import { onUserProfile, findUsersByIds } from '../../../utils/firebase/firestore/user'; import { - listenGroupsByLeader, listenGroupsByExpertRole, getGroupsByLeader, getGroupsByMember + onUserProfile, findUsersByIds, listenUsersByFilter +} from '../../../utils/firebase/firestore/user'; +import { + listenGroupsByLeader, listenGroupsByExpertRole, getGroupsByLeader, + getGroupsByMember, listenAllGroups } from '../../../utils/firebase/firestore/groups'; import { findThesisByGroupId } from '../../../utils/firebase/firestore/thesis'; import { createExpertRequestByGroup, listenExpertRequestsByGroup, } from '../../../utils/firebase/firestore/expertRequests'; import { filterActiveGroups, deriveExpertThesisHistory } from '../../../utils/expertProfileUtils'; -import { evaluateExpertCompatibility, type ThesisRoleStats } from '../../../utils/recommendUtils'; +import { + evaluateNormalizedCompatibility, aggregateThesisStats +} from '../../../utils/recommendUtils'; import { useSnackbar } from '../../../contexts/SnackbarContext'; import type { NavigationItem } from '../../../types/navigation'; import type { UserProfile, HistoricalThesisEntry, UserRole } from '../../../types/profile'; @@ -59,6 +64,9 @@ export default function ExpertProfileViewPage() { const [requestSubmitting, setRequestSubmitting] = React.useState(false); const [groupRequests, setGroupRequests] = React.useState>(new Map()); const [viewerThesisTitle, setViewerThesisTitle] = React.useState(null); + // For normalized compatibility calculation + const [allExpertsOfRole, setAllExpertsOfRole] = React.useState([]); + const [allGroups, setAllGroups] = React.useState([]); // Fetch viewer's thesis title for compatibility calculation React.useEffect(() => { @@ -98,6 +106,21 @@ export default function ExpertProfileViewPage() { }; }, [viewerUid]); + // Load all groups for thesis stats (used for normalized compatibility) + React.useEffect(() => { + const unsubscribe = listenAllGroups({ + onData: (groupsData) => setAllGroups(groupsData), + onError: (err) => console.error('Failed to load all groups:', err), + }); + return () => unsubscribe(); + }, []); + + // Compute thesis stats from all groups + const thesisStats = React.useMemo( + () => aggregateThesisStats(allGroups), + [allGroups] + ); + React.useEffect(() => { if (!uid) { setProfile(null); @@ -130,6 +153,23 @@ export default function ExpertProfileViewPage() { isExpertRole(profile?.role) ? profile.role : null ), [profile?.role]); + // Load all experts of the same role for normalized compatibility calculation + React.useEffect(() => { + if (!expertRole) { + setAllExpertsOfRole([]); + return () => { /* no-op */ }; + } + + const unsubscribe = listenUsersByFilter( + { role: expertRole }, + { + onData: (profiles) => setAllExpertsOfRole(profiles), + onError: (err) => console.error('Failed to load all experts of role:', err), + } + ); + return () => unsubscribe(); + }, [expertRole]); + React.useEffect(() => { if (!uid || !expertRole) { setGroups([]); @@ -229,28 +269,21 @@ export default function ExpertProfileViewPage() { return deriveExpertThesisHistory(groups, new Map(), profile.uid, expertRole); }, [groups, expertRole, profile]); - const roleStats = React.useMemo(() => { - if (!expertRole) { - return { adviserCount: 0, editorCount: 0, statisticianCount: 0 }; - } - const total = groups.length; - if (expertRole === 'adviser') { - return { adviserCount: total, editorCount: 0, statisticianCount: 0 }; - } - if (expertRole === 'editor') { - return { adviserCount: 0, editorCount: total, statisticianCount: 0 }; - } - return { adviserCount: 0, editorCount: 0, statisticianCount: total }; - }, [groups.length, expertRole]); - const capacity = profile?.slots ?? 0; const openSlots = capacity > 0 ? Math.max(capacity - activeAssignments.length, 0) : 0; const normalizedCapacity = Math.max(capacity, activeAssignments.length); const openSlotsDisplay = normalizedCapacity > 0 ? `${openSlots}/${normalizedCapacity}` : `${openSlots}/0`; - const compatibility = profile && expertRole - ? evaluateExpertCompatibility(profile, roleStats, expertRole, viewerThesisTitle) + // Use normalized compatibility (relative to all experts of same role) + const compatibility = profile && expertRole && allExpertsOfRole.length > 0 + ? evaluateNormalizedCompatibility( + profile, + allExpertsOfRole, + expertRole, + thesisStats, + viewerThesisTitle + ) : null; const sortedGroups = React.useMemo(() => { diff --git a/src/utils/recommendUtils.ts b/src/utils/recommendUtils.ts index 89c3b88..658c5f7 100644 --- a/src/utils/recommendUtils.ts +++ b/src/utils/recommendUtils.ts @@ -202,30 +202,35 @@ export function computeExpertCards( const score = compatibility + openSlots * 5 + skillMatchScore * 50; - // Log computation results for debugging - devLog('[recommendUtils] Expert scoring:', JSON.stringify({ + // Log computation results for debugging (includes ALL skills with weighted contributions) + devLog('[recommendUtils] Expert scoring:', { expert: `${profile.name.first} ${profile.name.last}`, uid: profile.uid, thesisTitle: thesisTitle ?? '(none)', scoring: { - skillMatchScore: Math.round(skillMatchScore * 100) / 100, - compatibility: compatibility, + tfidfScore: skillMatchScore, + skillMatchContribution: skillMatchScore * 50, + openSlotsContribution: openSlots * 5, + rawCompatibility: compatibility, finalScore: score, }, - skills: { - totalSkills: matchedSkills.length, - topMatches: matchedSkills.slice(0, 3).map(s => ({ - name: s.name, - rating: s.rating, - similarity: Math.round(s.similarity * 100) / 100, - })), - }, + topSkillsBreakdown: topSkills.map(s => ({ + name: s.name, + rating: s.rating, + similarity: s.similarity, + weighted: s.similarity * (s.rating / 10), + })), + allSkills: matchedSkills.map(s => ({ + name: s.name, + rating: s.rating, + similarity: s.similarity, + })), capacity: { slots: capacity, active, openSlots, }, - }, null, 2)); + }); return { profile, @@ -241,20 +246,20 @@ export function computeExpertCards( scored.sort((a, b) => b.score - a.score); - // Normalize compatibility scores relative to the highest score - // So the best match is 100% and others are proportional - const maxCompatibility = scored.length > 0 - ? Math.max(...scored.map((s) => s.compatibility)) + // Normalize using finalScore (not raw compatibility) so availability is factored in + // The best expert gets 100%, others are proportional to their finalScore + const maxScore = scored.length > 0 + ? Math.max(...scored.map((s) => s.score)) : 1; - const normalizeCompatibility = (raw: number): number => { - if (maxCompatibility === 0) return 0; - return Math.round((raw / maxCompatibility) * 100); + const normalizeScore = (raw: number): number => { + if (maxScore === 0) return 0; + return Math.round((raw / maxScore) * 100); }; return scored.map((entry, index) => ({ profile: entry.profile, stats: entry.stats, - compatibility: normalizeCompatibility(entry.compatibility), + compatibility: normalizeScore(entry.score), capacity: entry.capacity, activeCount: entry.activeCount, openSlots: entry.openSlots, @@ -291,6 +296,76 @@ export function evaluateExpertCompatibility( return computeCompatibilityWithSkills(profile, stats, role, skillMatchScore); } +/** + * Computes the full finalScore for an expert (same formula used in computeExpertCards). + * finalScore = compatibility + openSlots * 5 + skillMatchScore * 50 + */ +function computeFinalScore( + profile: UserProfile, + stats: ThesisRoleStats, + role: 'adviser' | 'editor' | 'statistician', + thesisTitle?: string | null +): number { + const capacity = profile.slots ?? 0; + const active = role === 'adviser' + ? stats.adviserCount + : role === 'editor' + ? stats.editorCount + : stats.statisticianCount; + const openSlots = capacity > 0 ? Math.max(capacity - active, 0) : 0; + + // Compute matched skills using TF-IDF + const matchedSkills = computeSkillMatches(thesisTitle, profile); + + // Calculate skill match score: weighted average of (similarity * rating/10) for top 3 skills + const topSkills = matchedSkills.slice(0, 3); + const skillMatchScore = topSkills.length > 0 + ? topSkills.reduce((sum, s) => sum + s.similarity * (s.rating / 10), 0) / topSkills.length + : 0; + + // Compute raw compatibility + const compatibility = computeCompatibilityWithSkills(profile, stats, role, skillMatchScore); + + // Return finalScore using the same formula as computeExpertCards + return compatibility + openSlots * 5 + skillMatchScore * 50; +} + +/** + * Computes normalized compatibility for a single expert relative to all experts. + * This ensures the ProfileView shows the same compatibility % as the Recommendations list. + * Uses finalScore (which includes availability) for normalization. + * @param targetProfile The expert being viewed + * @param allProfiles All experts of the same role (for normalization) + * @param role Expert role being evaluated + * @param statsMap Precomputed thesis role statistics + * @param thesisTitle Optional thesis title for skill matching + * @returns Normalized compatibility score (0-100) where best match = 100% + */ +export function evaluateNormalizedCompatibility( + targetProfile: UserProfile, + allProfiles: UserProfile[], + role: 'adviser' | 'editor' | 'statistician', + statsMap: Map, + thesisTitle?: string | null +): number { + // Compute finalScore for all experts (includes skill match + availability) + const allScores = allProfiles.map((profile) => { + const stats = statsMap.get(profile.uid) ?? { adviserCount: 0, editorCount: 0, statisticianCount: 0 }; + return computeFinalScore(profile, stats, role, thesisTitle); + }); + + // Find the maximum score for normalization + const maxScore = Math.max(...allScores, 1); + + // Compute target expert's finalScore + const targetStats = statsMap.get(targetProfile.uid) ?? { adviserCount: 0, editorCount: 0, statisticianCount: 0 }; + const targetScore = computeFinalScore(targetProfile, targetStats, role, thesisTitle); + + // Normalize: best match = 100% + if (maxScore === 0) return 0; + return Math.round((targetScore / maxScore) * 100); +} + /** * Calculates expert compatibility based purely on skill match score. * Uses TF-IDF skill match score for thesis-expert matching. From 153209fc94433a8b1589916602657705f3c057ff Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 14 Dec 2025 21:08:40 +0800 Subject: [PATCH 5/6] chore: Remove deprecated component --- .../MentorRequests/MentorRequestsPage.tsx | 597 ------------------ 1 file changed, 597 deletions(-) delete mode 100644 src/components/MentorRequests/MentorRequestsPage.tsx diff --git a/src/components/MentorRequests/MentorRequestsPage.tsx b/src/components/MentorRequests/MentorRequestsPage.tsx deleted file mode 100644 index d57c6e5..0000000 --- a/src/components/MentorRequests/MentorRequestsPage.tsx +++ /dev/null @@ -1,597 +0,0 @@ -import * as React from 'react'; -import { - Alert, Box, Button, Card, CardActions, CardContent, - Paper, Skeleton, Stack, TextField, Typography, -} from '@mui/material'; -import { useNavigate } from 'react-router-dom'; -import { useSession } from '@toolpad/core'; -import type { ExpertRequest, ExpertRequestRole } from '../../types/expertRequest'; -import type { ThesisGroup } from '../../types/group'; -import type { Session } from '../../types/session'; -import type { UserProfile, UserRole } from '../../types/profile'; -import { DEFAULT_MAX_EXPERT_SLOTS } from '../../types/slotRequest'; -import { AnimatedPage } from '../Animate'; -import { useSnackbar } from '../../contexts/SnackbarContext'; -import UnauthorizedNotice from '../../layouts/UnauthorizedNotice'; -import { findGroupById, listenGroupsByExpertRole } from '../../utils/firebase/firestore/groups'; -import { listenExpertRequestsByExpert } from '../../utils/firebase/firestore/expertRequests'; -import { findUsersByIds, onUserProfile, updateUserProfile } from '../../utils/firebase/firestore/user'; -import ExpertRequestCard from '../ExpertRequests/ExpertRequestCard'; -import { SlotRequestButton } from '../ExpertRequests/SlotRequestDialog'; - -interface ExpertRequestViewModel { - request: ExpertRequest; - group: ThesisGroup | null; - requester: UserProfile | null; - usersByUid: Map; -} - -function formatMinimumCapacityMessage(currentCount: number): string { - return `You currently expert ${currentCount} group${currentCount === 1 ? '' : 's'}. Slots cannot go below that.`; -} - -export interface ExpertRequestsPageProps { - role: ExpertRequestRole; - roleLabel: string; - allowedRoles?: UserRole[]; -} - -function useExpertRequestViewModels(requests: ExpertRequest[]): ExpertRequestViewModel[] { - const [groupsById, setGroupsById] = React.useState>(new Map()); - const [profilesByUid, setProfilesByUid] = React.useState>(new Map()); - - React.useEffect(() => { - let cancelled = false; - const uniqueGroupIds = Array.from( - new Set(requests.map((req) => req.groupId).filter((id): id is string => !!id)) - ); - if (uniqueGroupIds.length === 0) { - setGroupsById(new Map()); - return () => { /* no-op */ }; - } - - void (async () => { - try { - const resolved = await Promise.all( - uniqueGroupIds.map(async (groupId) => { - try { - const record = await findGroupById(groupId); - return [groupId, record] as const; - } catch (err) { - console.error(`Failed to resolve group ${groupId}:`, err); - return [groupId, null] as const; - } - }) - ); - if (!cancelled) { - setGroupsById(new Map(resolved)); - } - } catch (err) { - if (!cancelled) { - console.error('Failed to resolve service request groups:', err); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [requests]); - - React.useEffect(() => { - let cancelled = false; - const uniqueIds = new Set(); - requests.forEach((req) => { - if (req.requestedBy) { - uniqueIds.add(req.requestedBy); - } - }); - groupsById.forEach((groupRecord) => { - const leaderUid = groupRecord?.members.leader; - if (leaderUid) { - uniqueIds.add(leaderUid); - } - }); - const ids = Array.from(uniqueIds); - if (ids.length === 0) { - setProfilesByUid(new Map()); - return () => { /* no-op */ }; - } - - void (async () => { - try { - const profiles = await findUsersByIds(ids); - if (!cancelled) { - setProfilesByUid(new Map(profiles.map((profile) => [profile.uid, profile]))); - } - } catch (err) { - if (!cancelled) { - console.error('Failed to resolve group/requester profiles:', err); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [groupsById, requests]); - - return React.useMemo(() => ( - requests.map((request) => { - const group = request.groupId ? groupsById.get(request.groupId) ?? null : null; - const requester = profilesByUid.get(request.requestedBy) ?? null; - const usersByUid = new Map(); - if (requester) { - usersByUid.set(requester.uid, requester); - } - const leaderUid = group?.members.leader; - if (leaderUid) { - const leaderProfile = profilesByUid.get(leaderUid); - if (leaderProfile) { - usersByUid.set(leaderUid, leaderProfile); - } - } - return { - request, - group, - requester, - usersByUid, - } satisfies ExpertRequestViewModel; - }) - ), [groupsById, profilesByUid, requests]); -} - -/** - * Shared service requests experience for adviser/editor/statistician dashboards. - */ -export default function ExpertRequestsPage({ role, roleLabel, allowedRoles }: ExpertRequestsPageProps) { - const session = useSession(); - const expertUid = session?.user?.uid ?? null; - const viewerRole = session?.user?.role; - const permittedRoles = allowedRoles ?? [role]; - const { showNotification } = useSnackbar(); - const navigate = useNavigate(); - - const [requests, setRequests] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - // Dialog state removed: approvals are handled in the group view header now. - const [expertProfile, setExpertProfile] = React.useState(null); - const [capacityInput, setCapacityInput] = React.useState(''); - const [capacityError, setCapacityError] = React.useState(null); - const [capacitySaving, setCapacitySaving] = React.useState(false); - const [editingCapacity, setEditingCapacity] = React.useState(false); - const [assignments, setAssignments] = React.useState([]); - const [assignmentsLoading, setAssignmentsLoading] = React.useState(false); - - // Count only groups with 'active' status as taken slots - const activeAssignments = React.useMemo( - () => assignments.filter((group) => group.status === 'active'), - [assignments] - ); - const minimumCapacity = activeAssignments.length; - const expertCapacityRaw = typeof expertProfile?.slots === 'number' - ? expertProfile.slots - : 0; - const maxSlots = expertProfile?.maxSlots ?? DEFAULT_MAX_EXPERT_SLOTS; - const normalizedCapacity = Math.max(expertCapacityRaw, minimumCapacity); - const slotsSummary = expertProfile ? `${activeAssignments.length}/${normalizedCapacity}` : '—'; - const openSlots = Math.max(normalizedCapacity - activeAssignments.length, 0); - const capacityHelperHint = minimumCapacity > 0 - ? `Minimum ${minimumCapacity} to cover current assignments. Max allowed: ${maxSlots}.` - : `Max allowed: ${maxSlots}.`; - - const viewModels = useExpertRequestViewModels(requests); - - React.useEffect(() => { - if (!expertUid) { - setRequests([]); - setLoading(false); - return () => { /* no-op */ }; - } - - setLoading(true); - setError(null); - const unsubscribe = listenExpertRequestsByExpert(expertUid, role, { - onData: (records) => { - setRequests(records); - setLoading(false); - }, - onError: (listenerError) => { - console.error('Failed to load service requests:', listenerError); - setError('Unable to load service requests right now.'); - setLoading(false); - }, - }); - - return () => { - unsubscribe(); - }; - }, [expertUid, role]); - - React.useEffect(() => { - if (!expertUid) { - setExpertProfile(null); - return () => { /* no-op */ }; - } - - const unsubscribe = onUserProfile(expertUid, (profile) => { - setExpertProfile(profile); - }); - - return () => { - unsubscribe(); - }; - }, [expertUid]); - - React.useEffect(() => { - if (!expertProfile) { - setCapacityInput(''); - return; - } - if (editingCapacity) { - return; - } - - const baseValue = typeof expertProfile.slots === 'number' - ? expertProfile.slots - : 0; - const nextValue = Math.max(minimumCapacity, baseValue); - setCapacityInput(String(nextValue)); - }, [expertProfile, minimumCapacity, editingCapacity]); - - React.useEffect(() => { - if (!expertUid) { - setAssignments([]); - setAssignmentsLoading(false); - return () => { /* no-op */ }; - } - - setAssignmentsLoading(true); - const unsubscribe = listenGroupsByExpertRole(role, expertUid, { - onData: (groups: ThesisGroup[]) => { - setAssignments(groups); - setAssignmentsLoading(false); - }, - onError: (listenerError: Error) => { - console.error('Failed to load expert assignments for capacity view:', listenerError); - setAssignments([]); - setAssignmentsLoading(false); - }, - }); - - return () => { - unsubscribe(); - }; - }, [expertUid, role]); - - const pendingCount = React.useMemo( - () => requests.filter((record) => record.status === 'pending').length, - [requests] - ); - const approvedCount = React.useMemo( - () => requests.filter((record) => record.status === 'approved').length, - [requests] - ); - const rejectedCount = React.useMemo( - () => requests.filter((record) => record.status === 'rejected').length, - [requests] - ); - - const handleCapacityInputChange = React.useCallback((event: React.ChangeEvent) => { - const value = event.target.value; - if (value.trim() === '') { - setCapacityInput(value); - setCapacityError('Enter how many groups you can handle.'); - return; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - setCapacityInput(value); - setCapacityError('Enter a valid number.'); - } else if (!Number.isInteger(parsed)) { - setCapacityInput(value); - setCapacityError('Use whole numbers only.'); - } else if (parsed < 0) { - setCapacityInput(value); - setCapacityError('Slots cannot be negative.'); - } else { - // Clamp value between minimumCapacity and maxSlots - const clamped = Math.max(minimumCapacity, Math.min(parsed, maxSlots)); - setCapacityInput(String(clamped)); - setCapacityError(null); - } - }, [minimumCapacity, maxSlots]); - - const capacityHasChanged = React.useMemo(() => { - if (!expertProfile) { - return false; - } - const baseline = expertCapacityRaw; - const parsed = Number(capacityInput); - if (capacityInput.trim() === '' || Number.isNaN(parsed)) { - return false; - } - return parsed !== baseline; - }, [capacityInput, expertProfile]); - - const canSaveCapacity = Boolean( - expertUid && - !capacitySaving && - !capacityError && - capacityInput.trim() !== '' && - capacityHasChanged - ); - - const handleSaveCapacity = React.useCallback(async () => { - if (!expertUid) { - return; - } - - const parsed = Number(capacityInput.trim()); - if (capacityInput.trim() === '' || Number.isNaN(parsed) || parsed < 0 || !Number.isInteger(parsed)) { - setCapacityError('Provide a non-negative whole number.'); - return; - } - if (parsed < minimumCapacity) { - setCapacityError(formatMinimumCapacityMessage(minimumCapacity)); - return; - } - if (parsed > maxSlots) { - setCapacityError(`Cannot exceed your maximum limit of ${maxSlots}. Request more slots if needed.`); - return; - } - - setCapacitySaving(true); - try { - await updateUserProfile(expertUid, { slots: parsed }); - showNotification('Updated your available slots.', 'success'); - setEditingCapacity(false); - setCapacityError(null); - } catch (err) { - console.error('Failed to update expert capacity:', err); - const fallback = err instanceof Error ? err.message : 'Unable to update slots right now.'; - showNotification(fallback, 'error'); - } finally { - setCapacitySaving(false); - } - }, [capacityInput, expertUid, showNotification, minimumCapacity, maxSlots]); - - const handleStartEditing = React.useCallback(() => { - if (!expertProfile) return; - setEditingCapacity(true); - }, [expertProfile]); - - const handleCancelEditing = React.useCallback(() => { - if (expertProfile) { - const nextValue = typeof expertProfile.slots === 'number' - ? String(Math.max(minimumCapacity, expertProfile.slots)) - : String(minimumCapacity); - setCapacityInput(nextValue); - } - setCapacityError(null); - setEditingCapacity(false); - }, [expertProfile, minimumCapacity]); - - - - const handleOpenGroupView = React.useCallback((requestToOpen: ExpertRequest) => { - const basePath = role === 'adviser' - ? '/adviser-requests' - : role === 'editor' - ? '/editor-requests' - : '/statistician-requests'; - navigate(`${basePath}/${requestToOpen.groupId}`, { state: { expertRequest: requestToOpen } }); - }, [navigate, role]); - - - if (!viewerRole && session?.loading) { - return ( - - - - - - - ); - } - - if (!viewerRole || !permittedRoles.includes(viewerRole)) { - return ( - - ); - } - - if (!expertUid) { - return ( - - - You need to sign in again to manage service requests. - - - ); - } - - return ( - - - - - Review and respond to thesis groups requesting you as their {roleLabel.toLowerCase()}. - - - {error && ( - setError(null)}> - {error} - - )} - - - - - Pending - - {pendingCount} - - - - - - Approved - - {approvedCount} - - - - - - Rejected - - {rejectedCount} - - - - - {editingCapacity ? ( - <> - - Update accepted groups - - - - - - ) : ( - <> - - Accepted groups - - - - {assignmentsLoading ? '…' : slotsSummary} - - - {normalizedCapacity === 0 - ? 'Not accepting requests' - : openSlots > 0 - ? `${openSlots} slot${openSlots === 1 ? '' : 's'} open` - : 'No open slots'} - - - - )} - - - {editingCapacity ? ( - - - - - ) : ( - - - {expertUid && ( - - )} - - )} - - - - - - {loading ? ( - - - - - ) : viewModels.length === 0 ? ( - - - No service requests yet. Groups with an approved topic can send you a request from your profile page. - - - ) : ( - - {viewModels.map(({ request, group, requester, usersByUid }) => { - if (!group) { - return ( - - - - Group details are unavailable. Please ask the students to resend their request. - - - - ); - } - - return ( - - ); - })} - - )} - - - ); -} From 692f35e2a202686ab8dc8727b388d52c595381f1 Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 14 Dec 2025 21:23:30 +0800 Subject: [PATCH 6/6] fix: Fix statistician pages and cards --- package.json | 2 +- src/components/Profile/RecommendationProfileCard.tsx | 3 ++- src/pages/Statistician/ExpertRequests/ExpertRequests.tsx | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c58859f..f246fba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thesisflow", - "version": "5.7.3", + "version": "5.7.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/Profile/RecommendationProfileCard.tsx b/src/components/Profile/RecommendationProfileCard.tsx index aab0f30..8ac742b 100644 --- a/src/components/Profile/RecommendationProfileCard.tsx +++ b/src/components/Profile/RecommendationProfileCard.tsx @@ -38,7 +38,8 @@ export default function RecommendationProfileCard({ }, ]; - if (roleLabel !== 'Editor') { + // Only show compatibility for advisers (not editors/statisticians) + if (roleLabel === 'Adviser') { baseStats.push({ label: 'Compatibility', value: `${card.compatibility}%`, diff --git a/src/pages/Statistician/ExpertRequests/ExpertRequests.tsx b/src/pages/Statistician/ExpertRequests/ExpertRequests.tsx index dd7e1d6..042482c 100644 --- a/src/pages/Statistician/ExpertRequests/ExpertRequests.tsx +++ b/src/pages/Statistician/ExpertRequests/ExpertRequests.tsx @@ -4,10 +4,10 @@ import type { NavigationItem } from '../../../types/navigation'; import ExpertRequestsPage from '../../../components/ExpertRequests/ExpertRequestsPage'; export const metadata: NavigationItem = { - group: 'statistician', + group: 'experts', index: 0, title: 'Service Requests', - segment: 'statistician/requests', + segment: 'statistician-requests', icon: , roles: ['statistician'], };