diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c54587e..8fb72569 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '22' - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -63,7 +63,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '22' - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/apps/web/app/challenges/[id]/leaderboard/leaderboard-tabs.tsx b/apps/web/app/challenges/[id]/leaderboard/leaderboard-tabs.tsx new file mode 100644 index 00000000..5dd85ebd --- /dev/null +++ b/apps/web/app/challenges/[id]/leaderboard/leaderboard-tabs.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { LeaderboardList } from "./leaderboard-list"; +import { WeeklyCategoryLeaderboard } from "./weekly-category-leaderboard"; + +interface LeaderboardEntry { + rank: number; + user: { + id: string; + name: string | null; + username: string; + avatarUrl: string | null; + }; + totalPoints: number; + currentStreak: number; +} + +interface LeaderboardTabsProps { + entries: LeaderboardEntry[]; + challengeId: string; + currentUserId: string; +} + +type Tab = "overall" | "weekly"; + +export function LeaderboardTabs({ + entries, + challengeId, + currentUserId, +}: LeaderboardTabsProps) { + const [activeTab, setActiveTab] = useState("overall"); + + return ( +
+ {/* Tab switcher */} +
+ + +
+ + {/* Tab content */} + {activeTab === "overall" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/challenges/[id]/leaderboard/page.tsx b/apps/web/app/challenges/[id]/leaderboard/page.tsx index b268043c..837a4418 100644 --- a/apps/web/app/challenges/[id]/leaderboard/page.tsx +++ b/apps/web/app/challenges/[id]/leaderboard/page.tsx @@ -6,7 +6,7 @@ import type { Id } from "@repo/backend/_generated/dataModel"; import { getCurrentUser } from "@/lib/auth"; import { isAuthenticated } from "@/lib/server-auth"; import { DashboardLayoutWrapper } from "../notifications/dashboard-layout-wrapper"; -import { LeaderboardList } from "./leaderboard-list"; +import { LeaderboardTabs } from "./leaderboard-tabs"; const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!); @@ -66,7 +66,7 @@ export default async function LeaderboardPage({ params }: LeaderboardPageProps) >

Leaderboard

- , + weekNumber, + }); + + // Once we have data, if initialWeek wasn't provided, snap to current week + const hasSnapped = useRef(false); + if (data && !hasSnapped.current && !initialWeek) { + hasSnapped.current = true; + if (data.currentWeek >= 1 && data.currentWeek <= data.totalWeeks) { + setWeekNumber(data.currentWeek); + } + } + + if (!data) { + return ( +
+ +
+ ); + } + + const canGoPrev = weekNumber > 1; + const canGoNext = weekNumber < data.totalWeeks; + + return ( +
+ {/* Week navigator */} +
+ + +
+

+ Week {data.weekNumber} +

+ {data.weekNumber === data.currentWeek && ( +

Current week

+ )} +
+ + +
+ + {/* Category sections */} + {data.categories.length === 0 ? ( +
+ +

+ No activities this week +

+

+ No one has logged activities for week {data.weekNumber} yet. +

+
+ ) : ( + (data.categories as CategoryLeaderboard[]).map((category) => ( +
+

+ {category.category.name} +

+
+ {category.entries.map((entry: WeeklyLeaderboardEntry) => { + const isCurrentUser = entry.user.id === currentUserId; + + return ( + +
+ {entry.rank <= 3 ? ( + + ) : ( + entry.rank + )} +
+ + + +
+

+ {entry.user.name || entry.user.username} + {isCurrentUser && ( + + (You) + + )} +

+
+ +
+

+ {entry.weeklyPoints.toFixed(0)} +

+

pts

+
+ + ); + })} +
+
+ )) + )} +
+ ); +} diff --git a/apps/web/tests/api/weekly-leaderboard.test.ts b/apps/web/tests/api/weekly-leaderboard.test.ts new file mode 100644 index 00000000..ecf3bee8 --- /dev/null +++ b/apps/web/tests/api/weekly-leaderboard.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { api } from '@repo/backend'; +import { createTestContext, createTestUser, createTestChallenge } from '../helpers/convex'; +import type { Id } from '@repo/backend/_generated/dataModel'; + +describe('getWeeklyCategoryLeaderboard', () => { + let t: Awaited>; + + beforeEach(async () => { + t = createTestContext(); + }); + + // Helper: create a 28-day challenge starting Jan 1, 2024 + const setupChallenge = async () => { + const userId = await createTestUser(t, { email: 'creator@example.com', name: 'Creator' }); + const challengeId = await t.run(async (ctx) => { + return await ctx.db.insert('challenges', { + name: 'Test Challenge', + creatorId: userId, + startDate: '2024-01-01', + endDate: '2024-01-28', + durationDays: 28, + streakMinPoints: 10, + weekCalcMethod: 'fromStart', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + return { creatorId: userId, challengeId }; + }; + + // Helper: create a category + const createCategory = async (name: string) => { + return await t.run(async (ctx) => { + return await ctx.db.insert('categories', { + name, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }; + + // Helper: create an activity type linked to a category + const createActivityType = async ( + challengeId: Id<'challenges'>, + name: string, + categoryId?: Id<'categories'>, + ) => { + return await t.run(async (ctx) => { + return await ctx.db.insert('activityTypes', { + challengeId, + name, + categoryId, + scoringConfig: { unit: 'minutes', pointsPerUnit: 1, basePoints: 0 }, + contributesToStreak: true, + isNegative: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }; + + // Helper: create a user with participation + const createParticipant = async ( + challengeId: Id<'challenges'>, + email: string, + name: string, + ) => { + const userId = await createTestUser(t, { email, name, username: email.split('@')[0] }); + await t.run(async (ctx) => { + await ctx.db.insert('userChallenges', { + userId, + challengeId, + joinedAt: Date.now(), + totalPoints: 0, + currentStreak: 0, + modifierFactor: 1, + paymentStatus: 'paid', + updatedAt: Date.now(), + }); + }); + return userId; + }; + + // Helper: insert an activity directly (bypassing mutation for speed) + const insertActivity = async ( + userId: Id<'users'>, + challengeId: Id<'challenges'>, + activityTypeId: Id<'activityTypes'>, + loggedDate: number, + pointsEarned: number, + ) => { + return await t.run(async (ctx) => { + return await ctx.db.insert('activities', { + userId, + challengeId, + activityTypeId, + loggedDate, + metrics: {}, + source: 'manual', + pointsEarned, + flagged: false, + adminCommentVisibility: 'internal', + resolutionStatus: 'pending', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + }); + }; + + it('should return null for a non-existent challenge', async () => { + const fakeId = '0'.repeat(32) as Id<'challenges'>; + // convex-test will throw or return null for non-existent docs + // We need a valid-looking ID; use a real one from setup then delete it + const { challengeId } = await setupChallenge(); + await t.run(async (ctx) => { + await ctx.db.delete(challengeId); + }); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + expect(result).toBeNull(); + }); + + it('should return empty categories when no activities exist', async () => { + const { challengeId } = await setupChallenge(); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + expect(result).not.toBeNull(); + expect(result!.weekNumber).toBe(1); + expect(result!.totalWeeks).toBe(4); // 28 days / 7 = 4 weeks + expect(result!.categories).toEqual([]); + }); + + it('should return correct totalWeeks and weekNumber metadata', async () => { + const { challengeId } = await setupChallenge(); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 3, + }); + + expect(result!.weekNumber).toBe(3); + expect(result!.totalWeeks).toBe(4); + }); + + it('should clamp weekNumber to valid range', async () => { + const { challengeId } = await setupChallenge(); + + // Week 0 should clamp to 1 + const low = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 0, + }); + expect(low!.weekNumber).toBe(1); + + // Week 99 should clamp to totalWeeks (4) + const high = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 99, + }); + expect(high!.weekNumber).toBe(4); + }); + + it('should group activities by category and sum points per user', async () => { + const { challengeId } = await setupChallenge(); + const cardioCategory = await createCategory('Cardio'); + const strengthCategory = await createCategory('Strength'); + const runningType = await createActivityType(challengeId, 'Running', cardioCategory); + const liftingType = await createActivityType(challengeId, 'Lifting', strengthCategory); + + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + const bob = await createParticipant(challengeId, 'bob@test.com', 'Bob'); + + // Week 1: Jan 1-7, 2024 + // Alice: 30 running + 20 running = 50 cardio, 15 lifting = 15 strength + await insertActivity(alice, challengeId, runningType, Date.UTC(2024, 0, 2), 30); + await insertActivity(alice, challengeId, runningType, Date.UTC(2024, 0, 3), 20); + await insertActivity(alice, challengeId, liftingType, Date.UTC(2024, 0, 2), 15); + + // Bob: 40 running = 40 cardio, 25 lifting = 25 strength + await insertActivity(bob, challengeId, runningType, Date.UTC(2024, 0, 4), 40); + await insertActivity(bob, challengeId, liftingType, Date.UTC(2024, 0, 4), 25); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + expect(result!.categories).toHaveLength(2); + + // Categories should be sorted alphabetically + const cardio = result!.categories.find((c: any) => c.category.name === 'Cardio'); + const strength = result!.categories.find((c: any) => c.category.name === 'Strength'); + + expect(cardio).toBeDefined(); + expect(strength).toBeDefined(); + + // Cardio: Alice 50, Bob 40 + expect(cardio!.entries).toHaveLength(2); + expect(cardio!.entries[0].user.name).toBe('Alice'); + expect(cardio!.entries[0].weeklyPoints).toBe(50); + expect(cardio!.entries[0].rank).toBe(1); + expect(cardio!.entries[1].user.name).toBe('Bob'); + expect(cardio!.entries[1].weeklyPoints).toBe(40); + expect(cardio!.entries[1].rank).toBe(2); + + // Strength: Bob 25, Alice 15 + expect(strength!.entries).toHaveLength(2); + expect(strength!.entries[0].user.name).toBe('Bob'); + expect(strength!.entries[0].weeklyPoints).toBe(25); + expect(strength!.entries[1].user.name).toBe('Alice'); + expect(strength!.entries[1].weeklyPoints).toBe(15); + }); + + it('should only include activities from the requested week', async () => { + const { challengeId } = await setupChallenge(); + const category = await createCategory('Cardio'); + const actType = await createActivityType(challengeId, 'Running', category); + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + + // Week 1 activity (Jan 1-7) + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 3), 100); + // Week 2 activity (Jan 8-14) + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 10), 200); + + // Query week 1: should only see 100 pts + const week1 = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + expect(week1!.categories).toHaveLength(1); + expect(week1!.categories[0].entries[0].weeklyPoints).toBe(100); + + // Query week 2: should only see 200 pts + const week2 = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 2, + }); + expect(week2!.categories).toHaveLength(1); + expect(week2!.categories[0].entries[0].weeklyPoints).toBe(200); + + // Query week 3: no activities + const week3 = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 3, + }); + expect(week3!.categories).toHaveLength(0); + }); + + it('should handle uncategorized activity types', async () => { + const { challengeId } = await setupChallenge(); + // Activity type with no categoryId + const actType = await createActivityType(challengeId, 'Misc Workout'); + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 2), 50); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + expect(result!.categories).toHaveLength(1); + expect(result!.categories[0].category.name).toBe('Other'); + expect(result!.categories[0].category.id).toBe('uncategorized'); + expect(result!.categories[0].entries[0].weeklyPoints).toBe(50); + }); + + it('should sort categories alphabetically with "Other" last', async () => { + const { challengeId } = await setupChallenge(); + const zCategory = await createCategory('Zzz Sleep'); + const aCategory = await createCategory('Abs'); + + const zType = await createActivityType(challengeId, 'Sleep Tracking', zCategory); + const aType = await createActivityType(challengeId, 'Crunches', aCategory); + const uncatType = await createActivityType(challengeId, 'Misc'); + + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + + await insertActivity(alice, challengeId, zType, Date.UTC(2024, 0, 2), 10); + await insertActivity(alice, challengeId, aType, Date.UTC(2024, 0, 2), 10); + await insertActivity(alice, challengeId, uncatType, Date.UTC(2024, 0, 2), 10); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + expect(result!.categories).toHaveLength(3); + expect(result!.categories[0].category.name).toBe('Abs'); + expect(result!.categories[1].category.name).toBe('Zzz Sleep'); + expect(result!.categories[2].category.name).toBe('Other'); // Last + }); + + it('should rank users by points descending within a category', async () => { + const { challengeId } = await setupChallenge(); + const category = await createCategory('Cardio'); + const actType = await createActivityType(challengeId, 'Running', category); + + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + const bob = await createParticipant(challengeId, 'bob@test.com', 'Bob'); + const carol = await createParticipant(challengeId, 'carol@test.com', 'Carol'); + + // Carol: 100, Alice: 75, Bob: 50 + await insertActivity(carol, challengeId, actType, Date.UTC(2024, 0, 2), 100); + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 2), 75); + await insertActivity(bob, challengeId, actType, Date.UTC(2024, 0, 2), 50); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + const entries = result!.categories[0].entries; + expect(entries[0].user.name).toBe('Carol'); + expect(entries[0].rank).toBe(1); + expect(entries[0].weeklyPoints).toBe(100); + + expect(entries[1].user.name).toBe('Alice'); + expect(entries[1].rank).toBe(2); + expect(entries[1].weeklyPoints).toBe(75); + + expect(entries[2].user.name).toBe('Bob'); + expect(entries[2].rank).toBe(3); + expect(entries[2].weeklyPoints).toBe(50); + }); + + it('should limit to top 10 users per category', async () => { + const { challengeId } = await setupChallenge(); + const category = await createCategory('Cardio'); + const actType = await createActivityType(challengeId, 'Running', category); + + // Create 12 participants + const userIds: Id<'users'>[] = []; + for (let i = 0; i < 12; i++) { + const userId = await createParticipant( + challengeId, + `user${i}@test.com`, + `User ${i}`, + ); + userIds.push(userId); + await insertActivity(userId, challengeId, actType, Date.UTC(2024, 0, 2), (12 - i) * 10); + } + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + expect(result!.categories[0].entries).toHaveLength(10); + // Top user should have 120 pts, 10th should have 30 pts + expect(result!.categories[0].entries[0].weeklyPoints).toBe(120); + expect(result!.categories[0].entries[9].weeklyPoints).toBe(30); + }); + + it('should not include categories with zero entries', async () => { + const { challengeId } = await setupChallenge(); + const cardio = await createCategory('Cardio'); + const strength = await createCategory('Strength'); + + // Create activity types for both categories but only log to cardio + await createActivityType(challengeId, 'Running', cardio); + const liftingType = await createActivityType(challengeId, 'Lifting', strength); + + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + // Only cardio activities in week 1... but using liftingType, let's use a cardio type + // Actually, let's create activities with the running type but we need its ID + const runningType = await createActivityType(challengeId, 'Sprinting', cardio); + await insertActivity(alice, challengeId, runningType, Date.UTC(2024, 0, 2), 50); + + // No activities for strength category + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + // Only Cardio should appear, not Strength + expect(result!.categories).toHaveLength(1); + expect(result!.categories[0].category.name).toBe('Cardio'); + }); + + it('should aggregate multiple activities of same type for same user in same week', async () => { + const { challengeId } = await setupChallenge(); + const category = await createCategory('Cardio'); + const actType = await createActivityType(challengeId, 'Running', category); + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + + // Three runs in week 1 across different days + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 1), 10); + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 3), 20); + await insertActivity(alice, challengeId, actType, Date.UTC(2024, 0, 5), 30); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + expect(result!.categories[0].entries[0].weeklyPoints).toBe(60); // 10 + 20 + 30 + }); + + it('should aggregate activities of different types in the same category', async () => { + const { challengeId } = await setupChallenge(); + const category = await createCategory('Cardio'); + const running = await createActivityType(challengeId, 'Running', category); + const cycling = await createActivityType(challengeId, 'Cycling', category); + + const alice = await createParticipant(challengeId, 'alice@test.com', 'Alice'); + + await insertActivity(alice, challengeId, running, Date.UTC(2024, 0, 2), 30); + await insertActivity(alice, challengeId, cycling, Date.UTC(2024, 0, 3), 20); + + const result = await t.query(api.queries.participations.getWeeklyCategoryLeaderboard, { + challengeId, + weekNumber: 1, + }); + + // Both should roll up into the Cardio category + expect(result!.categories).toHaveLength(1); + expect(result!.categories[0].category.name).toBe('Cardio'); + expect(result!.categories[0].entries[0].weeklyPoints).toBe(50); + }); +}); diff --git a/apps/web/tests/lib/weeks.test.ts b/apps/web/tests/lib/weeks.test.ts new file mode 100644 index 00000000..56364eed --- /dev/null +++ b/apps/web/tests/lib/weeks.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { getChallengeWeekNumber, getWeekDateRange, getTotalWeeks } from '../../../../packages/backend/lib/weeks'; + +describe('weeks utilities', () => { + // Challenge starts Jan 1, 2024 + const startDate = '2024-01-01'; + const startMs = Date.UTC(2024, 0, 1); // Jan 1, 2024 00:00 UTC + + describe('getChallengeWeekNumber', () => { + it('should return week 1 for the challenge start date', () => { + expect(getChallengeWeekNumber(startDate, startMs)).toBe(1); + }); + + it('should return week 1 for day 6 (last day of first week)', () => { + const day6 = Date.UTC(2024, 0, 7); // Jan 7 + expect(getChallengeWeekNumber(startDate, day6)).toBe(1); + }); + + it('should return week 2 for day 7 (first day of second week)', () => { + const day7 = Date.UTC(2024, 0, 8); // Jan 8 + expect(getChallengeWeekNumber(startDate, day7)).toBe(2); + }); + + it('should return week 4 for day 21', () => { + const day21 = Date.UTC(2024, 0, 22); // Jan 22 + expect(getChallengeWeekNumber(startDate, day21)).toBe(4); + }); + + it('should return 0 for a date before the challenge starts', () => { + const before = Date.UTC(2023, 11, 31); // Dec 31, 2023 + expect(getChallengeWeekNumber(startDate, before)).toBe(0); + }); + + it('should handle numeric startDate (ms timestamp)', () => { + expect(getChallengeWeekNumber(startMs, startMs)).toBe(1); + }); + + it('should handle mid-day timestamps', () => { + // 3pm on Jan 1 should still be week 1 + const midDay = Date.UTC(2024, 0, 1, 15, 30, 0); + expect(getChallengeWeekNumber(startDate, midDay)).toBe(1); + }); + + it('should handle end-of-day timestamps at week boundary', () => { + // 23:59 on Jan 7 (day 6) should still be week 1 + const endOfDay6 = Date.UTC(2024, 0, 7, 23, 59, 59); + expect(getChallengeWeekNumber(startDate, endOfDay6)).toBe(1); + + // 00:00 on Jan 8 (day 7) should be week 2 + const startOfDay7 = Date.UTC(2024, 0, 8, 0, 0, 0); + expect(getChallengeWeekNumber(startDate, startOfDay7)).toBe(2); + }); + }); + + describe('getWeekDateRange', () => { + it('should return correct range for week 1', () => { + const { start, end } = getWeekDateRange(startDate, 1); + expect(start).toBe(Date.UTC(2024, 0, 1)); + expect(end).toBe(Date.UTC(2024, 0, 8)); + }); + + it('should return correct range for week 2', () => { + const { start, end } = getWeekDateRange(startDate, 2); + expect(start).toBe(Date.UTC(2024, 0, 8)); + expect(end).toBe(Date.UTC(2024, 0, 15)); + }); + + it('should return correct range for week 4', () => { + const { start, end } = getWeekDateRange(startDate, 4); + expect(start).toBe(Date.UTC(2024, 0, 22)); + expect(end).toBe(Date.UTC(2024, 0, 29)); + }); + + it('should produce non-overlapping consecutive ranges', () => { + const week1 = getWeekDateRange(startDate, 1); + const week2 = getWeekDateRange(startDate, 2); + expect(week1.end).toBe(week2.start); + }); + + it('should produce 7-day ranges', () => { + const msPerDay = 1000 * 60 * 60 * 24; + for (let week = 1; week <= 5; week++) { + const { start, end } = getWeekDateRange(startDate, week); + expect(end - start).toBe(7 * msPerDay); + } + }); + + it('should handle numeric startDate', () => { + const { start, end } = getWeekDateRange(startMs, 1); + expect(start).toBe(Date.UTC(2024, 0, 1)); + expect(end).toBe(Date.UTC(2024, 0, 8)); + }); + }); + + describe('getTotalWeeks', () => { + it('should return 1 for 7 days', () => { + expect(getTotalWeeks(7)).toBe(1); + }); + + it('should return 2 for 8 days (ceil)', () => { + expect(getTotalWeeks(8)).toBe(2); + }); + + it('should return 4 for exactly 28 days', () => { + expect(getTotalWeeks(28)).toBe(4); + }); + + it('should return 5 for 30 days (ceil)', () => { + expect(getTotalWeeks(30)).toBe(5); + }); + + it('should return 1 for 1 day', () => { + expect(getTotalWeeks(1)).toBe(1); + }); + }); + + describe('getChallengeWeekNumber + getWeekDateRange round-trip', () => { + it('should place dates within the correct week range', () => { + for (let week = 1; week <= 4; week++) { + const { start, end } = getWeekDateRange(startDate, week); + // Start of week should be in this week + expect(getChallengeWeekNumber(startDate, start)).toBe(week); + // End of week (exclusive) should be in the next week + expect(getChallengeWeekNumber(startDate, end)).toBe(week + 1); + // Mid-week should be in this week + const mid = start + (end - start) / 2; + expect(getChallengeWeekNumber(startDate, mid)).toBe(week); + } + }); + }); +}); diff --git a/packages/backend/_generated/api.d.ts b/packages/backend/_generated/api.d.ts index d7c6ca34..6139e0a7 100644 --- a/packages/backend/_generated/api.d.ts +++ b/packages/backend/_generated/api.d.ts @@ -28,6 +28,7 @@ import type * as lib_resend from "../lib/resend.js"; import type * as lib_scoring from "../lib/scoring.js"; import type * as lib_strava from "../lib/strava.js"; import type * as lib_stripe from "../lib/stripe.js"; +import type * as lib_weeks from "../lib/weeks.js"; import type * as migrations from "../migrations.js"; import type * as mutations_achievements from "../mutations/achievements.js"; import type * as mutations_activities from "../mutations/activities.js"; @@ -107,6 +108,7 @@ declare const fullApi: ApiFromModules<{ "lib/scoring": typeof lib_scoring; "lib/strava": typeof lib_strava; "lib/stripe": typeof lib_stripe; + "lib/weeks": typeof lib_weeks; migrations: typeof migrations; "mutations/achievements": typeof mutations_achievements; "mutations/activities": typeof mutations_activities; diff --git a/packages/backend/lib/weeks.ts b/packages/backend/lib/weeks.ts new file mode 100644 index 00000000..0bc96576 --- /dev/null +++ b/packages/backend/lib/weeks.ts @@ -0,0 +1,66 @@ +import { dateOnlyToUtcMs } from "./dateOnly"; + +/** + * Get the week number of the challenge for a given date. + * Week 1 = days 0-6, Week 2 = days 7-13, etc. + * Returns 0 if the date is before the challenge starts. + */ +export function getChallengeWeekNumber( + challengeStartDate: string | number, + loggedDate: number +): number { + const startDate = new Date(dateOnlyToUtcMs(challengeStartDate)); + const loggedDateObj = new Date(loggedDate); + + const startDayUtc = Date.UTC( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate() + ); + + const loggedDayUtc = Date.UTC( + loggedDateObj.getUTCFullYear(), + loggedDateObj.getUTCMonth(), + loggedDateObj.getUTCDate() + ); + + const daysSinceStart = Math.floor( + (loggedDayUtc - startDayUtc) / (1000 * 60 * 60 * 24) + ); + + if (daysSinceStart < 0) { + return 0; + } + + return Math.floor(daysSinceStart / 7) + 1; +} + +/** + * Get the UTC timestamp range [start, end) for a given challenge week number. + * Week 1 covers days 0-6, Week 2 covers days 7-13, etc. + */ +export function getWeekDateRange( + challengeStartDate: string | number, + weekNumber: number +): { start: number; end: number } { + const startMs = dateOnlyToUtcMs(challengeStartDate); + const startDate = new Date(startMs); + const startDayUtc = Date.UTC( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate() + ); + + const msPerDay = 1000 * 60 * 60 * 24; + const weekStart = startDayUtc + (weekNumber - 1) * 7 * msPerDay; + const weekEnd = weekStart + 7 * msPerDay; + + return { start: weekStart, end: weekEnd }; +} + +/** + * Get the total number of weeks in a challenge. + */ +export function getTotalWeeks(durationDays: number): number { + return Math.ceil(durationDays / 7); +} diff --git a/packages/backend/mutations/activities.ts b/packages/backend/mutations/activities.ts index 7030eb57..2d336089 100644 --- a/packages/backend/mutations/activities.ts +++ b/packages/backend/mutations/activities.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "../lib/ids"; import { isPaymentRequired } from "../lib/payments"; import type { Id } from "../_generated/dataModel"; import { dateOnlyToUtcMs } from "../lib/dateOnly"; +import { getChallengeWeekNumber } from "../lib/weeks"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -573,38 +574,6 @@ function getWeekStart(timestamp: number): number { return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff); } -/** - * Get the week number of the challenge for a given date - * Week 1 = days 1-7, Week 2 = days 8-14, etc. - * Returns 0 if the date is before the challenge starts - */ -function getChallengeWeekNumber(challengeStartDate: string | number, loggedDate: number): number { - // Normalize both to start of day UTC - const startDate = new Date(dateOnlyToUtcMs(challengeStartDate)); - const loggedDateObj = new Date(loggedDate); - - const startDayUtc = Date.UTC( - startDate.getUTCFullYear(), - startDate.getUTCMonth(), - startDate.getUTCDate() - ); - - const loggedDayUtc = Date.UTC( - loggedDateObj.getUTCFullYear(), - loggedDateObj.getUTCMonth(), - loggedDateObj.getUTCDate() - ); - - // Calculate days since challenge start (0-indexed) - const daysSinceStart = Math.floor((loggedDayUtc - startDayUtc) / (1000 * 60 * 60 * 24)); - - if (daysSinceStart < 0) { - return 0; // Before challenge started - } - - // Week 1 = days 0-6, Week 2 = days 7-13, etc. - return Math.floor(daysSinceStart / 7) + 1; -} // Generate an upload URL for activity media export const generateUploadUrl = mutation({ diff --git a/packages/backend/queries/participations.ts b/packages/backend/queries/participations.ts index 08eaeb01..dd22334b 100644 --- a/packages/backend/queries/participations.ts +++ b/packages/backend/queries/participations.ts @@ -2,6 +2,8 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; import { paginationOptsValidator } from "convex/server"; import { getCurrentUser } from "../lib/ids"; +import { getChallengeWeekNumber, getWeekDateRange, getTotalWeeks } from "../lib/weeks"; +import type { Id } from "../_generated/dataModel"; /** * Get recent participants for a challenge @@ -381,3 +383,150 @@ export const debugAdminStatus = query({ }; }, }); + +/** + * Get weekly category leaderboard for a challenge. + * Returns top users per category for a specific week number. + * Uses the challengeLoggedDate index for efficient date-range filtering. + */ +export const getWeeklyCategoryLeaderboard = query({ + args: { + challengeId: v.id("challenges"), + weekNumber: v.number(), + }, + handler: async (ctx, args) => { + const challenge = await ctx.db.get(args.challengeId); + if (!challenge) { + return null; + } + + const totalWeeks = getTotalWeeks(challenge.durationDays); + const currentWeek = getChallengeWeekNumber(challenge.startDate, Date.now()); + + // Clamp weekNumber to valid range + const weekNumber = Math.max(1, Math.min(args.weekNumber, totalWeeks)); + const { start, end } = getWeekDateRange(challenge.startDate, weekNumber); + + // Fetch activity types for this challenge to build categoryId mapping + const activityTypes = await ctx.db + .query("activityTypes") + .withIndex("challengeId", (q) => q.eq("challengeId", args.challengeId)) + .collect(); + + const activityTypeMap = new Map( + activityTypes.map((at) => [at._id, at]) + ); + + // Collect unique category IDs and fetch category docs + const categoryIds = new Set>(); + for (const at of activityTypes) { + if (at.categoryId) categoryIds.add(at.categoryId); + } + const categoryDocs = await Promise.all( + Array.from(categoryIds).map((id) => ctx.db.get(id)) + ); + const categoryMap = new Map( + categoryDocs + .filter((c): c is NonNullable => c !== null) + .map((c) => [c._id, c]) + ); + + // Query activities for this challenge in the week date range + // Uses challengeLoggedDate index: ["challengeId", "loggedDate"] + const activities = await ctx.db + .query("activities") + .withIndex("challengeLoggedDate", (q) => + q + .eq("challengeId", args.challengeId) + .gte("loggedDate", start) + .lt("loggedDate", end) + ) + .collect(); + + // Group points: categoryId -> userId -> totalPoints + const categoryUserPoints = new Map>(); + + for (const activity of activities) { + const at = activityTypeMap.get(activity.activityTypeId); + if (!at) continue; + + const catId = at.categoryId ?? "uncategorized"; + const catKey = catId as string; + + if (!categoryUserPoints.has(catKey)) { + categoryUserPoints.set(catKey, new Map()); + } + const userPoints = categoryUserPoints.get(catKey)!; + const current = userPoints.get(activity.userId) ?? 0; + userPoints.set(activity.userId, current + activity.pointsEarned); + } + + // Build leaderboard per category: sort users, take top 10, fetch user data + const userCache = new Map(); + + const categories = await Promise.all( + Array.from(categoryUserPoints.entries()).map(async ([catKey, userPointsMap]) => { + // Sort users by points descending + const sorted = Array.from(userPointsMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + // Fetch user data (with caching) + const entries = await Promise.all( + sorted.map(async ([userId, points], index) => { + if (!userCache.has(userId)) { + const user = await ctx.db.get(userId as Id<"users">); + userCache.set( + userId, + user + ? { + id: user._id, + name: user.name ?? null, + username: user.username, + avatarUrl: user.avatarUrl ?? null, + } + : null + ); + } + const user = userCache.get(userId); + if (!user) return null; + + return { + rank: index + 1, + user, + weeklyPoints: points, + }; + }) + ); + + const category = + catKey === "uncategorized" + ? { id: "uncategorized" as string, name: "Other" } + : categoryMap.has(catKey as Id<"categories">) + ? { id: catKey, name: categoryMap.get(catKey as Id<"categories">)!.name } + : { id: catKey, name: "Unknown" }; + + return { + category, + entries: entries.filter( + (e): e is NonNullable => e !== null + ), + }; + }) + ); + + // Sort categories alphabetically, but put "Other" last + categories.sort((a, b) => { + if (a.category.id === "uncategorized") return 1; + if (b.category.id === "uncategorized") return -1; + return a.category.name.localeCompare(b.category.name); + }); + + return { + weekNumber, + totalWeeks, + currentWeek, + categories: categories.filter((c) => c.entries.length > 0), + }; + }, +}); diff --git a/tasks/2026-02-08-weekly-leaderboard-view.md b/tasks/2026-02-08-weekly-leaderboard-view.md new file mode 100644 index 00000000..036051b4 --- /dev/null +++ b/tasks/2026-02-08-weekly-leaderboard-view.md @@ -0,0 +1,27 @@ +# Weekly Leaderboard View + +**Date:** 2026-02-08 +**Description:** Add a weekly category leaderboard view to the existing leaderboard page, allowing users to see top performers per activity category for each week of the challenge. + +## Requirements + +- [x] Add toggle on leaderboard page to switch between "Overall" and "Weekly" views +- [x] Weekly view shows category leaders grouped by category +- [x] Week selector to navigate between weeks (defaults to current week) +- [x] Efficient backend query using `challengeLoggedDate` index for date-range filtering +- [x] Real-time updates via Convex live query on the weekly view + +## Implementation Notes + +### Backend +- Shared week utility (`packages/backend/lib/weeks.ts`) for week number calculation +- New query `getWeeklyCategoryLeaderboard` in `queries/participations.ts` + - Uses `challengeLoggedDate` index to efficiently query activities within a week's date range + - Groups activities by category, then by user, summing points + - Returns top 10 users per category with user data + +### Frontend +- `LeaderboardTabs` client component wraps overall and weekly views +- `WeeklyCategoryLeaderboard` client component with week navigation and category sections +- Uses Convex `useQuery` for real-time weekly data +- Reuses existing `LeaderboardList` for the overall tab