small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
+327
View File
@@ -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>
)
}