Skip to content

Commit ebd86ca

Browse files
committed
Merged PR A and B into one
1 parent 1518bf8 commit ebd86ca

4 files changed

Lines changed: 237 additions & 128 deletions

File tree

‎apps/codebility/app/home/my-team/[projectId]/_components/CompactMemberGrid.tsx‎

Lines changed: 162 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useEffect, useRef } from "react";
44
import Image from "next/image";
55
import { SimpleMemberData } from "@/app/home/projects/actions";
66
import DefaultAvatar from "@/components/DefaultAvatar";
7-
import { Mail, Phone, Calendar, MapPin, Briefcase, Trophy, RefreshCw } from "lucide-react";
7+
import { Mail, Calendar, Trophy, RefreshCw, AlertCircle } from "lucide-react";
88
import { multipleMoveApplicantToOnboardingAction } from "@/app/home/applicants/_service/action";
99
const ATTENDANCE_POINTS_PER_DAY = 2;
1010
import { getTeamMonthlyAttendancePoints } from "../actions";
@@ -23,23 +23,31 @@ interface MemberPoints {
2323
};
2424
}
2525

26+
// Fix #4: Track per-member fetch errors separately from points data
27+
interface MemberErrors {
28+
[codevId: string]: boolean;
29+
}
30+
2631
const CompactMemberGrid = ({ members, teamLead, projectId }: CompactMemberGridProps) => {
2732
const allMembers = teamLead ? [teamLead, ...members] : members;
33+
const memberKey = allMembers.map((m) => m.id).join(",");
2834
const [memberPoints, setMemberPoints] = useState<MemberPoints>({});
29-
const [isLoadingPoints, setIsLoadingPoints] = useState(false); // Automatically loads on mount
35+
const [memberErrors, setMemberErrors] = useState<MemberErrors>({});
36+
const [isLoadingPoints, setIsLoadingPoints] = useState(false);
3037
const [monthlyAttendancePoints, setMonthlyAttendancePoints] = useState<{ uniqueCodevIdsPresentDays: { codevId: string; presentDays: number }[]; }>({ uniqueCodevIdsPresentDays: [] });
3138
const [selectedMember, setSelectedMember] = useState<SimpleMemberData | null>(null);
3239
const [isModalOpen, setIsModalOpen] = useState(false);
40+
const loadedMemberIdsRef = useRef<string>("");
3341

3442
// Load points for all members - runs automatically on mount and can be refreshed
3543
const loadAllPoints = async () => {
36-
if (isLoadingPoints) return; // Prevent multiple simultaneous calls
37-
44+
if (isLoadingPoints) return;
45+
3846
setIsLoadingPoints(true);
3947
const pointsData: MemberPoints = {};
48+
const errorsData: MemberErrors = {};
4049

4150
try {
42-
// Load points in smaller batches to improve perceived performance
4351
const batchSize = 3;
4452
for (let i = 0; i < allMembers.length; i += batchSize) {
4553
const batch = allMembers.slice(i, i + batchSize);
@@ -48,24 +56,29 @@ const CompactMemberGrid = ({ members, teamLead, projectId }: CompactMemberGridPr
4856
const response = await fetch(`/api/codev/${member.id}/points`);
4957
if (!response.ok) throw new Error('Failed to fetch');
5058
const data = await response.json() as { totalPoints?: number; attendancePoints?: number };
51-
return { memberId: member.id, data };
59+
return { memberId: member.id, data, failed: false };
5260
} catch (error) {
61+
// Fix #4: Record the failure instead of silently substituting 0
5362
console.warn(`Failed to load points for ${member.id}:`, error);
54-
return { memberId: member.id, data: { totalPoints: 0, attendancePoints: 0 } };
63+
return { memberId: member.id, data: null, failed: true };
5564
}
5665
});
5766

5867
const results = await Promise.all(pointsPromises);
59-
60-
results.forEach(({ memberId, data }) => {
61-
pointsData[memberId] = {
62-
totalPoints: data.totalPoints || 0,
63-
attendancePoints: data.attendancePoints || 0
64-
};
68+
69+
results.forEach(({ memberId, data, failed }) => {
70+
if (failed || !data) {
71+
errorsData[memberId] = true;
72+
} else {
73+
pointsData[memberId] = {
74+
totalPoints: data.totalPoints || 0,
75+
attendancePoints: data.attendancePoints || 0,
76+
};
77+
}
6578
});
6679

67-
// Update state after each batch for progressive loading
6880
setMemberPoints(prev => ({ ...prev, ...pointsData }));
81+
setMemberErrors(prev => ({ ...prev, ...errorsData }));
6982
}
7083
} catch (error) {
7184
console.error("Error loading points:", error);
@@ -74,6 +87,26 @@ const CompactMemberGrid = ({ members, teamLead, projectId }: CompactMemberGridPr
7487
}
7588
};
7689

90+
// Retry a single member's points fetch
91+
const retryMemberPoints = async (memberId: string) => {
92+
setMemberErrors(prev => ({ ...prev, [memberId]: false }));
93+
try {
94+
const response = await fetch(`/api/codev/${memberId}/points`);
95+
if (!response.ok) throw new Error('Failed to fetch');
96+
const data = await response.json() as { totalPoints?: number; attendancePoints?: number };
97+
setMemberPoints(prev => ({
98+
...prev,
99+
[memberId]: {
100+
totalPoints: data.totalPoints || 0,
101+
attendancePoints: data.attendancePoints || 0,
102+
},
103+
}));
104+
} catch (error) {
105+
console.warn(`Retry failed for ${memberId}:`, error);
106+
setMemberErrors(prev => ({ ...prev, [memberId]: true }));
107+
}
108+
};
109+
77110
useEffect(() => {
78111
const loadAttendancePoints = async () => {
79112
const currentDate = new Date();
@@ -85,23 +118,25 @@ const CompactMemberGrid = ({ members, teamLead, projectId }: CompactMemberGridPr
85118

86119
if (result.success) {
87120
setMonthlyAttendancePoints({
88-
uniqueCodevIdsPresentDays: result.uniqueCodevIdsPresentDays || []
121+
uniqueCodevIdsPresentDays: (result.uniqueCodevIdsPresentDays || []).map(item => ({
122+
codevId: item.codevId,
123+
presentDays: item.presentDays ?? 0
124+
}))
89125
});
90126
}
91127
};
92128

93129
loadAttendancePoints();
94130
}, [projectId]);
95131

96-
// Automatically load points when component mounts
97132
useEffect(() => {
98-
if (allMembers.length > 0) {
99-
loadAllPoints();
100-
}
101-
// eslint-disable-next-line react-hooks/exhaustive-deps
102-
}, []); // Only run once on mount
133+
if (allMembers.length === 0) return;
134+
if (memberKey === loadedMemberIdsRef.current) return;
135+
loadedMemberIdsRef.current = memberKey;
136+
loadAllPoints();
137+
}, [memberKey]);
103138

104-
const hasPointsData = Object.keys(memberPoints).length > 0;
139+
const hasPointsData = Object.keys(memberPoints).length > 0 || Object.keys(memberErrors).length > 0;
105140

106141
const handleMemberClick = (member: SimpleMemberData) => {
107142
setSelectedMember(member);
@@ -115,7 +150,7 @@ const CompactMemberGrid = ({ members, teamLead, projectId }: CompactMemberGridPr
115150

116151
return (
117152
<div className="space-y-4">
118-
{/* Refresh Points Button - Shows when data is loaded */}
153+
{/* Refresh Points Button */}
119154
{hasPointsData && !isLoadingPoints && allMembers.length > 0 && (
120155
<div className="flex justify-end">
121156
<button
@@ -142,104 +177,125 @@ const CompactMemberGrid = ({ members, teamLead, projectId }: CompactMemberGridPr
142177
const isLead = teamLead?.id === member.id;
143178
const imageUrl = member.image_url || "/assets/images/default-avatar-200x200.jpg";
144179
const memberHasPoints = memberPoints[member.id];
180+
const memberHasError = memberErrors[member.id];
145181

146182
return (
147183
<div
148184
key={member.id}
149185
onClick={() => handleMemberClick(member)}
150186
className="relative rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all duration-200 hover:shadow-md hover:scale-105 hover:border-blue-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-600 overflow-hidden cursor-pointer"
151187
>
152-
{/* Status indicator */}
153-
<div className="absolute top-2 right-2 h-2 w-2 rounded-full bg-green-500" title="Active" />
154-
155-
<div className="flex items-start gap-3">
156-
{/* Avatar */}
157-
<div className="relative flex-shrink-0">
158-
{member.image_url ? (
159-
<Image
160-
src={imageUrl}
161-
alt={`${member.first_name} ${member.last_name}`}
162-
width={48}
163-
height={48}
164-
className="h-12 w-12 rounded-full object-cover"
165-
/>
166-
) : (
167-
<DefaultAvatar size={48} />
168-
)}
169-
{isLead && (
170-
<div className="absolute -bottom-1 -right-1 rounded-full bg-blue-500 px-1.5 py-0.5">
171-
<span className="text-xs font-bold text-white">TL</span>
172-
</div>
173-
)}
174-
</div>
175-
176-
{/* Info */}
177-
<div className="min-w-0 flex-1">
178-
<h4 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white truncate">
179-
{member.first_name} {member.last_name}
188+
{/* Status indicator */}
189+
<div className="absolute top-2 right-2 h-2 w-2 rounded-full bg-green-500" title="Active" />
180190

191+
<div className="flex items-start gap-3">
192+
{/* Avatar */}
193+
<div className="relative flex-shrink-0">
194+
{member.image_url ? (
195+
<Image
196+
src={imageUrl}
197+
alt={`${member.first_name} ${member.last_name}`}
198+
width={48}
199+
height={48}
200+
className="h-12 w-12 rounded-full object-cover"
201+
/>
202+
) : (
203+
<DefaultAvatar size={48} />
204+
)}
205+
{isLead && (
206+
<div className="absolute -bottom-1 -right-1 rounded-full bg-blue-500 px-1.5 py-0.5">
207+
<span className="text-xs font-bold text-white">TL</span>
208+
</div>
209+
)}
210+
</div>
181211

182-
</h4>
212+
{/* Info */}
213+
<div className="min-w-0 flex-1">
214+
<h4 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white truncate">
215+
{member.first_name} {member.last_name}
216+
</h4>
183217

184-
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 truncate">
185-
{member.display_position || "Team Member"}
186-
</p>
218+
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 truncate">
219+
{member.display_position || "Team Member"}
220+
</p>
187221

188-
{/* Quick stats */}
189-
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 text-xs text-gray-500 dark:text-gray-400">
190-
<span className="flex items-center gap-1">
191-
<Calendar className="h-3 w-3 flex-shrink-0" />
192-
{new Date(member.joined_at).getFullYear()}
193-
</span>
194-
<span className="flex items-center gap-1 min-w-0">
195-
<Mail className="h-3 w-3 flex-shrink-0" />
196-
<span className="truncate">{member.email_address.split('@')[0]}</span>
197-
</span>
222+
{/* Quick stats */}
223+
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 text-xs text-gray-500 dark:text-gray-400">
224+
<span className="flex items-center gap-1">
225+
<Calendar className="h-3 w-3 flex-shrink-0" />
226+
{new Date(member.joined_at).getFullYear()}
227+
</span>
228+
<span className="flex items-center gap-1 min-w-0">
229+
<Mail className="h-3 w-3 flex-shrink-0" />
230+
<span className="truncate">{member.email_address.split('@')[0]}</span>
231+
</span>
232+
</div>
198233
</div>
199234
</div>
200-
</div>
201235

202-
{/* Points and Attendance summary */}
203-
{(memberHasPoints || hasPointsData) && (
204-
<div className="mt-3 border-t border-gray-100 pt-2 dark:border-gray-700">
205-
<div className="flex items-center justify-between">
206-
<div className="flex items-center gap-2 text-xs">
207-
<Trophy className="h-3 w-3 text-yellow-500" />
208-
<span className="text-gray-500">Total Points:</span>
209-
{memberHasPoints ? (
210-
<span className="font-semibold text-gray-900 dark:text-white">
211-
{memberPoints[member.id]?.totalPoints || 0}
236+
{/* Points and Attendance summary */}
237+
{(memberHasPoints || memberHasError || hasPointsData) && (
238+
<div className="mt-3 border-t border-gray-100 pt-2 dark:border-gray-700">
239+
{/* Fix #4: Show error state with retry instead of silent 0 */}
240+
{memberHasError ? (
241+
<div className="flex items-center justify-between text-xs">
242+
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
243+
<AlertCircle className="h-3 w-3" />
244+
Stats unavailable
212245
</span>
213-
) : (
214-
<div className="w-8 h-3 bg-gray-200 animate-pulse rounded"></div>
215-
)}
216-
</div>
217-
<div className="text-xs">
218-
<span className="text-gray-500">Attendance:</span>
219-
<span className="ml-1 font-medium text-green-600 dark:text-green-400">
220-
+{(() => {
221-
const found = monthlyAttendancePoints.uniqueCodevIdsPresentDays.find(
222-
item => item.codevId === member.id
223-
);
224-
return found ? found.presentDays * ATTENDANCE_POINTS_PER_DAY : 0;
225-
})()}
226-
</span>
227-
</div>
246+
<button
247+
onClick={(e) => {
248+
e.stopPropagation();
249+
retryMemberPoints(member.id);
250+
}}
251+
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
252+
>
253+
<RefreshCw className="h-3 w-3" />
254+
Retry
255+
</button>
256+
</div>
257+
) : (
258+
<>
259+
<div className="flex items-center justify-between">
260+
<div className="flex items-center gap-2 text-xs">
261+
<Trophy className="h-3 w-3 text-yellow-500" />
262+
<span className="text-gray-500">Total Points:</span>
263+
{memberHasPoints ? (
264+
<span className="font-semibold text-gray-900 dark:text-white">
265+
{memberPoints[member.id]?.totalPoints || 0}
266+
</span>
267+
) : (
268+
<div className="w-8 h-3 bg-gray-200 animate-pulse rounded"></div>
269+
)}
270+
</div>
271+
<div className="text-xs">
272+
<span className="text-gray-500">Attendance:</span>
273+
<span className="ml-1 font-medium text-green-600 dark:text-green-400">
274+
+{(() => {
275+
const found = monthlyAttendancePoints.uniqueCodevIdsPresentDays.find(
276+
item => item.codevId === member.id
277+
);
278+
return found ? found.presentDays * ATTENDANCE_POINTS_PER_DAY : 0;
279+
})()}
280+
</span>
281+
</div>
282+
</div>
283+
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
284+
<span>This month: </span>
285+
<span className="font-medium text-green-600 dark:text-green-400">
286+
{monthlyAttendancePoints.uniqueCodevIdsPresentDays.find(
287+
item => item.codevId === member.id
288+
)?.presentDays || 0}d
289+
</span>
290+
<span>present</span>
291+
</div>
292+
</>
293+
)}
228294
</div>
229-
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
230-
<span>This month: </span>
231-
<span className="font-medium text-green-600 dark:text-green-400">
232-
{monthlyAttendancePoints.uniqueCodevIdsPresentDays.find(
233-
item => item.codevId === member.id
234-
)?.presentDays || 0}d
235-
</span>
236-
<span>present</span>
237-
</div>
238-
</div>
239-
)}
240-
</div>
241-
);
242-
})}
295+
)}
296+
</div>
297+
);
298+
})}
243299
</div>
244300

245301
{/* Member Detail Modal */}

‎apps/codebility/app/home/my-team/[projectId]/_components/TeamDetailView.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ const TeamDetailView = ({ projectData }: TeamDetailViewProps) => {
133133
.filter(Boolean)
134134
.join(", ");
135135
const convertTo12Hour = (time24: string) => {
136+
if (!time24 || !/^\d{1,2}:\d{2}$/.test(time24.trim())) {
137+
return time24 || "—";
138+
}
136139
const parts = time24.split(":");
137140
const hours = parts[0] || "0";
138141
const minutes = parts[1] || "00";

0 commit comments

Comments
 (0)