Skip to content

Commit eaa75a2

Browse files
deocagunotclaude
andcommitted
fix(leaderboard): rank the dashboard board by points, not by recent activity
The dashboard leaderboard stopped being ranked by points. Three defects, all introduced in #583: 1. Ranking was activity-first (a sort that put every "recently active" codev above every inactive one, with points only breaking ties). A developer with 500 points inactive 31 days ranked below one with 5 points active yesterday, so the top 10 filled with low scorers and the visible numbers shrank. 2. Anyone inactive 5+ months was deleted from the board entirely. 3. Weekly/Monthly never computed weekly or monthly points. They filtered on when a codev was last active, then displayed that codev's ALL-TIME category total — so "Weekly" showed lifetime points for whoever had been active recently, and could read identical to All Time. "Activity" also meant the later of the last attendance row or codev.updated_at, never when points were earned, so a developer shipping tasks weekly counted as inactive if their profile row was untouched. Replaces the bespoke client-side query for the Technical and Projects tabs with the existing API routes, matching what the Soft Skills tab in this same file already does. The API ranks strictly by points: all-time from the codev_points ledger, weekly/monthly summed from tasks approved inside the window (the ledger holds no history, so only tasks can answer "earned this period"). Also stops shipping the entire codev_points table to every visitor's browser — the old query had no limit — and drops the now-unused date-fns imports. No data changes: codev_points was never wrong, only how it was ranked and read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7e5a6e7 commit eaa75a2

1 file changed

Lines changed: 37 additions & 217 deletions

File tree

‎apps/codebility/app/home/(dashboard)/_components/DashboardWeeklyTop.tsx‎

Lines changed: 37 additions & 217 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
TableRow,
1212
} from "@/components/ui/table";
1313
import { createClientClientComponent } from "@/utils/supabase/client";
14-
import { startOfMonth, startOfWeek, subDays, subWeeks, subMonths } from "date-fns";
1514
import { Trophy, Medal, Award, Star, Zap, Heart, Users, Calendar, UserRoundPen } from "lucide-react";
1615

