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