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

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:
Benjamin Admin
2026-04-16 07:26:44 +02:00
parent 9dddd80d7a
commit 6a165b36e5

View 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>
)
}