From 92c307a4fb0c71da474e0afd426f211155d1599c Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Mon, 25 May 2026 21:35:20 -0300 Subject: [PATCH 1/2] feat: implement local progress tracking with schema validation and introduce a gamification engine with leaderboard support --- README.md | 2 +- app/api/leaderboard/route.ts | 87 +++++++++ app/api/progress/merge/route.ts | 2 +- app/leaderboard/leaderboard-client.tsx | 96 ++++++++++ app/leaderboard/page.tsx | 116 ++++++++++++ app/problems/page.tsx | 24 ++- app/profile/page.tsx | 26 +-- components/billing/progress-sync.tsx | 35 +++- .../gamification/daily-challenge-banner.tsx | 134 +++++++++++++ components/gamification/leaderboard-table.tsx | 179 ++++++++++++++++++ .../gamification/review-queue-widget.tsx | 143 ++++++++++++++ components/gamification/streak-flame.tsx | 164 ++++++++++++++++ components/layout/session-nav.tsx | 8 +- lib/gamification/daily-challenge.ts | 60 ++++++ lib/gamification/xp-engine.ts | 123 ++++++++++++ lib/navigation-data.ts | 8 + lib/progress/local-progress-schema.ts | 12 ++ lib/progress/local-progress.ts | 18 +- lib/progress/merge-blobs.ts | 22 ++- 19 files changed, 1232 insertions(+), 27 deletions(-) create mode 100644 app/api/leaderboard/route.ts create mode 100644 app/leaderboard/leaderboard-client.tsx create mode 100644 app/leaderboard/page.tsx create mode 100644 components/gamification/daily-challenge-banner.tsx create mode 100644 components/gamification/leaderboard-table.tsx create mode 100644 components/gamification/review-queue-widget.tsx create mode 100644 components/gamification/streak-flame.tsx create mode 100644 lib/gamification/daily-challenge.ts create mode 100644 lib/gamification/xp-engine.ts diff --git a/README.md b/README.md index ef7b672..d6915c1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Algoria -![](https://img.shields.io/badge/Versão-1.4.7-black?style=for-the-badge) +![](https://img.shields.io/badge/Versão-1.5.0-black?style=for-the-badge) Plataforma em português para estudar **algoritmos e decisões em código** através de leitura guiada: catálogo de problemas com várias soluções (brute-force, óptima, alternativa), **code player** linha-a-linha com três níveis de explicação, mini-guias em **Conceitos**, **curso modular** com avaliações locais, hub de **inglês técnico para entrevistas** (conteúdo em inglês) e guias de **engenharia aplicada** (front, back, DevOps, SoftSkills e IA). diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts new file mode 100644 index 0000000..f15f90d --- /dev/null +++ b/app/api/leaderboard/route.ts @@ -0,0 +1,87 @@ +import { eq, sql, desc } from 'drizzle-orm'; +import { headers } from 'next/headers'; + +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { user, userProgress, userFollower } from '@/lib/db/schema'; + +/** + * GET /api/leaderboard?scope=global|following&page=1&limit=20 + * + * Retorna o ranking de utilizadores ordenados por XP (lido do JSON do userProgress). + */ +export async function GET(request: Request) { + const url = new URL(request.url); + const scope = url.searchParams.get('scope') ?? 'global'; + const page = Math.max(1, Number(url.searchParams.get('page') ?? '1')); + const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? '20'))); + const offset = (page - 1) * limit; + + try { + // Extrai XP do JSON armazenado em userProgress.data + const xpExtract = sql`COALESCE((${userProgress.data}::jsonb ->> 'xp')::int, 0)`; + const streakExtract = sql`COALESCE((${userProgress.data}::jsonb ->> 'streakCount')::int, 0)`; + const completedExtract = sql`COALESCE( + jsonb_object_keys_count((${userProgress.data}::jsonb -> 'problems')), + 0 + )`; + + let query; + + if (scope === 'following') { + // Filtrar por utilizadores que o user actual segue + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return Response.json({ error: 'Autenticação necessária para scope=following.' }, { status: 401 }); + } + + query = db + .select({ + id: user.id, + name: user.name, + image: user.image, + xp: xpExtract, + streakCount: streakExtract, + }) + .from(user) + .innerJoin(userFollower, eq(userFollower.followingId, user.id)) + .leftJoin(userProgress, eq(userProgress.userId, user.id)) + .where(eq(userFollower.followerId, session.user.id)) + .orderBy(desc(xpExtract)) + .limit(limit) + .offset(offset); + } else { + // Global: todos com XP > 0 + query = db + .select({ + id: user.id, + name: user.name, + image: user.image, + xp: xpExtract, + streakCount: streakExtract, + }) + .from(user) + .leftJoin(userProgress, eq(userProgress.userId, user.id)) + .orderBy(desc(xpExtract)) + .limit(limit) + .offset(offset); + } + + const rows = await query; + + // Adicionar ranking + const entries = rows.map((r, idx) => ({ + rank: offset + idx + 1, + id: r.id, + name: r.name, + image: r.image, + xp: r.xp ?? 0, + streakCount: r.streakCount ?? 0, + })); + + return Response.json({ entries, page, limit }); + } catch (error) { + console.error('[Leaderboard API]', error); + return Response.json({ entries: [], page, limit }); + } +} diff --git a/app/api/progress/merge/route.ts b/app/api/progress/merge/route.ts index 4b073be..49e330b 100644 --- a/app/api/progress/merge/route.ts +++ b/app/api/progress/merge/route.ts @@ -53,7 +53,7 @@ export async function POST(req: Request) { .where(eq(userProgress.userId, session.user.id)) .limit(1); - let server: ProgressBlob = { version: 1, problems: {} }; + let server: ProgressBlob = ProgressBlobSchema.parse({ version: 1, problems: {} }); if (rows[0]) { server = ProgressBlobSchema.parse(JSON.parse(rows[0].data)); } diff --git a/app/leaderboard/leaderboard-client.tsx b/app/leaderboard/leaderboard-client.tsx new file mode 100644 index 0000000..bef45ce --- /dev/null +++ b/app/leaderboard/leaderboard-client.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Globe, Users } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { LeaderboardTable } from "@/components/gamification/leaderboard-table"; +import { Button } from "@/components/ui/button"; + +interface Entry { + rank: number; + id: string; + name: string; + image: string | null; + xp: number; + streakCount: number; +} + +interface LeaderboardClientProps { + globalEntries: Entry[]; + followingEntries: Entry[]; + currentUserId?: string; + isLoggedIn: boolean; +} + +export function LeaderboardClient({ + globalEntries, + followingEntries, + currentUserId, + isLoggedIn, +}: LeaderboardClientProps) { + const [scope, setScope] = useState<"global" | "following">("global"); + + const entries = scope === "global" ? globalEntries : followingEntries; + + return ( +
+
+ + +
+ + {scope === "following" && !isLoggedIn && ( +
+

+ Faz login para veres o ranking dos teus amigos. +

+ +
+ )} + + + +
+

+ Ganha XP estudando problemas e completando desafios diários +

+ +
+
+ ); +} diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..bccfd57 --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,116 @@ +import { desc, eq, sql } from "drizzle-orm"; +import { Trophy } from "lucide-react"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; + +import { LeaderboardClient } from "@/app/leaderboard/leaderboard-client"; +import { Badge } from "@/components/ui/badge"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { user, userFollower, userProgress } from "@/lib/db/schema"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; + +export const metadata: Metadata = buildPublicMetadata({ + title: "Leaderboard — Ranking da Comunidade", + description: + "Vê quem está a liderar em XP na Algoria. Compara o teu progresso com o da comunidade e com os teus amigos.", + pathname: "/leaderboard", + keywords: ["leaderboard", "ranking", "xp", "gamificação", "Algoria"], +}); + +async function getLeaderboardData(scope: string, userId?: string) { + const xpExtract = sql`COALESCE((${userProgress.data}::jsonb ->> 'xp')::int, 0)`; + const streakExtract = sql`COALESCE((${userProgress.data}::jsonb ->> 'streakCount')::int, 0)`; + + try { + if (scope === "following" && userId) { + const rows = await db + .select({ + id: user.id, + name: user.name, + image: user.image, + xp: xpExtract, + streakCount: streakExtract, + }) + .from(user) + .innerJoin(userFollower, eq(userFollower.followingId, user.id)) + .leftJoin(userProgress, eq(userProgress.userId, user.id)) + .where(eq(userFollower.followerId, userId)) + .orderBy(desc(xpExtract)) + .limit(50); + + return rows.map((r, idx) => ({ + rank: idx + 1, + id: r.id, + name: r.name, + image: r.image, + xp: r.xp ?? 0, + streakCount: r.streakCount ?? 0, + })); + } + + const rows = await db + .select({ + id: user.id, + name: user.name, + image: user.image, + xp: xpExtract, + streakCount: streakExtract, + }) + .from(user) + .leftJoin(userProgress, eq(userProgress.userId, user.id)) + .orderBy(desc(xpExtract)) + .limit(50); + + return rows.map((r, idx) => ({ + rank: idx + 1, + id: r.id, + name: r.name, + image: r.image, + xp: r.xp ?? 0, + streakCount: r.streakCount ?? 0, + })); + } catch { + return []; + } +} + +export default async function LeaderboardPage() { + const session = await auth.api.getSession({ headers: await headers() }); + const userId = session?.user?.id; + + const [globalEntries, followingEntries] = await Promise.all([ + getLeaderboardData("global"), + userId ? getLeaderboardData("following", userId) : Promise.resolve([]), + ]); + + return ( +
+
+
+ + + Gamificação + +

+ Leaderboard +

+

+ Os programadores mais dedicados da plataforma. Estuda todos os dias, + ganha XP e sobe no ranking. +

+
+ + +
+
+ ); +} diff --git a/app/problems/page.tsx b/app/problems/page.tsx index 0c2ed2b..cfcfe56 100644 --- a/app/problems/page.tsx +++ b/app/problems/page.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import { ProblemsCatalogClient } from '@/components/catalog/problems-catalog-client'; +import { DailyChallengeBanner } from '@/components/gamification/daily-challenge-banner'; import { catalogModelsFromProblems } from '@/lib/catalog/problem-card-model'; import { getAllProblems } from '@/lib/content/loader'; +import { getDailyChallenge } from '@/lib/gamification/daily-challenge'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; export const metadata: Metadata = buildPublicMetadata({ @@ -24,5 +26,25 @@ export default async function ProblemsPage() { const problems = await getAllProblems(); const payload = catalogModelsFromProblems(problems); - return ; + const slugs = problems.map((p) => p.meta.slug); + const metaMap = new Map( + problems.map((p) => [p.meta.slug, { title: p.meta.title, difficulty: p.meta.difficulty }]), + ); + const daily = getDailyChallenge(slugs, metaMap); + + return ( +
+ {daily && ( +
+ +
+ )} + +
+ ); } diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 4394fb5..73c8892 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -8,8 +8,8 @@ import { redirect } from "next/navigation"; import { EditProfileForm } from "@/components/profile/edit-profile-form"; import { DeleteAccountForm } from "@/components/auth/delete-account-form"; -import { BecomeCreatorButton } from "@/components/profile/become-creator-button"; import { SignOutButton } from "@/components/auth/sign-out-button"; +import { BecomeCreatorButton } from "@/components/profile/become-creator-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -30,15 +30,17 @@ import { } from "@/lib/db/schema"; import { desc } from "drizzle-orm"; +import { ReviewQueueWidget } from "@/components/gamification/review-queue-widget"; +import { NetworkSection } from "@/components/profile/network-section"; import { ProfileActionsClient } from "@/components/profile/profile-actions-client"; import { ProfileAssessmentVisibilityToggle } from "@/components/profile/profile-assessment-visibility-toggle"; import { AssessmentCard } from "@/components/profile/public/assessment-card"; +import { getFollowers, getFollowing } from "@/lib/actions/follow"; +import { getContentRepository } from "@/lib/content/content-repository"; +import { getAllProblems } from "@/lib/content/loader"; import { ProgressBlobSchema } from "@/lib/progress/local-progress-schema"; import { buildPublicMetadata } from "@/lib/seo/build-metadata"; -import { getContentRepository } from "@/lib/content/content-repository"; import Image from "next/image"; -import { NetworkSection } from "@/components/profile/network-section"; -import { getFollowers, getFollowing } from "@/lib/actions/follow"; export async function generateMetadata(): Promise { const session = await auth.api.getSession({ headers: await headers() }); @@ -63,7 +65,6 @@ export default async function ProfilePage() { const { user: sessionUser } = session; - // Buscar progressos, subscrição e dados do user (role/status pedido) const [ progressRows, subRows, @@ -112,7 +113,6 @@ export default async function ProfilePage() { const activeSub = subRows[0]; const isPro = activeSub?.status === "active"; - // Processar o progresso let completedProblems = 0; let inProgressProblems = 0; let solutionsOpened = 0; @@ -137,6 +137,12 @@ export default async function ProfilePage() { const allTests = await getContentRepository().getAllTechnicalTests(); const testMap = new Map(allTests.map(t => [t.slug, t])); + const allProblems = await getAllProblems(); + const problemTitles: Record = {}; + for (const p of allProblems) { + problemTitles[p.meta.slug] = p.meta.title; + } + const initials = sessionUser.name?.substring(0, 2).toUpperCase() || sessionUser.email?.substring(0, 2).toUpperCase() || @@ -233,7 +239,6 @@ export default async function ProfilePage() { - {/* METRICS DASHBOARD */}
@@ -301,7 +306,8 @@ export default async function ProfilePage() {
- {/* TECHNICAL ASSESSMENTS SECTION */} + +
@@ -361,13 +367,10 @@ export default async function ProfilePage() { )}
- {/* NETWORK SECTION */}
- {/* MAIN PROFILE FORM */} -
@@ -393,7 +396,6 @@ export default async function ProfilePage() { - {/* Danger Zone */} diff --git a/components/billing/progress-sync.tsx b/components/billing/progress-sync.tsx index 6024d6c..b87e636 100644 --- a/components/billing/progress-sync.tsx +++ b/components/billing/progress-sync.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { authClient } from '@/lib/auth-client'; import { ProgressBlobSchema } from '@/lib/progress/local-progress-schema'; import { loadProgressBlob, saveProgressBlob } from '@/lib/progress/local-progress'; -/** Envia o progresso local ao servidor após login e guarda o merge no browser. */ +/** Envia o progresso local ao servidor após login e continuamente (debounced) quando o progresso muda. */ export function ProgressSyncOnLogin() { const { data: session, isPending } = authClient.useSession(); + const syncTimerRef = useRef(null); useEffect(() => { if (isPending || !session?.user?.id) return; + let cancelled = false; - void (async () => { + + const performSync = async () => { const local = loadProgressBlob(); try { const res = await fetch('/api/progress/merge', { @@ -25,15 +28,39 @@ export function ProgressSyncOnLogin() { if (!res.ok || cancelled) return; const json = (await res.json()) as { blob: unknown }; const merged = ProgressBlobSchema.parse(json.blob); + + // Remove listener temporarily to avoid infinite loop from saveProgressBlob + window.removeEventListener('algoria-progress', scheduleSync); saveProgressBlob(merged); + window.addEventListener('algoria-progress', scheduleSync); } catch { /* rede offline */ } - })(); + }; + + const scheduleSync = () => { + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current); + } + // Debounce the sync to avoid spamming the API (e.g., when scrolling through code player) + syncTimerRef.current = setTimeout(() => { + void performSync(); + }, 5000); // 5 seconds debounce + }; + + // 1. Initial sync on login/mount + void performSync(); + + // 2. Listen to ongoing progress changes + window.addEventListener('algoria-progress', scheduleSync); + return () => { cancelled = true; + window.removeEventListener('algoria-progress', scheduleSync); + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); }; }, [session?.user?.id, isPending]); return null; } + diff --git a/components/gamification/daily-challenge-banner.tsx b/components/gamification/daily-challenge-banner.tsx new file mode 100644 index 0000000..bfbe59c --- /dev/null +++ b/components/gamification/daily-challenge-banner.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ArrowRight, CalendarDays, CheckCircle2, Flame } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { completeDailyChallenge, isDailyChallengeCompleted } from "@/lib/gamification/xp-engine"; +import { loadProgressBlob, saveProgressBlob } from "@/lib/progress/local-progress"; + +interface DailyChallengeBannerProps { + slug: string; + title: string; + difficulty: string; + dateKey: string; +} + +const DIFFICULTY_COLORS: Record = { + easy: "text-green-500 border-green-500/30 bg-green-500/10", + medium: "text-yellow-500 border-yellow-500/30 bg-yellow-500/10", + hard: "text-red-500 border-red-500/30 bg-red-500/10", +}; + +export function DailyChallengeBanner({ + slug, + title, + difficulty, + dateKey, +}: DailyChallengeBannerProps) { + const [completed, setCompleted] = useState(false); + + useEffect(() => { + const blob = loadProgressBlob(); + setCompleted(isDailyChallengeCompleted(blob)); + + const sync = () => { + const b = loadProgressBlob(); + setCompleted(isDailyChallengeCompleted(b)); + }; + window.addEventListener("algoria-progress", sync); + return () => window.removeEventListener("algoria-progress", sync); + }, []); + + const handleComplete = () => { + if (completed) return; + let blob = loadProgressBlob(); + blob = completeDailyChallenge(blob); + saveProgressBlob(blob); + setCompleted(true); + }; + + return ( + +
+ +
+ +
+
+
+ {completed ? ( + + ) : ( + + )} +
+
+
+ + Desafio do Dia + + + {dateKey} + +
+ + {title} + + +
+ + {difficulty} + + + + +50 XP + +
+
+
+ +
+ {completed ? ( +
+ + + Concluído + +
+ ) : ( + + Aceitar Desafio + + + )} +
+
+
+ ); +} diff --git a/components/gamification/leaderboard-table.tsx b/components/gamification/leaderboard-table.tsx new file mode 100644 index 0000000..3dd1aeb --- /dev/null +++ b/components/gamification/leaderboard-table.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Crown, Flame, Medal, Trophy } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; + +interface LeaderboardEntry { + rank: number; + id: string; + name: string; + image: string | null; + xp: number; + streakCount: number; +} + +interface LeaderboardTableProps { + entries: LeaderboardEntry[]; + currentUserId?: string; +} + +function getTierInfo(xp: number): { label: string; color: string } { + if (xp >= 5000) return { label: "MASTER", color: "text-purple-500" }; + if (xp >= 2000) return { label: "EXPERT", color: "text-yellow-500" }; + if (xp >= 500) return { label: "INTERMEDIATE", color: "text-blue-500" }; + return { label: "ROOKIE", color: "text-muted-foreground" }; +} + +function RankIcon({ rank }: { rank: number }) { + if (rank === 1) + return ; + if (rank === 2) + return ; + if (rank === 3) + return ; + return ( + + {rank} + + ); +} + +export function LeaderboardTable({ + entries, + currentUserId, +}: LeaderboardTableProps) { + if (entries.length === 0) { + return ( +
+ +

+ Nenhum utilizador com XP registado ainda. +

+

+ Começa a estudar para aparecer no ranking! +

+
+ ); + } + + return ( +
+ {/* Header */} +
+ + # + + + Desenvolvedor + + + XP + + + Streak + + + Tier + +
+ + {/* Rows */} + {entries.map((entry, idx) => { + const isCurrentUser = entry.id === currentUserId; + const tier = getTierInfo(entry.xp); + const isTopThree = entry.rank <= 3; + + return ( + + +
+ +
+ +
+
+ {entry.image ? ( + {entry.name} + ) : ( + + {entry.name?.substring(0, 2).toUpperCase() ?? "?"} + + )} +
+
+

+ {entry.name} + {isCurrentUser && ( + + (tu) + + )} +

+
+
+ +
+ + {entry.xp.toLocaleString("pt-BR")} + +
+ +
+ {entry.streakCount > 0 && ( + + )} + 0 + ? "text-orange-500" + : "text-muted-foreground/40" + }`} + > + {entry.streakCount} + +
+ +
+ + {tier.label} + +
+ +
+ ); + })} +
+ ); +} diff --git a/components/gamification/review-queue-widget.tsx b/components/gamification/review-queue-widget.tsx new file mode 100644 index 0000000..95a4d6e --- /dev/null +++ b/components/gamification/review-queue-widget.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { motion } from "framer-motion"; +import { BookOpen, Clock, RefreshCw } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { loadProgressBlob } from "@/lib/progress/local-progress"; +import { getProblemSlugsDueForReview } from "@/lib/progress/review"; + +const REVIEW_INTERVALS = [ + { days: 1, label: "1d", color: "text-red-500 border-red-500/30 bg-red-500/10" }, + { days: 3, label: "3d", color: "text-orange-500 border-orange-500/30 bg-orange-500/10" }, + { days: 7, label: "7d", color: "text-yellow-500 border-yellow-500/30 bg-yellow-500/10" }, + { days: 14, label: "14d", color: "text-blue-500 border-blue-500/30 bg-blue-500/10" }, + { days: 30, label: "30d", color: "text-purple-500 border-purple-500/30 bg-purple-500/10" }, +] as const; + +interface ReviewItem { + slug: string; + daysAgo: number; + intervalLabel: string; + intervalColor: string; +} + +interface ReviewQueueWidgetProps { + problemTitles: Record; +} + +export function ReviewQueueWidget({ problemTitles }: ReviewQueueWidgetProps) { + const [items, setItems] = useState([]); + + useEffect(() => { + function sync() { + const blob = loadProgressBlob(); + const now = Date.now(); + const reviewItems: ReviewItem[] = []; + + for (const interval of REVIEW_INTERVALS) { + const slugs = getProblemSlugsDueForReview(blob, interval.days, now); + for (const slug of slugs) { + if (reviewItems.some((r) => r.slug === slug)) continue; + + const completedAt = blob.problems[slug]?.markedCompleteAt; + const daysAgo = completedAt + ? Math.round((now - Date.parse(completedAt)) / 86_400_000) + : interval.days; + + reviewItems.push({ + slug, + daysAgo, + intervalLabel: interval.label, + intervalColor: interval.color, + }); + } + } + + setItems(reviewItems.slice(0, 5)); + } + + sync(); + window.addEventListener("algoria-progress", sync); + return () => window.removeEventListener("algoria-progress", sync); + }, []); + + if (items.length === 0) { + return null; + } + + return ( +
+
+
+

+ Fila de Revisão +

+ + + Repetição Espaçada + +
+ +

+ Problemas que já estudaste e que estão no momento ideal para revisão. + Rever consolida o conhecimento e protege a tua streak. +

+ +
+ {items.map((item, idx) => ( + + +
+ +
+
+

+ {problemTitles[item.slug] ?? item.slug} +

+
+ + {item.intervalLabel} + + + + Há {item.daysAgo} dia{item.daysAgo !== 1 ? "s" : ""} + +
+
+ +
+ ))} +
+ + {items.length >= 5 && ( +
+ +
+ )} +
+ ); +} diff --git a/components/gamification/streak-flame.tsx b/components/gamification/streak-flame.tsx new file mode 100644 index 0000000..cadd51f --- /dev/null +++ b/components/gamification/streak-flame.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +import { getStreakMultiplier } from "@/lib/gamification/xp-engine"; +import { loadProgressBlob } from "@/lib/progress/local-progress"; + +/** + * Ícone de chama animado que mostra a streak actual do utilizador. + * Lê do localStorage e actualiza em tempo real via evento custom. + */ +export function StreakFlame() { + const [streak, setStreak] = useState(0); + const [xp, setXp] = useState(0); + const [showTooltip, setShowTooltip] = useState(false); + + useEffect(() => { + function sync() { + const blob = loadProgressBlob(); + setStreak(blob.streakCount); + setXp(blob.xp); + } + + sync(); + + // Escuta atualizações de progresso (emitidas pelo saveProgressBlob) + window.addEventListener("algoria-progress", sync); + return () => window.removeEventListener("algoria-progress", sync); + }, []); + + const multiplier = getStreakMultiplier(streak); + const isActive = streak > 0; + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + + + + {showTooltip && ( + +
+
+ + Streak Actual + + + {streak} {streak === 1 ? "dia" : "dias"} + +
+ +
+ +
+ + XP Total + + + {xp.toLocaleString("pt-BR")} + +
+ +
+ + Multiplicador + + 1 ? "text-green-500" : "text-muted-foreground" + }`} + > + {multiplier}x + +
+ + {multiplier < 2.0 && ( +

+ {streak < 7 + ? `Mais ${7 - streak} dia${7 - streak === 1 ? "" : "s"} para 1.2x XP` + : streak < 14 + ? `Mais ${14 - streak} dia${14 - streak === 1 ? "" : "s"} para 1.5x XP` + : `Mais ${30 - streak} dia${30 - streak === 1 ? "" : "s"} para 2.0x XP`} +

+ )} +
+ + )} + +
+ ); +} diff --git a/components/layout/session-nav.tsx b/components/layout/session-nav.tsx index c10e5c6..cbc89f0 100644 --- a/components/layout/session-nav.tsx +++ b/components/layout/session-nav.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { StreakFlame } from "@/components/gamification/streak-flame"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; import Image from "next/image"; @@ -17,8 +18,6 @@ export function SessionNav() { const router = useRouter(); useEffect(() => { - // Usamos setTimeout para evitar o aviso de 'cascading renders' síncrono. - // Isto move a atualização para a próxima iteração do event loop. const timer = setTimeout(() => { setMounted(true); }, 0); @@ -74,7 +73,9 @@ export function SessionNav() { }; return ( -
+
+ +
)} +
); } diff --git a/lib/gamification/daily-challenge.ts b/lib/gamification/daily-challenge.ts new file mode 100644 index 0000000..a03f434 --- /dev/null +++ b/lib/gamification/daily-challenge.ts @@ -0,0 +1,60 @@ +/** + * daily-challenge.ts — Selecciona deterministicamente o "Desafio do Dia". + * + * Usa um hash simples da data (YYYY-MM-DD) para indexar a lista de + * problemas publicados, garantindo que todos os utilizadores vêem + * o mesmo desafio no mesmo dia. + */ + +import { toDateKey } from './xp-engine'; + +/** + * Hash simples de string → número positivo (djb2). + * Usado para gerar um índice determinístico a partir da data. + */ +function hashString(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return Math.abs(hash); +} + +export interface DailyChallengeInfo { + /** Slug do problema seleccionado para hoje. */ + slug: string; + /** Título do problema. */ + title: string; + /** Dificuldade. */ + difficulty: string; + /** Data formatada do desafio. */ + dateKey: string; +} + +/** + * Retorna o problema do dia de forma determinística. + * + * @param problemSlugs Lista de slugs disponíveis (deve estar ordenada para consistência). + * @param problemMeta Map de slug → { title, difficulty } para enriquecer a resposta. + * @param now Data actual (opcional, para testes). + */ +export function getDailyChallenge( + problemSlugs: string[], + problemMeta: Map, + now: Date = new Date(), +): DailyChallengeInfo | null { + if (problemSlugs.length === 0) return null; + + const dateKey = toDateKey(now); + const sorted = [...problemSlugs].sort(); + const idx = hashString(`algoria-daily-${dateKey}`) % sorted.length; + const slug = sorted[idx]!; + const meta = problemMeta.get(slug); + + return { + slug, + title: meta?.title ?? slug, + difficulty: meta?.difficulty ?? 'medium', + dateKey, + }; +} diff --git a/lib/gamification/xp-engine.ts b/lib/gamification/xp-engine.ts new file mode 100644 index 0000000..1ace443 --- /dev/null +++ b/lib/gamification/xp-engine.ts @@ -0,0 +1,123 @@ +/** + * xp-engine.ts — Funções puras para cálculo de XP e streaks. + * + * Toda a lógica é determinística (data passada como argumento) + * para facilitar testes e funcionar tanto no browser como no servidor. + */ + +import type { ProgressBlob } from '@/lib/progress/local-progress-schema'; + +/* ── Constantes de XP ──────────────────────────────────────────── */ + +export const XP_EVENTS = { + /** Concluir a leitura de uma solução (abrir e percorrer). */ + solution_read: 20, + /** Marcar um problema como completo. */ + problem_complete: 30, + /** Completar o desafio diário. */ + daily_challenge: 50, +} as const; + +export type XpEvent = keyof typeof XP_EVENTS; + +/* ── Streak ────────────────────────────────────────────────────── */ + +/** Formato de data usado para comparações: YYYY-MM-DD */ +export function toDateKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function daysBetween(a: string, b: string): number { + const msA = Date.parse(a + 'T00:00:00Z'); + const msB = Date.parse(b + 'T00:00:00Z'); + if (Number.isNaN(msA) || Number.isNaN(msB)) return Infinity; + return Math.round(Math.abs(msB - msA) / 86_400_000); +} + +/** + * Atualiza a streak com base na data de hoje. + * - Se `lastActiveDate` é ontem → incrementa streak + * - Se `lastActiveDate` é hoje → noop + * - Caso contrário → reseta para 1 + * + * Retorna o blob atualizado (cópia rasa — imutável o suficiente). + */ +export function updateStreak(blob: ProgressBlob, now: Date = new Date()): ProgressBlob { + const today = toDateKey(now); + + if (blob.lastActiveDate === today) { + return blob; // já contou hoje + } + + const gap = blob.lastActiveDate ? daysBetween(blob.lastActiveDate, today) : Infinity; + + let streakCount: number; + if (gap === 1) { + // dia consecutivo + streakCount = blob.streakCount + 1; + } else { + // quebrou a streak (ou primeiro acesso) + streakCount = 1; + } + + const longestStreak = Math.max(blob.longestStreak, streakCount); + + return { + ...blob, + streakCount, + longestStreak, + lastActiveDate: today, + }; +} + +/* ── XP ────────────────────────────────────────────────────────── */ + +/** + * Multiplicador de XP baseado na streak actual. + * 1–6 dias: 1.0x │ 7–13 dias: 1.2x │ 14–29 dias: 1.5x │ 30+ dias: 2.0x + */ +export function getStreakMultiplier(streak: number): number { + if (streak >= 30) return 2.0; + if (streak >= 14) return 1.5; + if (streak >= 7) return 1.2; + return 1.0; +} + +/** + * Concede XP a um blob, aplicando o multiplicador de streak. + * O blob já deve ter tido a streak atualizada nesta sessão. + */ +export function awardXP(blob: ProgressBlob, event: XpEvent): ProgressBlob { + const base = XP_EVENTS[event]; + const multiplier = getStreakMultiplier(blob.streakCount); + const earned = Math.round(base * multiplier); + + return { + ...blob, + xp: blob.xp + earned, + }; +} + +/** + * Marca o desafio diário como concluído para hoje e concede o XP bónus. + */ +export function completeDailyChallenge(blob: ProgressBlob, now: Date = new Date()): ProgressBlob { + const today = toDateKey(now); + if (blob.dailyChallengesCompleted.includes(today)) { + return blob; // já completou hoje + } + + let updated: ProgressBlob = { + ...blob, + dailyChallengesCompleted: [...blob.dailyChallengesCompleted, today], + }; + updated = awardXP(updated, 'daily_challenge'); + return updated; +} + +/** + * Verifica se o desafio diário já foi completado hoje. + */ +export function isDailyChallengeCompleted(blob: ProgressBlob, now: Date = new Date()): boolean { + return blob.dailyChallengesCompleted.includes(toDateKey(now)); +} diff --git a/lib/navigation-data.ts b/lib/navigation-data.ts index 1ffa82f..33b9a64 100644 --- a/lib/navigation-data.ts +++ b/lib/navigation-data.ts @@ -7,6 +7,7 @@ import { Languages, Map, Sparkles, + Trophy, Users, } from "lucide-react"; @@ -53,6 +54,12 @@ export const NAVIGATION_ITEMS = [ description: "Encontrar talentos", Icon: Users, }, + { + href: "/leaderboard", + label: "Leaderboard", + description: "Ranking semanal de XP", + Icon: Trophy, + }, { href: "/tests", label: "Testes técnicos", @@ -72,3 +79,4 @@ export const NAVIGATION_ITEMS = [ Icon: Sparkles, }, ] as const; + diff --git a/lib/progress/local-progress-schema.ts b/lib/progress/local-progress-schema.ts index e1b4934..9a7ec4d 100644 --- a/lib/progress/local-progress-schema.ts +++ b/lib/progress/local-progress-schema.ts @@ -27,6 +27,18 @@ export const ProgressBlobSchema = z.object({ .refine((p) => Object.keys(p).length <= 600, { message: 'Demasiados problemas no progresso.', }), + + /* ── Gamificação ──────────────────────────────────────────── */ + /** Pontos de experiência acumulados. */ + xp: z.number().int().nonnegative().default(0), + /** Dias consecutivos com actividade na plataforma. */ + streakCount: z.number().int().nonnegative().default(0), + /** Maior streak já atingida. */ + longestStreak: z.number().int().nonnegative().default(0), + /** Data ISO (YYYY-MM-DD) do último dia de actividade. */ + lastActiveDate: z.string().optional(), + /** Datas (YYYY-MM-DD) em que completou o desafio diário. */ + dailyChallengesCompleted: z.array(z.string()).max(400).default([]), }); export type ProgressBlob = z.infer; diff --git a/lib/progress/local-progress.ts b/lib/progress/local-progress.ts index 6d6ab17..67ddb77 100644 --- a/lib/progress/local-progress.ts +++ b/lib/progress/local-progress.ts @@ -1,9 +1,10 @@ import { ProgressBlobSchema, type ProgressBlob, type StudyStatus } from './local-progress-schema'; +import { awardXP, updateStreak } from '@/lib/gamification/xp-engine'; export const PROGRESS_STORAGE_KEY = 'algoria:progress:v1'; function emptyBlob(): ProgressBlob { - return { version: 1, problems: {} }; + return ProgressBlobSchema.parse({ version: 1, problems: {} }); } export function loadProgressBlob(): ProgressBlob { @@ -40,9 +41,10 @@ export function touchProblemVisited(problemSlug: string): ProgressBlob { /** Regista abertura de uma página de solução. */ export function touchSolutionVisited(problemSlug: string, solutionSlug: string): ProgressBlob { - const blob = loadProgressBlob(); + let blob = loadProgressBlob(); const cur = blob.problems[problemSlug] ?? { openedSolutions: [] }; const set = new Set(cur.openedSolutions ?? []); + const isFirstVisit = !set.has(solutionSlug); set.add(solutionSlug); blob.problems[problemSlug] = { ...cur, @@ -50,6 +52,13 @@ export function touchSolutionVisited(problemSlug: string, solutionSlug: string): markedCompleteAt: cur.markedCompleteAt, openedSolutions: [...set], }; + + // Gamificação: streak + XP na primeira visita à solução + blob = updateStreak(blob); + if (isFirstVisit) { + blob = awardXP(blob, 'solution_read'); + } + saveProgressBlob(blob); return blob; } @@ -99,7 +108,7 @@ export function importProgressReplace(jsonText: string): { ok: true } | { ok: fa } export function toggleProblemMarkedComplete(problemSlug: string): ProgressBlob { - const blob = loadProgressBlob(); + let blob = loadProgressBlob(); const cur = blob.problems[problemSlug] ?? { openedSolutions: [] }; if (cur.markedCompleteAt) { blob.problems[problemSlug] = { ...cur, markedCompleteAt: undefined }; @@ -109,6 +118,9 @@ export function toggleProblemMarkedComplete(problemSlug: string): ProgressBlob { markedCompleteAt: new Date().toISOString(), visitedAt: cur.visitedAt ?? new Date().toISOString(), }; + // Gamificação: XP ao marcar como completo + blob = updateStreak(blob); + blob = awardXP(blob, 'problem_complete'); } saveProgressBlob(blob); return blob; diff --git a/lib/progress/merge-blobs.ts b/lib/progress/merge-blobs.ts index 929746e..83b8836 100644 --- a/lib/progress/merge-blobs.ts +++ b/lib/progress/merge-blobs.ts @@ -1,4 +1,4 @@ -import type { ProblemStudyState, ProgressBlob } from './local-progress-schema'; +import { type ProblemStudyState, type ProgressBlob, ProgressBlobSchema } from './local-progress-schema'; function latestIso(a?: string, b?: string): string | undefined { if (!a) return b; @@ -35,5 +35,23 @@ export function mergeProgressBlobs(local: ProgressBlob, server: ProgressBlob): P const srv = problems[slug]; problems[slug] = srv ? mergeStudy(localState, srv) : localState; } - return { version: 1, problems }; + + // Gamificação — Usa o maior XP, streak, etc. + const xp = Math.max(local.xp ?? 0, server.xp ?? 0); + const streakCount = Math.max(local.streakCount ?? 0, server.streakCount ?? 0); + const longestStreak = Math.max(local.longestStreak ?? 0, server.longestStreak ?? 0); + const lastActiveDate = latestIso(local.lastActiveDate, server.lastActiveDate); + const dailyChallengesCompleted = Array.from( + new Set([...(local.dailyChallengesCompleted ?? []), ...(server.dailyChallengesCompleted ?? [])]) + ); + + return ProgressBlobSchema.parse({ + version: 1, + problems, + xp, + streakCount, + longestStreak, + lastActiveDate, + dailyChallengesCompleted + }); } From b79443cffba705810d8d3d8cfd1e837980ccd50f Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Mon, 25 May 2026 21:42:29 -0300 Subject: [PATCH 2/2] feat: implement leaderboard API, cookie policy page, and modular visualizer and UI components --- app/admin/content/_components/raw-metadata-editor.tsx | 2 +- app/api/leaderboard/route.ts | 5 +---- app/legal/cookies/page.tsx | 2 +- app/pricing/page.tsx | 8 ++------ .../code-player/visualizers/generic-visualizer.tsx | 2 +- .../code-player/visualizers/two-sum-visualizer.tsx | 2 +- components/engenharia-trabalho/hub-selection-grid.tsx | 2 +- components/gamification/daily-challenge-banner.tsx | 11 +++++++---- 8 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/admin/content/_components/raw-metadata-editor.tsx b/app/admin/content/_components/raw-metadata-editor.tsx index af93389..8f5d4df 100644 --- a/app/admin/content/_components/raw-metadata-editor.tsx +++ b/app/admin/content/_components/raw-metadata-editor.tsx @@ -40,7 +40,7 @@ export function RawMetadataEditor({ meta, setMeta }: RawMetadataEditorProps) { setTimeout(() => setIsSuccess(false), 2000); } } - } catch (e) { + } catch { // Não mostramos erro no auto-apply para não incomodar enquanto digita } }, 1000); diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts index f15f90d..028764d 100644 --- a/app/api/leaderboard/route.ts +++ b/app/api/leaderboard/route.ts @@ -21,10 +21,7 @@ export async function GET(request: Request) { // Extrai XP do JSON armazenado em userProgress.data const xpExtract = sql`COALESCE((${userProgress.data}::jsonb ->> 'xp')::int, 0)`; const streakExtract = sql`COALESCE((${userProgress.data}::jsonb ->> 'streakCount')::int, 0)`; - const completedExtract = sql`COALESCE( - jsonb_object_keys_count((${userProgress.data}::jsonb -> 'problems')), - 0 - )`; + let query; diff --git a/app/legal/cookies/page.tsx b/app/legal/cookies/page.tsx index cdde05b..081c9e8 100644 --- a/app/legal/cookies/page.tsx +++ b/app/legal/cookies/page.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Cookie, HelpCircle, Settings } from "lucide-react"; +import { ArrowLeft, Cookie, Settings } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index ffec840..0621eb9 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -30,7 +30,7 @@ export default async function PricingPage() { const session = await auth.api.getSession({ headers: await headers() }); const hasPro = await userHasPro(session?.user?.id); - const repo = getContentRepository(); + const plans = await getPricingPlans(); const proPlan = plans.find((p) => p.id === "pro"); @@ -38,11 +38,7 @@ export default async function PricingPage() { const proFeatures = await getPricingFeatures("pro"); const inventory = await getPricingInventory(); - // Agrupar inventário por categoria de pricing - const inventoryGroups = inventory.reduce((acc, item) => { - acc[item.pricingCategory] = (acc[item.pricingCategory] || 0) + 1; - return acc; - }, {} as Record); + const title = proPlan?.title || "Planos e Preços"; const description = diff --git a/components/code-player/visualizers/generic-visualizer.tsx b/components/code-player/visualizers/generic-visualizer.tsx index 94d3ee0..e1c3eed 100644 --- a/components/code-player/visualizers/generic-visualizer.tsx +++ b/components/code-player/visualizers/generic-visualizer.tsx @@ -11,7 +11,7 @@ interface Props { solutionSlug?: string; } -export function GenericVisualizer({ steps, solutionSlug }: Props) { +export function GenericVisualizer({ steps }: Props) { const currentLine = usePlayerStore((s) => s.currentLine); const currentStepIndex = usePlayerStore((s) => s.currentStepIndex); diff --git a/components/code-player/visualizers/two-sum-visualizer.tsx b/components/code-player/visualizers/two-sum-visualizer.tsx index 0887f8b..86f33a6 100644 --- a/components/code-player/visualizers/two-sum-visualizer.tsx +++ b/components/code-player/visualizers/two-sum-visualizer.tsx @@ -11,7 +11,7 @@ interface Props { solutionSlug?: string; } -export function TwoSumVisualizer({ steps, solutionSlug }: Props) { +export function TwoSumVisualizer({ steps }: Props) { const currentLine = usePlayerStore((s) => s.currentLine); const currentStepIndex = usePlayerStore((s) => s.currentStepIndex); diff --git a/components/engenharia-trabalho/hub-selection-grid.tsx b/components/engenharia-trabalho/hub-selection-grid.tsx index f1eda86..4586f8b 100644 --- a/components/engenharia-trabalho/hub-selection-grid.tsx +++ b/components/engenharia-trabalho/hub-selection-grid.tsx @@ -16,7 +16,7 @@ interface HubSelectionGridProps { export function HubSelectionGrid({ guidesCount }: HubSelectionGridProps) { return (
- {ENGINEERING_WORK_PILLARS.map((pillar, idx) => { + {ENGINEERING_WORK_PILLARS.map((pillar) => { const count = guidesCount[pillar] || 0; return ( diff --git a/components/gamification/daily-challenge-banner.tsx b/components/gamification/daily-challenge-banner.tsx index bfbe59c..e3d6e5f 100644 --- a/components/gamification/daily-challenge-banner.tsx +++ b/components/gamification/daily-challenge-banner.tsx @@ -31,15 +31,18 @@ export function DailyChallengeBanner({ const [completed, setCompleted] = useState(false); useEffect(() => { - const blob = loadProgressBlob(); - setCompleted(isDailyChallengeCompleted(blob)); - const sync = () => { const b = loadProgressBlob(); setCompleted(isDailyChallengeCompleted(b)); }; + + const timer = setTimeout(sync, 0); + window.addEventListener("algoria-progress", sync); - return () => window.removeEventListener("algoria-progress", sync); + return () => { + clearTimeout(timer); + window.removeEventListener("algoria-progress", sync); + }; }, []); const handleComplete = () => {