mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import { BookmarkPlus, Clock3, History, Star } from 'lucide-solid'
|
|
import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
|
|
import { MediaCard } from '@/components/media/media-card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { DataRow } from '@/components/ui/data-row'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { MetricCard } from '@/components/ui/metric-card'
|
|
import { SectionHeading } from '@/components/ui/section-heading'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Tabs } from '@/components/ui/tabs'
|
|
import { dashboardService } from '@/services/dashboard-service'
|
|
import { watchLaterService } from '@/services/watch-later-service'
|
|
import { useAuth } from '@/stores/auth-store'
|
|
import type { MediaItem, MediaType } from '@/types/domain'
|
|
import { formatDate, formatRating } from '@/utils/format'
|
|
import { mediaBadgeVariant, mediaTypeLabel } from '@/utils/media'
|
|
|
|
type HistoryScope = 'all' | MediaType
|
|
|
|
interface CompletionEntry {
|
|
item: MediaItem
|
|
watchedAt: string
|
|
personalScore: number
|
|
summary: string
|
|
}
|
|
|
|
const scopeOptions = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'movie', label: 'Movies' },
|
|
{ value: 'show', label: 'Shows' },
|
|
{ value: 'game', label: 'Games' },
|
|
] as const
|
|
|
|
const completionOffsets = [1, 3, 5, 8, 12, 16, 21, 27]
|
|
const scoreAdjustments = [0.4, 0.1, 0.3, 0.2, -0.1, 0.2, 0.1, 0.4]
|
|
|
|
const clampScore = (value: number): number => Math.max(6.5, Math.min(9.9, Number(value.toFixed(1))))
|
|
|
|
const completionSummary = (item: MediaItem): string =>
|
|
item.type === 'movie'
|
|
? 'Closed cleanly and worth keeping in your replay lane.'
|
|
: item.type === 'show'
|
|
? 'Finished a strong run of episodes and filed it for an easy revisit.'
|
|
: 'Campaign wrapped with enough momentum to stay in the backlog conversation.'
|
|
|
|
const relativeTimeLabel = (isoDate: string): string => {
|
|
const diffMs = Math.max(0, Date.now() - new Date(isoDate).getTime())
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
|
|
if (diffHours < 24) {
|
|
return diffHours <= 1 ? '1 hour ago' : `${diffHours} hours ago`
|
|
}
|
|
|
|
if (diffDays === 1) {
|
|
return 'Yesterday'
|
|
}
|
|
|
|
if (diffDays < 14) {
|
|
return `${diffDays} days ago`
|
|
}
|
|
|
|
return formatDate(isoDate)
|
|
}
|
|
|
|
const buildCompletionEntries = (items: MediaItem[]): CompletionEntry[] =>
|
|
items
|
|
.map((item, index) => {
|
|
const watchedAt = new Date()
|
|
watchedAt.setDate(watchedAt.getDate() - (completionOffsets[index] ?? (index + 1) * 4))
|
|
watchedAt.setHours(item.type === 'game' ? 22 : 21 - (index % 2), 10 + ((index * 11) % 35), 0, 0)
|
|
|
|
return {
|
|
item,
|
|
watchedAt: watchedAt.toISOString(),
|
|
personalScore: clampScore(item.rating + (scoreAdjustments[index] ?? 0)),
|
|
summary: completionSummary(item),
|
|
}
|
|
})
|
|
.sort((left, right) => new Date(right.watchedAt).getTime() - new Date(left.watchedAt).getTime())
|
|
|
|
export const WatchedPage = () => {
|
|
const auth = useAuth()
|
|
const [scope, setScope] = createSignal<HistoryScope>('all')
|
|
const [busyMediaId, setBusyMediaId] = createSignal<number | null>(null)
|
|
const [actionError, setActionError] = createSignal<string | null>(null)
|
|
const [reloadToken, setReloadToken] = createSignal(0)
|
|
|
|
const [dashboardData, { refetch: refetchDashboard }] = createResource(reloadToken, () =>
|
|
dashboardService.getDashboard(),
|
|
)
|
|
|
|
const [queueData, { refetch: refetchQueue }] = createResource(reloadToken, async () => {
|
|
const accessToken = auth.accessToken()
|
|
|
|
if (!accessToken) {
|
|
return []
|
|
}
|
|
|
|
return watchLaterService.getWatchLater(accessToken)
|
|
})
|
|
|
|
const completionEntries = createMemo(() => buildCompletionEntries(dashboardData()?.recentlyWatched ?? []))
|
|
const queueIds = createMemo(() => new Set((queueData() ?? []).map((item) => item.id)))
|
|
const filteredEntries = createMemo(() =>
|
|
scope() === 'all'
|
|
? completionEntries()
|
|
: completionEntries().filter((entry) => entry.item.type === scope()),
|
|
)
|
|
const averageScore = createMemo(() => {
|
|
const entries = filteredEntries()
|
|
|
|
if (entries.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return formatRating(entries.reduce((total, entry) => total + entry.personalScore, 0) / entries.length)
|
|
})
|
|
|
|
const toggleReplayQueue = async (mediaId: number): Promise<void> => {
|
|
const accessToken = auth.accessToken()
|
|
|
|
if (!accessToken) {
|
|
setActionError('Your session expired. Please sign in again.')
|
|
return
|
|
}
|
|
|
|
setActionError(null)
|
|
setBusyMediaId(mediaId)
|
|
|
|
try {
|
|
if (queueIds().has(mediaId)) {
|
|
await watchLaterService.removeWatchLater(accessToken, mediaId)
|
|
} else {
|
|
await watchLaterService.addWatchLater(accessToken, mediaId)
|
|
}
|
|
|
|
setReloadToken((value) => value + 1)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unable to update replay queue'
|
|
setActionError(message)
|
|
} finally {
|
|
setBusyMediaId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section class="space-y-6" data-testid="watched-page">
|
|
<Card class="animate-stagger">
|
|
<CardContent class="space-y-6 pt-6">
|
|
<SectionHeading
|
|
eyebrow="Watched"
|
|
title="A clean ledger of what you finished and what deserves a replay."
|
|
description="History stays readable, with queue actions available only when you need them."
|
|
badge={<Badge variant="neutral">{filteredEntries().length} entries</Badge>}
|
|
/>
|
|
|
|
<div class="flex flex-wrap gap-3">
|
|
<Tabs
|
|
label="History scope"
|
|
value={scope()}
|
|
onChange={(value) => setScope(value as HistoryScope)}
|
|
options={scopeOptions.map((option) => ({ value: option.value, label: option.label }))}
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
<MetricCard
|
|
icon={History}
|
|
label="Completed"
|
|
value={`${filteredEntries().length}`}
|
|
detail="logged finishes"
|
|
tone="accent"
|
|
/>
|
|
<MetricCard
|
|
icon={Star}
|
|
label="Average score"
|
|
value={averageScore() ? `${averageScore()}` : '0.0'}
|
|
detail="personal rating"
|
|
/>
|
|
<MetricCard
|
|
icon={BookmarkPlus}
|
|
label="Queued again"
|
|
value={`${filteredEntries().filter((entry) => queueIds().has(entry.item.id)).length}`}
|
|
detail="replay candidates saved"
|
|
tone="secondary"
|
|
/>
|
|
<MetricCard
|
|
icon={Clock3}
|
|
label="Latest finish"
|
|
value={filteredEntries()[0] ? relativeTimeLabel(filteredEntries()[0]!.watchedAt) : 'No history'}
|
|
detail="most recent completion"
|
|
/>
|
|
</div>
|
|
|
|
<Show when={actionError()}>
|
|
<div class="rounded-[1.4rem] border border-secondary/24 bg-secondary/10 px-4 py-3 text-sm text-fg">
|
|
{actionError()}
|
|
</div>
|
|
</Show>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_380px]">
|
|
<Card>
|
|
<CardContent class="space-y-5 pt-6">
|
|
<SectionHeading
|
|
title="History"
|
|
description="The most recent finishes across your selected scope."
|
|
badge={<Badge variant="accent">{filteredEntries().length} logged</Badge>}
|
|
/>
|
|
|
|
<Show when={dashboardData.loading && filteredEntries().length === 0}>
|
|
<div class="space-y-3">
|
|
<For each={Array.from({ length: 4 }, (_, index) => index)}>{() => <Skeleton class="h-24" />}</For>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show
|
|
when={filteredEntries().length > 0}
|
|
fallback={
|
|
<EmptyState
|
|
title="No history yet"
|
|
description="Finished titles will appear here once your activity starts flowing."
|
|
/>
|
|
}
|
|
>
|
|
<div class="space-y-3">
|
|
<For each={filteredEntries()}>
|
|
{(entry) => (
|
|
<DataRow
|
|
tone={mediaBadgeVariant(entry.item.type)}
|
|
eyebrow={relativeTimeLabel(entry.watchedAt)}
|
|
title={entry.item.title}
|
|
description={entry.summary}
|
|
meta={`${formatDate(entry.watchedAt)} · Personal score ${formatRating(entry.personalScore)}`}
|
|
badges={
|
|
<Badge variant={mediaBadgeVariant(entry.item.type)}>
|
|
{mediaTypeLabel(entry.item.type)}
|
|
</Badge>
|
|
}
|
|
trailing={
|
|
<Button
|
|
size="sm"
|
|
variant={queueIds().has(entry.item.id) ? 'secondary' : 'primary'}
|
|
disabled={busyMediaId() === entry.item.id}
|
|
onClick={() => void toggleReplayQueue(entry.item.id)}
|
|
>
|
|
{busyMediaId() === entry.item.id
|
|
? 'Saving…'
|
|
: queueIds().has(entry.item.id)
|
|
? 'Queued'
|
|
: 'Replay'}
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent class="space-y-5 pt-6">
|
|
<SectionHeading
|
|
title="Replay Candidates"
|
|
description="Strong recent finishes you might want to pull back into the queue."
|
|
badge={<Badge variant="secondary">{filteredEntries().slice(0, 3).length} surfaced</Badge>}
|
|
/>
|
|
|
|
<Show
|
|
when={filteredEntries().length > 0}
|
|
fallback={
|
|
<EmptyState
|
|
title="No replay candidates"
|
|
description="Replay suggestions will show up after you finish a few more titles."
|
|
/>
|
|
}
|
|
>
|
|
<div class="space-y-4">
|
|
<For each={filteredEntries().slice(0, 3)}>
|
|
{(entry) => (
|
|
<div class="space-y-3">
|
|
<MediaCard item={entry.item} subtitle={entry.summary} />
|
|
<Button
|
|
size="sm"
|
|
variant={queueIds().has(entry.item.id) ? 'secondary' : 'primary'}
|
|
disabled={busyMediaId() === entry.item.id}
|
|
onClick={() => void toggleReplayQueue(entry.item.id)}
|
|
>
|
|
{busyMediaId() === entry.item.id
|
|
? 'Saving…'
|
|
: queueIds().has(entry.item.id)
|
|
? 'Remove replay'
|
|
: 'Queue replay'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Show when={dashboardData.error || queueData.error}>
|
|
<div class="rounded-[1.5rem] border border-secondary/24 bg-secondary/10 px-4 py-4 text-sm text-fg">
|
|
{dashboardData.error?.message ?? queueData.error?.message ?? 'Unable to load watched history.'}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
class="ml-3"
|
|
onClick={() => {
|
|
refetchDashboard()
|
|
refetchQueue()
|
|
}}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</Show>
|
|
</section>
|
|
)
|
|
}
|