A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
|
|
|
/**
|
|
* Activity Tracking System
|
|
*
|
|
* Tracks user activities and calculates time savings compared to manual work.
|
|
* Used for the "Zeitersparnis" dashboard tile and weekly reports.
|
|
*/
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
export type ActivityType =
|
|
| 'vocab_extraction'
|
|
| 'klausur_correction'
|
|
| 'worksheet_creation'
|
|
| 'fairness_check'
|
|
| 'gutachten_generation'
|
|
|
|
export interface ActivityMetadata {
|
|
vocabCount?: number
|
|
studentCount?: number
|
|
criteriaCount?: number
|
|
elementCount?: number
|
|
pageCount?: number
|
|
description?: string
|
|
}
|
|
|
|
export interface ActivityLog {
|
|
id: string
|
|
type: ActivityType
|
|
startedAt: string // ISO string for serialization
|
|
completedAt: string
|
|
actualSeconds: number
|
|
estimatedManualSeconds: number
|
|
savedSeconds: number
|
|
metadata: ActivityMetadata
|
|
}
|
|
|
|
export interface ActivityStats {
|
|
todaySavedSeconds: number
|
|
weekSavedSeconds: number
|
|
monthSavedSeconds: number
|
|
totalSavedSeconds: number
|
|
activityCount: number
|
|
lastActivity: ActivityLog | null
|
|
}
|
|
|
|
interface ActiveActivity {
|
|
type: ActivityType
|
|
startedAt: Date
|
|
metadata: ActivityMetadata
|
|
}
|
|
|
|
interface ActivityContextType {
|
|
// Start tracking an activity
|
|
startActivity: (type: ActivityType, metadata?: ActivityMetadata) => void
|
|
// Complete the current activity
|
|
completeActivity: (additionalMetadata?: ActivityMetadata) => ActivityLog | null
|
|
// Cancel without saving
|
|
cancelActivity: () => void
|
|
// Check if currently tracking
|
|
isTracking: boolean
|
|
currentActivity: ActiveActivity | null
|
|
// Get statistics
|
|
stats: ActivityStats
|
|
// Get recent activities
|
|
recentActivities: ActivityLog[]
|
|
// Refresh stats from storage
|
|
refreshStats: () => void
|
|
}
|
|
|
|
// =============================================================================
|
|
// ESTIMATION FORMULAS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Calculate estimated manual time based on activity type and metadata
|
|
*/
|
|
function calculateManualEstimate(type: ActivityType, metadata: ActivityMetadata): number {
|
|
switch (type) {
|
|
case 'vocab_extraction': {
|
|
// Typing: ~25 seconds per vocab (word + translation + example)
|
|
// Layout: 15 minutes base
|
|
const vocabCount = metadata.vocabCount || 20
|
|
return (vocabCount * 25) + (15 * 60)
|
|
}
|
|
|
|
case 'klausur_correction': {
|
|
// Reading + grading + gutachten: ~45 minutes per student
|
|
const studentCount = metadata.studentCount || 1
|
|
return studentCount * 45 * 60
|
|
}
|
|
|
|
case 'worksheet_creation': {
|
|
// Design + formatting: ~30 seconds per element + 20 min base
|
|
const elementCount = metadata.elementCount || 10
|
|
return (elementCount * 30) + (20 * 60)
|
|
}
|
|
|
|
case 'fairness_check': {
|
|
// Manual comparison: ~5 minutes per student
|
|
const studentCount = metadata.studentCount || 10
|
|
return studentCount * 5 * 60
|
|
}
|
|
|
|
case 'gutachten_generation': {
|
|
// Writing gutachten manually: ~15 minutes per student
|
|
const studentCount = metadata.studentCount || 1
|
|
return studentCount * 15 * 60
|
|
}
|
|
|
|
default:
|
|
return 10 * 60 // 10 minutes default
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// STORAGE
|
|
// =============================================================================
|
|
|
|
const STORAGE_KEY = 'breakpilot_activity_logs'
|
|
|
|
function loadActivities(): ActivityLog[] {
|
|
if (typeof window === 'undefined') return []
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
return stored ? JSON.parse(stored) : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function saveActivities(activities: ActivityLog[]): void {
|
|
if (typeof window === 'undefined') return
|
|
try {
|
|
// Keep only last 1000 activities to prevent storage overflow
|
|
const trimmed = activities.slice(-1000)
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
|
} catch (e) {
|
|
console.error('Failed to save activities:', e)
|
|
}
|
|
}
|
|
|
|
function addActivity(activity: ActivityLog): void {
|
|
const activities = loadActivities()
|
|
activities.push(activity)
|
|
saveActivities(activities)
|
|
}
|
|
|
|
// =============================================================================
|
|
// STATISTICS CALCULATION
|
|
// =============================================================================
|
|
|
|
function calculateStats(activities: ActivityLog[]): ActivityStats {
|
|
const now = new Date()
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const weekStart = new Date(todayStart)
|
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1) // Monday
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
|
|
let todaySavedSeconds = 0
|
|
let weekSavedSeconds = 0
|
|
let monthSavedSeconds = 0
|
|
let totalSavedSeconds = 0
|
|
|
|
for (const activity of activities) {
|
|
const activityDate = new Date(activity.completedAt)
|
|
totalSavedSeconds += activity.savedSeconds
|
|
|
|
if (activityDate >= monthStart) {
|
|
monthSavedSeconds += activity.savedSeconds
|
|
}
|
|
if (activityDate >= weekStart) {
|
|
weekSavedSeconds += activity.savedSeconds
|
|
}
|
|
if (activityDate >= todayStart) {
|
|
todaySavedSeconds += activity.savedSeconds
|
|
}
|
|
}
|
|
|
|
const lastActivity = activities.length > 0 ? activities[activities.length - 1] : null
|
|
|
|
return {
|
|
todaySavedSeconds,
|
|
weekSavedSeconds,
|
|
monthSavedSeconds,
|
|
totalSavedSeconds,
|
|
activityCount: activities.length,
|
|
lastActivity,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONTEXT
|
|
// =============================================================================
|
|
|
|
const ActivityContext = createContext<ActivityContextType | null>(null)
|
|
|
|
export function ActivityProvider({ children }: { children: React.ReactNode }) {
|
|
const [currentActivity, setCurrentActivity] = useState<ActiveActivity | null>(null)
|
|
const [stats, setStats] = useState<ActivityStats>({
|
|
todaySavedSeconds: 0,
|
|
weekSavedSeconds: 0,
|
|
monthSavedSeconds: 0,
|
|
totalSavedSeconds: 0,
|
|
activityCount: 0,
|
|
lastActivity: null,
|
|
})
|
|
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
|
|
|
|
// Load initial stats
|
|
useEffect(() => {
|
|
refreshStats()
|
|
}, [])
|
|
|
|
const refreshStats = useCallback(() => {
|
|
const activities = loadActivities()
|
|
setStats(calculateStats(activities))
|
|
setRecentActivities(activities.slice(-10).reverse())
|
|
}, [])
|
|
|
|
const startActivity = useCallback((type: ActivityType, metadata: ActivityMetadata = {}) => {
|
|
setCurrentActivity({
|
|
type,
|
|
startedAt: new Date(),
|
|
metadata,
|
|
})
|
|
}, [])
|
|
|
|
const completeActivity = useCallback((additionalMetadata: ActivityMetadata = {}): ActivityLog | null => {
|
|
if (!currentActivity) return null
|
|
|
|
const completedAt = new Date()
|
|
const actualSeconds = Math.round((completedAt.getTime() - currentActivity.startedAt.getTime()) / 1000)
|
|
const mergedMetadata = { ...currentActivity.metadata, ...additionalMetadata }
|
|
const estimatedManualSeconds = calculateManualEstimate(currentActivity.type, mergedMetadata)
|
|
const savedSeconds = Math.max(0, estimatedManualSeconds - actualSeconds)
|
|
|
|
const log: ActivityLog = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
type: currentActivity.type,
|
|
startedAt: currentActivity.startedAt.toISOString(),
|
|
completedAt: completedAt.toISOString(),
|
|
actualSeconds,
|
|
estimatedManualSeconds,
|
|
savedSeconds,
|
|
metadata: mergedMetadata,
|
|
}
|
|
|
|
addActivity(log)
|
|
setCurrentActivity(null)
|
|
refreshStats()
|
|
|
|
return log
|
|
}, [currentActivity, refreshStats])
|
|
|
|
const cancelActivity = useCallback(() => {
|
|
setCurrentActivity(null)
|
|
}, [])
|
|
|
|
return (
|
|
<ActivityContext.Provider
|
|
value={{
|
|
startActivity,
|
|
completeActivity,
|
|
cancelActivity,
|
|
isTracking: currentActivity !== null,
|
|
currentActivity,
|
|
stats,
|
|
recentActivities,
|
|
refreshStats,
|
|
}}
|
|
>
|
|
{children}
|
|
</ActivityContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useActivity() {
|
|
const context = useContext(ActivityContext)
|
|
if (!context) {
|
|
// Return a no-op version if not wrapped in provider
|
|
return {
|
|
startActivity: () => {},
|
|
completeActivity: () => null,
|
|
cancelActivity: () => {},
|
|
isTracking: false,
|
|
currentActivity: null,
|
|
stats: {
|
|
todaySavedSeconds: 0,
|
|
weekSavedSeconds: 0,
|
|
monthSavedSeconds: 0,
|
|
totalSavedSeconds: 0,
|
|
activityCount: 0,
|
|
lastActivity: null,
|
|
},
|
|
recentActivities: [],
|
|
refreshStats: () => {},
|
|
}
|
|
}
|
|
return context
|
|
}
|
|
|
|
// =============================================================================
|
|
// UTILITY FUNCTIONS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Format seconds as human-readable duration
|
|
*/
|
|
export function formatDuration(seconds: number): string {
|
|
if (seconds < 60) {
|
|
return `${seconds}s`
|
|
}
|
|
|
|
const hours = Math.floor(seconds / 3600)
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
|
|
if (hours > 0) {
|
|
return minutes > 0 ? `${hours}h ${minutes}min` : `${hours}h`
|
|
}
|
|
|
|
return `${minutes}min`
|
|
}
|
|
|
|
/**
|
|
* Format seconds as compact duration (for tiles)
|
|
*/
|
|
export function formatDurationCompact(seconds: number): { value: string; unit: string } {
|
|
if (seconds < 60) {
|
|
return { value: String(seconds), unit: 's' }
|
|
}
|
|
|
|
const hours = Math.floor(seconds / 3600)
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
|
|
if (hours > 0) {
|
|
const decimal = Math.round((minutes / 60) * 10) / 10
|
|
return { value: String(hours + (decimal > 0 ? decimal : 0)), unit: 'h' }
|
|
}
|
|
|
|
return { value: String(minutes), unit: 'min' }
|
|
}
|
|
|
|
/**
|
|
* Get activity type display name
|
|
*/
|
|
export function getActivityTypeName(type: ActivityType): string {
|
|
const names: Record<ActivityType, string> = {
|
|
vocab_extraction: 'Vokabelextraktion',
|
|
klausur_correction: 'Klausurkorrektur',
|
|
worksheet_creation: 'Arbeitsblatt erstellt',
|
|
fairness_check: 'Fairness-Check',
|
|
gutachten_generation: 'Gutachten generiert',
|
|
}
|
|
return names[type] || type
|
|
}
|