Add Phase 5.1: LearningProgress dashboard widget
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 51s
CI / test-go-edu-search (push) Successful in 46s
CI / test-python-klausur (push) Failing after 2m39s
CI / test-python-agent-core (push) Successful in 41s
CI / test-nodejs-website (push) Successful in 32s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 51s
CI / test-go-edu-search (push) Successful in 46s
CI / test-python-klausur (push) Failing after 2m39s
CI / test-python-agent-core (push) Successful in 41s
CI / test-nodejs-website (push) Successful in 32s
Eltern-Dashboard widget showing per-unit learning stats: accuracy ring, coins, crowns, streak, and recent unit list. Uses ProgressRing and CrownBadge gamification components. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
200
studio-v2/components/dashboard/LearningProgress.tsx
Normal file
200
studio-v2/components/dashboard/LearningProgress.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ProgressRing } from '@/components/gamification/ProgressRing'
|
||||
import { CrownBadge } from '@/components/gamification/CrownBadge'
|
||||
|
||||
interface UnitProgress {
|
||||
unit_id: string
|
||||
coins: number
|
||||
crowns: number
|
||||
streak_days: number
|
||||
last_activity: string | null
|
||||
exercises: {
|
||||
flashcards?: { completed: number; correct: number; incorrect: number }
|
||||
quiz?: { completed: number; correct: number; incorrect: number }
|
||||
type?: { completed: number; correct: number; incorrect: number }
|
||||
story?: { generated: number }
|
||||
}
|
||||
}
|
||||
|
||||
interface LearningUnit {
|
||||
id: string
|
||||
label: string
|
||||
meta: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface LearningProgressProps {
|
||||
isDark: boolean
|
||||
glassCard: string
|
||||
}
|
||||
|
||||
function getBackendUrl() {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8001'
|
||||
const { hostname, protocol } = window.location
|
||||
if (hostname === 'localhost') return 'http://localhost:8001'
|
||||
return `${protocol}//${hostname}:8001`
|
||||
}
|
||||
|
||||
export function LearningProgress({ isDark, glassCard }: LearningProgressProps) {
|
||||
const [units, setUnits] = useState<LearningUnit[]>([])
|
||||
const [progress, setProgress] = useState<UnitProgress[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [unitsResp, progressResp] = await Promise.all([
|
||||
fetch(`${getBackendUrl()}/api/learning-units/`),
|
||||
fetch(`${getBackendUrl()}/api/progress/`),
|
||||
])
|
||||
|
||||
if (unitsResp.ok) setUnits(await unitsResp.json())
|
||||
if (progressResp.ok) setProgress(await progressResp.json())
|
||||
} catch (err) {
|
||||
console.error('Failed to load learning data:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalCoins = progress.reduce((sum, p) => sum + (p.coins || 0), 0)
|
||||
const totalCrowns = progress.reduce((sum, p) => sum + (p.crowns || 0), 0)
|
||||
const maxStreak = Math.max(0, ...progress.map((p) => p.streak_days || 0))
|
||||
|
||||
const totalCorrect = progress.reduce((sum, p) => {
|
||||
const ex = p.exercises || {}
|
||||
return sum +
|
||||
(ex.flashcards?.correct || 0) +
|
||||
(ex.quiz?.correct || 0) +
|
||||
(ex.type?.correct || 0)
|
||||
}, 0)
|
||||
|
||||
const totalAnswered = progress.reduce((sum, p) => {
|
||||
const ex = p.exercises || {}
|
||||
return sum +
|
||||
(ex.flashcards?.completed || 0) +
|
||||
(ex.quiz?.completed || 0) +
|
||||
(ex.type?.completed || 0)
|
||||
}, 0)
|
||||
|
||||
const accuracy = totalAnswered > 0 ? Math.round((totalCorrect / totalAnswered) * 100) : 0
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className={`w-6 h-6 border-2 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (units.length === 0) {
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Lernfortschritt
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Noch keine Lernmodule vorhanden.
|
||||
</p>
|
||||
<Link
|
||||
href="/vocab-worksheet"
|
||||
className={`inline-block mt-3 text-sm font-medium ${isDark ? 'text-blue-300 hover:text-blue-200' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Vokabeln scannen →
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Lernfortschritt
|
||||
</h3>
|
||||
<Link
|
||||
href="/learn"
|
||||
className={`text-sm font-medium ${isDark ? 'text-blue-300 hover:text-blue-200' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Alle Module →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<ProgressRing
|
||||
progress={accuracy}
|
||||
size={64}
|
||||
strokeWidth={5}
|
||||
label="Genauigkeit"
|
||||
value={`${accuracy}%`}
|
||||
color="#22c55e"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-2xl">🪙</span>
|
||||
<span className={`text-lg font-bold ${isDark ? 'text-yellow-300' : 'text-yellow-600'}`}>{totalCoins}</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Muenzen</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<CrownBadge crowns={totalCrowns} size="md" showLabel={false} />
|
||||
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{totalCrowns}</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Kronen</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-2xl">🔥</span>
|
||||
<span className={`text-lg font-bold ${isDark ? 'text-orange-300' : 'text-orange-600'}`}>{maxStreak}</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Tage-Streak</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unit List */}
|
||||
<div className="space-y-2">
|
||||
{units.slice(0, 3).map((unit) => {
|
||||
const unitProgress = progress.find((p) => p.unit_id === unit.id)
|
||||
const unitCorrect = unitProgress
|
||||
? (unitProgress.exercises?.flashcards?.correct || 0) +
|
||||
(unitProgress.exercises?.quiz?.correct || 0) +
|
||||
(unitProgress.exercises?.type?.correct || 0)
|
||||
: 0
|
||||
const unitTotal = unitProgress
|
||||
? (unitProgress.exercises?.flashcards?.completed || 0) +
|
||||
(unitProgress.exercises?.quiz?.completed || 0) +
|
||||
(unitProgress.exercises?.type?.completed || 0)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={unit.id}
|
||||
href={`/learn/${unit.id}/flashcards`}
|
||||
className={`flex items-center justify-between p-3 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{unit.label}
|
||||
</span>
|
||||
<span className={`text-xs ml-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{unitTotal > 0 ? `${unitCorrect}/${unitTotal} richtig` : 'Noch nicht geubt'}
|
||||
</span>
|
||||
</div>
|
||||
<svg className={`w-4 h-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user