diff --git a/src/App.tsx b/src/App.tsx
index 19d7e81..906b064 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -10,8 +10,9 @@ import { MovieDetailModal } from './components/MovieDetailModal';
import { StatisticsDashboard } from './components/StatisticsDashboard';
import { AchievementsGrid } from './components/AchievementsGrid';
import { BottomNav } from './components/BottomNav';
+import { Recommendations } from './components/Recommendations';
import { useToast } from './components/Toast';
-import { Search, Plus, Trash2, Heart, Eye, Shield, ListPlus } from 'lucide-react';
+import { Search, Plus, Trash2, Heart, Eye, Shield, ListPlus, Sparkles } from 'lucide-react';
import { SplashScreen } from '@capacitor/splash-screen';
import { shareMovie } from './lib/share';
@@ -121,11 +122,16 @@ function App({ conductor }: AppProps) {
}
const filteredItems = state.items.filter((movie) => {
- if (state.filter === 'favorites') return movie.favorite;
- if (state.filter === 'watched') return movie.watched;
+ if (state.filter === 'favorites' && !movie.favorite) return false;
+ if (state.filter === 'watched' && !movie.watched) return false;
+ if (state.tagFilter && !(movie.tags || []).includes(state.tagFilter)) return false;
return true;
});
+ const allTags = Array.from(
+ new Set(state.items.flatMap((m) => m.tags || []).filter(Boolean))
+ ).sort();
+
return (
@@ -191,6 +197,12 @@ function App({ conductor }: AppProps) {
) : state.filter === 'statistics' ? (
+ ) : state.filter === 'recommendations' ? (
+
) : (
/* Movie Grid */
<>
@@ -201,6 +213,37 @@ function App({ conductor }: AppProps) {
{state.customLists.find(l => l.id === state.activeListId)?.name}
)}
+
+ {/* Tag filter chips: nur sichtbar, wenn Tags existieren */}
+ {allTags.length > 0 && (
+
+ Tags:
+ {state.tagFilter && (
+ conductor.dispatch({ type: 'SET_TAG_FILTER', payload: null })}
+ className="bg-app-secondary text-app-text-muted hover:text-app-text px-2 py-1 rounded-full border border-app-border"
+ >
+ Alle
+
+ )}
+ {allTags.slice(0, 12).map(tag => {
+ const active = state.tagFilter === tag;
+ return (
+ conductor.dispatch({ type: 'SET_TAG_FILTER', payload: active ? null : tag })}
+ className={`px-2.5 py-1 rounded-full border transition ${
+ active
+ ? 'bg-blue-500 text-white border-blue-500'
+ : 'bg-blue-500/10 text-blue-300 border-blue-500/30 hover:bg-blue-500/20'
+ }`}
+ >
+ #{tag}
+
+ );
+ })}
+
+ )}
{filteredItems.map((movie) => (
{movie.source !== 'tmdb' && (
-
- { e.stopPropagation(); conductor.dispatch({ type: 'TOGGLE_FAVORITE', payload: movie.id }); }}
- />
- { e.stopPropagation(); conductor.dispatch({ type: 'TOGGLE_WATCHED', payload: movie.id }); }}
- />
-
+ <>
+ {!!movie.tags?.length && (
+
+ {movie.tags!.slice(0, 2).map(tag => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ { e.stopPropagation(); conductor.dispatch({ type: 'TOGGLE_FAVORITE', payload: movie.id }); }}
+ />
+ { e.stopPropagation(); conductor.dispatch({ type: 'TOGGLE_WATCHED', payload: movie.id }); }}
+ />
+ {typeof movie.userRating === 'number' && movie.userRating > 0 && (
+ ★ {movie.userRating}
+ )}
+
+ >
)}
@@ -301,6 +358,7 @@ function App({ conductor }: AppProps) {
onShowWatched={() => conductor.dispatch({ type: 'SET_FILTER', payload: 'watched' })}
onShowAchievements={() => conductor.dispatch({ type: 'SET_FILTER', payload: 'achievements' })}
onShowStatistics={() => conductor.dispatch({ type: 'SET_FILTER', payload: 'statistics' })}
+ onShowRecommendations={() => conductor.dispatch({ type: 'SET_FILTER', payload: 'recommendations' })}
/>
{/* Profile Modal */}
diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx
index f567d8f..27e9233 100644
--- a/src/components/BottomNav.tsx
+++ b/src/components/BottomNav.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
-import { Home, Heart, User, Eye, Zap, BarChart2 } from 'lucide-react';
+import { Home, Heart, User, Eye, Zap, BarChart2, Sparkles } from 'lucide-react';
interface BottomNavProps {
currentFilter: string;
@@ -10,6 +10,7 @@ interface BottomNavProps {
onShowWatched: () => void;
onShowAchievements: () => void;
onShowStatistics: () => void;
+ onShowRecommendations?: () => void;
}
export function BottomNav({
@@ -20,7 +21,8 @@ export function BottomNav({
onShowProfile,
onShowWatched,
onShowAchievements,
- onShowStatistics
+ onShowStatistics,
+ onShowRecommendations
}: BottomNavProps) {
const { t } = useTranslation();
@@ -85,6 +87,18 @@ export function BottomNav({
>
+
+ {onShowRecommendations && (
+
+
+
+ )}
);
}
diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx
index 4529f2e..67acd0a 100644
--- a/src/components/LoginScreen.tsx
+++ b/src/components/LoginScreen.tsx
@@ -98,7 +98,7 @@ export function LoginScreen({ onLoginSuccess }: LoginScreenProps) {
required
value={email}
onChange={(e) => setEmail(e.target.value)}
- className="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"
+ 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"
/>
@@ -115,7 +115,7 @@ export function LoginScreen({ onLoginSuccess }: LoginScreenProps) {
minLength={6}
value={password}
onChange={(e) => setPassword(e.target.value)}
- className="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"
+ 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="••••••••"
/>
diff --git a/src/components/MovieDetailModal.tsx b/src/components/MovieDetailModal.tsx
index b974bf7..2912e09 100644
--- a/src/components/MovieDetailModal.tsx
+++ b/src/components/MovieDetailModal.tsx
@@ -1,8 +1,8 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Movie, CastMember, WatchProvider } from '../types/domain';
import { MovieConductor } from '../core/conductor/MovieConductor';
-import { X, Play, Check, Plus, Share2, ListPlus } from 'lucide-react';
+import { X, Play, Check, Plus, Share2, ListPlus, Star, Tag, NotebookPen } from 'lucide-react';
import { ListCreationModal } from './ListCreationModal';
interface MovieDetailModalProps {
@@ -42,13 +42,20 @@ export function MovieDetailModal({
-
+
+
+
+ {movie.title}
+
+
+
-
+
-
-
-
-
+
+
+ {/* Persönliche Sektion: Bewertung + Notizen + Tags. Nur sichtbar, sobald der Film
+ tatsächlich in der Library liegt — vor dem Hinzufügen gibt es nichts zu speichern. */}
+ {movie.source === 'database' && (
+
+ )}
+
conductor.dispatch({ type: 'SELECT_MOVIE', payload: id })}
@@ -75,15 +99,15 @@ export function MovieDetailModal({
);
}
-// ==================== SUB-COMPONENTS (vollständig, typisiert) ====================
+// ==================== SUB-COMPONENTS ====================
function HeroSection({ movie }: { movie: Movie }) {
return (
-
+
{movie.trailerKey ? (
VIDEO
)}
-
-
-
- {movie.title}
-
-
+
);
}
@@ -127,9 +146,8 @@ function ActionButtons({
const [showListCreation, setShowListCreation] = useState(false);
return (
-
-
- {/* Play Trailer */}
+
+
{movie.trailerKey ? (
window.open(`https://youtube.com/watch?v=${movie.trailerKey}`, '_blank')} className="flex items-center gap-2 bg-app-text text-app-bg hover:bg-app-text/90 px-3 py-2 rounded-lg font-medium text-sm shadow-lg active:scale-95">
{t('common.playTrailer')}
@@ -140,7 +158,6 @@ function ActionButtons({
)}
- {/* Add to Library */}
{isInLibrary ? (
{t('common.inLibrary')}
@@ -151,17 +168,15 @@ function ActionButtons({
)}
- {/* Share */}
- {/* Add to List */}
{
conductor.dispatch({ type: 'ADD_TO_LIST', payload: { listId, movie } });
- onShowToast('Zum Liste hinzugefügt', 'success');
+ onShowToast('Zur Liste hinzugefügt', 'success');
}}
onCreateNewList={() => setShowListCreation(true)}
/>
@@ -217,23 +232,272 @@ function ListMenu({
);
}
-// Placeholder-Sub-Components (ersetze später mit vollem Original-Code)
-function MetadataRow({ movie }: { movie: Movie }) { return {movie.releaseDate} • {movie.runtime} min
; }
-function PlotSection({ movie }: { movie: Movie }) { return {movie.overview}
; }
-function MetadataGrid({ movie }: { movie: Movie }) { return {/* Genres, Rating etc. */}
; }
-function CastSection({ cast }: { cast?: CastMember[] }) { return {cast?.slice(0, 6).map(c =>
{c.name}
)}
; }
-function WatchProvidersSection({ watchProviders }: any) { return Watch Providers (später ausbauen)
; }
+function MetadataRow({ movie }: { movie: Movie }) {
+ const year = movie.releaseDate?.slice(0, 4);
+ const parts: string[] = [];
+ if (year) parts.push(year);
+ if (movie.runtime) parts.push(`${movie.runtime} min`);
+ if (movie.mediaType) parts.push(movie.mediaType === 'tv' ? 'Serie' : 'Film');
+ return (
+
+ {parts.map((p, i) => (
+
{p}
+ ))}
+ {movie.voteAverage != null && (
+
+ ★ {movie.voteAverage.toFixed(1)}
+
+ )}
+ {!!movie.tags?.length && (
+
+ {movie.tags!.slice(0, 4).map(tag => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function PlotSection({ movie }: { movie: Movie }) {
+ if (!movie.overview) return null;
+ return (
+
+ Handlung
+ {movie.overview}
+
+ );
+}
+
+function MetadataGrid({ movie }: { movie: Movie }) {
+ const items: { label: string; value: string | null }[] = [
+ { label: 'Regie', value: movie.director || null },
+ { label: 'Genres', value: movie.genres?.join(', ') || null },
+ { label: 'Veröffentlichung', value: movie.releaseDate || null },
+ ];
+ const filtered = items.filter(i => i.value);
+ if (filtered.length === 0) return null;
+ return (
+
+ {filtered.map(i => (
+
+
{i.label}
+
{i.value}
+
+ ))}
+
+ );
+}
+
+function CastSection({ cast }: { cast?: CastMember[] }) {
+ if (!cast?.length) return null;
+ return (
+
+ Besetzung
+
+ {cast.slice(0, 6).map(c => (
+
+ {c.profilePath ? (
+
+ ) : (
+
No image
+ )}
+
{c.name}
+
{c.character}
+
+ ))}
+
+
+ );
+}
+
+function WatchProvidersSection({ watchProviders }: { watchProviders?: Movie['watchProviders'] }) {
+ const flat = watchProviders?.flatrate || [];
+ const rent = watchProviders?.rent || [];
+ const buy = watchProviders?.buy || [];
+ if (flat.length === 0 && rent.length === 0 && buy.length === 0) return null;
+
+ const renderRow = (label: string, items: WatchProvider[]) =>
+ items.length === 0 ? null : (
+
+
{label}
+
+ {items.slice(0, 6).map(p => (
+
+ ))}
+
+
+ );
+ return (
+
+ Wo zu sehen
+ {renderRow('Streaming', flat)}
+ {renderRow('Leihen', rent)}
+ {renderRow('Kaufen', buy)}
+
+ );
+}
+
function RecommendationsSection({ recommendations, onSelectMovie }: { recommendations?: Movie[]; onSelectMovie: (id: string) => void }) {
+ if (!recommendations?.length) return null;
return (
-
-
Ähnliche Filme
+
+ Ähnliche Filme
- {recommendations?.slice(0, 5).map(rec => (
-
onSelectMovie(rec.id)} className="cursor-pointer">
-
+ {recommendations.slice(0, 5).map(rec => (
+
onSelectMovie(rec.id)} className="cursor-pointer group">
+ {rec.posterPath ? (
+
+ ) : (
+
No image
+ )}
+
{rec.title}
))}
-
+
+ );
+}
+
+// ==================== Personal: Rating + Notes + Tags ====================
+
+function PersonalSection({
+ movie,
+ conductor,
+ onShowToast,
+}: {
+ movie: Movie;
+ conductor: MovieConductor;
+ onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
+}) {
+ const [rating, setRating] = useState
(movie.userRating ?? null);
+ const [notes, setNotes] = useState(movie.notes ?? '');
+ const [tagInput, setTagInput] = useState('');
+ const [tags, setTags] = useState(movie.tags ?? []);
+ const [savingNotes, setSavingNotes] = useState(false);
+
+ // Re-sync if a different movie is selected.
+ useEffect(() => {
+ setRating(movie.userRating ?? null);
+ setNotes(movie.notes ?? '');
+ setTags(movie.tags ?? []);
+ }, [movie.id]);
+
+ const persistRating = (value: number | null) => {
+ setRating(value);
+ conductor.dispatch({ type: 'UPDATE_USER_RATING', payload: { id: movie.id, userRating: value } });
+ };
+
+ const persistNotes = async () => {
+ setSavingNotes(true);
+ await conductor.dispatch({ type: 'UPDATE_NOTES', payload: { id: movie.id, notes } });
+ setSavingNotes(false);
+ onShowToast('Notiz gespeichert', 'success');
+ };
+
+ const addTag = (raw: string) => {
+ const cleaned = raw.trim().replace(/^#+/, '').toLowerCase();
+ if (!cleaned || tags.includes(cleaned)) return;
+ const next = [...tags, cleaned].slice(0, 12);
+ setTags(next);
+ setTagInput('');
+ conductor.dispatch({ type: 'UPDATE_TAGS', payload: { id: movie.id, tags: next } });
+ };
+
+ const removeTag = (tag: string) => {
+ const next = tags.filter(t => t !== tag);
+ setTags(next);
+ conductor.dispatch({ type: 'UPDATE_TAGS', payload: { id: movie.id, tags: next } });
+ };
+
+ return (
+
+
+ Mein Eintrag
+
+
+ {/* Rating */}
+
+
+ Eigene Bewertung
+
+
+ {Array.from({ length: 10 }, (_, i) => i + 1).map(i => {
+ const active = rating !== null && i <= rating;
+ return (
+ persistRating(rating === i ? null : i)}
+ aria-label={`Bewertung ${i}`}
+ className={`w-7 h-7 sm:w-8 sm:h-8 rounded-md text-xs font-bold transition-colors ${
+ active
+ ? 'bg-yellow-400/90 text-black'
+ : 'bg-app-secondary/60 text-app-text-muted hover:bg-app-secondary'
+ }`}
+ >
+ {i}
+
+ );
+ })}
+ {rating !== null && (
+ persistRating(null)}
+ className="ml-2 text-xs text-app-text-muted underline hover:text-app-text"
+ >
+ zurücksetzen
+
+ )}
+
+
+
+ {/* Notes */}
+
+
+ {/* Tags */}
+
+
+ Tags
+
+
+ {tags.map(tag => (
+ removeTag(tag)}
+ className="text-xs bg-blue-500/10 text-blue-300 border border-blue-500/30 rounded-full px-2 py-1 hover:bg-red-500/20 hover:text-red-300 transition-colors"
+ title="Entfernen"
+ >
+ #{tag} ✕
+
+ ))}
+
+
setTagInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ addTag(tagInput);
+ }
+ }}
+ placeholder="Tag eingeben und Enter drücken (z.B. comfort, oscar, freitagabend)"
+ className="w-full bg-app-bg border border-app-border rounded-lg p-2 text-sm text-app-text placeholder-app-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
);
}
diff --git a/src/components/Recommendations.tsx b/src/components/Recommendations.tsx
new file mode 100644
index 0000000..d1e36d5
--- /dev/null
+++ b/src/components/Recommendations.tsx
@@ -0,0 +1,150 @@
+import { useEffect, useState } from 'react';
+import { Sparkles, Plus } from 'lucide-react';
+import { Movie } from '../types/domain';
+import { getSmartRecommendations, RecommendationItem, UserPreferences } from '../services/Recommendations';
+import { MovieConductor } from '../core/conductor/MovieConductor';
+
+interface RecommendationsProps {
+ library: Movie[];
+ conductor: MovieConductor;
+ onAddToLibrary: (movie: Movie) => void;
+}
+
+export function Recommendations({ library, conductor, onAddToLibrary }: RecommendationsProps) {
+ const [items, setItems] = useState([]);
+ const [prefs, setPrefs] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const apiKey = import.meta.env.VITE_TMDB_API_KEY;
+ setLoading(true);
+ setError(null);
+ getSmartRecommendations(library, apiKey)
+ .then(({ items, prefs }) => {
+ if (cancelled) return;
+ setItems(items);
+ setPrefs(prefs);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(err instanceof Error ? err.message : 'Empfehlungen konnten nicht geladen werden');
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [library]);
+
+ const isEmpty = !loading && items.length === 0;
+
+ return (
+
+
+
+ {prefs && prefs.totalCount > 0 && prefs.topGenres.length > 0 && (
+
+
Dein Profil
+
+ {prefs.totalCount} Filme · {prefs.favoriteCount} Favoriten · {prefs.watchedCount} gesehen
+ {prefs.averageUserRating !== null && (
+ · ⌀ Bewertung {prefs.averageUserRating.toFixed(1)}
+ )}
+
+
+ Top-Genres:{' '}
+ {prefs.topGenres.map((g, i) => (
+
+ {i > 0 ? ', ' : ''}{g.name}
+
+ ))}
+
+
+ )}
+
+ {loading && (
+
Empfehlungen werden gesucht…
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ {isEmpty && !error && (
+
+
✨
+
Noch zu wenig Daten
+
+ Bewerte oder markiere ein paar Filme als Favorit, um Empfehlungen zu verbessern.
+
+
+ )}
+
+ {items.length > 0 && (
+
+ {items.map((rec) => (
+ conductor.dispatch({ type: 'SELECT_MOVIE', payload: rec.movie.id })}
+ onAdd={() => onAddToLibrary(rec.movie)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+function RecommendationCard({
+ item,
+ onSelect,
+ onAdd,
+}: {
+ item: RecommendationItem;
+ onSelect: () => void;
+ onAdd: () => void;
+}) {
+ return (
+
+
+ {item.movie.posterPath ? (
+
+ ) : (
+ No image
+ )}
+
+
+
+ {item.movie.title}
+
+ {item.movie.releaseDate?.slice(0, 4)}
+ {item.movie.voteAverage != null && · ★ {item.movie.voteAverage.toFixed(1)} }
+
+
+
+ {item.reasons.join(' · ')}
+
+
+ Auf Watchlist
+
+
+
+ );
+}
diff --git a/src/components/StatisticsDashboard.tsx b/src/components/StatisticsDashboard.tsx
index e54d62c..8ec3f7b 100644
--- a/src/components/StatisticsDashboard.tsx
+++ b/src/components/StatisticsDashboard.tsx
@@ -1,7 +1,8 @@
+import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MovieStatistics } from '../types/domain';
-import { Popcorn, Library } from 'lucide-react';
-import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
+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'];
@@ -9,115 +10,190 @@ interface StatisticsDashboardProps {
statistics: MovieStatistics;
}
+type Range = 'all' | 'year';
+
export function StatisticsDashboard({ statistics }: StatisticsDashboardProps) {
const { t } = useTranslation();
+ const [range, setRange] = useState('all');
+
+ const isEmpty = statistics.totalMovies === 0;
+
+ const filteredKpis = useMemo(() => {
+ if (range === 'year') {
+ return {
+ total: statistics.thisYearCount ?? 0,
+ watched: statistics.watchedCount,
+ hours: statistics.totalRuntimeMinutes / 60,
+ };
+ }
+ return {
+ total: statistics.totalMovies,
+ watched: statistics.watchedCount,
+ hours: statistics.totalRuntimeMinutes / 60,
+ };
+ }, [range, statistics]);
+
+ const yearData = (statistics.byYear || []).slice(-8);
+
+ if (isEmpty) {
+ return (
+
+
📊
+
Noch keine Statistiken
+
Füge Filme hinzu, um Auswertungen zu sehen.
+
+ );
+ }
return (
-
- {/* Section 1: KPIs */}
-
-
-
{t('stats.total')}
-
{statistics.totalMovies}
-
-
-
{t('stats.watched')}
-
{statistics.watchedCount}
-
-
-
{t('stats.hours')}
-
- {(statistics.totalRuntimeMinutes / 60).toFixed(1)}
-
+
+ {/* Range Switch */}
+
+
+ setRange('all')}
+ className={`px-4 py-1.5 rounded-xl text-sm font-medium transition ${
+ range === 'all' ? 'bg-blue-500 text-white' : 'text-app-text-muted hover:text-app-text'
+ }`}
+ >
+ Allzeit
+
+ setRange('year')}
+ className={`px-4 py-1.5 rounded-xl text-sm font-medium transition ${
+ range === 'year' ? 'bg-blue-500 text-white' : 'text-app-text-muted hover:text-app-text'
+ }`}
+ >
+ Dieses Jahr
+
- {/* Section 2: Genres (Pie Chart) */}
+ {/* KPIs */}
+
+
+
+
+ 0 ? (statistics.averageUserRating ?? 0).toFixed(1) : '—'}
+ color="text-pink-400"
+ icon={ }
+ />
+
+
+ {/* Genres */}
{statistics.byGenre.length > 0 && (
-
-
+
+
{t('stats.genres')}
-
+
{statistics.byGenre.map((_, index) => (
- |
+ |
))}
- {/* Legend */}
{statistics.byGenre.slice(0, 5).map((entry, index) => (
-
- {entry.name}
+
+ {entry.name}
({entry.value})
))}
)}
- {/* Section 3: Decades (Bar Chart) */}
- {statistics.byDecade.length > 0 && (
-
-
+ {/* 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}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function Kpi({ label, value, color, icon }: { label: string; value: number | string; color: string; icon?: React.ReactNode }) {
+ return (
+
+
+ {icon}{label}
+
+
{value}
);
}
diff --git a/src/core/conductor/MovieConductor.test.ts b/src/core/conductor/MovieConductor.test.ts
index 8235c06..c47b5fc 100644
--- a/src/core/conductor/MovieConductor.test.ts
+++ b/src/core/conductor/MovieConductor.test.ts
@@ -160,4 +160,87 @@ describe('MovieConductor', () => {
expect(novice?.unlocked).toBe(true);
});
});
+
+ describe('SELECT_MOVIE id resolution', () => {
+ it('uses tmdbId (not the internal DB id) when fetching details for a saved movie', async () => {
+ const savedMovie: Movie = {
+ id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', // internal Supabase UUID
+ tmdbId: 1924, // Superman
+ mediaType: 'movie',
+ title: 'Superman',
+ posterPath: null,
+ runtime: 143,
+ releaseDate: '1978-12-15',
+ overview: 'Saved record',
+ voteAverage: 7.3,
+ source: 'database',
+ watched: true,
+ favorite: false,
+ };
+
+ const tmdbDetails: Movie = {
+ id: '1924',
+ tmdbId: 1924,
+ title: 'Superman',
+ posterPath: null,
+ runtime: 143,
+ releaseDate: '1978-12-15',
+ overview: 'Trailer-rich details',
+ voteAverage: 7.3,
+ trailerKey: 'SUPERMAN-TRAILER',
+ source: 'tmdb',
+ };
+
+ const detailsSpy = vi.fn().mockResolvedValue(tmdbDetails);
+ const adapter: MovieServiceAdapter = {
+ ...mockAdapter,
+ getTrending: vi.fn().mockResolvedValue([savedMovie]),
+ getMovieDetails: detailsSpy,
+ };
+ const c = new MovieConductor(adapter);
+
+ await c.dispatch({ type: 'LOAD_MOVIES' });
+ await c.dispatch({ type: 'SELECT_MOVIE', payload: savedMovie.id });
+
+ // Adapter must be called with the TMDB id, not the UUID
+ expect(detailsSpy).toHaveBeenCalledTimes(1);
+ expect(detailsSpy).toHaveBeenCalledWith('1924', 'movie');
+
+ // Selected movie keeps the local identity but gets the TMDB trailer
+ const selected = c.getState().selectedMovie!;
+ expect(selected.id).toBe(savedMovie.id);
+ expect(selected.tmdbId).toBe(1924);
+ expect(selected.trailerKey).toBe('SUPERMAN-TRAILER');
+ expect(selected.watched).toBe(true);
+ });
+
+ it('falls back to the local item when a saved movie has no tmdbId', async () => {
+ const orphan: Movie = {
+ id: 'ffffffff-1111-2222-3333-444444444444',
+ title: 'Legacy Entry',
+ posterPath: null,
+ runtime: 90,
+ releaseDate: null,
+ overview: null,
+ voteAverage: null,
+ source: 'database',
+ };
+
+ const detailsSpy = vi.fn().mockResolvedValue({} as Movie);
+ const adapter: MovieServiceAdapter = {
+ ...mockAdapter,
+ getTrending: vi.fn().mockResolvedValue([orphan]),
+ getMovieDetails: detailsSpy,
+ };
+ const c = new MovieConductor(adapter);
+
+ await c.dispatch({ type: 'LOAD_MOVIES' });
+ await c.dispatch({ type: 'SELECT_MOVIE', payload: orphan.id });
+
+ // No TMDB call with the wrong id
+ expect(detailsSpy).not.toHaveBeenCalled();
+ // Selected movie is the local record so the modal does not show stranger trailers
+ expect(c.getState().selectedMovie?.id).toBe(orphan.id);
+ });
+ });
});
diff --git a/src/core/conductor/MovieConductor.ts b/src/core/conductor/MovieConductor.ts
index 7fb3f2b..ba72c69 100644
--- a/src/core/conductor/MovieConductor.ts
+++ b/src/core/conductor/MovieConductor.ts
@@ -26,6 +26,7 @@ export class MovieConductor {
error: null,
filter: 'all',
activeListId: null,
+ tagFilter: null,
};
constructor(adapter: MovieServiceAdapter) {
@@ -62,6 +63,39 @@ export class MovieConductor {
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;
+ }
+ }
+
+ 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) {
+ // Revert on failure
+ 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',
+ });
+ }
}
}
@@ -165,7 +199,8 @@ export class MovieConductor {
private async handleRemoveMovie(id: string): Promise {
try {
await this.adapter.delete(id);
- this.updateState({ items: this.state.items.filter(m => m.id !== 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' });
}
@@ -177,7 +212,7 @@ export class MovieConductor {
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 });
+ this.updateState({ items: updated, statistics: this.calculateStatistics(updated) });
} catch (error) {
this.updateState({ error: error instanceof Error ? error.message : 'Toggle watched failed' });
}
@@ -189,7 +224,7 @@ export class MovieConductor {
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 });
+ this.updateState({ items: updated, statistics: this.calculateStatistics(updated) });
} catch (error) {
this.updateState({ error: error instanceof Error ? error.message : 'Toggle favorite failed' });
}
@@ -197,29 +232,112 @@ export class MovieConductor {
private async handleSelectMovie(id: string): Promise {
try {
+ const localMovie =
+ this.state.items.find(m => m.id === id) ||
+ (await this.adapter.getById(id)) ||
+ null;
+
+ // Resolve which TMDB id to query: prefer the saved tmdbId for DB-backed movies,
+ // fall back to the raw payload id (which is the TMDB id for tmdb-source movies).
+ const tmdbId = localMovie?.tmdbId != null ? String(localMovie.tmdbId) : (localMovie?.source === 'database' ? null : id);
+ const mediaType = localMovie?.mediaType;
+
let details: Movie | null = null;
- try {
- details = await this.adapter.getMovieDetails(id);
- } catch {
- details = null;
+ if (tmdbId) {
+ try {
+ details = await this.adapter.getMovieDetails(tmdbId, mediaType);
+ } catch {
+ details = null;
+ }
}
- if (!details) {
- details = (await this.adapter.getById(id)) || this.state.items.find(m => m.id === id) || 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 });
+
+ 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;
+
+ 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++;
+ }
+
+ (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 })));
+
return {
totalMovies: items.length,
watchedCount: items.filter(m => m.watched).length,
totalRuntimeMinutes: items.reduce((sum, m) => sum + (m.runtime || 0), 0),
favoriteCount: items.filter(m => m.favorite).length,
- byGenre: [],
- byDecade: []
+ byGenre: byGenre.slice(0, 8),
+ byDecade,
+ averageUserRating: ratedCount > 0 ? Number((ratingSum / ratedCount).toFixed(1)) : 0,
+ ratedCount,
+ byYear,
+ thisYearCount,
+ allTimeCount: items.length,
+ topTags: topTags.slice(0, 8),
};
}
diff --git a/src/index.css b/src/index.css
index 52c23da..15dc12f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -29,3 +29,26 @@ body {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
+
+/* Auth inputs: ensure readable text/placeholder/autofill on light backgrounds */
+.auth-input {
+ color: #111827;
+ caret-color: #111827;
+ -webkit-text-fill-color: #111827;
+}
+
+.auth-input::placeholder {
+ color: #6b7280;
+ opacity: 1;
+}
+
+.auth-input:-webkit-autofill,
+.auth-input:-webkit-autofill:hover,
+.auth-input:-webkit-autofill:focus,
+.auth-input:-webkit-autofill:active {
+ -webkit-text-fill-color: #111827;
+ caret-color: #111827;
+ -webkit-box-shadow: 0 0 0 1000px #ffffff inset;
+ box-shadow: 0 0 0 1000px #ffffff inset;
+ transition: background-color 9999s ease-in-out 0s;
+}
diff --git a/src/services/Recommendations.ts b/src/services/Recommendations.ts
new file mode 100644
index 0000000..8055f19
--- /dev/null
+++ b/src/services/Recommendations.ts
@@ -0,0 +1,186 @@
+import { Movie } from '../types/domain';
+
+const TMDB_GENRE_NAME_TO_ID: Record = {
+ 'Action': 28,
+ 'Adventure': 12,
+ 'Animation': 16,
+ 'Comedy': 35,
+ 'Crime': 80,
+ 'Documentary': 99,
+ 'Drama': 18,
+ 'Family': 10751,
+ 'Fantasy': 14,
+ 'History': 36,
+ 'Horror': 27,
+ 'Music': 10402,
+ 'Mystery': 9648,
+ 'Romance': 10749,
+ 'Science Fiction': 878,
+ 'Sci-Fi': 878,
+ 'TV Movie': 10770,
+ 'Thriller': 53,
+ 'War': 10752,
+ 'Western': 37,
+ // German labels (TMDB returns German genre names when language=de-DE)
+ 'Abenteuer': 12,
+ 'Animation ': 16,
+ 'Komödie': 35,
+ 'Dokumentarfilm': 99,
+ 'Familie': 10751,
+ 'Fantasy ': 14,
+ 'Historie': 36,
+ 'Horror ': 27,
+ 'Musik': 10402,
+ 'Krimi': 80,
+ 'Mystery ': 9648,
+ 'Liebesfilm': 10749,
+ 'Science Fiction ': 878,
+ 'Thriller ': 53,
+ 'Krieg': 10752,
+ 'Drama ': 18,
+};
+
+export interface RecommendationItem {
+ movie: Movie;
+ score: number;
+ reasons: string[];
+}
+
+export interface UserPreferences {
+ topGenres: { name: string; weight: number }[];
+ averageUserRating: number | null;
+ prefersHighlyRated: boolean;
+ favoriteCount: number;
+ watchedCount: number;
+ totalCount: number;
+}
+
+export function deriveUserPreferences(library: Movie[]): UserPreferences {
+ const genreScores = new Map();
+ let ratingSum = 0;
+ let ratedCount = 0;
+ const total = library.length;
+
+ for (const m of library) {
+ let weight = 1;
+ if (m.favorite) weight += 2;
+ if (m.watched) weight += 0.5;
+ if (typeof m.userRating === 'number') {
+ weight += Math.max(0, (m.userRating - 5) / 2); // ratings >5 add weight
+ ratingSum += m.userRating;
+ ratedCount++;
+ }
+ (m.genres || []).forEach(g => {
+ if (!g) return;
+ genreScores.set(g, (genreScores.get(g) || 0) + weight);
+ });
+ }
+
+ const sortedGenres = Array.from(genreScores.entries())
+ .map(([name, weight]) => ({ name, weight }))
+ .sort((a, b) => b.weight - a.weight);
+
+ const avg = ratedCount > 0 ? ratingSum / ratedCount : null;
+
+ return {
+ topGenres: sortedGenres.slice(0, 3),
+ averageUserRating: avg,
+ prefersHighlyRated: avg !== null && avg >= 7,
+ favoriteCount: library.filter(m => m.favorite).length,
+ watchedCount: library.filter(m => m.watched).length,
+ totalCount: total,
+ };
+}
+
+interface FetchOpts {
+ apiKey: string;
+ language?: string;
+}
+
+async function fetchDiscoverByGenre(genreId: number, opts: FetchOpts): Promise {
+ const lang = opts.language || 'de-DE';
+ const url = `https://api.themoviedb.org/3/discover/movie?api_key=${opts.apiKey}&language=${lang}&sort_by=vote_average.desc&vote_count.gte=300&with_genres=${genreId}&page=1`;
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return [];
+ const data = await res.json();
+ return (data.results || []).slice(0, 12).map((r: any) => ({
+ id: String(r.id),
+ tmdbId: r.id,
+ title: r.title || r.name,
+ posterPath: r.poster_path ? `https://image.tmdb.org/t/p/w500${r.poster_path}` : null,
+ releaseDate: r.release_date || null,
+ overview: r.overview || null,
+ voteAverage: r.vote_average || null,
+ runtime: null,
+ mediaType: 'movie' as const,
+ source: 'tmdb' as const,
+ }));
+ } catch (err) {
+ console.warn('Recommendations discover failed:', err);
+ return [];
+ }
+}
+
+export async function getSmartRecommendations(
+ library: Movie[],
+ apiKey: string | undefined,
+ limit = 12
+): Promise<{ items: RecommendationItem[]; prefs: UserPreferences }> {
+ const prefs = deriveUserPreferences(library);
+
+ if (!apiKey || prefs.totalCount === 0 || prefs.topGenres.length === 0) {
+ return { items: [], prefs };
+ }
+
+ const ownedTmdbIds = new Set(
+ library
+ .map(m => (typeof m.tmdbId === 'number' ? m.tmdbId : null))
+ .filter((id): id is number => id !== null)
+ );
+
+ const genreIdsSeen = new Set();
+ const requests: Promise<{ genreName: string; movies: Movie[] }>[] = [];
+ for (const g of prefs.topGenres) {
+ const id = TMDB_GENRE_NAME_TO_ID[g.name] ?? TMDB_GENRE_NAME_TO_ID[g.name.trim()];
+ if (id && !genreIdsSeen.has(id)) {
+ genreIdsSeen.add(id);
+ requests.push(
+ fetchDiscoverByGenre(id, { apiKey }).then(movies => ({ genreName: g.name, movies }))
+ );
+ }
+ }
+
+ const results = await Promise.all(requests);
+ const candidatesById = new Map();
+
+ for (const { genreName, movies } of results) {
+ for (const movie of movies) {
+ if (movie.tmdbId && ownedTmdbIds.has(movie.tmdbId)) continue;
+ const reason = `weil du ${genreName} magst`;
+ const existing = candidatesById.get(movie.id);
+ const baseScore = (movie.voteAverage || 0) + (prefs.prefersHighlyRated ? 1 : 0);
+ if (existing) {
+ existing.score += baseScore;
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
+ } else {
+ candidatesById.set(movie.id, {
+ movie,
+ score: baseScore,
+ reasons: [reason],
+ });
+ }
+ }
+ }
+
+ const ranked = Array.from(candidatesById.values())
+ .sort((a, b) => b.score - a.score)
+ .slice(0, limit);
+
+ // Add a fluent reason summarizing top genres for the first few.
+ if (ranked.length > 0 && prefs.prefersHighlyRated) {
+ ranked.slice(0, 3).forEach(r => r.reasons.push('hoch bewertet, passt zu deinem Geschmack'));
+ }
+
+ return { items: ranked, prefs };
+}
diff --git a/src/services/SupabaseMovieService.ts b/src/services/SupabaseMovieService.ts
index 8ddd10f..ef4abf6 100644
--- a/src/services/SupabaseMovieService.ts
+++ b/src/services/SupabaseMovieService.ts
@@ -66,6 +66,7 @@ export class SupabaseMovieService implements MovieServiceAdapter {
*/
private mapRowToMovie(row: MovieRow): Movie {
const tmdbId = row.tmdb_id ? Number(row.tmdb_id) : undefined;
+ const anyRow = row as any;
return {
id: row.id.toString(),
@@ -81,6 +82,10 @@ export class SupabaseMovieService implements MovieServiceAdapter {
mediaType: (row.media_type as 'movie' | 'tv') || 'movie',
watched: row.watched ?? false,
favorite: row.favorite ?? false,
+ userRating: anyRow.user_rating ?? null,
+ notes: anyRow.notes ?? null,
+ tags: Array.isArray(anyRow.tags) ? anyRow.tags : [],
+ genres: Array.isArray(anyRow.genres) ? anyRow.genres : undefined,
};
}
@@ -89,7 +94,7 @@ export class SupabaseMovieService implements MovieServiceAdapter {
const { data, error } = await this.client
.from('movies')
.select('*')
- .limit(20)
+ .limit(500)
.order('created_at', { ascending: false });
if (error) {
@@ -336,20 +341,31 @@ export class SupabaseMovieService implements MovieServiceAdapter {
release_date: cleanMovie.releaseDate,
overview: cleanMovie.overview,
vote_average: cleanMovie.voteAverage,
- media_type: cleanMovie.mediaType || 'movie' // Persist media type
+ media_type: cleanMovie.mediaType || 'movie'
};
+ if (Array.isArray(cleanMovie.genres) && cleanMovie.genres.length > 0) {
+ (mappedData as any).genres = cleanMovie.genres;
+ }
- const { data, error } = await this.client
- .from('movies')
- .insert(mappedData)
- .select()
- .single();
+ const tryInsert = async (payload: MovieInsert) => {
+ return await this.client.from('movies').insert(payload).select().single();
+ };
+
+ let { data, error } = await tryInsert(mappedData);
+
+ // Tolerate missing optional column "genres" if migration is pending.
+ if (error && /genres|column .* does not exist/i.test(error.message || '')) {
+ const fallback = { ...mappedData } as any;
+ delete fallback.genres;
+ ({ data, error } = await tryInsert(fallback));
+ if (!error) console.warn('genres column missing, saved without it:', error);
+ }
if (error) {
throw new Error(`Supabase add error: ${error.message}`);
}
- return this.mapRowToMovie(data);
+ return this.mapRowToMovie(data!);
}
async delete(id: string): Promise {
@@ -368,24 +384,45 @@ export class SupabaseMovieService implements MovieServiceAdapter {
async update(id: string, updates: Partial): Promise {
if (!this.isUUID(id)) return;
- const dbUpdate: MovieUpdate = {};
- // Map CamelCase to SnakeCase
+ const dbUpdate: Record = {};
if (updates.watched !== undefined) dbUpdate.watched = updates.watched;
if (updates.favorite !== undefined) dbUpdate.favorite = updates.favorite;
if (updates.title !== undefined) dbUpdate.title = updates.title;
if (updates.overview !== undefined) dbUpdate.overview = updates.overview;
if (updates.voteAverage !== undefined) dbUpdate.vote_average = updates.voteAverage;
if (updates.posterPath !== undefined) dbUpdate.poster_path = updates.posterPath;
-
+ if (updates.userRating !== undefined) dbUpdate.user_rating = updates.userRating;
+ if (updates.notes !== undefined) dbUpdate.notes = updates.notes;
+ if (updates.tags !== undefined) dbUpdate.tags = updates.tags;
+
if (Object.keys(dbUpdate).length === 0) return;
const { error } = await this.client
.from('movies')
- .update(dbUpdate)
+ .update(dbUpdate as MovieUpdate)
.eq('id', id);
if (error) {
- throw new Error(`Supabase update error: ${error.message}`);
+ const msg = error.message || '';
+ // Tolerate missing optional columns (no migration applied yet).
+ if (/column .* does not exist|user_rating|notes|tags/i.test(msg)) {
+ const safeUpdate: Record = { ...dbUpdate };
+ delete safeUpdate.user_rating;
+ delete safeUpdate.notes;
+ delete safeUpdate.tags;
+ if (Object.keys(safeUpdate).length === 0) {
+ console.warn('Skipping update — only optional columns were requested but column is missing:', msg);
+ return;
+ }
+ const retry = await this.client
+ .from('movies')
+ .update(safeUpdate as MovieUpdate)
+ .eq('id', id);
+ if (retry.error) throw new Error(`Supabase update error: ${retry.error.message}`);
+ console.warn('Optional columns missing, persisted core fields only:', msg);
+ return;
+ }
+ throw new Error(`Supabase update error: ${msg}`);
}
}
@@ -489,7 +526,11 @@ export class SupabaseMovieService implements MovieServiceAdapter {
watched: m.watched,
favorite: m.favorite,
source: 'database',
- addedAt: m.created_at
+ addedAt: m.created_at,
+ userRating: m.user_rating ?? null,
+ notes: m.notes ?? null,
+ tags: Array.isArray(m.tags) ? m.tags : [],
+ genres: Array.isArray(m.genres) ? m.genres : undefined,
} as Movie;
}).filter((m: any) => m !== null) as Movie[];
}
diff --git a/src/types/domain.ts b/src/types/domain.ts
index ce7a769..8bff196 100644
--- a/src/types/domain.ts
+++ b/src/types/domain.ts
@@ -30,6 +30,9 @@ export interface Movie {
mediaType?: 'movie' | 'tv';
watched?: boolean;
favorite?: boolean;
+ userRating?: number | null; // 0..10, persisted
+ notes?: string | null; // private notes
+ tags?: string[]; // free-form tags
genres?: string[];
cast?: CastMember[];
director?: string;
@@ -57,6 +60,12 @@ export interface MovieStatistics {
favoriteCount: number;
byGenre: { name: string; value: number }[];
byDecade: { decade: string; count: number }[];
+ averageUserRating?: number; // 0..10
+ ratedCount?: number;
+ byYear?: { year: string; count: number }[]; // by addedAt year
+ thisYearCount?: number;
+ allTimeCount?: number;
+ topTags?: { name: string; value: number }[];
}
export interface CustomList {
@@ -74,13 +83,17 @@ export type UserIntent =
| { type: 'REMOVE_MOVIE'; payload: string }
| { type: 'TOGGLE_WATCHED'; payload: string }
| { type: 'TOGGLE_FAVORITE'; payload: string }
- | { type: 'SET_FILTER'; payload: 'all' | 'favorites' | 'watched' | 'achievements' | 'statistics' | 'lists' }
+ | { type: 'SET_FILTER'; payload: 'all' | 'favorites' | 'watched' | 'achievements' | 'statistics' | 'lists' | 'recommendations' }
| { type: 'SELECT_MOVIE'; payload: string }
| { type: 'CLOSE_DETAILS' }
| { type: 'CREATE_LIST'; payload: { name: string, description?: string } }
| { type: 'DELETE_LIST'; payload: string }
| { type: 'ADD_TO_LIST'; payload: { listId: string, movie: Movie } }
- | { type: 'SELECT_LIST'; payload: string }; // listId
+ | { type: 'SELECT_LIST'; payload: string } // listId
+ | { type: 'UPDATE_USER_RATING'; payload: { id: string; userRating: number | null } }
+ | { type: 'UPDATE_NOTES'; payload: { id: string; notes: string } }
+ | { type: 'UPDATE_TAGS'; payload: { id: string; tags: string[] } }
+ | { type: 'SET_TAG_FILTER'; payload: string | null };
export interface WatchlistState {
items: Movie[];
@@ -90,8 +103,9 @@ export interface WatchlistState {
selectedMovie: Movie | null;
status: 'idle' | 'loading' | 'error';
error: string | null;
- filter: 'all' | 'favorites' | 'watched' | 'achievements' | 'statistics' | 'lists' | 'list'; // Added 'list'
+ filter: 'all' | 'favorites' | 'watched' | 'achievements' | 'statistics' | 'lists' | 'list' | 'recommendations';
activeListId: string | null; // Added activeListId
+ tagFilter?: string | null;
}
export interface MovieServiceAdapter {
diff --git a/src/types/supabase.ts b/src/types/supabase.ts
index 8368800..83b68bd 100644
--- a/src/types/supabase.ts
+++ b/src/types/supabase.ts
@@ -89,6 +89,10 @@ export type Database = {
user_id: string | null
vote_average: number | null
watched: boolean | null
+ user_rating: number | null
+ notes: string | null
+ tags: string[] | null
+ genres: string[] | null
}
Insert: {
created_at?: string
@@ -104,6 +108,10 @@ export type Database = {
user_id?: string | null
vote_average?: number | null
watched?: boolean | null
+ user_rating?: number | null
+ notes?: string | null
+ tags?: string[] | null
+ genres?: string[] | null
}
Update: {
created_at?: string
@@ -119,6 +127,10 @@ export type Database = {
user_id?: string | null
vote_average?: number | null
watched?: boolean | null
+ user_rating?: number | null
+ notes?: string | null
+ tags?: string[] | null
+ genres?: string[] | null
}
Relationships: []
}
diff --git a/supabase/migrations/20260509_add_user_rating_notes_tags.sql b/supabase/migrations/20260509_add_user_rating_notes_tags.sql
new file mode 100644
index 0000000..6a89e80
--- /dev/null
+++ b/supabase/migrations/20260509_add_user_rating_notes_tags.sql
@@ -0,0 +1,24 @@
+-- Adds user-personal fields (rating, notes, tags) and persisted genres to movies.
+-- Safe to run multiple times. Apply via Supabase SQL editor or `supabase db push`.
+
+ALTER TABLE public.movies
+ ADD COLUMN IF NOT EXISTS user_rating NUMERIC(3,1),
+ ADD COLUMN IF NOT EXISTS notes TEXT,
+ ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT ARRAY[]::TEXT[],
+ ADD COLUMN IF NOT EXISTS genres TEXT[] DEFAULT ARRAY[]::TEXT[];
+
+-- Optional sanity constraint for rating range (0..10)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.constraint_column_usage
+ WHERE table_name = 'movies' AND constraint_name = 'movies_user_rating_range'
+ ) THEN
+ ALTER TABLE public.movies
+ ADD CONSTRAINT movies_user_rating_range
+ CHECK (user_rating IS NULL OR (user_rating >= 0 AND user_rating <= 10));
+ END IF;
+END $$;
+
+-- GIN index for tag filtering
+CREATE INDEX IF NOT EXISTS idx_movies_tags ON public.movies USING GIN (tags);