diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 634fb96..f9ec181 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,5 @@
import { LocaleProvider } from '@/lib/i18n'
+import { getLocale } from '@/lib/i18n/server'
import { ThemeProvider, themeInitScript } from '@/lib/theme'
import { cn } from '@/lib/utils'
import { Analytics } from '@vercel/analytics/next'
@@ -37,14 +38,15 @@ export const metadata: Metadata = {
'A coding environment where the AI never hands you the answer. It makes you find it. For devs who want to actually learn in the AI era.',
}
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
+ const locale = await getLocale()
return (
- {children}
+ {children}
diff --git a/src/features/challenges/components/custom-challenge-dialog.tsx b/src/features/challenges/components/custom-challenge-dialog.tsx
index 1be3419..50aab8c 100644
--- a/src/features/challenges/components/custom-challenge-dialog.tsx
+++ b/src/features/challenges/components/custom-challenge-dialog.tsx
@@ -1,5 +1,7 @@
'use client'
+import { STACKS as DOMAIN_STACKS } from '@/domain/stacks'
+import { getAccessToken } from '@/lib/api/client'
import { useT } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
@@ -15,17 +17,13 @@ import { AnimatePresence, motion } from 'motion/react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { generateChallenge, getTrainingRecommendation } from '../actions'
-import { getAccessToken } from '@/lib/api/client'
type Kind = 'code' | 'design'
type Level = 'beginner' | 'intermediate' | 'advanced'
const LEVEL_IDS: Level[] = ['beginner', 'intermediate', 'advanced']
-const STACKS = [
- { id: 'javascript', label: 'JavaScript' },
- { id: 'typescript', label: 'TypeScript' },
-]
+const STACKS = DOMAIN_STACKS.map((s) => ({ id: s.id, label: s.label }))
const MAX_PROMPT = 500
@@ -225,9 +223,7 @@ export function CustomChallengeDialog({
{t.badge}
-
- {t.heading}
-
+ {t.heading}
{t.subheading}
@@ -351,7 +347,7 @@ export function CustomChallengeDialog({
{error && (
-
+
{error}
)}
@@ -361,7 +357,7 @@ export function CustomChallengeDialog({
@@ -369,7 +365,7 @@ export function CustomChallengeDialog({
type='button'
onClick={submit}
disabled={prompt.trim().length < 10}
- className='group bg-ink hover:bg-primary inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium tracking-tight text-background transition-colors disabled:cursor-not-allowed disabled:opacity-50'
+ className='group inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full bg-ink px-5 py-2.5 text-sm font-medium tracking-tight text-background transition-colors hover:bg-primary disabled:cursor-not-allowed disabled:opacity-50'
>
{t.generate}
diff --git a/src/features/challenges/hooks/use-socratic-session.ts b/src/features/challenges/hooks/use-socratic-session.ts
index 63c8652..f9822ea 100644
--- a/src/features/challenges/hooks/use-socratic-session.ts
+++ b/src/features/challenges/hooks/use-socratic-session.ts
@@ -6,10 +6,16 @@ import { getHintBalance } from '@/features/hints/actions'
import type { ChatMsg } from '@/lib/ai/types'
import { track } from '@/lib/analytics'
import { apiFetch, getAccessToken } from '@/lib/api/client'
+import { useT } from '@/lib/i18n'
import * as React from 'react'
import { completeSession, startSession } from '../actions'
import { loadDraft, saveDraft } from '../draft'
+const copy = {
+ en: { buyFailed: "Couldn't start checkout." },
+ pt: { buyFailed: 'Não foi possível iniciar a compra.' },
+}
+
export function useSocraticSession
(opts: {
challenge: { id: string } | null
initialWork: TWork
@@ -18,6 +24,7 @@ export function useSocraticSession(opts: {
}) {
const { challenge, initialWork, initialMessages, paused = false } = opts
const { user, loading: authLoading } = useUser()
+ const t = useT(copy)
const [messages, setMessages] = React.useState([])
const [input, setInput] = React.useState('')
@@ -170,8 +177,7 @@ export function useSocraticSession(opts: {
text += decoder.decode(value, { stream: true })
patchLast(text)
}
- } catch {
- }
+ } catch {}
if (!text.trim() && opts?.fallback) patchLast(opts.fallback)
return text
}
@@ -212,7 +218,7 @@ export function useSocraticSession(opts: {
mock?: boolean
error?: string
}
- if (!res.ok) throw new Error(data.error || 'Não foi possível iniciar a compra.')
+ if (!res.ok) throw new Error(data.error || t.buyFailed)
if (data.url) {
track('checkout_started', { challenge_id: challenge?.id })
window.location.href = data.url
@@ -226,7 +232,7 @@ export function useSocraticSession(opts: {
track('hints_purchased', { remaining: b.remaining })
setTimeout(() => setBought(false), 2500)
} catch (e) {
- setBuyError(e instanceof Error ? e.message : '')
+ setBuyError(e instanceof Error ? e.message : t.buyFailed)
setTimeout(() => setBuyError(null), 5000)
} finally {
buyingRef.current = false
diff --git a/src/features/dashboard/actions.ts b/src/features/dashboard/actions.ts
index b86e9c9..778bf90 100644
--- a/src/features/dashboard/actions.ts
+++ b/src/features/dashboard/actions.ts
@@ -3,6 +3,11 @@
import { computeIndependence } from '@/domain/scoring'
import { authActionUser } from '@/lib/api/guard'
import { supabaseAdmin } from '@/lib/supabase/server'
+import {
+ independenceTrend,
+ skillBreakdown,
+ type SkillSession,
+} from './independence'
import { calcStreak } from './streak'
import type { Stats } from './types'
@@ -50,7 +55,7 @@ export async function getDashboardStats(
supabaseAdmin
.from('sessions')
.select(
- 'id, status, started_at, completed_at, challenge_id, independence',
+ 'id, status, started_at, completed_at, challenge_id, independence, challenges(stack, kind)',
)
.eq('user_id', userId),
supabaseAdmin
@@ -116,6 +121,20 @@ export async function getDashboardStats(
const streak = calcStreak(completed.map((s) => s.started_at))
const weekProgress = buildWeekProgress(weekSessions.map((s) => s.started_at))
+ const skillSessions: SkillSession[] = completed.map((s) => {
+ const challenge = (
+ s as { challenges: { stack: string; kind: string | null } | null }
+ ).challenges
+ return {
+ challengeId: s.challenge_id,
+ independence:
+ s.independence ?? computeIndependence(hintsBySession.get(s.id) ?? []),
+ completedAt: s.completed_at ?? s.started_at,
+ stack: challenge?.stack ?? null,
+ kind: challenge?.kind ?? null,
+ }
+ })
+
return {
total_completed: completedCount,
total_hints: totalHints,
@@ -123,5 +142,7 @@ export async function getDashboardStats(
independence_score: independenceScore,
streak_days: streak,
week_progress: weekProgress,
+ skill_breakdown: skillBreakdown(skillSessions),
+ independence_trend: independenceTrend(skillSessions),
}
}
diff --git a/src/features/dashboard/components/dashboard-view.tsx b/src/features/dashboard/components/dashboard-view.tsx
index 797537d..c5d4837 100644
--- a/src/features/dashboard/components/dashboard-view.tsx
+++ b/src/features/dashboard/components/dashboard-view.tsx
@@ -13,6 +13,7 @@ import {
} from '@/features/challenges/actions'
import { CustomChallengeDialog } from '@/features/challenges/components/custom-challenge-dialog'
import { getDashboardStats } from '@/features/dashboard/actions'
+import { skillTarget, weakestSkill } from '@/features/dashboard/independence'
import type { Stats } from '@/features/dashboard/types'
import { activityLevel } from '@/features/dashboard/utils'
import { Halftone, glyph } from '@/features/landing/components/halftone'
@@ -27,6 +28,7 @@ import {
Network,
PenLine,
Sparkles,
+ Target,
TrendingUp,
} from 'lucide-react'
import { motion } from 'motion/react'
@@ -39,6 +41,8 @@ import {
RadialBarChart,
ResponsiveContainer,
} from 'recharts'
+import { IndependenceTrend } from './independence-trend'
+import { SkillBreakdown, TIER_BAR } from './skill-breakdown'
const copy = {
en: {
@@ -68,6 +72,9 @@ const copy = {
scoreEyebrow: 'Current score',
scoreTitle: 'Total independence',
scoreCaption: 'how much you solve on your own',
+ scoreEmpty: 'no challenges yet',
+ zeroHeadline:
+ 'Your independence score starts with your first solved challenge.',
historyEyebrow: 'History',
historyTitle: 'Recent challenges',
of: 'of',
@@ -87,6 +94,9 @@ const copy = {
'What comes out of your head is worth a thousand times more than what comes out of mine.',
quoteBy: 'Socratic tutor, just now',
daily: 'Daily challenge',
+ weakEyebrow: 'Train your weakest skill',
+ weakBody: 'is where you lean on hints the most.',
+ weakCta: 'Practice it',
},
pt: {
welcome: 'Bem-vindo de volta',
@@ -114,6 +124,9 @@ const copy = {
scoreEyebrow: 'Score atual',
scoreTitle: 'Independência total',
scoreCaption: 'quanto você resolve sozinho',
+ scoreEmpty: 'nenhum desafio ainda',
+ zeroHeadline:
+ 'Seu score de independência começa no seu primeiro desafio resolvido.',
historyEyebrow: 'Histórico',
historyTitle: 'Desafios recentes',
of: 'de',
@@ -133,6 +146,9 @@ const copy = {
'O que sai da sua cabeça vale mil vezes mais que o que sai da minha.',
quoteBy: 'Tutor Socrático, agora há pouco',
daily: 'Desafio do dia',
+ weakEyebrow: 'Treine sua skill mais fraca',
+ weakBody: 'é onde você mais depende de hints.',
+ weakCta: 'Praticar',
},
}
@@ -155,8 +171,7 @@ export function DashboardView({ user }: { user: User }) {
const [loadError, setLoadError] = React.useState(false)
const [reloadKey, setReloadKey] = React.useState(0)
const [startError, setStartError] = React.useState(null)
- const [genDesign, setGenDesign] = React.useState(false)
- const [genCode, setGenCode] = React.useState(false)
+ const [pending, setPending] = React.useState(null)
const [customOpen, setCustomOpen] = React.useState(false)
const [daily, setDaily] = React.useState(
undefined,
@@ -174,58 +189,49 @@ export function DashboardView({ user }: { user: User }) {
return () => clearTimeout(id)
}, [startError])
- async function startDesign() {
- if (genDesign || !user) return
- setGenDesign(true)
- try {
- const level =
- (user?.user_metadata?.preferred_level as string | undefined) ??
- 'intermediate'
- const data = await getNextChallenge({
- kind: 'design',
- level: level as 'beginner' | 'intermediate' | 'advanced',
- token: await getAccessToken(),
- })
- if (!('error' in data) && data?.id) router.push(`/design?id=${data.id}`)
- else {
- setStartError('error' in data ? data.error : t.startFailed)
- setGenDesign(false)
- }
- } catch {
- setStartError(t.startFailed)
- setGenDesign(false)
- }
- }
-
- async function startCode() {
- if (genCode || !user) return
+ async function startChallenge(
+ id: string,
+ target: { kind: 'code' | 'design'; stack?: string },
+ ) {
+ if (pending || !user) return
const meta = user.user_metadata as
| { preferred_stack?: string; preferred_level?: string }
| undefined
- if (!meta?.preferred_stack || !meta?.preferred_level) {
- router.push('/onboarding')
- return
+ let stack = target.stack
+ if (target.kind === 'code' && !stack) {
+ if (!meta?.preferred_stack || !meta?.preferred_level) {
+ router.push('/onboarding')
+ return
+ }
+ stack = meta.preferred_stack
}
- setGenCode(true)
+ setPending(id)
try {
const data = await getNextChallenge({
- kind: 'code',
- stack: meta.preferred_stack,
- level: meta.preferred_level as 'beginner' | 'intermediate' | 'advanced',
+ kind: target.kind,
+ stack,
+ level: (meta?.preferred_level ?? 'intermediate') as
+ | 'beginner'
+ | 'intermediate'
+ | 'advanced',
token: await getAccessToken(),
})
- if (!('error' in data) && data?.id)
- router.push(`/challenge?id=${data.id}`)
- else {
+ if (!('error' in data) && data?.id) {
+ router.push(
+ `${target.kind === 'design' ? '/design' : '/challenge'}?id=${data.id}`,
+ )
+ } else {
setStartError('error' in data ? data.error : t.startFailed)
- setGenCode(false)
+ setPending(null)
}
} catch {
setStartError(t.startFailed)
- setGenCode(false)
+ setPending(null)
}
}
+ const startCode = () => startChallenge('code', { kind: 'code' })
+
React.useEffect(() => {
if (!user) return
let active = true
@@ -258,6 +264,8 @@ export function DashboardView({ user }: { user: User }) {
}
const score = stats?.independence_score ?? 100
+ const hasScore = !!stats && stats.total_completed > 0
+ const weakest = stats ? weakestSkill(stats.skill_breakdown) : null
return (
@@ -291,14 +299,18 @@ export function DashboardView({ user }: { user: User }) {
{t.startPrompt}
) : (
<>
-
- {t.youAre}{' '}
-
- {score}
- {t.independentSuffix}
-
- .
-
+ {hasScore ? (
+
+ {t.youAre}{' '}
+
+ {score}
+ {t.independentSuffix}
+
+ .
+
+ ) : (
+
{t.zeroHeadline}
+ )}
{stats && stats.streak_days > 0 && (
@@ -328,8 +340,8 @@ export function DashboardView({ user }: { user: User }) {
- {t.scoreCaption}
+ {empty ? t.scoreEmpty : t.scoreCaption}
)
diff --git a/src/features/dashboard/components/independence-trend.tsx b/src/features/dashboard/components/independence-trend.tsx
new file mode 100644
index 0000000..972a7eb
--- /dev/null
+++ b/src/features/dashboard/components/independence-trend.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import type { TrendPoint } from '@/features/dashboard/independence'
+import { useLocale, useT } from '@/lib/i18n'
+import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from 'recharts'
+
+const copy = {
+ en: {
+ eyebrow: 'Over time',
+ title: 'Independence trend',
+ suffix: '% independent',
+ tooSoon: 'A couple more challenges and your trend shows up here.',
+ },
+ pt: {
+ eyebrow: 'Ao longo do tempo',
+ title: 'Evolução da independência',
+ suffix: '% independente',
+ tooSoon: 'Mais um ou dois desafios e sua evolução aparece aqui.',
+ },
+}
+
+export function IndependenceTrend({ trend }: { trend: TrendPoint[] }) {
+ const t = useT(copy)
+ const { locale } = useLocale()
+ const dateLocale = locale === 'pt' ? 'pt-BR' : 'en-US'
+
+ return (
+
+
{t.eyebrow}
+
{t.title}
+
+ {/* A line needs two points. One completed challenge draws nothing useful. */}
+ {trend.length < 2 ? (
+
{t.tooSoon}
+ ) : (
+
+
+
+
+ {
+ if (!active || !payload?.length) return null
+ const point = payload[0].payload as TrendPoint
+ return (
+
+
+ {point.value}
+ {t.suffix}
+
+
+ {new Date(point.date).toLocaleDateString(dateLocale)}
+
+
+ )
+ }}
+ />
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/dashboard/components/skill-breakdown.tsx b/src/features/dashboard/components/skill-breakdown.tsx
new file mode 100644
index 0000000..62a12c9
--- /dev/null
+++ b/src/features/dashboard/components/skill-breakdown.tsx
@@ -0,0 +1,101 @@
+'use client'
+
+import type { SkillStat } from '@/features/dashboard/independence'
+import { useT } from '@/lib/i18n'
+import { motion } from 'motion/react'
+
+const copy = {
+ en: {
+ eyebrow: 'By skill',
+ title: 'Where you stand',
+ empty: 'Finish a challenge and your skills show up here.',
+ completedOne: 'challenge',
+ completedMany: 'challenges',
+ tierLabel: {
+ high: 'Independent',
+ mid: 'Getting there',
+ low: 'Leaning on hints',
+ },
+ },
+ pt: {
+ eyebrow: 'Por skill',
+ title: 'Onde você está',
+ empty: 'Conclua um desafio e suas skills aparecem aqui.',
+ completedOne: 'desafio',
+ completedMany: 'desafios',
+ tierLabel: {
+ high: 'Independente',
+ mid: 'Chegando lá',
+ low: 'Dependendo de hints',
+ },
+ },
+}
+
+export const TIER_BAR: Record = {
+ high: 'bg-chart-1',
+ mid: 'bg-chart-2',
+ low: 'bg-chart-3',
+}
+
+const EASE = [0.16, 1, 0.3, 1] as const
+
+export function SkillBreakdown({ breakdown }: { breakdown: SkillStat[] }) {
+ const t = useT(copy)
+
+ return (
+
+
{t.eyebrow}
+
{t.title}
+
+ {breakdown.length === 0 ? (
+
{t.empty}
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/features/dashboard/independence.test.ts b/src/features/dashboard/independence.test.ts
new file mode 100644
index 0000000..977bb33
--- /dev/null
+++ b/src/features/dashboard/independence.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest'
+import {
+ independenceTrend,
+ skillBreakdown,
+ skillTarget,
+ weakestSkill,
+ type SkillSession,
+} from './independence'
+
+function session(over: Partial): SkillSession {
+ return {
+ challengeId: 'c1',
+ independence: 100,
+ completedAt: '2026-07-01T10:00:00Z',
+ stack: 'javascript',
+ kind: 'code',
+ ...over,
+ }
+}
+
+describe('skillBreakdown', () => {
+ it('is empty with no sessions', () => {
+ expect(skillBreakdown([])).toEqual([])
+ })
+
+ it('groups by stack and averages independence', () => {
+ const stats = skillBreakdown([
+ session({ challengeId: 'a', stack: 'javascript', independence: 80 }),
+ session({ challengeId: 'b', stack: 'javascript', independence: 60 }),
+ session({ challengeId: 'c', stack: 'python', independence: 40 }),
+ ])
+ const js = stats.find((s) => s.key === 'javascript')
+ const py = stats.find((s) => s.key === 'python')
+ expect(js).toMatchObject({ avgIndependence: 70, completed: 2 })
+ expect(py).toMatchObject({ avgIndependence: 40, completed: 1 })
+ })
+
+ it('classifies design challenges by kind regardless of stack', () => {
+ const stats = skillBreakdown([
+ session({ challengeId: 'd', kind: 'design', stack: null, independence: 55 }),
+ ])
+ expect(stats).toHaveLength(1)
+ expect(stats[0]).toMatchObject({ key: 'design', label: 'System Design' })
+ })
+
+ it('counts each challenge once, keeping the most recent completion', () => {
+ const stats = skillBreakdown([
+ session({ challengeId: 'a', completedAt: '2026-07-01T10:00:00Z', independence: 20 }),
+ session({ challengeId: 'a', completedAt: '2026-07-05T10:00:00Z', independence: 90 }),
+ ])
+ expect(stats[0]).toMatchObject({ completed: 1, avgIndependence: 90 })
+ })
+
+ it('ignores sessions with an unknown stack', () => {
+ const stats = skillBreakdown([
+ session({ challengeId: 'x', stack: 'rust', kind: 'code' }),
+ ])
+ expect(stats).toEqual([])
+ })
+
+ it('attaches a tier derived from the average', () => {
+ const stats = skillBreakdown([
+ session({ challengeId: 'a', stack: 'react', independence: 90 }),
+ ])
+ expect(stats[0].tier).toBe('high')
+ })
+})
+
+describe('independenceTrend', () => {
+ it('returns points oldest to newest', () => {
+ const trend = independenceTrend([
+ session({ challengeId: 'b', completedAt: '2026-07-02T10:00:00Z', independence: 70 }),
+ session({ challengeId: 'a', completedAt: '2026-07-01T10:00:00Z', independence: 40 }),
+ ])
+ expect(trend.map((p) => p.value)).toEqual([40, 70])
+ })
+
+ it('dedupes by challenge before plotting', () => {
+ const trend = independenceTrend([
+ session({ challengeId: 'a', completedAt: '2026-07-01T10:00:00Z', independence: 30 }),
+ session({ challengeId: 'a', completedAt: '2026-07-04T10:00:00Z', independence: 80 }),
+ ])
+ expect(trend).toEqual([{ date: '2026-07-04T10:00:00Z', value: 80 }])
+ })
+
+ it('keeps only the last N points', () => {
+ const many = Array.from({ length: 5 }, (_, i) =>
+ session({
+ challengeId: `c${i}`,
+ completedAt: `2026-07-0${i + 1}T10:00:00Z`,
+ independence: i * 10,
+ }),
+ )
+ expect(independenceTrend(many, 2).map((p) => p.value)).toEqual([30, 40])
+ })
+})
+
+describe('weakestSkill', () => {
+ it('returns null when nothing meets the minimum sample', () => {
+ const breakdown = skillBreakdown([
+ session({ challengeId: 'a', stack: 'javascript', independence: 50 }),
+ ])
+ expect(weakestSkill(breakdown, 2)).toBeNull()
+ })
+
+ it('picks the lowest average', () => {
+ const breakdown = skillBreakdown([
+ session({ challengeId: 'a', stack: 'javascript', independence: 90 }),
+ session({ challengeId: 'b', stack: 'python', independence: 30 }),
+ ])
+ expect(weakestSkill(breakdown)?.key).toBe('python')
+ })
+
+ it('breaks ties toward more evidence', () => {
+ const breakdown = skillBreakdown([
+ session({ challengeId: 'a', stack: 'javascript', independence: 50 }),
+ session({ challengeId: 'b', stack: 'python', independence: 50 }),
+ session({ challengeId: 'c', stack: 'python', independence: 50 }),
+ ])
+ expect(weakestSkill(breakdown)?.key).toBe('python')
+ })
+})
+
+describe('skillTarget', () => {
+ it('maps a code stack to a code challenge target', () => {
+ expect(skillTarget('typescript')).toEqual({ kind: 'code', stack: 'typescript' })
+ })
+
+ it('maps design to a design challenge target', () => {
+ expect(skillTarget('design')).toEqual({ kind: 'design' })
+ })
+})
diff --git a/src/features/dashboard/independence.ts b/src/features/dashboard/independence.ts
new file mode 100644
index 0000000..81233e7
--- /dev/null
+++ b/src/features/dashboard/independence.ts
@@ -0,0 +1,130 @@
+import { independenceTier } from '@/domain/scoring'
+import { stackById } from '@/domain/stacks'
+
+export type SkillKey =
+ | 'javascript'
+ | 'typescript'
+ | 'python'
+ | 'react'
+ | 'design'
+
+export type SkillSession = {
+ challengeId: string
+ independence: number
+ completedAt: string
+ stack: string | null
+ kind: string | null
+}
+
+export type SkillStat = {
+ key: SkillKey
+ label: string
+ avgIndependence: number
+ completed: number
+ tier: ReturnType
+}
+
+export type TrendPoint = { date: string; value: number }
+
+const SKILL_ORDER: readonly SkillKey[] = [
+ 'javascript',
+ 'typescript',
+ 'python',
+ 'react',
+ 'design',
+] as const
+
+function clampScore(n: number): number {
+ return Math.min(100, Math.max(0, Math.round(n)))
+}
+
+function labelFor(key: SkillKey): string {
+ return key === 'design' ? 'System Design' : (stackById(key)?.label ?? key)
+}
+
+function categoryOf(s: SkillSession): SkillKey | null {
+ if (s.kind === 'design') return 'design'
+ switch (s.stack) {
+ case 'javascript':
+ case 'typescript':
+ case 'python':
+ case 'react':
+ return s.stack
+ default:
+ return null
+ }
+}
+
+function uniqueByChallenge(sessions: SkillSession[]): SkillSession[] {
+ const byChallenge = new Map()
+ for (const s of sessions) {
+ const prev = byChallenge.get(s.challengeId)
+ if (!prev || s.completedAt > prev.completedAt)
+ byChallenge.set(s.challengeId, s)
+ }
+ return [...byChallenge.values()]
+}
+
+/** Average independence per skill category, for skills with at least one completed challenge. */
+export function skillBreakdown(sessions: SkillSession[]): SkillStat[] {
+ const groups = new Map()
+ for (const s of uniqueByChallenge(sessions)) {
+ const key = categoryOf(s)
+ if (!key) continue
+ const list = groups.get(key) ?? []
+ list.push(clampScore(s.independence))
+ groups.set(key, list)
+ }
+
+ const stats: SkillStat[] = []
+ for (const key of SKILL_ORDER) {
+ const values = groups.get(key)
+ if (!values?.length) continue
+ const avg = Math.round(values.reduce((a, b) => a + b, 0) / values.length)
+ stats.push({
+ key,
+ label: labelFor(key),
+ avgIndependence: avg,
+ completed: values.length,
+ tier: independenceTier(avg),
+ })
+ }
+ return stats
+}
+
+/** Chronological independence for the last `limit` unique completed challenges (oldest → newest). */
+export function independenceTrend(
+ sessions: SkillSession[],
+ limit = 20,
+): TrendPoint[] {
+ return uniqueByChallenge(sessions)
+ .sort((a, b) => a.completedAt.localeCompare(b.completedAt))
+ .slice(-limit)
+ .map((s) => ({ date: s.completedAt, value: clampScore(s.independence) }))
+}
+
+/** The skill with the lowest average independence, among those with enough evidence. */
+export function weakestSkill(
+ breakdown: SkillStat[],
+ minSample = 1,
+): SkillStat | null {
+ const eligible = breakdown.filter((s) => s.completed >= minSample)
+ if (!eligible.length) return null
+ return eligible.reduce((worst, cur) => {
+ if (cur.avgIndependence < worst.avgIndependence) return cur
+ if (
+ cur.avgIndependence === worst.avgIndependence &&
+ cur.completed > worst.completed
+ )
+ return cur
+ return worst
+ })
+}
+
+/** Map a skill key back to the params getNextChallenge / getTrainingRecommendation expect. */
+export function skillTarget(key: SkillKey): {
+ kind: 'code' | 'design'
+ stack?: string
+} {
+ return key === 'design' ? { kind: 'design' } : { kind: 'code', stack: key }
+}
diff --git a/src/features/dashboard/types.ts b/src/features/dashboard/types.ts
index 3faed78..4c95307 100644
--- a/src/features/dashboard/types.ts
+++ b/src/features/dashboard/types.ts
@@ -1,3 +1,5 @@
+import type { SkillStat, TrendPoint } from './independence'
+
export type Stats = {
total_completed: number
total_hints: number
@@ -5,4 +7,6 @@ export type Stats = {
independence_score: number
streak_days: number
week_progress: { day: string; value: number }[]
+ skill_breakdown: SkillStat[]
+ independence_trend: TrendPoint[]
}
diff --git a/src/features/onboarding/components/onboarding-flow.tsx b/src/features/onboarding/components/onboarding-flow.tsx
index 49eb233..b435781 100644
--- a/src/features/onboarding/components/onboarding-flow.tsx
+++ b/src/features/onboarding/components/onboarding-flow.tsx
@@ -246,7 +246,7 @@ const copy = {
designNotePost: ' (serviços, dados, fluxo) num canvas e a IA analisa.',
trackLabel: 'Trilha',
levelLabel: 'Nível',
- designValue: 'Design System',
+ designValue: 'System Design',
codeValue: 'Código',
genError: 'A IA não conseguiu gerar o desafio agora. Tente de novo.',
connError: 'Falha ao falar com a IA. Verifique a conexão e tente de novo.',
diff --git a/src/features/profile/components/profile-view.tsx b/src/features/profile/components/profile-view.tsx
index 872a0a1..6f371c1 100644
--- a/src/features/profile/components/profile-view.tsx
+++ b/src/features/profile/components/profile-view.tsx
@@ -329,7 +329,11 @@ export function ProfileView({ user }: { user: User }) {
label={t.statCompleted}
/>
0
+ ? `${stats.independence_score}%`
+ : '—'
+ }
label={t.statIndependence}
/>
void
}>({ locale: 'en', setLocale: () => {} })
-function detectLocale(): Locale {
- if (typeof window === 'undefined') return 'en'
- const stored = window.localStorage.getItem(LOCALE_COOKIE)
- if (stored === 'pt' || stored === 'en') return stored
- return navigator.language?.toLowerCase().startsWith('pt') ? 'pt' : 'en'
-}
-
-export function LocaleProvider({ children }: { children: React.ReactNode }) {
- const [locale, setLocaleState] = React.useState('en')
+export function LocaleProvider({
+ initialLocale = 'en',
+ children,
+}: {
+ initialLocale?: Locale
+ children: React.ReactNode
+}) {
+ const [locale, setLocaleState] = React.useState(initialLocale)
React.useEffect(() => {
- const detected = detectLocale()
- setLocaleState(detected)
- }, [])
+ window.localStorage.setItem(LOCALE_COOKIE, locale)
+ }, [locale])
React.useEffect(() => {
document.documentElement.lang = locale === 'pt' ? 'pt-BR' : 'en'
diff --git a/src/lib/i18n/server.ts b/src/lib/i18n/server.ts
index 3a2413d..273cb28 100644
--- a/src/lib/i18n/server.ts
+++ b/src/lib/i18n/server.ts
@@ -1,7 +1,18 @@
-import { cookies } from 'next/headers'
+import { cookies, headers } from 'next/headers'
import type { Locale } from './index'
+const LOCALE_COOKIE = 'locale'
+
+/**
+ * The single source of truth for the active locale. An explicit cookie (set by
+ * the language toggle) always wins; otherwise fall back to the browser's
+ * Accept-Language so a first-time pt visitor is served Portuguese on the very
+ * first paint rather than after a client-side correction.
+ */
export async function getLocale(): Promise {
- const store = await cookies()
- return store.get('locale')?.value === 'pt' ? 'pt' : 'en'
+ const cookie = (await cookies()).get(LOCALE_COOKIE)?.value
+ if (cookie === 'pt' || cookie === 'en') return cookie
+
+ const acceptLanguage = (await headers()).get('accept-language')?.toLowerCase()
+ return acceptLanguage?.startsWith('pt') ? 'pt' : 'en'
}