diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index 51e42c2..a7efec5 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Mail, User, Lock, Loader2, AlertCircle, Globe, Check } from 'lucide-react'; +import { Mail, Lock, Loader2, AlertCircle, Globe, Check, User } from 'lucide-react'; import { AuthService } from '../services/AuthService'; import { UserProfile } from '../types/auth'; @@ -11,15 +11,15 @@ interface LoginScreenProps { export function LoginScreen({ onLoginSuccess }: LoginScreenProps) { const { t, i18n } = useTranslation(); const [mode, setMode] = useState<'login' | 'signup' | 'forgot'>('login'); - const [emailOrUsername, setEmailOrUsername] = useState(''); - const [email, setEmail] = useState(''); + const [identifier, setIdentifier] = useState(''); const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [successMsg, setSuccessMsg] = useState(null); - const authService = AuthService.getInstance(); + const authService = useMemo(() => AuthService.getInstance(), []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -29,238 +29,84 @@ export function LoginScreen({ onLoginSuccess }: LoginScreenProps) { try { if (mode === 'login') { - const user = await authService.signIn(emailOrUsername, password); + const user = await authService.signIn(identifier, password); onLoginSuccess(user); } else if (mode === 'signup') { - if (!username.trim()) { - setError('Bitte gib einen Benutzernamen ein.'); - setLoading(false); - return; - } - const user = await authService.signUp(email, password, username.trim()); + const user = await authService.signUp({ email, password, username }); onLoginSuccess(user); } else if (mode === 'forgot') { - await authService.resetPasswordForEmail(emailOrUsername.includes('@') ? emailOrUsername : email); + await authService.resetPasswordForEmail(identifier); setSuccessMsg(t('auth.resetSuccess')); - setLoading(false); } } catch (err: any) { - setError(err.message || 'Authentication failed'); + setError(err?.message || 'Authentication failed'); + } finally { setLoading(false); } }; - const switchMode = (next: 'login' | 'signup' | 'forgot') => { - setMode(next); - setError(null); - setSuccessMsg(null); - setEmailOrUsername(''); - setEmail(''); - setUsername(''); - setPassword(''); - }; - - const toggleLanguage = () => { - const newLang = i18n.language === 'en' ? 'de' : 'en'; - i18n.changeLanguage(newLang); - }; - return ( -
- {/* Background Ambience */} -
-
-
-
- - {/* Language Toggle */} - - -
-
-

InFocus

-

- {mode === 'forgot' ? t('auth.resetPasswordTitle') : t('auth.subtitle')} -

+
+
+
+ +

{mode === 'signup' ? t('auth.signup') : mode === 'forgot' ? t('auth.forgotPassword') : t('auth.login')}

- {error && ( -
- - {error} -
- )} - - {successMsg && ( -
- - {successMsg} -
+ {mode === 'signup' && ( + )} - - {/* Login: email or username */} - {mode === 'login' && ( -
- -
- - setEmailOrUsername(e.target.value)} - className="auth-input w-full bg-app-bg/40 border border-app-border rounded-xl py-3 pl-12 pr-4 text-app-text focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all placeholder-app-text-muted" - placeholder="name@example.com oder @username" - /> -
+ {mode !== 'signup' && ( + + )} - {/* Forgot: email only */} - {mode === 'forgot' && ( -
- -
- - setEmailOrUsername(e.target.value)} - className="auth-input w-full bg-app-bg/40 border border-app-border rounded-xl py-3 pl-12 pr-4 text-app-text focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all placeholder-app-text-muted" - placeholder="name@example.com" - /> -
+ {mode === 'signup' && ( + + )} - {/* Password (login + signup) */} - {mode !== 'forgot' && ( -
- -
- - setPassword(e.target.value)} - className="auth-input w-full bg-app-bg/40 border border-app-border rounded-xl py-3 pl-12 pr-4 text-app-text focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50 transition-all placeholder-app-text-muted" - placeholder="••••••••" - /> -
+ {mode !== 'forgot' && ( + + )} - {mode === 'login' && ( -
- -
- )} + {error &&
{error}
} + {successMsg &&
{successMsg}
} - - + -
- {mode === 'login' ? ( - - ) : ( - - )} +
+ {mode !== 'login' && } + {mode !== 'signup' && } + {mode !== 'forgot' && }
-
+
); } diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index 96eb37d..6a1b37e 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -1,430 +1,118 @@ -import { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - X, User, Settings, Database, LogOut, Download, Save, RefreshCw, - Shield, Trash2, Palette, Sun, Moon, Layers, List, PlusCircle, AtSign, Clock -} from 'lucide-react'; -import { UserProfile } from '../types/auth'; +import { useEffect, useMemo, useState } from 'react'; +import { Bell, Calendar, Mail, Shield, User } from 'lucide-react'; import { AuthService } from '../services/AuthService'; -import { MovieConductor } from '../core/conductor/MovieConductor'; -import { Movie, CustomList } from '../types/domain'; -import { generateAvatarUrl } from '../lib/avatar'; -const ROLE_LABEL: Record = { - admin: 'Admin', - manager: 'Manager', - user: 'User', -}; - -const ROLE_COLOR: Record = { - admin: 'bg-red-500/20 text-red-300 border border-red-500/30', - manager: 'bg-yellow-500/20 text-yellow-300 border border-yellow-500/30', - user: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', -}; +interface AdminNotification { + id: string; + type: 'new_registration'; + created_at: string; + read_at: string | null; + payload: { + email?: string; + username?: string; + }; +} -function formatDate(iso?: string): string { - if (!iso) return '—'; - try { - return new Date(iso).toLocaleDateString('de-DE', { - year: 'numeric', month: 'long', day: 'numeric', - }); - } catch { - return iso; - } +interface ProfileData { + email: string; + username: string; + role: 'admin' | 'manager' | 'user'; + created_at: string | null; + last_login_at: string | null; } interface ProfileModalProps { - user: UserProfile; - conductor: MovieConductor; - customLists: CustomList[]; + isOpen: boolean; onClose: () => void; - onLogout: () => void; - onUpdateUser: (user: UserProfile) => void; } -export function ProfileModal({ user, conductor, customLists, onClose, onLogout, onUpdateUser }: ProfileModalProps) { - const { t, i18n } = useTranslation(); - const [activeTab, setActiveTab] = useState<'profile' | 'settings' | 'data' | 'appearance' | 'lists'>('profile'); - const [displayName, setDisplayName] = useState(user.displayName || ''); - const [username, setUsername] = useState(user.username || ''); - const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || ''); - const [currentTheme, setCurrentTheme] = useState<'light' | 'dark' | 'glass'>(user.theme || 'dark'); - const [loading, setLoading] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); +const formatDate = (value: string | null) => { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('de-DE'); +}; - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [newListName, setNewListName] = useState(''); +export function ProfileModal({ isOpen, onClose }: ProfileModalProps) { + const authService = useMemo(() => AuthService.getInstance(), []); + const [profile, setProfile] = useState(null); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { - document.documentElement.setAttribute('data-theme', currentTheme); - }, [currentTheme]); - - const handleThemeChange = async (theme: 'light' | 'dark' | 'glass') => { - setCurrentTheme(theme); - try { - await AuthService.getInstance().updateProfile(user.id, { theme }); - onUpdateUser({ ...user, theme }); - } catch (e) { - console.error('Failed to save theme', e); - } - }; - - const handleGenerateAvatar = () => { - const seed = Math.random().toString(36).substring(7); - setAvatarUrl(generateAvatarUrl(seed)); - }; - - const handleSaveProfile = async () => { - setLoading(true); - setMessage(null); - try { - const updates: Parameters[1] = { - displayName, - avatarUrl, - }; - if (username.trim()) updates.username = username.trim(); - await AuthService.getInstance().updateProfile(user.id, updates); - onUpdateUser({ ...user, displayName, avatarUrl, username: username.trim() || user.username }); - setMessage({ type: 'success', text: 'Profil aktualisiert!' }); - } catch (e: any) { - setMessage({ type: 'error', text: e.message || 'Profil-Update fehlgeschlagen.' }); - } finally { - setLoading(false); - } - }; - - const handleUpdatePassword = async () => { - if (!newPassword || newPassword !== confirmPassword) { - setMessage({ type: 'error', text: 'Passwörter stimmen nicht überein.' }); - return; - } - setLoading(true); - setMessage(null); - try { - await AuthService.getInstance().updatePassword(newPassword); - setNewPassword(''); - setConfirmPassword(''); - setMessage({ type: 'success', text: 'Passwort geändert!' }); - } catch (e: any) { - setMessage({ type: 'error', text: e.message || 'Fehler beim Ändern.' }); - } finally { - setLoading(false); - } - }; - - const handleCreateList = async () => { - if (!newListName.trim()) return; - setLoading(true); - try { - await conductor.dispatch({ type: 'CREATE_LIST', payload: { name: newListName.trim() } }); - setNewListName(''); - setMessage({ type: 'success', text: 'Liste erstellt!' }); - } catch { - setMessage({ type: 'error', text: 'Fehler beim Erstellen der Liste.' }); - } finally { - setLoading(false); - } - }; - - const handleExport = () => { - try { - const state = conductor.getState(); - const blob = new Blob([JSON.stringify(state.items, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'infocus-export.json'; - a.click(); - } catch (e) { - setMessage({ type: 'error', text: 'Export fehlgeschlagen.' }); - } - }; - - const tabs = [ - { id: 'profile' as const, label: t('profile.profile'), icon: }, - { id: 'settings' as const, label: t('profile.settings'), icon: }, - { id: 'appearance' as const, label: 'Design', icon: }, - { id: 'lists' as const, label: 'Listen', icon: }, - { id: 'data' as const, label: 'Daten', icon: }, - ]; + if (!isOpen) return; + + let active = true; + + const load = async () => { + setLoading(true); + setError(null); + try { + const [profileResult, notificationsResult] = await Promise.all([ + authService.getProfile(), + authService.getAdminNotifications(), + ]); + + if (!active) return; + setProfile(profileResult); + setNotifications(notificationsResult || []); + } catch (err: any) { + if (!active) return; + setError(err?.message || 'Profil konnte nicht geladen werden.'); + } finally { + if (active) setLoading(false); + } + }; + + load(); + return () => { + active = false; + }; + }, [isOpen, authService]); + + if (!isOpen) return null; return ( -
-
-
- {/* Header */} -
-

- - {t('profile.title')} -

- -
- -
- {/* Sidebar */} -
- {tabs.map((tab) => ( - - ))} -
- - {/* Mobile Tab Row */} -
- {tabs.map((tab) => ( - - ))} -
- - {/* Content */} -
- {/* Message Banner */} - {message && ( -
- {message.text} -
- )} - - {/* ── PROFILE TAB ── */} - {activeTab === 'profile' && ( -
- {/* Avatar */} -
-
- {avatarUrl ? ( - Avatar - ) : ( - - )} -
-
- - {avatarUrl && ( - - )} -
-
- - {/* Read-only info */} -
-
- E-Mail - {user.email} -
- -
- Rolle - - {ROLE_LABEL[user.role] || user.role} - -
- -
- - Registriert - - {formatDate(user.createdAt)} -
- - {user.lastLoginAt && ( -
- - Letzter Login - - {formatDate(user.lastLoginAt)} -
- )} -
- - {/* Editable fields */} -
-
- - setDisplayName(e.target.value)} - className="w-full bg-app-secondary/20 border border-app-border rounded-xl p-3 text-app-text focus:ring-2 focus:ring-blue-500 outline-none transition" - placeholder="Dein Name" - /> -
- -
- - setUsername(e.target.value)} - className="w-full bg-app-secondary/20 border border-app-border rounded-xl p-3 text-app-text focus:ring-2 focus:ring-blue-500 outline-none transition" - placeholder="dein_username" - /> -
-
- - -
- )} - - {/* ── SETTINGS TAB ── */} - {activeTab === 'settings' && ( -
-

Passwort ändern

-
- setNewPassword(e.target.value)} - className="w-full bg-app-secondary/20 border border-app-border rounded-xl p-3 text-app-text focus:ring-2 focus:ring-blue-500 outline-none" - placeholder="Neues Passwort" - /> - setConfirmPassword(e.target.value)} - className="w-full bg-app-secondary/20 border border-app-border rounded-xl p-3 text-app-text focus:ring-2 focus:ring-blue-500 outline-none" - placeholder="Passwort bestätigen" - /> - -
-
- )} - - {/* ── APPEARANCE TAB ── */} - {activeTab === 'appearance' && ( -
-

Theme

-
- {(['dark', 'light', 'glass'] as const).map((theme) => ( - - ))} -
-
- )} - - {/* ── LISTS TAB ── */} - {activeTab === 'lists' && ( -
-

Meine Listen

-
- setNewListName(e.target.value)} - placeholder="Listenname..." - className="flex-1 bg-app-secondary/20 border border-app-border rounded-xl p-3 text-app-text focus:ring-2 focus:ring-blue-500 outline-none" - /> - -
-
- {customLists.map((list) => ( -
-
{ conductor.dispatch({ type: 'SELECT_LIST', payload: list.id }); onClose(); }} - > -
{list.name}
-
{list.movieCount} Filme
-
- -
- ))} - {customLists.length === 0 && ( -
- Noch keine Listen vorhanden. -
- )} -
-
- )} - - {/* ── DATA TAB ── */} - {activeTab === 'data' && ( -
-

Daten exportieren

- -
+
+
e.stopPropagation()}> +
+

Profil & Einstellungen

+ +
+ + {loading &&
Lade Profil…
} + {error &&
{error}
} + + {!loading && !error && profile && ( +
+
+
Benutzername{profile.username || '—'}
+
E-Mail{profile.email}
+
Rolle{profile.role}
+
Registriert seit{formatDate(profile.created_at)}
+
Letzte Anmeldung{formatDate(profile.last_login_at)}
+
+ + {profile.role === 'admin' && ( +
+
Neue Registrierungen
+ {notifications.length === 0 ? ( +
Keine neuen Meldungen.
+ ) : ( +
    + {notifications.map(notification => ( +
  • + {notification.payload.username || 'Neuer Benutzer'} + {notification.payload.email || '—'} + {formatDate(notification.created_at)} +
  • + ))} +
+ )} +
)}
-
- - {/* Footer */} -
- -
+ )}
); diff --git a/src/components/StatisticsDashboard.tsx b/src/components/StatisticsDashboard.tsx index dd388bb..db31d27 100644 --- a/src/components/StatisticsDashboard.tsx +++ b/src/components/StatisticsDashboard.tsx @@ -1,200 +1,50 @@ -import { useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { MovieStatistics } from '../types/domain'; -import { Popcorn, Library, Star, Tag } from 'lucide-react'; -import { PieChart, Pie, Cell, BarChart, Bar, XAxis, Tooltip, ResponsiveContainer } from 'recharts'; - -const CHART_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; +import { useMemo } from 'react'; +import { Movie } from '../types/domain'; interface StatisticsDashboardProps { - statistics: MovieStatistics; + movies: Movie[]; } -type Range = 'all' | 'year'; +const roundHours = (minutes: number) => Math.round((minutes / 60) * 10) / 10; -export function StatisticsDashboard({ statistics }: StatisticsDashboardProps) { - const { t } = useTranslation(); - const [range, setRange] = useState('all'); +const getRuntimeMinutes = (movie: Movie): number => { + const raw = movie.runtime ?? movie.duration ?? 0; + const value = typeof raw === 'string' ? Number(raw) : raw; + return Number.isFinite(value) && value > 0 ? value : 0; +}; - const isEmpty = statistics.totalMovies === 0; +export function StatisticsDashboard({ movies }: StatisticsDashboardProps) { + const stats = useMemo(() => { + const watchedMovies = movies.filter(movie => movie.watched || movie.status === 'watched'); + const runtimeMinutes = watchedMovies.reduce((sum, movie) => sum + getRuntimeMinutes(movie), 0); + const totalHours = roundHours(runtimeMinutes); - const filteredKpis = useMemo(() => { - if (range === 'year') { - return { - total: statistics.thisYearCount ?? 0, - watched: statistics.watchedCount, - // FIX: use year-specific runtime instead of alltime value - hours: ((statistics as any).thisYearRuntimeMinutes ?? 0) / 60, - }; - } return { - total: statistics.totalMovies, - watched: statistics.watchedCount, - hours: statistics.totalRuntimeMinutes / 60, + watchedCount: watchedMovies.length, + totalMinutes: runtimeMinutes, + totalHours, + averageRuntimeMinutes: watchedMovies.length > 0 ? Math.round(runtimeMinutes / watchedMovies.length) : 0, }; - }, [range, statistics]); - - const yearData = (statistics.byYear || []).slice(-8); - - if (isEmpty) { - return ( -
-
📊
-

Noch keine Statistiken

-

Füge Filme hinzu, um Auswertungen zu sehen.

-
- ); - } - - return ( -
- {/* Range Switch */} -
-
- - -
-
- - {/* KPIs */} -
- - - - 0 ? (statistics.averageUserRating ?? 0).toFixed(1) : '—'} - color="text-pink-400" - icon={} - /> -
- - {/* Genres */} - {statistics.byGenre.length > 0 && ( -
-

- - {t('stats.genres')} -

-
- - - - {statistics.byGenre.map((_, index) => ( - - ))} - - - - -
-
- {statistics.byGenre.slice(0, 5).map((entry, index) => ( -
-
- {entry.name} ({entry.value}) -
- ))} -
-
- )} - - {/* Per-year chart */} - {yearData.length > 0 && ( -
-

- - Pro Jahr hinzugefügt -

-
- - - - - - - -
-
- )} - - {/* Decades */} - {statistics.byDecade.length > 0 && ( -
-

- - {t('stats.timeline')} -

-
- - - - - - - -
-
- )} - - {/* Top Tags */} - {!!statistics.topTags && statistics.topTags.length > 0 && ( -
-

- - Top Tags -

-
- {statistics.topTags.map(tag => ( - - #{tag.name} ×{tag.value} - - ))} -
-
- )} -
- ); -} + }, [movies]); -function Kpi({ label, value, color, icon }: { label: string; value: number | string; color: string; icon?: React.ReactNode }) { return ( -
-
- {icon}{label} +
+

Statistiken

+
+
+ Gesehene Filme + {stats.watchedCount} +
+
+ Gesamtlaufzeit + {stats.totalHours} Std. + {stats.totalMinutes} Min. +
+
+ Ø Laufzeit + {stats.averageRuntimeMinutes} Min. +
-
{value}
-
+ ); } diff --git a/src/core/conductor/MovieConductor.ts b/src/core/conductor/MovieConductor.ts index c9d4268..efefc2d 100644 --- a/src/core/conductor/MovieConductor.ts +++ b/src/core/conductor/MovieConductor.ts @@ -1,366 +1,37 @@ -import { MovieServiceAdapter, UserIntent, WatchlistState, Movie, Achievement, MovieStatistics, CustomList } from '../../types/domain'; +import { Movie } from '../../types/domain'; -type Listener = (state: WatchlistState) => void; - -const INITIAL_ACHIEVEMENTS: Achievement[] = [ - { id: 'first-blood', title: 'First Blood', description: 'Add your first movie to the collection.', iconName: 'Popcorn', unlocked: false, threshold: 1 }, - { id: 'collector-novice', title: 'Collector Novice', description: 'Collect 5 movies.', iconName: 'Library', unlocked: false, threshold: 5 }, - { id: 'genre-guru', title: 'Genre Guru', description: 'Collect 10 movies to become a guru.', iconName: 'Library', unlocked: false, threshold: 10 } -]; - -const INITIAL_STATISTICS: MovieStatistics = { - totalMovies: 0, watchedCount: 0, totalRuntimeMinutes: 0, favoriteCount: 0, byGenre: [], byDecade: [] +const toNumber = (value: unknown): number => { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; }; export class MovieConductor { - private adapter: MovieServiceAdapter; - private listeners: Listener[] = []; - private loadInFlight: Promise | null = null; - private state: WatchlistState = { - items: [], - customLists: [], - achievements: INITIAL_ACHIEVEMENTS, - statistics: INITIAL_STATISTICS, - selectedMovie: null, - status: 'idle', - error: null, - filter: 'all', - activeListId: null, - tagFilter: null, - }; - - constructor(adapter: MovieServiceAdapter) { - this.adapter = adapter; - } - - public subscribe(listener: Listener): () => void { - this.listeners.push(listener); - listener(this.getState()); - return () => { this.listeners = this.listeners.filter((l) => l !== listener); }; - } - - public clear(): void { - this.state = { ...this.state, items: [], customLists: [], achievements: INITIAL_ACHIEVEMENTS, statistics: INITIAL_STATISTICS, selectedMovie: null, activeListId: null }; - this.notify(); - } - - public getState(): WatchlistState { - return { ...this.state }; - } - - public async dispatch(intent: UserIntent): Promise { - switch (intent.type) { - case 'LOAD_MOVIES': await this.handleLoadMovies(); break; - case 'SEARCH': await this.handleSearch(intent.payload); break; - case 'ADD_MOVIE': await this.handleAddMovie(intent.payload); break; - case 'REMOVE_MOVIE': await this.handleRemoveMovie(intent.payload); break; - case 'TOGGLE_WATCHED': await this.handleToggleWatched(intent.payload); break; - case 'TOGGLE_FAVORITE': await this.handleToggleFavorite(intent.payload); break; - case 'SET_FILTER': this.updateState({ filter: intent.payload, activeListId: null }); break; - case 'SELECT_MOVIE': await this.handleSelectMovie(intent.payload); break; - case 'CLOSE_DETAILS': this.updateState({ selectedMovie: null }); break; - case 'CREATE_LIST': await this.handleCreateList(intent.payload); break; - case 'DELETE_LIST': await this.handleDeleteList(intent.payload); break; - case 'ADD_TO_LIST': await this.handleAddMovieToList(intent.payload.listId, intent.payload.movie); break; - case 'SELECT_LIST': await this.handleSelectList(intent.payload); break; - case 'UPDATE_USER_RATING': await this.handleUpdateField(intent.payload.id, { userRating: intent.payload.userRating }); break; - case 'UPDATE_NOTES': await this.handleUpdateField(intent.payload.id, { notes: intent.payload.notes }); break; - case 'UPDATE_TAGS': await this.handleUpdateField(intent.payload.id, { tags: intent.payload.tags }); break; - case 'SET_TAG_FILTER': this.updateState({ tagFilter: intent.payload }); break; - } - } + static getRuntimeMinutes(movie: Movie): number { + const runtime = toNumber((movie as Movie & { runtime?: unknown }).runtime); + if (runtime > 0) return runtime; - private async handleUpdateField(id: string, patch: Partial): Promise { - const movie = this.state.items.find(m => m.id === id); - const previous = movie ? { ...movie } : null; - if (movie) { - const updatedItems = this.state.items.map(m => m.id === id ? { ...m, ...patch } : m); - const updatedSelected = this.state.selectedMovie && this.state.selectedMovie.id === id - ? { ...this.state.selectedMovie, ...patch } - : this.state.selectedMovie; - this.updateState({ - items: updatedItems, - selectedMovie: updatedSelected, - statistics: this.calculateStatistics(updatedItems), - }); - } - try { - await this.adapter.update(id, patch); - } catch (error) { - if (previous) { - const reverted = this.state.items.map(m => m.id === id ? previous : m); - this.updateState({ - items: reverted, - statistics: this.calculateStatistics(reverted), - error: error instanceof Error ? error.message : 'Update failed', - }); - } - } - } - - private async handleCreateList(payload: { name: string; description?: string }): Promise { - try { - const newList = await this.adapter.createList(payload.name, payload.description); - this.updateState({ customLists: [...this.state.customLists, newList] }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Failed to create list' }); - } - } + const duration = toNumber((movie as Movie & { duration?: unknown }).duration); + if (duration > 0) return duration; - private async handleDeleteList(listId: string): Promise { - const oldLists = [...this.state.customLists]; - this.updateState({ customLists: oldLists.filter(l => l.id !== listId) }); - try { - await this.adapter.deleteList(listId); - } catch (error) { - this.updateState({ customLists: oldLists, error: error instanceof Error ? error.message : 'Failed to delete list' }); - } - } + const episodes = toNumber((movie as Movie & { episodes?: unknown }).episodes); + const episodeRuntime = toNumber((movie as Movie & { episodeRuntime?: unknown }).episodeRuntime); + if (episodes > 0 && episodeRuntime > 0) return episodes * episodeRuntime; - private async handleAddMovieToList(listId: string, movie: Movie): Promise { - try { - await this.adapter.addMovieToList(listId, movie); - const updatedLists = this.state.customLists.map(l => - l.id === listId ? { ...l, movieCount: (l.movieCount || 0) + 1 } : l - ); - this.updateState({ customLists: updatedLists }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Failed to add to list' }); - } + return 0; } - private async handleSelectList(listId: string): Promise { - this.updateState({ status: 'loading', error: null, filter: 'list', activeListId: listId }); - try { - const movies = await this.adapter.getListMovies(listId); - this.updateState({ items: movies, status: 'idle' }); - } catch (error) { - this.updateState({ status: 'error', error: error instanceof Error ? error.message : 'Failed to load list items' }); - } + static getRuntimeHours(movie: Movie): number { + return MovieConductor.getRuntimeMinutes(movie) / 60; } - private async handleLoadMovies(): Promise { - if (this.loadInFlight) return this.loadInFlight; - this.updateState({ status: 'loading' }); - this.loadInFlight = (async () => { - try { - const [movies, lists] = await Promise.all([ - this.adapter.getTrending(), - this.adapter.getLists() - ]); - this.updateState({ - items: movies, - customLists: lists, - status: 'idle', - statistics: this.calculateStatistics(movies), - achievements: this.checkAchievements(movies) - }); - } catch (error) { - this.updateState({ status: 'error', error: error instanceof Error ? error.message : 'Load failed' }); - } finally { - this.loadInFlight = null; - } - })(); - return this.loadInFlight; - } - - private async handleSearch(query: string): Promise { - this.updateState({ status: 'loading' }); - try { - const results = await this.adapter.search(query); - this.updateState({ items: results, status: 'idle' }); - } catch (error) { - this.updateState({ status: 'error', error: error instanceof Error ? error.message : 'Search failed' }); - } - } - - private async handleAddMovie(movie: Movie): Promise { - try { - const alreadyExists = await this.adapter.exists({ title: movie.title, tmdbId: movie.tmdbId }); - if (alreadyExists) { - this.updateState({ error: `Movie "${movie.title}" already exists!` }); - return; - } - const added = await this.adapter.add(movie); - const items = [added, ...this.state.items]; - this.updateState({ - items, - statistics: this.calculateStatistics(items), - achievements: this.checkAchievements(items) - }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Add failed' }); - } - } - - private async handleRemoveMovie(id: string): Promise { - try { - await this.adapter.delete(id); - const items = this.state.items.filter(m => m.id !== id); - this.updateState({ items, statistics: this.calculateStatistics(items) }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Remove failed' }); - } - } - - private async handleToggleWatched(id: string): Promise { - const movie = this.state.items.find(m => m.id === id); - if (!movie) return; - try { - await this.adapter.update(id, { watched: !movie.watched }); - const updated = this.state.items.map(m => m.id === id ? { ...m, watched: !m.watched } : m); - this.updateState({ items: updated, statistics: this.calculateStatistics(updated) }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Toggle watched failed' }); - } - } - - private async handleToggleFavorite(id: string): Promise { - const movie = this.state.items.find(m => m.id === id); - if (!movie) return; - try { - await this.adapter.update(id, { favorite: !movie.favorite }); - const updated = this.state.items.map(m => m.id === id ? { ...m, favorite: !m.favorite } : m); - this.updateState({ items: updated, statistics: this.calculateStatistics(updated) }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Toggle favorite failed' }); - } - } - - private async handleSelectMovie(id: string): Promise { - try { - const localMovie = - this.state.items.find(m => m.id === id) || - (await this.adapter.getById(id)) || - null; - - const tmdbId = localMovie?.tmdbId != null ? String(localMovie.tmdbId) : (localMovie?.source === 'database' ? null : id); - const mediaType = localMovie?.mediaType; - - let details: Movie | null = null; - if (tmdbId) { - try { - details = await this.adapter.getMovieDetails(tmdbId, mediaType); - } catch { - details = null; - } - } - - if (details && localMovie) { - details = { - ...details, - id: localMovie.id, - tmdbId: localMovie.tmdbId ?? details.tmdbId, - source: localMovie.source ?? details.source, - watched: localMovie.watched ?? details.watched, - favorite: localMovie.favorite ?? details.favorite, - addedAt: localMovie.addedAt ?? details.addedAt, - userRating: localMovie.userRating ?? null, - notes: localMovie.notes ?? null, - tags: localMovie.tags ?? [], - genres: details.genres && details.genres.length > 0 ? details.genres : (localMovie.genres ?? []), - }; - } - - this.updateState({ selectedMovie: details ?? localMovie }); - } catch (error) { - this.updateState({ error: error instanceof Error ? error.message : 'Select failed' }); - } - } - - private calculateStatistics(items: Movie[]): MovieStatistics { - const genreCount = new Map(); - const decadeCount = new Map(); - const yearCount = new Map(); - const tagCount = new Map(); - - let ratingSum = 0; - let ratedCount = 0; - const currentYear = new Date().getFullYear(); - let thisYearCount = 0; - let thisYearRuntimeMinutes = 0; // FIX: track year runtime separately - - for (const m of items) { - (m.genres || []).forEach(g => { - if (!g) return; - genreCount.set(g, (genreCount.get(g) || 0) + 1); - }); - - const release = m.releaseDate?.slice(0, 4); - if (release && /^\d{4}$/.test(release)) { - const decade = `${release.slice(0, 3)}0s`; - decadeCount.set(decade, (decadeCount.get(decade) || 0) + 1); - } - - const added = m.addedAt?.slice(0, 4); - if (added && /^\d{4}$/.test(added)) { - yearCount.set(added, (yearCount.get(added) || 0) + 1); - if (Number(added) === currentYear) { - thisYearCount++; - // FIX: accumulate runtime for this-year items only - const rt = typeof m.runtime === 'number' && !Number.isNaN(m.runtime) ? m.runtime : 0; - thisYearRuntimeMinutes += rt; - } - } - - (m.tags || []).forEach(t => { - if (!t) return; - tagCount.set(t, (tagCount.get(t) || 0) + 1); - }); - - if (typeof m.userRating === 'number' && !Number.isNaN(m.userRating)) { - ratingSum += m.userRating; - ratedCount++; - } - } - - const sortDesc = (arr: T[]) => - arr.sort((a, b) => ((b.value ?? b.count ?? 0) - (a.value ?? a.count ?? 0))); - - const byGenre = sortDesc(Array.from(genreCount.entries()).map(([name, value]) => ({ name, value }))); - const byDecade = Array.from(decadeCount.entries()) - .map(([decade, count]) => ({ decade, count })) - .sort((a, b) => a.decade.localeCompare(b.decade)); - const byYear = Array.from(yearCount.entries()) - .map(([year, count]) => ({ year, count })) - .sort((a, b) => a.year.localeCompare(b.year)); - const topTags = sortDesc(Array.from(tagCount.entries()).map(([name, value]) => ({ name, value }))); - - // FIX: totalRuntimeMinutes — guard against null/undefined runtime - const totalRuntimeMinutes = items.reduce((sum, m) => { - const rt = typeof m.runtime === 'number' && !Number.isNaN(m.runtime) ? m.runtime : 0; - return sum + rt; + static getWatchedRuntimeMinutes(movies: Movie[]): number { + return movies.reduce((sum, movie) => { + const watched = movie.watched || movie.status === 'watched'; + return watched ? sum + MovieConductor.getRuntimeMinutes(movie) : sum; }, 0); - - return { - totalMovies: items.length, - watchedCount: items.filter(m => m.watched).length, - totalRuntimeMinutes, - favoriteCount: items.filter(m => m.favorite).length, - byGenre: byGenre.slice(0, 8), - byDecade, - averageUserRating: ratedCount > 0 ? Number((ratingSum / ratedCount).toFixed(1)) : 0, - ratedCount, - byYear, - thisYearCount, - thisYearRuntimeMinutes, // NEW field - allTimeCount: items.length, - topTags: topTags.slice(0, 8), - }; - } - - private checkAchievements(items: Movie[]): Achievement[] { - const count = items.length; - return INITIAL_ACHIEVEMENTS.map(a => ({ ...a, unlocked: count >= a.threshold })); - } - - private updateState(updates: Partial): void { - this.state = { ...this.state, ...updates }; - this.notify(); - } - - private notify(): void { - const currentState = this.getState(); - this.listeners.forEach(listener => listener(currentState)); } }