Skip to content

Commit 6cf1d58

Browse files
deocagunotclaude
andcommitted
fix(leaderboard): restore all-time points to the codev_points ledger
All-time totals regressed in ce3a3d4, which switched every time filter to recompute points from the tasks table. codev_points is an append-only ledger written when a task is approved; tasks is mutable. Recomputing all-time totals from tasks therefore dropped points whose originating task was later deleted, un-archived, or had its skill_category cleared or changed, so the all-time leaderboard under-reported. This shipped to production in the #610 release. Restores the all-time branch in both the technical and project leaderboard routes to read codev_points, matching pre-regression behaviour and the totals still used by the dashboards, developer levels, and the /profiles showcase gate. Weekly and monthly views are unchanged: they keep deriving from approved tasks via approved_at and the Monday-start getWeekRange/getMonthRange helpers, which was the sound part of ce3a3d4. No data migration is needed — the ledger still holds the original values, this was a read-path regression only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f244768 commit 6cf1d58

2 files changed

Lines changed: 150 additions & 6 deletions

File tree

‎apps/codebility/app/api/project-leaderboard/route.ts‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,87 @@ export async function GET(request: NextRequest) {
3535

3636
let processedLeaders: ProjectLeader[] = [];
3737

38-
// The tasks table is the single source of truth for points to ensure consistency
39-
// across all-time, monthly, and weekly leaderboards.
38+
// All-time totals come from the codev_points ledger via project membership, NOT from
39+
// the tasks table. codev_points is append-only; the tasks table is mutable, so
40+
// recomputing all-time totals from tasks silently drops points whose originating task
41+
// was later deleted, un-archived, or had its skill_category cleared. Time-ranged views
42+
// below still derive from tasks, where the approval date is the whole point.
43+
if (timeFilter === "all") {
44+
const { data: rawData, error: allTimeError } = await supabase
45+
.from("projects")
46+
.select(`
47+
id,
48+
name,
49+
project_members(
50+
codev_id,
51+
codev_points(
52+
points,
53+
skill_category:skill_category_id(name)
54+
)
55+
)
56+
`);
57+
58+
if (allTimeError) {
59+
console.error("Error fetching all-time project leaderboard:", allTimeError);
60+
return NextResponse.json(
61+
{ error: "Failed to fetch leaderboard data", details: allTimeError.message },
62+
{ status: 500 }
63+
);
64+
}
65+
66+
const allTimeMap = new Map<string, {
67+
project_id: string;
68+
project_name: string;
69+
total_points: number;
70+
members: Set<string>;
71+
skill_breakdown: Record<string, number>;
72+
}>();
73+
74+
rawData?.forEach((project: any) => {
75+
const projectId = project.id;
76+
if (!projectId) return;
77+
78+
if (!allTimeMap.has(projectId)) {
79+
allTimeMap.set(projectId, {
80+
project_id: projectId,
81+
project_name: project.name || "Unknown Project",
82+
total_points: 0,
83+
members: new Set(),
84+
skill_breakdown: {}
85+
});
86+
}
87+
const projectData = allTimeMap.get(projectId)!;
88+
89+
project.project_members?.forEach((member: any) => {
90+
if (member.codev_id) projectData.members.add(member.codev_id);
91+
member.codev_points?.forEach((point: any) => {
92+
const skillName = point.skill_category?.name || "Other";
93+
const val = point.points || 0;
94+
projectData.total_points += val;
95+
projectData.skill_breakdown[skillName] = (projectData.skill_breakdown[skillName] || 0) + val;
96+
});
97+
});
98+
});
99+
100+
processedLeaders = Array.from(allTimeMap.values())
101+
.map(p => ({
102+
project_id: p.project_id,
103+
project_name: p.project_name,
104+
total_points: p.total_points,
105+
member_count: p.members.size,
106+
skill_breakdown: p.skill_breakdown
107+
}))
108+
.filter(p => p.total_points > 0)
109+
.sort((a, b) => b.total_points - a.total_points)
110+
.slice(0, limit);
111+
112+
return NextResponse.json({
113+
leaders: processedLeaders,
114+
totalCount: processedLeaders.length
115+
});
116+
}
117+
118+
// Weekly/monthly are derived from approved tasks within the requested window.
40119
let query = supabase
41120
.from("tasks")
42121
.select(`
@@ -53,7 +132,7 @@ export async function GET(request: NextRequest) {
53132
if (timeFilter === "weekly") {
54133
const { startDate, endDate } = getWeekRange();
55134
query = query.gte("approved_at", startDate.toISOString()).lte("approved_at", endDate.toISOString());
56-
} else if (timeFilter === "monthly") {
135+
} else {
57136
const { startDate, endDate } = getMonthRange();
58137
query = query.gte("approved_at", startDate.toISOString()).lte("approved_at", endDate.toISOString());
59138
}

‎apps/codebility/app/api/technical-leaderboard/route.ts‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,73 @@ export async function GET(request: NextRequest) {
3535

3636
let processedLeaders: TechnicalLeader[] = [];
3737

38-
// The tasks table is the single source of truth for points to ensure consistency
39-
// across all-time, monthly, and weekly leaderboards.
38+
// All-time totals come from the codev_points ledger, NOT from the tasks table.
39+
//
40+
// codev_points is append-only: points are added when a task is approved and are
41+
// never removed. The tasks table is mutable, so recomputing all-time totals from it
42+
// silently drops every point whose originating task was later deleted, un-archived,
43+
// or had its skill_category cleared — which is what caused the all-time leaderboard
44+
// to under-report. Time-ranged views below still derive from tasks, where the
45+
// approval date is the whole point of the query.
46+
if (timeFilter === "all") {
47+
const { data: rawPoints, error: pointsError } = await supabase
48+
.from("codev_points")
49+
.select(`
50+
codev_id,
51+
points,
52+
created_at,
53+
codev:codev_id!inner(first_name),
54+
skill_category:skill_category_id!inner(name)
55+
`)
56+
.eq("skill_category.name", category)
57+
.not("codev.first_name", "is", null);
58+
59+
if (pointsError) {
60+
console.error("Error fetching all-time technical leaderboard:", pointsError);
61+
return NextResponse.json(
62+
{ error: "Failed to fetch leaderboard data", details: pointsError.message },
63+
{ status: 500 }
64+
);
65+
}
66+
67+
// codev_points holds one row per (codev, skill category), but aggregate defensively
68+
// in case duplicate rows exist for a pair.
69+
const allTimeMap = new Map<string, TechnicalLeader>();
70+
71+
rawPoints?.forEach((entry: any) => {
72+
const userId = entry.codev_id;
73+
if (!userId) return;
74+
75+
const points = entry.points || 0;
76+
const existing = allTimeMap.get(userId);
77+
78+
if (existing) {
79+
existing.total_points += points;
80+
if (entry.created_at && entry.created_at > existing.latest_update) {
81+
existing.latest_update = entry.created_at;
82+
}
83+
} else {
84+
allTimeMap.set(userId, {
85+
codev_id: userId,
86+
first_name: entry.codev?.first_name || "Unknown",
87+
total_points: points,
88+
latest_update: entry.created_at || new Date(0).toISOString()
89+
});
90+
}
91+
});
92+
93+
processedLeaders = Array.from(allTimeMap.values())
94+
.filter(leader => leader.total_points > 0)
95+
.sort((a, b) => b.total_points - a.total_points)
96+
.slice(0, limit);
97+
98+
return NextResponse.json({
99+
leaders: processedLeaders,
100+
totalCount: processedLeaders.length
101+
});
102+
}
103+
104+
// Weekly/monthly are derived from approved tasks within the requested window.
40105
let query = supabase
41106
.from("tasks")
42107
.select(`
@@ -53,7 +118,7 @@ export async function GET(request: NextRequest) {
53118
if (timeFilter === "weekly") {
54119
const { startDate, endDate } = getWeekRange();
55120
query = query.gte("approved_at", startDate.toISOString()).lte("approved_at", endDate.toISOString());
56-
} else if (timeFilter === "monthly") {
121+
} else {
57122
const { startDate, endDate } = getMonthRange();
58123
query = query.gte("approved_at", startDate.toISOString()).lte("approved_at", endDate.toISOString());
59124
}

0 commit comments

Comments
 (0)