1716
import {
@@ -194,117 +193,23 @@ export default function WeeklyTop() {
194193
if (leaderboardType !== "projects") return;
195194

196195
let isMounted = true;
197-
const supabase = createClientClientComponent();
198-
if (!supabase) return;
199196

200197
const fetchProjectsLeaderboard = async () => {
201198
setIsLoading(true);
202-
203-
try {
204-
let query = supabase
205-
.from("codev_points")
206-
.select(
207-
`
208-
points,
209-
codev_id,
210-
skill_category:skill_category_id!inner(name),
211-
created_at
212-
`,
213-
);
214-
215-
// Removed .gte("created_at") from supabase query level to prevent dropping legacy users.
216-
const { data, error } = await query;
217199

218-
if (!isMounted) return;
200+
try {
201+
const response = await fetch(
202+
`/api/project-leaderboard?timeFilter=${timePeriod}&limit=10`,
203+
);
219204

220-
if (error) {
221-
console.error("Error fetching project points:", error);
222-
return;
205+
if (!response.ok) {
206+
throw new Error("Failed to fetch project leaderboard");
223207
}
224208

225-
if (data) {
226-
227-
// First, get all projects
228-
const { data: projects, error: projectsError } = await supabase
229-
.from("projects")
230-
.select("id, name");
231-
232-
if (!isMounted) return;
233-
234-
if (projectsError) {
235-
console.error("Error fetching projects:", projectsError);
236-
setProjectLeaders([]);
237-
return;
238-
}
239-
240-
// Get project members to map codevs to projects
241-
const { data: projectMembers, error: membersError } = await supabase
242-
.from("project_members")
243-
.select("codev_id, project_id");
244-
245-
if (!isMounted) return;
246-
247-
if (membersError) {
248-
console.error("Error fetching project members:", membersError);
249-
setProjectLeaders([]);
250-
return;
251-
}
252-
253-
// Create a map of codev_id to project_id
254-
const codevToProjectMap = new Map<string, string>();
255-
projectMembers?.forEach((member: any) => {
256-
codevToProjectMap.set(member.codev_id, member.project_id);
257-
});
258-
259-
// Initialize project map with all projects
260-
const projectMap = new Map<string, {
261-
project_id: string;
262-
project_name: string;
263-
total_points: number;
264-
members: Set<string>;
265-
skill_breakdown: Record<string, number>;
266-
}>();
267-
268-
projects?.forEach((project: any) => {
269-
projectMap.set(project.id, {
270-
project_id: project.id,
271-
project_name: project.name,
272-
total_points: 0,
273-
members: new Set(),
274-
skill_breakdown: {}
275-
});
276-
});
277-
278-
// Add points to projects based on member assignments
279-
data.forEach((item: any) => {
280-
const projectId = codevToProjectMap.get(item.codev_id);
281-
if (!projectId || !projectMap.has(projectId)) {
282-
return; // Skip if developer isn't assigned to any project
283-
}
284-
285-
const project = projectMap.get(projectId)!;
286-
const skillCategory = item.skill_category?.name || "Other";
287-
288-
project.total_points += item.points || 0;
289-
project.members.add(item.codev_id);
290-
project.skill_breakdown[skillCategory] = (project.skill_breakdown[skillCategory] || 0) + (item.points || 0);
291-
});
209+
const data = (await response.json()) as { leaders?: ProjectLeader[] };
292210

293-
// Convert to array and sort by total points
294-
const projectsArray: ProjectLeader[] = Array.from(projectMap.values())
295-
.map(project => ({
296-
project_id: project.project_id,
297-
project_name: project.project_name,
298-
total_points: project.total_points,
299-
member_count: project.members.size,
300-
skill_breakdown: project.skill_breakdown
301-
}))
302-
.sort((a, b) => b.total_points - a.total_points)
303-
.slice(0, 10);
304-
305-
if (isMounted) {
306-
setProjectLeaders(projectsArray);
307-
}
211+
if (isMounted) {
212+
setProjectLeaders(data.leaders || []);
308213
}
309214
} catch (error) {
310215
if (isMounted) {
@@ -326,133 +231,42 @@ export default function WeeklyTop() {
326231
}, [leaderboardType, timePeriod]);
327232

328233
useEffect(() => {
329-
if (allCategories.length === 0 || leaderboardType !== "technical") return;
234+
if (leaderboardType !== "technical" || !selectedCategory) return;
330235

331236
let isMounted = true;
332237
const supabase = createClientClientComponent();
333-
if (!supabase) return;
334238

335239
const fetchTopCodevs = async () => {
336240
setIsLoading(true);
337241
try {
338-
let query = supabase
339-
.from("codev_points")
340-
.select(
341-
`
342-
points,
343-
codev_id,
344-
codev:codev_id!inner(first_name, last_name, updated_at),
345-
skill_category:skill_category_id!inner(name),
346-
created_at
347-
`,
348-
)
349-
.order("points", { ascending: false });
350-
351-
// Fetch both codev_points and attendance_points concurrently
352-
// Note: We deliberately query without .gte("created_at") here, shifting boundary checks locally.
353-
const [pointsRes, attendanceRes] = await Promise.all([
354-
query,
355-
supabase.from("attendance_points").select("*")
356-
]);
357-
358-
const { data, error } = pointsRes;
242+
const response = await fetch(
243+
`/api/technical-leaderboard?category=${encodeURIComponent(selectedCategory)}&timeFilter=${timePeriod}&limit=10`,
244+
);
359245

360-
if (error) {
361-
console.error("Error fetching top codevs:", error);
362-
return;
246+
if (!response.ok) {
247+
throw new Error("Failed to fetch technical leaderboard");
363248
}
364249

365-
if (data) {
366-
// Group all records by category first
367-
const groupedData: Record<string, any[]> = {};
368-
369-
allCategories.forEach((category) => {
370-
groupedData[category] = [];
371-
});
250+
const data = (await response.json()) as {
251+
leaders?: { codev_id: string; first_name: string; total_points: number }[];
252+
};
372253

373-
// Build a map of latest attendance activity per user
374-
const latestAttendance = new Map<string, Date>();
375-
if (attendanceRes.data) {
376-
attendanceRes.data.forEach((row) => {
377-
const dateStr = row.last_updated || row.updated_at || row.created_at;
378-
if (dateStr) {
379-
const d = new Date(dateStr);
380-
const current = latestAttendance.get(row.codev_id);
381-
if (!current || d > current) {
382-
latestAttendance.set(row.codev_id, d);
383-
}
384-
}
385-
});
386-
}
387-
388-
// Calculate activity thresholds
389-
const now = new Date();
390-
const fiveMonthsAgo = new Date();
391-
fiveMonthsAgo.setMonth(now.getMonth() - 5);
392-
393-
const weekStart = startOfWeek(subWeeks(new Date(), 1));
394-
const monthStart = startOfMonth(subMonths(new Date(), 1));
395-
396-
const activeThreshold = new Date();
397-
activeThreshold.setDate(now.getDate() - 30); // 30 days of inactivity threshold
398-
399-
data.forEach((item: any) => {
400-
const category = item.skill_category?.name || "Uncategorized";
401-
402-
// Priority Check: Attendance Date > Profile Updated_At
403-
const codevDate = item.codev?.updated_at ? new Date(item.codev.updated_at) : undefined;
404-
const attDate = latestAttendance.get(item.codev_id);
405-
// Defaulting fallback string logic
406-
let userDate = attDate;
407-
if (!userDate || (codevDate && codevDate > userDate)) {
408-
userDate = codevDate;
409-
}
410-
411-
// 1. Core Removal limit
412-
if (!userDate || userDate < fiveMonthsAgo) {
413-
return;
414-
}
415-
416-
// 2. Period Filter limit (Weekly / Monthly boundary)
417-
if (timePeriod === "weekly" && userDate < weekStart) {
418-
return;
419-
} else if (timePeriod === "monthly" && userDate < monthStart) {
420-
return;
421-
}
422-
423-
if (groupedData[category]) {
424-
const isRecentlyActive = userDate >= activeThreshold;
425-
groupedData[category].push({
426-
points: item.points,
427-
codev: item.codev,
428-
skill_category: item.skill_category,
429-
isRecentlyActive,
430-
});
431-
}
432-
});
433-
434-
const finalCategoryData: CategoryData = {};
254+
if (!isMounted) return;
435255

436-
// 2. Sort to prioritize active users, then sort by points, finally slice top 10
437-
Object.keys(groupedData).forEach((category) => {
438-
const categoryArray = groupedData[category];
439-
if (!categoryArray) return;
440-
const sorted = categoryArray.sort((a, b) => {
441-
if (a.isRecentlyActive && !b.isRecentlyActive) return -1;
442-
if (!a.isRecentlyActive && b.isRecentlyActive) return 1;
443-
return b.points - a.points;
444-
});
445-
446-
finalCategoryData[category] = sorted.slice(0, 10);
447-
});
256+
// Ranking is by points alone. All-time totals come from the codev_points ledger;
257+
// weekly/monthly are summed from tasks approved inside the window. Activity is
258+
// deliberately not part of the ordering — a leaderboard ranks scores.
259+
const leaders: TopCodev[] = (data.leaders || []).map((leader) => ({
260+
points: leader.total_points,
261+
codev: { first_name: leader.first_name },
262+
skill_category: { name: selectedCategory },
263+
}));
448264

449-
if (isMounted) {
450-
setCategoryData(finalCategoryData);
451-
}
452-
}
265+
setCategoryData((prev) => ({ ...prev, [selectedCategory]: leaders }));
453266
} catch (error) {
454267
if (isMounted) {
455268
console.error("Error in fetchTopCodevs:", error);
269+
setCategoryData((prev) => ({ ...prev, [selectedCategory]: [] }));
456270
}
457271
} finally {
458272
if (isMounted) {
@@ -463,7 +277,13 @@ export default function WeeklyTop() {
463277

464278
fetchTopCodevs();
465279

466-
// 3. Realtime subscription to points changes
280+
// Realtime subscription so the board reflects newly awarded points.
281+
if (!supabase) {
282+
return () => {
283+
isMounted = false;
284+
};
285+
}
286+
467287
const channel = supabase
468288
.channel("public:codev_points_changes")
469289
.on(
@@ -479,7 +299,7 @@ export default function WeeklyTop() {
479299
isMounted = false;
480300
supabase.removeChannel(channel);
481301
};
482-
}, [timePeriod, allCategories, leaderboardType]);
302+
}, [timePeriod, selectedCategory, leaderboardType]);
483303

484304
const getRankIcon = (rank: number, isTraditional: boolean = true) => {
485305
if (!isTraditional) {

0 commit comments

Comments
 (0)