Remove Companion module entirely from admin-v2. Rebuild in studio-v2 as a focused lesson timer (no dashboard mode). Direct flow: start → active → ended. Fix timer bug where lastTickRef reset prevented countdown. Add companion link to Sidebar and i18n translations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
6.4 KiB
TypeScript
221 lines
6.4 KiB
TypeScript
'use client'
|
|
|
|
import { Pause, Play } from 'lucide-react'
|
|
import { TimerColorStatus } from '@/lib/companion/types'
|
|
import {
|
|
PIE_TIMER_RADIUS,
|
|
PIE_TIMER_CIRCUMFERENCE,
|
|
PIE_TIMER_STROKE_WIDTH,
|
|
PIE_TIMER_SIZE,
|
|
TIMER_COLOR_CLASSES,
|
|
TIMER_BG_COLORS,
|
|
formatTime,
|
|
} from '@/lib/companion/constants'
|
|
|
|
interface VisualPieTimerProps {
|
|
progress: number // 0-1 (how much time has elapsed)
|
|
remainingSeconds: number
|
|
totalSeconds: number
|
|
colorStatus: TimerColorStatus
|
|
isPaused: boolean
|
|
currentPhaseName: string
|
|
phaseColor: string
|
|
onTogglePause?: () => void
|
|
size?: 'sm' | 'md' | 'lg'
|
|
}
|
|
|
|
const sizeConfig = {
|
|
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
|
|
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
|
|
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
|
|
}
|
|
|
|
export function VisualPieTimer({
|
|
progress,
|
|
remainingSeconds,
|
|
totalSeconds,
|
|
colorStatus,
|
|
isPaused,
|
|
currentPhaseName,
|
|
phaseColor,
|
|
onTogglePause,
|
|
size = 'lg',
|
|
}: VisualPieTimerProps) {
|
|
const config = sizeConfig[size]
|
|
const circumference = 2 * Math.PI * config.radius
|
|
|
|
// Calculate stroke-dashoffset for progress
|
|
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
|
|
const strokeDashoffset = circumference * (1 - progress)
|
|
|
|
// For overtime, show a pulsing full circle
|
|
const isOvertime = colorStatus === 'overtime'
|
|
const displayTime = formatTime(remainingSeconds)
|
|
|
|
// Get color classes based on status
|
|
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
|
|
const bgColorClass = TIMER_BG_COLORS[colorStatus]
|
|
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
{/* Timer Circle */}
|
|
<div
|
|
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
|
|
style={{ width: config.outer, height: config.outer }}
|
|
>
|
|
<svg
|
|
width="100%"
|
|
height="100%"
|
|
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
|
|
className="transform -rotate-90"
|
|
>
|
|
{/* Background circle */}
|
|
<circle
|
|
cx={config.viewBox / 2}
|
|
cy={config.viewBox / 2}
|
|
r={config.radius}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={config.stroke}
|
|
className="text-slate-200"
|
|
/>
|
|
|
|
{/* Progress circle */}
|
|
<circle
|
|
cx={config.viewBox / 2}
|
|
cy={config.viewBox / 2}
|
|
r={config.radius}
|
|
fill="none"
|
|
stroke={isOvertime ? '#dc2626' : phaseColor}
|
|
strokeWidth={config.stroke}
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
|
|
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
|
|
/>
|
|
</svg>
|
|
|
|
{/* Center Content */}
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
{/* Time Display */}
|
|
<span
|
|
className={`
|
|
font-mono font-bold ${config.fontSize}
|
|
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
|
|
`}
|
|
>
|
|
{displayTime}
|
|
</span>
|
|
|
|
{/* Phase Name */}
|
|
<span className="text-sm text-slate-500 mt-1">
|
|
{currentPhaseName}
|
|
</span>
|
|
|
|
{/* Paused Indicator */}
|
|
{isPaused && (
|
|
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
|
|
<Pause className="w-3 h-3" />
|
|
Pausiert
|
|
</span>
|
|
)}
|
|
|
|
{/* Overtime Badge */}
|
|
{isOvertime && (
|
|
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
|
|
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pause/Play Button (overlay) */}
|
|
{onTogglePause && (
|
|
<button
|
|
onClick={onTogglePause}
|
|
className={`
|
|
absolute inset-0 rounded-full
|
|
flex items-center justify-center
|
|
opacity-0 hover:opacity-100
|
|
bg-black/20 backdrop-blur-sm
|
|
transition-opacity duration-200
|
|
`}
|
|
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
|
|
>
|
|
{isPaused ? (
|
|
<Play className="w-12 h-12 text-white" />
|
|
) : (
|
|
<Pause className="w-12 h-12 text-white" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Text */}
|
|
<div className="mt-4 text-center">
|
|
{isOvertime ? (
|
|
<p className="text-red-600 font-semibold animate-pulse">
|
|
Ueberzogen - Zeit fuer die naechste Phase!
|
|
</p>
|
|
) : colorStatus === 'critical' ? (
|
|
<p className="text-red-500 font-medium">
|
|
Weniger als 2 Minuten verbleibend
|
|
</p>
|
|
) : colorStatus === 'warning' ? (
|
|
<p className="text-amber-500">
|
|
Weniger als 5 Minuten verbleibend
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Compact timer for header/toolbar
|
|
*/
|
|
export function CompactTimer({
|
|
remainingSeconds,
|
|
colorStatus,
|
|
isPaused,
|
|
phaseName,
|
|
phaseColor,
|
|
}: {
|
|
remainingSeconds: number
|
|
colorStatus: TimerColorStatus
|
|
isPaused: boolean
|
|
phaseName: string
|
|
phaseColor: string
|
|
}) {
|
|
const isOvertime = colorStatus === 'overtime'
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
|
|
{/* Phase indicator */}
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: phaseColor }}
|
|
/>
|
|
|
|
{/* Phase name */}
|
|
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
|
|
|
|
{/* Time */}
|
|
<span
|
|
className={`
|
|
font-mono font-bold
|
|
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
|
|
`}
|
|
>
|
|
{formatTime(remainingSeconds)}
|
|
</span>
|
|
|
|
{/* Paused badge */}
|
|
{isPaused && (
|
|
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
|
|
Pausiert
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|