mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-05 04:53:01 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user