| import type { CodingQuestion } from '../data/codingQuestions'; |
| import { getActivityDateKey } from './activityDates'; |
|
|
| export type WeeklyContest = { |
| id: string; |
| startTime: Date; |
| endTime: Date; |
| durationMinutes: number; |
| isLive: boolean; |
| canJoin: boolean; |
| }; |
|
|
| export type WeeklyContestPrizeTier = { |
| fromRank: number; |
| toRank: number; |
| label: string; |
| coins: number; |
| }; |
|
|
| const WEEKLY_CONTEST_DAY = 0; |
| const WEEKLY_CONTEST_START_HOUR = 20; |
| const WEEKLY_CONTEST_DURATION_MINUTES = 90; |
| const WEEKLY_CONTEST_JOIN_STORAGE_PREFIX = 'ryp.weeklyContest.joined'; |
| const WEEKLY_CONTEST_CLAIM_STORAGE_PREFIX = 'ryp.weeklyContest.claimed'; |
|
|
| export const WEEKLY_CONTEST_PRIZE_TIERS: WeeklyContestPrizeTier[] = [ |
| { fromRank: 1, toRank: 1, label: '1st place', coins: 5000 }, |
| { fromRank: 2, toRank: 2, label: '2nd place', coins: 3000 }, |
| { fromRank: 3, toRank: 3, label: '3rd place', coins: 1000 }, |
| { fromRank: 4, toRank: 10, label: '4th - 10th', coins: 100 }, |
| ] as const; |
|
|
| const contestPointsByDifficulty: Record<CodingQuestion['difficulty'], number> = { |
| Easy: 150, |
| Medium: 300, |
| Hard: 550, |
| }; |
|
|
| export function getWeeklyContest(now: Date) { |
| const thisWeekContest = buildContestForWeek(now, 0, now); |
| if (now >= thisWeekContest.endTime) { |
| return buildContestForWeek(now, 1, now); |
| } |
|
|
| return thisWeekContest; |
| } |
|
|
| export function getLatestCompletedWeeklyContest(now: Date) { |
| const thisWeekContest = buildContestForWeek(now, 0, now); |
| if (now >= thisWeekContest.endTime) { |
| return thisWeekContest; |
| } |
|
|
| return buildContestForWeek(now, -1, now); |
| } |
|
|
| export function getWeeklyContestQuestions(questions: CodingQuestion[]) { |
| const buckets = { |
| Easy: questions.filter((question) => question.difficulty === 'Easy'), |
| Medium: questions.filter((question) => question.difficulty === 'Medium'), |
| Hard: questions.filter((question) => question.difficulty === 'Hard'), |
| }; |
|
|
| const selected = [ |
| buckets.Easy[0], |
| buckets.Medium[0], |
| buckets.Medium[1], |
| buckets.Hard[0], |
| ].filter(Boolean) as CodingQuestion[]; |
|
|
| if (selected.length >= 4) { |
| return selected.slice(0, 4); |
| } |
|
|
| const selectedIds = new Set(selected.map((question) => question.id)); |
| for (const question of questions) { |
| if (selectedIds.has(question.id)) { |
| continue; |
| } |
|
|
| selected.push(question); |
| selectedIds.add(question.id); |
|
|
| if (selected.length === 4) { |
| break; |
| } |
| } |
|
|
| return selected; |
| } |
|
|
| export function getContestQuestionPoints(question: CodingQuestion) { |
| return contestPointsByDifficulty[question.difficulty]; |
| } |
|
|
| export function getContestPrizeForRank(rank: number) { |
| const tier = WEEKLY_CONTEST_PRIZE_TIERS.find( |
| (entry) => rank >= entry.fromRank && rank <= entry.toRank, |
| ); |
|
|
| return tier?.coins ?? 0; |
| } |
|
|
| export function getContestPrizePool() { |
| return WEEKLY_CONTEST_PRIZE_TIERS.reduce( |
| (sum, tier) => sum + (tier.toRank - tier.fromRank + 1) * tier.coins, |
| 0, |
| ); |
| } |
|
|
| export function formatDurationFromNow(now: Date, target: Date) { |
| const diffMs = Math.max(target.getTime() - now.getTime(), 0); |
| const totalMinutes = Math.ceil(diffMs / 60000); |
| const days = Math.floor(totalMinutes / (60 * 24)); |
| const hours = Math.floor((totalMinutes % (60 * 24)) / 60); |
| const minutes = totalMinutes % 60; |
| const parts: string[] = []; |
|
|
| if (days > 0) { |
| parts.push(`${days}d`); |
| } |
| if (hours > 0) { |
| parts.push(`${hours}h`); |
| } |
| if ((days === 0 && hours === 0) || minutes > 0) { |
| parts.push(`${minutes}m`); |
| } |
|
|
| return parts.join(' '); |
| } |
|
|
| export function readJoinedContestIds(userId: string) { |
| return readStoredContestIds(getJoinedContestStorageKey(userId)); |
| } |
|
|
| export function joinWeeklyContest(userId: string, contestId: string) { |
| const nextIds = mergeStoredContestId(readJoinedContestIds(userId), contestId); |
| writeStoredContestIds(getJoinedContestStorageKey(userId), nextIds); |
| return nextIds; |
| } |
|
|
| export function readClaimedContestIds(userId: string) { |
| return readStoredContestIds(getClaimedContestStorageKey(userId)); |
| } |
|
|
| export function markWeeklyContestPrizeClaimed(userId: string, contestId: string) { |
| const nextIds = mergeStoredContestId(readClaimedContestIds(userId), contestId); |
| writeStoredContestIds(getClaimedContestStorageKey(userId), nextIds); |
| return nextIds; |
| } |
|
|
| function buildContestForWeek(now: Date, weekOffset: number, referenceNow: Date): WeeklyContest { |
| const startTime = getContestStartForWeek(now, weekOffset); |
| const endTime = new Date( |
| startTime.getTime() + WEEKLY_CONTEST_DURATION_MINUTES * 60 * 1000, |
| ); |
|
|
| return { |
| id: getActivityDateKey(startTime), |
| startTime, |
| endTime, |
| durationMinutes: WEEKLY_CONTEST_DURATION_MINUTES, |
| isLive: referenceNow >= startTime && referenceNow < endTime, |
| canJoin: referenceNow < startTime, |
| }; |
| } |
|
|
| function getContestStartForWeek(now: Date, weekOffset: number) { |
| const contestStart = new Date(now); |
| const dayDelta = WEEKLY_CONTEST_DAY - contestStart.getDay() + weekOffset * 7; |
|
|
| contestStart.setDate(contestStart.getDate() + dayDelta); |
| contestStart.setHours(WEEKLY_CONTEST_START_HOUR, 0, 0, 0); |
|
|
| return contestStart; |
| } |
|
|
| function getJoinedContestStorageKey(userId: string) { |
| return `${WEEKLY_CONTEST_JOIN_STORAGE_PREFIX}.${userId}`; |
| } |
|
|
| function getClaimedContestStorageKey(userId: string) { |
| return `${WEEKLY_CONTEST_CLAIM_STORAGE_PREFIX}.${userId}`; |
| } |
|
|
| function readStoredContestIds(storageKey: string) { |
| if (typeof window === 'undefined') { |
| return [] as string[]; |
| } |
|
|
| try { |
| const raw = window.localStorage.getItem(storageKey); |
| if (!raw) { |
| return []; |
| } |
|
|
| const parsed = JSON.parse(raw); |
| if (Array.isArray(parsed)) { |
| return normalizeContestIds(parsed); |
| } |
|
|
| if (typeof parsed === 'string') { |
| return normalizeContestIds([parsed]); |
| } |
| } catch { |
| try { |
| const legacyValue = window.localStorage.getItem(storageKey); |
| return legacyValue ? normalizeContestIds([legacyValue]) : []; |
| } catch { |
| return []; |
| } |
| } |
|
|
| return []; |
| } |
|
|
| function writeStoredContestIds(storageKey: string, ids: string[]) { |
| if (typeof window === 'undefined') { |
| return; |
| } |
|
|
| try { |
| window.localStorage.setItem(storageKey, JSON.stringify(normalizeContestIds(ids))); |
| } catch { |
| |
| } |
| } |
|
|
| function mergeStoredContestId(ids: string[], contestId: string) { |
| return normalizeContestIds([...ids, contestId]); |
| } |
|
|
| function normalizeContestIds(ids: string[]) { |
| const seen = new Set<string>(); |
| const normalized: string[] = []; |
|
|
| for (const rawId of ids) { |
| const id = String(rawId).trim(); |
| if (!id || seen.has(id)) { |
| continue; |
| } |
|
|
| seen.add(id); |
| normalized.push(id); |
| } |
|
|
| return normalized; |
| } |
|
|