feat(companion): Migrate Companion from admin-v2 to studio-v2 as pure lesson tool

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>
This commit is contained in:
BreakPilot Dev
2026-02-09 23:50:20 +01:00
parent a7a5674818
commit 754a812d4b
35 changed files with 327 additions and 1435 deletions

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/companion/feedback
* Submit feedback (bug report, feature request, general feedback)
* Proxy to backend /api/feedback
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate required fields
if (!body.type || !body.title || !body.description) {
return NextResponse.json(
{
success: false,
error: 'Missing required fields: type, title, description',
},
{ status: 400 }
)
}
// Validate feedback type
const validTypes = ['bug', 'feature', 'feedback']
if (!validTypes.includes(body.type)) {
return NextResponse.json(
{
success: false,
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
},
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/feedback`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify({
// type: body.type,
// title: body.title,
// description: body.description,
// screenshot: body.screenshot,
// sessionId: body.sessionId,
// metadata: {
// ...body.metadata,
// source: 'companion',
// timestamp: new Date().toISOString(),
// userAgent: request.headers.get('user-agent'),
// },
// }),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the submission
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
console.log('Feedback received:', {
id: feedbackId,
type: body.type,
title: body.title,
description: body.description.substring(0, 100) + '...',
hasScreenshot: !!body.screenshot,
sessionId: body.sessionId,
})
return NextResponse.json({
success: true,
message: 'Feedback submitted successfully',
data: {
feedbackId,
submittedAt: new Date().toISOString(),
},
})
} catch (error) {
console.error('Submit feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/companion/feedback
* Get feedback history (admin only)
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')
const limit = parseInt(searchParams.get('limit') || '10')
// TODO: Replace with actual backend call
// Mock response - empty list for now
return NextResponse.json({
success: true,
data: {
feedback: [],
total: 0,
page: 1,
limit,
},
})
} catch (error) {
console.error('Get feedback error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/companion/lesson
* Start a new lesson session
* Proxy to backend /api/classroom/sessions
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - create a new session
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const mockSession = {
success: true,
data: {
sessionId,
classId: body.classId,
className: body.className || body.classId,
subject: body.subject,
topic: body.topic,
startTime: new Date().toISOString(),
phases: [
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
],
totalPlannedDuration: 50,
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
},
}
return NextResponse.json(mockSession)
} catch (error) {
console.error('Start lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET /api/companion/lesson
* Get current lesson session or list of recent sessions
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const url = sessionId
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
// : `${backendUrl}/api/classroom/sessions`
//
// const response = await fetch(url)
// const data = await response.json()
// return NextResponse.json(data)
// Mock response
if (sessionId) {
return NextResponse.json({
success: true,
data: null, // No active session stored on server in mock
})
}
return NextResponse.json({
success: true,
data: {
sessions: [], // Empty list for now
},
})
} catch (error) {
console.error('Get lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/companion/lesson
* Update lesson session (timer state, phase changes, etc.)
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const { sessionId, ...updates } = body
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(updates),
// })
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the update
return NextResponse.json({
success: true,
message: 'Session updated',
})
} catch (error) {
console.error('Update lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* DELETE /api/companion/lesson
* End/delete a lesson session
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json(
{ success: false, error: 'Session ID required' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Session ended',
})
} catch (error) {
console.error('End lesson error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server'
const DEFAULT_SETTINGS = {
defaultPhaseDurations: {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
},
preferredLessonLength: 45,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
/**
* GET /api/companion/settings
* Get teacher settings
* Proxy to backend /api/teacher/settings
*/
export async function GET() {
try {
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - return default settings
return NextResponse.json({
success: true,
data: DEFAULT_SETTINGS,
})
} catch (error) {
console.error('Get settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PUT /api/companion/settings
* Update teacher settings
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
// Validate the settings structure
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid settings data' },
{ status: 400 }
)
}
// TODO: Replace with actual backend call
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// // Add auth headers
// },
// body: JSON.stringify(body),
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response - just acknowledge the save
return NextResponse.json({
success: true,
message: 'Settings saved',
data: body,
})
} catch (error) {
console.error('Save settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* PATCH /api/companion/settings
* Partially update teacher settings
*/
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
// TODO: Replace with actual backend call
return NextResponse.json({
success: true,
message: 'Settings updated',
data: body,
})
} catch (error) {
console.error('Update settings error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,51 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
export default function CompanionPage() {
const { isDark } = useTheme()
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Animated Background Blobs */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Companion
</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Companion Dashboard */}
<div className="flex-1 overflow-auto">
<CompanionDashboard />
</div>
</div>
</div>
)
}

View File

@@ -81,6 +81,12 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)},
{ id: 'companion', labelKey: 'nav_companion', href: '/companion', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2" />
</svg>
)},
{ id: 'meet', labelKey: 'nav_meet', href: '/meet', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
@@ -111,6 +117,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
// Determine active item based on pathname or selectedTab
const getActiveItem = () => {
if (pathname === '/companion') return 'companion'
if (pathname === '/meet') return 'meet'
if (pathname === '/vocab-worksheet') return 'vokabeln'
if (pathname === '/worksheet-editor') return 'worksheet-editor'

View File

@@ -0,0 +1,222 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, Timer } from 'lucide-react'
import { TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { LessonStartForm } from './lesson-mode/LessonStartForm'
import { LessonActiveView } from './lesson-mode/LessonActiveView'
import { LessonEndedView } from './lesson-mode/LessonEndedView'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
}, [])
// Lesson session hook
const {
session,
startLesson,
endLesson,
clearSession,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: session ? handlePauseToggle : undefined,
onExtend: session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Determine current view based on session status
const renderContent = () => {
if (!session) {
return <LessonStartForm onStart={startLesson} />
}
if (session.status === 'completed') {
return (
<LessonEndedView
session={session}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
onStartNew={clearSession}
/>
)
}
// in_progress or paused
return (
<LessonActiveView
session={session}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onEndLesson={endLesson}
/>
)
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Timer className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-slate-900">Unterrichtsstunde</h2>
</div>
<div className="flex items-center gap-2">
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{renderContent()}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
import { Homework } from '@/lib/companion/types'
interface HomeworkSectionProps {
homeworkList: Homework[]
onAdd: (title: string, dueDate: string) => void
onRemove: (id: string) => void
}
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
const [newTitle, setNewTitle] = useState('')
const [newDueDate, setNewDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
// Default due date to next week
const getDefaultDueDate = () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date.toISOString().split('T')[0]
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
setNewTitle('')
setNewDueDate('')
setIsAdding(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-slate-400" />
Hausaufgaben
</h3>
{!isAdding && (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
)}
</div>
{/* Add Form */}
{isAdding && (
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabe
</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Faellig am
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={!newTitle.trim()}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Speichern
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewTitle('')
setNewDueDate('')
}}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
</div>
</div>
</form>
)}
{/* Homework List */}
{homeworkList.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
<p className="text-sm text-slate-400 mt-1">
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
</p>
</div>
) : (
<div className="space-y-3">
{homeworkList.map((hw) => (
<div
key={hw.id}
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
>
<div className="flex-1">
<p className="font-medium text-slate-900">{hw.title}</p>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
<Calendar className="w-3 h-3" />
<span>Faellig: {formatDate(hw.dueDate)}</span>
</div>
</div>
<button
onClick={() => onRemove(hw.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,172 @@
'use client'
import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from './PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
getTimerColorStatus,
} from '@/lib/companion/constants'
interface LessonActiveViewProps {
session: LessonSession
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onEndLesson: () => void
}
export function LessonActiveView({
session,
onPauseToggle,
onExtendTime,
onSkipPhase,
onEndLesson,
}: LessonActiveViewProps) {
const currentPhase = session.phases[session.currentPhaseIndex]
const phaseId = currentPhase?.phase || 'einstieg'
const phaseColor = PHASE_COLORS[phaseId].hex
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
// Calculate timer values
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
const elapsedInPhase = currentPhase?.actualTime || 0
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
// Calculate total elapsed
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
return (
<div className="space-y-6">
{/* Header with Session Info */}
<div
className="bg-gradient-to-r rounded-xl p-6 text-white"
style={{
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
<Users className="w-4 h-4" />
<span>{session.className}</span>
<span className="mx-2">|</span>
<BookOpen className="w-4 h-4" />
<span>{session.subject}</span>
</div>
<h2 className="text-2xl font-bold">
{session.topic || phaseName}
</h2>
</div>
<div className="text-right">
<div className="text-white/80 text-sm">Gesamtzeit</div>
<div className="text-xl font-mono font-bold">
{formatTime(session.elapsedTime)}
</div>
</div>
</div>
</div>
{/* Main Timer Section */}
<div className="bg-white border border-slate-200 rounded-xl p-8">
<div className="flex flex-col items-center">
{/* Visual Pie Timer */}
<VisualPieTimer
progress={progress}
remainingSeconds={remainingSeconds}
totalSeconds={phaseDurationSeconds}
colorStatus={colorStatus}
isPaused={session.isPaused}
currentPhaseName={phaseName}
phaseColor={phaseColor}
onTogglePause={onPauseToggle}
size="lg"
/>
{/* Quick Actions */}
<div className="mt-8 w-full max-w-md">
<QuickActionsBar
onExtend={onExtendTime}
onPause={onPauseToggle}
onResume={onPauseToggle}
onSkip={onSkipPhase}
onEnd={onEndLesson}
isPaused={session.isPaused}
isLastPhase={isLastPhase}
/>
</div>
</div>
</div>
{/* Phase Timeline */}
<PhaseTimelineDetailed
phases={session.phases.map((p, i) => ({
id: p.phase,
shortName: p.phase[0].toUpperCase(),
displayName: PHASE_DISPLAY_NAMES[p.phase],
duration: p.duration,
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
actualTime: p.actualTime,
color: PHASE_COLORS[p.phase].hex,
}))}
currentPhaseIndex={session.currentPhaseIndex}
onPhaseClick={(index) => {
// Optional: Allow clicking to navigate to a phase
}}
/>
{/* Lesson Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
<div className="text-sm text-slate-500">Minuten vergangen</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<div
className="w-5 h-5 rounded-full mx-auto mb-2"
style={{ backgroundColor: phaseColor }}
/>
<div className="text-2xl font-bold text-slate-900">
{session.currentPhaseIndex + 1}/{session.phases.length}
</div>
<div className="text-sm text-slate-500">Phase</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">
{session.totalPlannedDuration - totalElapsedMinutes}
</div>
<div className="text-sm text-slate-500">Minuten verbleibend</div>
</div>
</div>
{/* Keyboard Shortcuts Hint */}
<div className="text-center text-sm text-slate-400">
<span className="inline-flex items-center gap-4">
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
</span>
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { HomeworkSection } from './HomeworkSection'
import { ReflectionSection } from './ReflectionSection'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonEndedViewProps {
session: LessonSession
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
onStartNew: () => void
}
export function LessonEndedView({
session,
onSaveReflection,
onAddHomework,
onRemoveHomework,
onStartNew,
}: LessonEndedViewProps) {
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
// Calculate analytics
const totalPlannedSeconds = session.totalPlannedDuration * 60
const totalActualSeconds = session.elapsedTime
const timeDiff = totalActualSeconds - totalPlannedSeconds
const timeDiffMinutes = Math.round(timeDiff / 60)
const startTime = new Date(session.startTime)
const endTime = session.endTime ? new Date(session.endTime) : new Date()
return (
<div className="space-y-6">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-full">
<CheckCircle className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
<p className="text-green-100">
{session.className} - {session.subject}
{session.topic && ` - ${session.topic}`}
</p>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
{[
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
font-medium transition-all duration-200
${activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}
`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Time Overview */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Zeitauswertung
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatTime(totalActualSeconds)}
</div>
<div className="text-sm text-slate-500">Tatsaechlich</div>
</div>
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatMinutes(session.totalPlannedDuration)}
</div>
<div className="text-sm text-slate-500">Geplant</div>
</div>
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
</div>
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
Differenz
</div>
</div>
</div>
{/* Session Times */}
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/* Phase Breakdown */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-slate-400" />
Phasen-Analyse
</h3>
<div className="space-y-4">
{session.phases.map((phase) => {
const plannedSeconds = phase.duration * 60
const actualSeconds = phase.actualTime
const diff = actualSeconds - plannedSeconds
const diffMinutes = Math.round(diff / 60)
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
return (
<div key={phase.phase} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
/>
<span className="font-medium text-slate-700">
{PHASE_DISPLAY_NAMES[phase.phase]}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
`}>
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(percentage, 100)}%`,
backgroundColor: percentage > 100
? '#f59e0b' // amber for overtime
: PHASE_COLORS[phase.phase].hex,
}}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{activeTab === 'homework' && (
<HomeworkSection
homeworkList={session.homeworkList}
onAdd={onAddHomework}
onRemove={onRemoveHomework}
/>
)}
{activeTab === 'reflection' && (
<ReflectionSection
reflection={session.reflection}
onSave={onSaveReflection}
/>
)}
{/* Start New Lesson Button */}
<div className="pt-4">
<button
onClick={onStartNew}
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-5 h-5" />
Neue Stunde starten
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
import {
SYSTEM_TEMPLATES,
DEFAULT_PHASE_DURATIONS,
PHASE_DISPLAY_NAMES,
PHASE_ORDER,
calculateTotalDuration,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonStartFormProps {
onStart: (data: {
classId: string
subject: string
topic?: string
templateId?: string
}) => void
loading?: boolean
availableClasses?: Class[]
}
// Mock classes for development
const MOCK_CLASSES: Class[] = [
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
]
const SUBJECTS = [
'Deutsch',
'Mathematik',
'Englisch',
'Biologie',
'Physik',
'Chemie',
'Geschichte',
'Geographie',
'Politik',
'Kunst',
'Musik',
'Sport',
'Informatik',
'Sonstiges',
]
export function LessonStartForm({
onStart,
loading,
availableClasses = MOCK_CLASSES,
}: LessonStartFormProps) {
const [selectedClass, setSelectedClass] = useState('')
const [selectedSubject, setSelectedSubject] = useState('')
const [topic, setTopic] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
SYSTEM_TEMPLATES[0] as LessonTemplate
)
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
const totalDuration = selectedTemplate
? calculateTotalDuration(selectedTemplate.durations)
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
const canStart = selectedClass && selectedSubject
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
onStart({
classId: selectedClass,
subject: selectedSubject,
topic: topic || undefined,
templateId: selectedTemplate?.templateId,
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-100 rounded-xl">
<Play className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
<p className="text-sm text-slate-500">
Waehlen Sie Klasse, Fach und Template
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-2" />
Klasse *
</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Klasse auswaehlen...</option>
{availableClasses.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name} ({cls.studentCount} Schueler)
</option>
))}
</select>
</div>
{/* Subject Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
Fach *
</label>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Fach auswaehlen...</option>
{SUBJECTS.map((subject) => (
<option key={subject} value={subject}>
{subject}
</option>
))}
</select>
</div>
{/* Topic (Optional) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Thema (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-2" />
Template
</label>
<div className="space-y-2">
{SYSTEM_TEMPLATES.map((template) => {
const tpl = template as LessonTemplate
const isSelected = selectedTemplate?.templateId === tpl.templateId
const total = calculateTotalDuration(tpl.durations)
return (
<button
key={tpl.templateId}
type="button"
onClick={() => setSelectedTemplate(tpl)}
className={`
w-full p-4 rounded-xl border text-left transition-all
${isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}
`}
>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
{tpl.name}
</p>
<p className="text-sm text-slate-500">{tpl.description}</p>
</div>
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
{formatMinutes(total)}
</span>
</div>
</button>
)
})}
</div>
{/* Template Details Toggle */}
{selectedTemplate && (
<button
type="button"
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<Info className="w-4 h-4" />
Phasendauern anzeigen
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
</button>
)}
{/* Template Details */}
{showTemplateDetails && selectedTemplate && (
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PHASE_ORDER.map((phaseId) => (
<div key={phaseId} className="text-center">
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
<p className="font-medium text-slate-900">
{selectedTemplate.durations[phaseId]} Min
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Summary & Start Button */}
<div className="pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-slate-600">
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
</div>
{selectedClass && (
<div className="text-sm text-slate-600">
Klasse: <span className="font-semibold">
{availableClasses.find((c) => c.id === selectedClass)?.name}
</span>
</div>
)}
</div>
<button
type="submit"
disabled={!canStart || loading}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
transition-all duration-200
${canStart && !loading
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Stunde wird gestartet...
</>
) : (
<>
<Play className="w-5 h-5" />
Stunde starten
</>
)}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import { Check } from 'lucide-react'
import { formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelinePhase {
id: string
shortName: string
displayName: string
duration: number
status: string
actualTime?: number
color: string
}
interface PhaseTimelineDetailedProps {
phases: PhaseTimelinePhase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
}
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineDetailedProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
<div className="flex items-start justify-between">
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
{/* Top connector line */}
<div className="w-full flex items-center mb-2">
{index > 0 && (
<div
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? phases[index - 1].color
: '#e2e8f0',
}}
/>
)}
{index === 0 && <div className="flex-1" />}
{/* Phase Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative w-12 h-12 rounded-full
flex items-center justify-center
font-bold text-lg
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? phase.color : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${phase.color}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
<Check className="w-6 h-6" />
) : (
phase.shortName
)}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: phase.color }}
/>
)}
</button>
{index < phases.length - 1 && (
<div
className="flex-1 h-1"
style={{
background: isCompleted ? phase.color : '#e2e8f0',
}}
/>
)}
{index === phases.length - 1 && <div className="flex-1" />}
</div>
{/* Phase Label */}
<span
className={`
text-sm font-medium mt-2
${isActive ? 'text-slate-900' : 'text-slate-500'}
`}
>
{phase.displayName}
</span>
{/* Duration */}
<span
className={`
text-xs mt-1
${isActive ? 'text-slate-700' : 'text-slate-400'}
`}
>
{formatMinutes(phase.duration)}
</span>
{/* Actual time if completed */}
{phase.actualTime !== undefined && phase.actualTime > 0 && (
<span className="text-xs text-slate-400 mt-0.5">
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
</span>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
onEnd: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}
export function QuickActionsBar({
onExtend,
onPause,
onResume,
onSkip,
onEnd,
isPaused,
isLastPhase,
disabled,
}: QuickActionsBarProps) {
return (
<div
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
role="toolbar"
aria-label="Steuerung"
>
{/* Extend +5 Min */}
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
}
`}
title="+5 Minuten (E)"
aria-keyshortcuts="e"
aria-label="5 Minuten verlaengern"
>
<Plus className="w-5 h-5" />
<span>5 Min</span>
</button>
{/* Pause / Resume */}
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
flex items-center gap-2 px-6 py-3 rounded-xl
font-semibold transition-all duration-200
min-w-[52px] min-h-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isPaused
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
}
`}
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
aria-keyshortcuts="Space"
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
>
{isPaused ? (
<>
<Play className="w-5 h-5" />
<span>Fortsetzen</span>
</>
) : (
<>
<Pause className="w-5 h-5" />
<span>Pause</span>
</>
)}
</button>
{/* Skip Phase / End Lesson */}
{isLastPhase ? (
<button
onClick={onEnd}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
}
`}
title="Stunde beenden"
aria-label="Stunde beenden"
>
<Square className="w-5 h-5" />
<span>Beenden</span>
</button>
) : (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
}
`}
title="Naechste Phase (N)"
aria-keyshortcuts="n"
aria-label="Zur naechsten Phase springen"
>
<SkipForward className="w-5 h-5" />
<span>Weiter</span>
</button>
)}
</div>
)
}
/**
* Compact version for mobile or sidebar
*/
export function QuickActionsCompact({
onExtend,
onPause,
onResume,
onSkip,
isPaused,
isLastPhase,
disabled,
}: Omit<QuickActionsBarProps, 'onEnd'>) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-blue-600 hover:bg-blue-50'
}
`}
title="+5 Min"
>
<Clock className="w-5 h-5" />
</button>
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
p-2 rounded-lg transition-all
${disabled
? 'text-slate-300'
: isPaused
? 'text-green-600 hover:bg-green-50'
: 'text-amber-600 hover:bg-amber-50'
}
`}
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
</button>
{!isLastPhase && (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-slate-600 hover:bg-slate-50'
}
`}
title="Naechste Phase"
>
<SkipForward className="w-5 h-5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, Save, CheckCircle } from 'lucide-react'
import { LessonReflection } from '@/lib/companion/types'
interface ReflectionSectionProps {
reflection?: LessonReflection
onSave: (rating: number, notes: string, nextSteps: string) => void
}
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
const [rating, setRating] = useState(reflection?.rating || 0)
const [notes, setNotes] = useState(reflection?.notes || '')
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
const [hoverRating, setHoverRating] = useState(0)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (reflection) {
setRating(reflection.rating)
setNotes(reflection.notes)
setNextSteps(reflection.nextSteps)
}
}, [reflection])
const handleSave = () => {
if (rating === 0) return
onSave(rating, notes, nextSteps)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const ratingLabels = [
'', // 0
'Verbesserungsbedarf',
'Okay',
'Gut',
'Sehr gut',
'Ausgezeichnet',
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Wie lief die Stunde?
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= (hoverRating || rating)
return (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
className={`w-8 h-8 ${
isFilled
? 'fill-amber-400 text-amber-400'
: 'text-slate-300'
}`}
/>
</button>
)
})}
{(hoverRating || rating) > 0 && (
<span className="ml-3 text-sm text-slate-600">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notizen zur Stunde
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Next Steps */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Naechste Schritte
</label>
<textarea
value={nextSteps}
onChange={(e) => setNextSteps(e.target.value)}
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Save Button */}
<button
onClick={handleSave}
disabled={rating === 0}
className={`
w-full py-3 px-6 rounded-xl font-semibold
flex items-center justify-center gap-2
transition-all duration-200
${saved
? 'bg-green-600 text-white'
: rating === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{saved ? (
<>
<CheckCircle className="w-5 h-5" />
Gespeichert!
</>
) : (
<>
<Save className="w-5 h-5" />
Reflexion speichern
</>
)}
</button>
{/* Previous Reflection Info */}
{reflection?.savedAt && (
<p className="text-center text-sm text-slate-400">
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,220 @@
'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>
)
}

View File

@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
import { FeedbackType } from '@/lib/companion/types'
interface FeedbackModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
}
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
]
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
const [type, setType] = useState<FeedbackType>('feedback')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSubmitting(true)
try {
await onSubmit(type, title.trim(), description.trim())
setIsSuccess(true)
setTimeout(() => {
setIsSuccess(false)
setTitle('')
setDescription('')
setType('feedback')
onClose()
}, 2000)
} catch (error) {
console.error('Failed to submit feedback:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-xl">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Success State */}
{isSuccess ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
{/* Content */}
<div className="p-6 space-y-6">
{/* Feedback Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Art des Feedbacks
</label>
<div className="grid grid-cols-3 gap-3">
{feedbackTypes.map((ft) => (
<button
key={ft.id}
type="button"
onClick={() => setType(ft.id)}
className={`
p-4 rounded-xl border-2 text-center transition-all
${type === ft.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}
`}
>
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
<ft.icon className="w-5 h-5" />
</div>
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
{ft.label}
</span>
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
type === 'bug'
? 'z.B. Timer stoppt nach Pause nicht mehr'
: type === 'feature'
? 'z.B. Materialien an Stunde anhaengen'
: 'z.B. Super nuetzliches Tool!'
}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
type === 'bug'
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
: type === 'feature'
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
: 'Teilen Sie uns Ihre Gedanken mit...'
}
rows={5}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
required
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim() || !description.trim() || isSubmitting}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${!title.trim() || !description.trim() || isSubmitting
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Senden...
</>
) : (
<>
<Send className="w-4 h-4" />
Absenden
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: { state?: string; schoolType?: string }) => void
}
const STATES = [
'Baden-Wuerttemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thueringen',
]
const SCHOOL_TYPES = [
'Grundschule',
'Hauptschule',
'Realschule',
'Gymnasium',
'Gesamtschule',
'Berufsschule',
'Foerderschule',
'Andere',
]
interface Step {
id: number
title: string
description: string
icon: typeof GraduationCap
}
const steps: Step[] = [
{
id: 1,
title: 'Willkommen',
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
icon: GraduationCap,
},
{
id: 2,
title: 'Ihre Schule',
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
icon: Settings,
},
{
id: 3,
title: 'Bereit!',
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
icon: Timer,
},
]
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(1)
const [selectedState, setSelectedState] = useState('')
const [selectedSchoolType, setSelectedSchoolType] = useState('')
if (!isOpen) return null
const canProceed = () => {
if (currentStep === 2) {
return selectedState !== '' && selectedSchoolType !== ''
}
return true
}
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1)
} else {
onComplete({
state: selectedState,
schoolType: selectedSchoolType,
})
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const currentStepData = steps[currentStep - 1]
const Icon = currentStepData.icon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
{/* Progress Bar */}
<div className="h-1 bg-slate-100">
<div
className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={`
w-3 h-3 rounded-full transition-all
${step.id === currentStep
? 'bg-blue-600 scale-125'
: step.id < currentStep
? 'bg-blue-600'
: 'bg-slate-200'
}
`}
/>
))}
</div>
{/* Icon */}
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon className="w-8 h-8 text-blue-600" />
</div>
{/* Title & Description */}
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
{currentStepData.title}
</h2>
<p className="text-slate-600 text-center mb-8">
{currentStepData.description}
</p>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4 text-center">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-xl">
<div className="text-2xl mb-1">5</div>
<div className="text-xs text-slate-600">Phasen</div>
</div>
<div className="p-4 bg-green-50 rounded-xl">
<div className="text-2xl mb-1">45</div>
<div className="text-xs text-slate-600">Minuten</div>
</div>
<div className="p-4 bg-purple-50 rounded-xl">
<div className="text-2xl mb-1"></div>
<div className="text-xs text-slate-600">Flexibel</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-4">
Einstieg Erarbeitung Sicherung Transfer Reflexion
</p>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* State Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Bundesland
</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
{/* School Type Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schulform
</label>
<select
value={selectedSchoolType}
onChange={(e) => setSelectedSchoolType(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{SCHOOL_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Check className="w-10 h-10 text-green-600" />
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-600">
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
</p>
<p className="text-sm text-slate-600">
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
</p>
</div>
<p className="text-sm text-slate-500">
Sie koennen diese Einstellungen jederzeit aendern.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={currentStep === 1 ? onClose : handleBack}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{currentStep === 1 ? (
'Ueberspringen'
) : (
<>
<ChevronLeft className="w-4 h-4" />
Zurueck
</>
)}
</button>
<button
onClick={handleNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${canProceed()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{currentStep === 3 ? (
<>
<Check className="w-4 h-4" />
Fertig
</>
) : (
<>
Weiter
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Settings, Save, RotateCcw } from 'lucide-react'
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
import {
DEFAULT_TEACHER_SETTINGS,
DEFAULT_PHASE_DURATIONS,
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
calculateTotalDuration,
} from '@/lib/companion/constants'
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
settings: TeacherSettings
onSave: (settings: TeacherSettings) => void
}
export function SettingsModal({
isOpen,
onClose,
settings,
onSave,
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
useEffect(() => {
setLocalSettings(settings)
setDurations(settings.defaultPhaseDurations)
}, [settings])
if (!isOpen) return null
const totalDuration = calculateTotalDuration(durations)
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
setDurations(newDurations)
}
const handleReset = () => {
setDurations(DEFAULT_PHASE_DURATIONS)
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
}
const handleSave = () => {
const newSettings: TeacherSettings = {
...localSettings,
defaultPhaseDurations: durations,
}
onSave(newSettings)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-xl">
<Settings className="w-5 h-5 text-slate-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Phase Durations */}
<div>
<h3 className="text-sm font-medium text-slate-700 mb-4">
Standard-Phasendauern (Minuten)
</h3>
<div className="space-y-4">
{PHASE_ORDER.map((phase) => (
<div key={phase} className="flex items-center gap-4">
<div className="flex items-center gap-2 w-32">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
/>
<span className="text-sm text-slate-700">
{PHASE_DISPLAY_NAMES[phase]}
</span>
</div>
<input
type="number"
min={1}
max={60}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="range"
min={1}
max={45}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
className="flex-1"
style={{
accentColor: PHASE_COLORS[phase].hex,
}}
/>
</div>
))}
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
<span className="text-sm text-slate-600">Gesamtdauer:</span>
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
</div>
</div>
{/* Other Settings */}
<div className="space-y-4 pt-4 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-4">
Weitere Einstellungen
</h3>
{/* Auto Advance */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Automatischer Phasenwechsel
</span>
<p className="text-xs text-slate-500">
Phasen automatisch wechseln wenn Zeit abgelaufen
</p>
</div>
<input
type="checkbox"
checked={localSettings.autoAdvancePhases}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Ton-Benachrichtigungen
</span>
<p className="text-xs text-slate-500">
Signalton bei Phasenende und Warnungen
</p>
</div>
<input
type="checkbox"
checked={localSettings.soundNotifications}
onChange={(e) =>
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Keyboard Shortcuts */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Tastaturkuerzel anzeigen
</span>
<p className="text-xs text-slate-500">
Hinweise zu Tastaturkuerzeln einblenden
</p>
</div>
<input
type="checkbox"
checked={localSettings.showKeyboardShortcuts}
onChange={(e) =>
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* High Contrast */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Hoher Kontrast
</span>
<p className="text-xs text-slate-500">
Bessere Sichtbarkeit durch erhoehten Kontrast
</p>
</div>
<input
type="checkbox"
checked={localSettings.highContrastMode}
onChange={(e) =>
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4" />
Speichern
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { useLessonSession } from './useLessonSession'
export { useKeyboardShortcuts, useKeyboardShortcutHints } from './useKeyboardShortcuts'

View File

@@ -0,0 +1,113 @@
'use client'
import { useEffect, useCallback, useRef } from 'react'
import { KEYBOARD_SHORTCUTS } from '@/lib/companion/constants'
interface UseKeyboardShortcutsOptions {
onPauseResume?: () => void
onExtend?: () => void
onNextPhase?: () => void
onCloseModal?: () => void
onShowHelp?: () => void
enabled?: boolean
}
export function useKeyboardShortcuts({
onPauseResume,
onExtend,
onNextPhase,
onCloseModal,
onShowHelp,
enabled = true,
}: UseKeyboardShortcutsOptions) {
// Track if we're in an input field
const isInputFocused = useRef(false)
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return
// Don't trigger shortcuts when typing in inputs
const target = event.target as HTMLElement
const isInput =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
if (isInput) {
isInputFocused.current = true
// Only allow Escape in inputs
if (event.key !== 'Escape') return
} else {
isInputFocused.current = false
}
// Handle shortcuts
switch (event.key) {
case KEYBOARD_SHORTCUTS.PAUSE_RESUME:
if (!isInput) {
event.preventDefault()
onPauseResume?.()
}
break
case KEYBOARD_SHORTCUTS.EXTEND_5MIN:
case KEYBOARD_SHORTCUTS.EXTEND_5MIN.toUpperCase():
if (!isInput) {
event.preventDefault()
onExtend?.()
}
break
case KEYBOARD_SHORTCUTS.NEXT_PHASE:
case KEYBOARD_SHORTCUTS.NEXT_PHASE.toUpperCase():
if (!isInput) {
event.preventDefault()
onNextPhase?.()
}
break
case KEYBOARD_SHORTCUTS.CLOSE_MODAL:
event.preventDefault()
onCloseModal?.()
break
case KEYBOARD_SHORTCUTS.SHOW_HELP:
if (!isInput) {
event.preventDefault()
onShowHelp?.()
}
break
}
},
[enabled, onPauseResume, onExtend, onNextPhase, onCloseModal, onShowHelp]
)
useEffect(() => {
if (!enabled) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [enabled, handleKeyDown])
return {
isInputFocused: isInputFocused.current,
}
}
/**
* Hook to display keyboard shortcut hints
*/
export function useKeyboardShortcutHints(show: boolean) {
const shortcuts = [
{ key: 'Leertaste', action: 'Pause/Fortsetzen', code: 'space' },
{ key: 'E', action: '+5 Minuten', code: 'e' },
{ key: 'N', action: 'Naechste Phase', code: 'n' },
{ key: 'Esc', action: 'Modal schliessen', code: 'escape' },
]
if (!show) return null
return shortcuts
}

View File

@@ -0,0 +1,441 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { LessonSession, LessonPhase, TimerState, PhaseDurations } from '@/lib/companion/types'
import {
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
DEFAULT_PHASE_DURATIONS,
SYSTEM_TEMPLATES,
getTimerColorStatus,
STORAGE_KEYS,
} from '@/lib/companion/constants'
interface UseLessonSessionOptions {
onPhaseComplete?: (phaseIndex: number) => void
onLessonComplete?: (session: LessonSession) => void
onOvertimeStart?: () => void
}
interface UseLessonSessionReturn {
session: LessonSession | null
timerState: TimerState | null
startLesson: (data: {
classId: string
className?: string
subject: string
topic?: string
templateId?: string
}) => void
endLesson: () => void
clearSession: () => void
pauseLesson: () => void
resumeLesson: () => void
extendTime: (minutes: number) => void
skipPhase: () => void
saveReflection: (rating: number, notes: string, nextSteps: string) => void
addHomework: (title: string, dueDate: string) => void
removeHomework: (id: string) => void
isRunning: boolean
isPaused: boolean
}
function createInitialPhases(durations: PhaseDurations): LessonPhase[] {
return PHASE_ORDER.map((phaseId) => ({
phase: phaseId,
duration: durations[phaseId],
status: 'planned',
actualTime: 0,
}))
}
function generateSessionId(): string {
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
export function useLessonSession(
options: UseLessonSessionOptions = {}
): UseLessonSessionReturn {
const { onPhaseComplete, onLessonComplete, onOvertimeStart } = options
const [session, setSession] = useState<LessonSession | null>(null)
const [timerState, setTimerState] = useState<TimerState | null>(null)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const lastTickRef = useRef<number>(Date.now())
const hasTriggeredOvertimeRef = useRef(false)
// Calculate timer state from session
const calculateTimerState = useCallback((sess: LessonSession): TimerState | null => {
if (!sess || sess.status === 'completed') return null
const currentPhase = sess.phases[sess.currentPhaseIndex]
if (!currentPhase) return null
const phaseDurationSeconds = currentPhase.duration * 60
const elapsedInPhase = currentPhase.actualTime
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
return {
isRunning: sess.status === 'in_progress' && !sess.isPaused,
isPaused: sess.isPaused,
elapsedSeconds: elapsedInPhase,
remainingSeconds: Math.max(remainingSeconds, -999),
totalSeconds: phaseDurationSeconds,
progress,
colorStatus: getTimerColorStatus(remainingSeconds, isOvertime),
currentPhase,
}
}, [])
// Timer tick function
const tick = useCallback(() => {
if (!session || session.isPaused || session.status !== 'in_progress') return
const now = Date.now()
const delta = Math.floor((now - lastTickRef.current) / 1000)
if (delta <= 0) return
lastTickRef.current = now
setSession((prev) => {
if (!prev) return null
const updatedPhases = [...prev.phases]
const currentPhase = updatedPhases[prev.currentPhaseIndex]
if (!currentPhase) return prev
currentPhase.actualTime += delta
// Check for overtime
const phaseDurationSeconds = currentPhase.duration * 60
if (
currentPhase.actualTime > phaseDurationSeconds &&
!hasTriggeredOvertimeRef.current
) {
hasTriggeredOvertimeRef.current = true
onOvertimeStart?.()
}
// Update total elapsed time
const totalElapsed = prev.elapsedTime + delta
return {
...prev,
phases: updatedPhases,
elapsedTime: totalElapsed,
overtimeMinutes: Math.max(
0,
Math.floor((currentPhase.actualTime - phaseDurationSeconds) / 60)
),
}
})
}, [session, onOvertimeStart])
// Start/stop timer based on session state
useEffect(() => {
if (session?.status === 'in_progress' && !session.isPaused) {
lastTickRef.current = Date.now()
timerRef.current = setInterval(tick, 100)
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [session?.status, session?.isPaused, tick])
// Update timer state when session changes
useEffect(() => {
if (session) {
setTimerState(calculateTimerState(session))
} else {
setTimerState(null)
}
}, [session, calculateTimerState])
// Persist session to localStorage
useEffect(() => {
if (session) {
localStorage.setItem(STORAGE_KEYS.CURRENT_SESSION, JSON.stringify(session))
}
}, [session])
// Restore session from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.CURRENT_SESSION)
if (stored) {
try {
const parsed = JSON.parse(stored) as LessonSession
const sessionTime = new Date(parsed.startTime).getTime()
const isRecent = Date.now() - sessionTime < 24 * 60 * 60 * 1000
if (parsed.status !== 'completed' && isRecent) {
setSession({ ...parsed, isPaused: true })
}
} catch {
// Invalid stored session, ignore
}
}
}, [])
const startLesson = useCallback(
(data: {
classId: string
className?: string
subject: string
topic?: string
templateId?: string
}) => {
let durations = DEFAULT_PHASE_DURATIONS
if (data.templateId) {
const template = SYSTEM_TEMPLATES.find((t) => t.templateId === data.templateId)
if (template) {
durations = template.durations as PhaseDurations
}
}
const phases = createInitialPhases(durations)
phases[0].status = 'active'
phases[0].startedAt = new Date().toISOString()
const newSession: LessonSession = {
sessionId: generateSessionId(),
classId: data.classId,
className: data.className || data.classId,
subject: data.subject,
topic: data.topic,
startTime: new Date().toISOString(),
phases,
totalPlannedDuration: Object.values(durations).reduce((a, b) => a + b, 0),
currentPhaseIndex: 0,
elapsedTime: 0,
isPaused: false,
pauseDuration: 0,
overtimeMinutes: 0,
status: 'in_progress',
homeworkList: [],
materials: [],
}
hasTriggeredOvertimeRef.current = false
setSession(newSession)
},
[]
)
const endLesson = useCallback(() => {
if (!session) return
const completedSession: LessonSession = {
...session,
status: 'completed',
endTime: new Date().toISOString(),
phases: session.phases.map((p, i) => ({
...p,
status: i <= session.currentPhaseIndex ? 'completed' : 'skipped',
completedAt: i <= session.currentPhaseIndex ? new Date().toISOString() : undefined,
})),
}
setSession(completedSession)
onLessonComplete?.(completedSession)
}, [session, onLessonComplete])
const clearSession = useCallback(() => {
setSession(null)
localStorage.removeItem(STORAGE_KEYS.CURRENT_SESSION)
}, [])
const pauseLesson = useCallback(() => {
if (!session || session.isPaused) return
setSession((prev) =>
prev
? {
...prev,
isPaused: true,
pausedAt: new Date().toISOString(),
status: 'paused',
}
: null
)
}, [session])
const resumeLesson = useCallback(() => {
if (!session || !session.isPaused) return
const pausedAt = session.pausedAt ? new Date(session.pausedAt).getTime() : Date.now()
const pauseDelta = Math.floor((Date.now() - pausedAt) / 1000)
setSession((prev) =>
prev
? {
...prev,
isPaused: false,
pausedAt: undefined,
pauseDuration: prev.pauseDuration + pauseDelta,
status: 'in_progress',
}
: null
)
lastTickRef.current = Date.now()
}, [session])
const extendTime = useCallback(
(minutes: number) => {
if (!session) return
setSession((prev) => {
if (!prev) return null
const updatedPhases = [...prev.phases]
const currentPhase = updatedPhases[prev.currentPhaseIndex]
if (!currentPhase) return prev
currentPhase.duration += minutes
if (hasTriggeredOvertimeRef.current) {
const phaseDurationSeconds = currentPhase.duration * 60
if (currentPhase.actualTime < phaseDurationSeconds) {
hasTriggeredOvertimeRef.current = false
}
}
return {
...prev,
phases: updatedPhases,
totalPlannedDuration: prev.totalPlannedDuration + minutes,
}
})
},
[session]
)
const skipPhase = useCallback(() => {
if (!session) return
const nextPhaseIndex = session.currentPhaseIndex + 1
if (nextPhaseIndex >= session.phases.length) {
endLesson()
return
}
setSession((prev) => {
if (!prev) return null
const updatedPhases = [...prev.phases]
updatedPhases[prev.currentPhaseIndex] = {
...updatedPhases[prev.currentPhaseIndex],
status: 'completed',
completedAt: new Date().toISOString(),
}
updatedPhases[nextPhaseIndex] = {
...updatedPhases[nextPhaseIndex],
status: 'active',
startedAt: new Date().toISOString(),
}
return {
...prev,
phases: updatedPhases,
currentPhaseIndex: nextPhaseIndex,
overtimeMinutes: 0,
}
})
hasTriggeredOvertimeRef.current = false
onPhaseComplete?.(session.currentPhaseIndex)
}, [session, endLesson, onPhaseComplete])
const saveReflection = useCallback(
(rating: number, notes: string, nextSteps: string) => {
if (!session) return
setSession((prev) =>
prev
? {
...prev,
reflection: {
rating,
notes,
nextSteps,
savedAt: new Date().toISOString(),
},
}
: null
)
},
[session]
)
const addHomework = useCallback(
(title: string, dueDate: string) => {
if (!session) return
const newHomework = {
id: `hw-${Date.now()}`,
title,
dueDate,
completed: false,
}
setSession((prev) =>
prev
? {
...prev,
homeworkList: [...prev.homeworkList, newHomework],
}
: null
)
},
[session]
)
const removeHomework = useCallback(
(id: string) => {
if (!session) return
setSession((prev) =>
prev
? {
...prev,
homeworkList: prev.homeworkList.filter((hw) => hw.id !== id),
}
: null
)
},
[session]
)
return {
session,
timerState,
startLesson,
endLesson,
clearSession,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isRunning: session?.status === 'in_progress' && !session?.isPaused,
isPaused: session?.isPaused ?? false,
}
}

View File

@@ -0,0 +1,364 @@
/**
* Constants for Companion Module
* Phase colors, defaults, and configuration
*/
import { PhaseId, PhaseDurations, Phase, TeacherSettings } from './types'
// ============================================================================
// Phase Colors (Didactic Color Psychology)
// ============================================================================
export const PHASE_COLORS: Record<PhaseId, { hex: string; tailwind: string; gradient: string }> = {
einstieg: {
hex: '#4A90E2',
tailwind: 'bg-blue-500',
gradient: 'from-blue-500 to-blue-600',
},
erarbeitung: {
hex: '#F5A623',
tailwind: 'bg-orange-500',
gradient: 'from-orange-500 to-orange-600',
},
sicherung: {
hex: '#7ED321',
tailwind: 'bg-green-500',
gradient: 'from-green-500 to-green-600',
},
transfer: {
hex: '#9013FE',
tailwind: 'bg-purple-600',
gradient: 'from-purple-600 to-purple-700',
},
reflexion: {
hex: '#6B7280',
tailwind: 'bg-gray-500',
gradient: 'from-gray-500 to-gray-600',
},
}
// ============================================================================
// Phase Definitions
// ============================================================================
export const PHASE_SHORT_NAMES: Record<PhaseId, string> = {
einstieg: 'E',
erarbeitung: 'A',
sicherung: 'S',
transfer: 'T',
reflexion: 'R',
}
export const PHASE_DISPLAY_NAMES: Record<PhaseId, string> = {
einstieg: 'Einstieg',
erarbeitung: 'Erarbeitung',
sicherung: 'Sicherung',
transfer: 'Transfer',
reflexion: 'Reflexion',
}
export const PHASE_DESCRIPTIONS: Record<PhaseId, string> = {
einstieg: 'Motivation, Kontext setzen, Vorwissen aktivieren',
erarbeitung: 'Hauptinhalt, aktives Lernen, neue Konzepte',
sicherung: 'Konsolidierung, Zusammenfassung, Uebungen',
transfer: 'Anwendung, neue Kontexte, kreative Aufgaben',
reflexion: 'Rueckblick, Selbsteinschaetzung, Ausblick',
}
export const PHASE_ORDER: PhaseId[] = [
'einstieg',
'erarbeitung',
'sicherung',
'transfer',
'reflexion',
]
// ============================================================================
// Default Durations (in minutes)
// ============================================================================
export const DEFAULT_PHASE_DURATIONS: PhaseDurations = {
einstieg: 8,
erarbeitung: 20,
sicherung: 10,
transfer: 7,
reflexion: 5,
}
export const DEFAULT_LESSON_LENGTH = 45 // minutes (German standard)
export const EXTENDED_LESSON_LENGTH = 50 // minutes (with buffer)
// ============================================================================
// Timer Thresholds (in seconds)
// ============================================================================
export const TIMER_WARNING_THRESHOLD = 5 * 60 // 5 minutes = warning (yellow)
export const TIMER_CRITICAL_THRESHOLD = 2 * 60 // 2 minutes = critical (red)
// ============================================================================
// SVG Pie Timer Constants
// ============================================================================
export const PIE_TIMER_RADIUS = 42
export const PIE_TIMER_CIRCUMFERENCE = 2 * Math.PI * PIE_TIMER_RADIUS // ~263.89
export const PIE_TIMER_STROKE_WIDTH = 8
export const PIE_TIMER_SIZE = 120 // viewBox size
// ============================================================================
// Timer Color Classes
// ============================================================================
export const TIMER_COLOR_CLASSES = {
plenty: 'text-green-500 stroke-green-500',
warning: 'text-amber-500 stroke-amber-500',
critical: 'text-red-500 stroke-red-500',
overtime: 'text-red-600 stroke-red-600 animate-pulse',
}
export const TIMER_BG_COLORS = {
plenty: 'bg-green-500/10',
warning: 'bg-amber-500/10',
critical: 'bg-red-500/10',
overtime: 'bg-red-600/20',
}
// ============================================================================
// Keyboard Shortcuts
// ============================================================================
export const KEYBOARD_SHORTCUTS = {
PAUSE_RESUME: ' ', // Spacebar
EXTEND_5MIN: 'e',
NEXT_PHASE: 'n',
CLOSE_MODAL: 'Escape',
SHOW_HELP: '?',
} as const
export const KEYBOARD_SHORTCUT_DESCRIPTIONS: Record<string, string> = {
' ': 'Pause/Fortsetzen',
'e': '+5 Minuten',
'n': 'Naechste Phase',
'Escape': 'Modal schliessen',
'?': 'Hilfe anzeigen',
}
// ============================================================================
// Default Settings
// ============================================================================
export const DEFAULT_TEACHER_SETTINGS: TeacherSettings = {
defaultPhaseDurations: DEFAULT_PHASE_DURATIONS,
preferredLessonLength: DEFAULT_LESSON_LENGTH,
autoAdvancePhases: true,
soundNotifications: true,
showKeyboardShortcuts: true,
highContrastMode: false,
onboardingCompleted: false,
}
// ============================================================================
// System Templates
// ============================================================================
export const SYSTEM_TEMPLATES = [
{
templateId: 'standard-45',
name: 'Standard (45 Min)',
description: 'Klassische Unterrichtsstunde',
durations: DEFAULT_PHASE_DURATIONS,
isSystemTemplate: true,
},
{
templateId: 'double-90',
name: 'Doppelstunde (90 Min)',
description: 'Fuer laengere Arbeitsphasen',
durations: {
einstieg: 10,
erarbeitung: 45,
sicherung: 15,
transfer: 12,
reflexion: 8,
},
isSystemTemplate: true,
},
{
templateId: 'math-focused',
name: 'Mathematik-fokussiert',
description: 'Lange Erarbeitung und Sicherung',
durations: {
einstieg: 5,
erarbeitung: 25,
sicherung: 10,
transfer: 5,
reflexion: 5,
},
isSystemTemplate: true,
},
{
templateId: 'language-practice',
name: 'Sprachpraxis',
description: 'Betont kommunikative Phasen',
durations: {
einstieg: 10,
erarbeitung: 15,
sicherung: 8,
transfer: 10,
reflexion: 7,
},
isSystemTemplate: true,
},
]
// ============================================================================
// Suggestion Icons (Lucide icon names)
// ============================================================================
export const SUGGESTION_ICONS = {
grading: 'ClipboardCheck',
homework: 'BookOpen',
planning: 'Calendar',
meeting: 'Users',
deadline: 'Clock',
material: 'FileText',
communication: 'MessageSquare',
default: 'Lightbulb',
}
// ============================================================================
// Priority Colors
// ============================================================================
export const PRIORITY_COLORS = {
urgent: {
bg: 'bg-red-100',
text: 'text-red-700',
border: 'border-red-200',
dot: 'bg-red-500',
},
high: {
bg: 'bg-orange-100',
text: 'text-orange-700',
border: 'border-orange-200',
dot: 'bg-orange-500',
},
medium: {
bg: 'bg-yellow-100',
text: 'text-yellow-700',
border: 'border-yellow-200',
dot: 'bg-yellow-500',
},
low: {
bg: 'bg-slate-100',
text: 'text-slate-700',
border: 'border-slate-200',
dot: 'bg-slate-400',
},
}
// ============================================================================
// Event Type Icons & Colors
// ============================================================================
export const EVENT_TYPE_CONFIG = {
exam: {
icon: 'FileQuestion',
color: 'text-red-600',
bg: 'bg-red-50',
},
parent_meeting: {
icon: 'Users',
color: 'text-blue-600',
bg: 'bg-blue-50',
},
deadline: {
icon: 'Clock',
color: 'text-amber-600',
bg: 'bg-amber-50',
},
other: {
icon: 'Calendar',
color: 'text-slate-600',
bg: 'bg-slate-50',
},
}
// ============================================================================
// Storage Keys
// ============================================================================
export const STORAGE_KEYS = {
SETTINGS: 'companion_settings',
CURRENT_SESSION: 'companion_current_session',
ONBOARDING_STATE: 'companion_onboarding',
CUSTOM_TEMPLATES: 'companion_custom_templates',
LAST_MODE: 'companion_last_mode',
}
// ============================================================================
// API Endpoints (relative to backend)
// ============================================================================
export const API_ENDPOINTS = {
DASHBOARD: '/api/state/dashboard',
LESSON_START: '/api/classroom/sessions',
LESSON_UPDATE: '/api/classroom/sessions', // + /{id}
TEMPLATES: '/api/classroom/templates',
SETTINGS: '/api/teacher/settings',
FEEDBACK: '/api/feedback',
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Create default phases array from durations
*/
export function createDefaultPhases(durations: PhaseDurations = DEFAULT_PHASE_DURATIONS): Phase[] {
return PHASE_ORDER.map((phaseId, index) => ({
id: phaseId,
shortName: PHASE_SHORT_NAMES[phaseId],
displayName: PHASE_DISPLAY_NAMES[phaseId],
duration: durations[phaseId],
status: index === 0 ? 'active' : 'planned',
color: PHASE_COLORS[phaseId].hex,
}))
}
/**
* Calculate total duration from phase durations
*/
export function calculateTotalDuration(durations: PhaseDurations): number {
return Object.values(durations).reduce((sum, d) => sum + d, 0)
}
/**
* Get timer color status based on remaining time
*/
export function getTimerColorStatus(
remainingSeconds: number,
isOvertime: boolean
): 'plenty' | 'warning' | 'critical' | 'overtime' {
if (isOvertime) return 'overtime'
if (remainingSeconds <= TIMER_CRITICAL_THRESHOLD) return 'critical'
if (remainingSeconds <= TIMER_WARNING_THRESHOLD) return 'warning'
return 'plenty'
}
/**
* Format seconds as MM:SS
*/
export function formatTime(seconds: number): string {
const absSeconds = Math.abs(seconds)
const mins = Math.floor(absSeconds / 60)
const secs = absSeconds % 60
const sign = seconds < 0 ? '-' : ''
return `${sign}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
/**
* Format minutes as "X Min"
*/
export function formatMinutes(minutes: number): string {
return `${minutes} Min`
}

View File

@@ -0,0 +1,2 @@
export * from './types'
export * from './constants'

View File

@@ -0,0 +1,329 @@
/**
* TypeScript Types for Companion Module
* Migration from Flask companion.py/companion_js.py
*/
// ============================================================================
// Phase System
// ============================================================================
export type PhaseId = 'einstieg' | 'erarbeitung' | 'sicherung' | 'transfer' | 'reflexion'
export interface Phase {
id: PhaseId
shortName: string // E, A, S, T, R
displayName: string
duration: number // minutes
status: 'planned' | 'active' | 'completed'
actualTime?: number // seconds (actual time spent)
color: string // hex color
}
export interface PhaseContext {
currentPhase: PhaseId
phaseDisplayName: string
}
// ============================================================================
// Dashboard / Companion Mode
// ============================================================================
export interface CompanionStats {
classesCount: number
studentsCount: number
learningUnitsCreated: number
gradesEntered: number
}
export interface Progress {
percentage: number
completed: number
total: number
}
export type SuggestionPriority = 'urgent' | 'high' | 'medium' | 'low'
export interface Suggestion {
id: string
title: string
description: string
priority: SuggestionPriority
icon: string // lucide icon name
actionTarget: string // navigation path
estimatedTime: number // minutes
}
export type EventType = 'exam' | 'parent_meeting' | 'deadline' | 'other'
export interface UpcomingEvent {
id: string
title: string
date: string // ISO date string
type: EventType
inDays: number
}
export interface CompanionData {
context: PhaseContext
stats: CompanionStats
phases: Phase[]
progress: Progress
suggestions: Suggestion[]
upcomingEvents: UpcomingEvent[]
}
// ============================================================================
// Lesson Mode
// ============================================================================
export type LessonStatus =
| 'not_started'
| 'in_progress'
| 'paused'
| 'completed'
| 'overtime'
export interface LessonPhase {
phase: PhaseId
duration: number // planned duration in minutes
status: 'planned' | 'active' | 'completed' | 'skipped'
actualTime: number // actual time spent in seconds
startedAt?: string // ISO timestamp
completedAt?: string // ISO timestamp
}
export interface Homework {
id: string
title: string
description?: string
dueDate: string // ISO date
attachments?: string[]
completed?: boolean
}
export interface Material {
id: string
title: string
type: 'document' | 'video' | 'presentation' | 'link' | 'other'
url?: string
fileName?: string
}
export interface LessonReflection {
rating: number // 1-5 stars
notes: string
nextSteps: string
savedAt?: string
}
export interface LessonSession {
sessionId: string
classId: string
className: string
subject: string
topic?: string
startTime: string // ISO timestamp
endTime?: string // ISO timestamp
phases: LessonPhase[]
totalPlannedDuration: number // minutes
currentPhaseIndex: number
elapsedTime: number // seconds
isPaused: boolean
pausedAt?: string
pauseDuration: number // total pause time in seconds
overtimeMinutes: number
status: LessonStatus
homeworkList: Homework[]
materials: Material[]
reflection?: LessonReflection
}
// ============================================================================
// Lesson Templates
// ============================================================================
export interface PhaseDurations {
einstieg: number
erarbeitung: number
sicherung: number
transfer: number
reflexion: number
}
export interface LessonTemplate {
templateId: string
name: string
description?: string
subject?: string
durations: PhaseDurations
isSystemTemplate: boolean
createdBy?: string
createdAt?: string
}
// ============================================================================
// Settings
// ============================================================================
export interface TeacherSettings {
defaultPhaseDurations: PhaseDurations
preferredLessonLength: number // minutes (default 45)
autoAdvancePhases: boolean
soundNotifications: boolean
showKeyboardShortcuts: boolean
highContrastMode: boolean
onboardingCompleted: boolean
selectedTemplateId?: string
}
// ============================================================================
// Timer State
// ============================================================================
export type TimerColorStatus = 'plenty' | 'warning' | 'critical' | 'overtime'
export interface TimerState {
isRunning: boolean
isPaused: boolean
elapsedSeconds: number
remainingSeconds: number
totalSeconds: number
progress: number // 0-1
colorStatus: TimerColorStatus
currentPhase: LessonPhase | null
}
// ============================================================================
// Forms
// ============================================================================
export interface LessonStartFormData {
classId: string
subject: string
topic?: string
templateId?: string
customDurations?: PhaseDurations
}
export interface Class {
id: string
name: string
grade: string
studentCount: number
}
// ============================================================================
// Feedback
// ============================================================================
export type FeedbackType = 'bug' | 'feature' | 'feedback'
export interface FeedbackSubmission {
type: FeedbackType
title: string
description: string
screenshot?: string // base64
sessionId?: string
metadata?: Record<string, unknown>
}
// ============================================================================
// Onboarding
// ============================================================================
export interface OnboardingStep {
step: number
title: string
description: string
completed: boolean
}
export interface OnboardingState {
currentStep: number
totalSteps: number
steps: OnboardingStep[]
selectedState?: string // Bundesland
selectedSchoolType?: string
completed: boolean
}
// ============================================================================
// WebSocket Messages
// ============================================================================
export type WSMessageType =
| 'phase_update'
| 'timer_tick'
| 'overtime_warning'
| 'pause_toggle'
| 'session_end'
| 'sync_request'
export interface WSMessage {
type: WSMessageType
payload: {
sessionId: string
phase?: number
elapsed?: number
isPaused?: boolean
overtimeMinutes?: number
[key: string]: unknown
}
timestamp: string
}
// ============================================================================
// API Responses
// ============================================================================
export interface APIResponse<T> {
success: boolean
data?: T
error?: string
message?: string
}
export interface DashboardResponse extends APIResponse<CompanionData> {}
export interface LessonResponse extends APIResponse<LessonSession> {}
export interface TemplatesResponse extends APIResponse<{ templates: LessonTemplate[] }> {}
export interface SettingsResponse extends APIResponse<TeacherSettings> {}
// ============================================================================
// Component Props
// ============================================================================
export type CompanionMode = 'companion' | 'lesson' | 'classic'
export interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
}
export interface PhaseTimelineProps {
phases: Phase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
}
export interface VisualPieTimerProps {
progress: number // 0-1
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
}
export interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}

View File

@@ -37,7 +37,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Arbeitsblätter',
nav_worksheet_cleanup: 'Bereinigung',
nav_korrektur: 'Korrekturen',
nav_compliance_pipeline: 'Compliance Pipeline',
nav_companion: 'Companion', nav_compliance_pipeline: 'Compliance Pipeline',
nav_meet: 'Meet',
nav_alerts: 'Alerts',
nav_alerts_b2b: 'B2B Alerts',
@@ -98,7 +98,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Worksheets',
nav_worksheet_cleanup: 'Cleanup',
nav_korrektur: 'Corrections',
nav_compliance_pipeline: 'Compliance Pipeline',
nav_companion: 'Companion', nav_compliance_pipeline: 'Compliance Pipeline',
nav_meet: 'Meet',
nav_alerts: 'Alerts',
nav_alerts_b2b: 'B2B Alerts',
@@ -145,7 +145,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Çalışma Sayfaları',
nav_worksheet_cleanup: 'Temizleme',
nav_korrektur: 'Düzeltmeler',
nav_compliance_pipeline: 'Uyum Boru Hattı',
nav_companion: 'Companion', nav_compliance_pipeline: 'Uyum Boru Hattı',
nav_meet: 'Meet',
nav_alerts: 'Uyarılar',
nav_alerts_b2b: 'B2B Uyarılar',
@@ -192,7 +192,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'أوراق العمل',
nav_worksheet_cleanup: 'تنظيف',
nav_korrektur: 'التصحيحات',
nav_compliance_pipeline: 'خط أنابيب الامتثال',
nav_companion: 'المرافق', nav_compliance_pipeline: 'خط أنابيب الامتثال',
nav_meet: 'اجتماع',
nav_alerts: 'تنبيهات',
nav_alerts_b2b: 'تنبيهات الشركات',
@@ -239,7 +239,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Рабочие листы',
nav_worksheet_cleanup: 'Очистка',
nav_korrektur: 'Проверки',
nav_compliance_pipeline: 'Пайплайн соответствия',
nav_companion: 'Компаньон', nav_compliance_pipeline: 'Пайплайн соответствия',
nav_meet: 'Встреча',
nav_alerts: 'Оповещения',
nav_alerts_b2b: 'B2B оповещения',
@@ -286,7 +286,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Робочі аркуші',
nav_worksheet_cleanup: 'Очищення',
nav_korrektur: 'Перевірки',
nav_compliance_pipeline: 'Пайплайн відповідності',
nav_companion: 'Компаньйон', nav_compliance_pipeline: 'Пайплайн відповідності',
nav_meet: 'Зустріч',
nav_alerts: 'Сповіщення',
nav_alerts_b2b: 'B2B сповіщення',
@@ -333,7 +333,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Arkusze robocze',
nav_worksheet_cleanup: 'Czyszczenie',
nav_korrektur: 'Korekty',
nav_compliance_pipeline: 'Pipeline zgodności',
nav_companion: 'Companion', nav_compliance_pipeline: 'Pipeline zgodności',
nav_meet: 'Spotkanie',
nav_alerts: 'Powiadomienia',
nav_alerts_b2b: 'Powiadomienia B2B',