miru/src/routes/app/schedule/+page.svelte
ThaUnknown de3da8acf8
Some checks failed
Check / check (push) Has been cancelled
feat: hide hentai by default
fix: improve airing schedule results
2025-09-29 17:30:21 +02:00

235 lines
12 KiB
Svelte

<script lang='ts'>
import { Button as ButtonPrimitive } from 'bits-ui'
import { addMonths, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek, subMonths } from 'date-fns'
import { persisted } from 'svelte-persisted-store'
import Cross2 from 'svelte-radix/Cross2.svelte'
import type { Schedule, ScheduleMedia } from '$lib/modules/anilist/queries'
import type { ResultOf } from 'gql.tada'
import StatusDot from '$lib/components/StatusDot.svelte'
import { ChevronLeft, ChevronRight } from '$lib/components/icons/animated'
import { Button } from '$lib/components/ui/button'
import * as Drawer from '$lib/components/ui/drawer'
import { Label } from '$lib/components/ui/label'
import { Switch } from '$lib/components/ui/switch'
import * as Tooltip from '$lib/components/ui/tooltip'
import { dedupeAiring } from '$lib/modules/anilist'
import { authAggregator, list } from '$lib/modules/auth'
import { dragScroll } from '$lib/modules/navigate'
import { cn, breakpoints } from '$lib/utils'
const onList = persisted('schedule-on-list', true)
$: query = authAggregator.schedule($onList || null)
let now = new Date()
$: monthName = now.toLocaleString('en-US', { month: 'long' })
$: firstDay = startOfWeek(startOfMonth(now), { weekStartsOn: 1 })
$: lastDay = endOfWeek(endOfMonth(now), { weekStartsOn: 1 })
function prevMonth () {
now = subMonths(now, 1)
}
function nextMonth () {
now = addMonths(now, 1)
}
function listDays (firstDay: Date, lastDay: Date) {
// create an array of days with start and end time for given day
const days = []
// eslint-disable-next-line no-unmodified-loop-condition
for (let start = new Date(firstDay); start <= lastDay; start.setDate(start.getDate() + 1)) {
days.push({ date: new Date(start), number: start.getDate() })
}
return days
}
$: dayList = listDays(firstDay, lastDay)
interface DayAirTimes { day: { date: Date, number: number }, episodes: Array<ResultOf<typeof ScheduleMedia> & { episode: number, airTime: Date }> }
function aggregate (data: ResultOf<typeof Schedule>, dayList: Array<{ date: Date, number: number }>) {
// join media from all queries into single list, de-duplicate it, and make sure it's not dropped
const mediaList = [...data.curr1?.media ?? [], ...data.curr2?.media ?? [], ...data.curr3?.media ?? [], ...data.residue?.media ?? [], ...data.next1?.media ?? [], ...data.next2?.media ?? []]
.filter((v, i, a) => v != null && a.findIndex(s => s?.id === v.id) === i && list(v) !== 'DROPPED') as Array<ResultOf<typeof ScheduleMedia>>
const dayMap: Record<string, DayAirTimes | undefined> = Object.fromEntries(dayList.map(day => [+day.date, { day, episodes: [] }]))
for (const media of mediaList) {
// dedupe airing lists
const episodes = dedupeAiring(media)
for (const { a: airingAt, e: episode } of episodes) {
const airTime = new Date(airingAt * 1000)
airTime.setHours(0, 0, 0, 0)
const day = dayMap[+airTime]
if (day) day.episodes.push({ ...media, episode, airTime: new Date(airingAt * 1000) })
}
}
for (const { episodes } of Object.values(dayMap)as DayAirTimes[]) {
episodes.sort((a, b) => +a.airTime - +b.airTime)
}
return Object.values(dayMap) as DayAirTimes[]
}
// very stupid fix, for a very stupid bug
const _list = list
</script>
<div class='flex flex-col items-center w-full h-full overflow-y-auto p-3 md:p-10 min-w-0' use:dragScroll>
<div class='space-y-0.5 self-start mb-6'>
<h2 class='text-2xl font-bold'>Airing Calendar</h2>
<p class='text-muted-foreground'>
View upcoming episodes and their air times for the current season.
</p>
</div>
<div class='grid grid-cols-7 border rounded-lg [&>*:not(:nth-child(7n+1)):nth-child(n+8)]:border-r [&>*:nth-last-child(n+8)]:border-b [&>*:nth-child(-n+8)]:border-b w-full max-w-[1800px]'>
<div class='col-span-full flex justify-between items-center p-4'>
<Button size='icon' on:click={prevMonth} variant='outline' class='bg-transparent animated-icon'>
<ChevronLeft class='h-6 w-6' />
</Button>
<div class='text-center font-bold text-xl'>
{monthName}
<div class='self-start flex items-center space-x-2 mt-1 text-muted-foreground'>
<Switch bind:checked={$onList} id='schedule-on-list' hideState={true} />
<Label for='schedule-on-list'>My list</Label>
</div>
</div>
<Button size='icon' on:click={nextMonth} variant='outline' class='bg-transparent animated-icon'>
<ChevronRight class='h-6 w-6' />
</Button>
</div>
<div class='text-center py-2'>Mon</div>
<div class='text-center py-2'>Tue</div>
<div class='text-center py-2'>Wed</div>
<div class='text-center py-2'>Thu</div>
<div class='text-center py-2'>Fri</div>
<div class='text-center py-2'>Sat</div>
<div class='text-center py-2'>Sun</div>
{#if $query.fetching}
{#each dayList as { date, number } (date)}
{@const sameMonth = isSameMonth(now, date)}
<div>
<div class='flex flex-col text-xs py-3 h-24 lg:h-48' class:opacity-30={!sameMonth}>
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(date) && 'bg-[rgb(61,180,242)] rounded-full')}>
{number}
</div>
</div>
</div>
{/each}
{:else if $query.error}
<div class='p-5 flex items-center justify-center h-96 col-span-full'>
<div>
<div class='mb-1 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
Looks like something went wrong!
</div>
<div class='text-lg text-center text-muted-foreground'>
{$query.error.message}
</div>
</div>
</div>
{:else if $query.data?.curr1?.media}
{#each aggregate($query.data, dayList) as { day, episodes } (day.date)}
{@const sameMonth = isSameMonth(now, day.date)}
<div>
<div class='flex flex-col text-xs py-3 h-24 lg:h-48' class:opacity-30={!sameMonth}>
{#if !$breakpoints.lg}
<Drawer.Root shouldScaleBackground portal='html'>
<Drawer.Trigger class='h-full flex flex-col'>
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(day.date) && 'bg-[rgb(61,180,242)] rounded-full')}>
{day.number}
</div>
{#if episodes.length}
<div class='px-3 mt-auto text-ellipsis overflow-hidden text-nowrap w-full'>
{episodes.length} ep{episodes.length > 1 ? 's' : ''}
</div>
{/if}
</Drawer.Trigger>
<Drawer.Content tabindex={null}>
<Drawer.Header>
<Drawer.Close class='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'>
<Cross2 class='h-4 w-4' />
<span class='sr-only'>Close</span>
</Drawer.Close>
</Drawer.Header>
<Drawer.Footer>
{#each episodes as episode, i (i)}
{@const status = _list(episode)}
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group mt-1.5 px-3', +episode.airTime < Date.now() && 'opacity-30')} href='/app/anime/{episode.id}'>
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
{#if status}
<StatusDot variant={status} class='hidden' />
{/if}
{episode.title?.userPreferred}
</div>
<div class='ml-auto mr-1 text-nowrap'>#{episode.episode}</div>
<div class='text-neutral-400 group-select:text-neutral-200'>{format(episode.airTime, 'HH:mm')}</div>
</ButtonPrimitive.Root>
{/each}
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{:else}
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(day.date) && 'bg-[rgb(61,180,242)] rounded-full')}>
{day.number}
</div>
<div class='mt-auto'>
{#each episodes.length > 6 ? episodes.slice(0, 5) : episodes as episode, i (i)}
{@const status = _list(episode)}
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group mt-1.5 px-3', +episode.airTime < Date.now() && 'opacity-30')} href='/app/anime/{episode.id}'>
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
{#if status}
<StatusDot variant={status} class='hidden xl:inline-flex' />
{/if}
{episode.title?.userPreferred}
</div>
<div class='ml-auto mr-1 text-nowrap hidden xl:inline-flex'>#{episode.episode}</div>
<div class='text-neutral-400 group-select:text-neutral-200 ml-auto xl:ml-0'>{format(episode.airTime, 'HH:mm')}</div>
</ButtonPrimitive.Root>
{/each}
{#if episodes.length > 6}
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class='text-neutral-500 w-full text-left px-3 mt-1.5'>
+ {episodes.length - 5} more...
</Tooltip.Trigger>
<Tooltip.Content sameWidth={true} class='text-center gap-1.5'>
{#each episodes.slice(5) as episode, i (i)}
{@const status = _list(episode)}
<ButtonPrimitive.Root class={cn('flex items-center h-4 w-full group', +episode.airTime < Date.now() && 'text-neutral-400')} href='/app/anime/{episode.id}'>
<div class='font-medium text-nowrap text-ellipsis overflow-hidden pr-2' title={episode.title?.userPreferred}>
{#if status}
<StatusDot variant={status} class='hidden xl:inline-flex' />
{/if}
{episode.title?.userPreferred}
</div>
<div class='ml-auto mr-1 text-nowrap hidden xl:inline-flex'>#{episode.episode}</div>
<div class='text-neutral-400 group-select:text-neutral-900 ml-auto xl:ml-0'>{format(episode.airTime, 'HH:mm')}</div>
</ButtonPrimitive.Root>
{/each}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{:else}
{#each dayList as { date, number } (date)}
{@const sameMonth = isSameMonth(now, date)}
<div>
<div class='flex flex-col text-xs py-3 h-48' class:opacity-30={!sameMonth}>
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(date) && 'bg-[rgb(61,180,242)] rounded-full')}>
{number}
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>