feat: calendar diary view on profile (#15)

This commit is contained in:
2026-07-10 22:22:13 +02:00
parent a0c0ba1c5d
commit 0a8b52514d
2 changed files with 133 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { useMemo } from "react"
import { Calendar } from "@/components/ui/calendar"
import { posterUrl } from "@/lib/api/client"
import type { DiaryEntryDto } from "@/lib/api/common"
import type { DayButton } from "react-day-picker"
type DiaryCalendarProps = {
entries: DiaryEntryDto[]
onSelectDate?: (entries: DiaryEntryDto[]) => void
}
function parseDate(dateStr: string): string {
return dateStr.slice(0, 10)
}
export function DiaryCalendar({ entries, onSelectDate }: DiaryCalendarProps) {
const byDate = useMemo(() => {
const map = new Map<string, DiaryEntryDto[]>()
for (const e of entries) {
const key = parseDate(e.review.watched_at)
const list = map.get(key)
if (list) list.push(e)
else map.set(key, [e])
}
return map
}, [entries])
const watchedDates = useMemo(
() => [...byDate.keys()].map((d) => new Date(d + "T00:00:00")),
[byDate],
)
return (
<Calendar
mode="single"
classNames={{
month: "flex w-full flex-col gap-2",
day: "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none",
}}
modifiers={{ watched: watchedDates }}
modifiersClassNames={{ watched: "bg-primary/20 rounded-(--cell-radius)" }}
onDayClick={(day) => {
const key = day.toISOString().slice(0, 10)
const dayEntries = byDate.get(key)
if (dayEntries?.length && onSelectDate) onSelectDate(dayEntries)
}}
components={{
DayButton: (props) => (
<CalendarDay {...props} byDate={byDate} />
),
}}
/>
)
}
function CalendarDay({
day,
byDate,
...props
}: React.ComponentProps<typeof DayButton> & {
byDate: Map<string, DiaryEntryDto[]>
}) {
const key = day.date.toISOString().slice(0, 10)
const dayEntries = byDate.get(key)
const poster = dayEntries?.[0]?.movie.poster_path
return (
<button
type="button"
className="relative flex size-full min-h-10 flex-col items-center justify-start rounded-(--cell-radius) p-0.5 text-xs transition-colors hover:bg-muted/50"
{...props}
>
<span className="leading-tight">{day.date.getDate()}</span>
{poster && (
<img
src={posterUrl(poster)}
alt=""
className="mt-0.5 aspect-[2/3] w-full max-w-6 rounded-sm object-cover opacity-80"
/>
)}
{dayEntries && dayEntries.length > 1 && (
<span className="absolute bottom-0.5 right-0.5 flex size-3.5 items-center justify-center rounded-full bg-primary text-[8px] font-bold text-primary-foreground">
{dayEntries.length}
</span>
)}
{dayEntries && dayEntries.length === 1 && !poster && (
<span className="mt-1 size-1.5 rounded-full bg-primary" />
)}
</button>
)
}

View File

@@ -17,6 +17,7 @@ import { useInfiniteDiary } from "@/features/diary"
import { TimeAgo } from "@/components/time-ago" import { TimeAgo } from "@/components/time-ago"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums" import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import { ReviewDetailSheet } from "@/components/review-detail-sheet" import { ReviewDetailSheet } from "@/components/review-detail-sheet"
import { DiaryCalendar } from "@/components/diary-calendar"
import type { DiaryEntryDto } from "@/lib/api/common" import type { DiaryEntryDto } from "@/lib/api/common"
import type { UserProfileResponse } from "@/features/users" import type { UserProfileResponse } from "@/features/users"
@@ -51,6 +52,7 @@ export function ProfileView({
const profileTabs = [ const profileTabs = [
{ value: "recent", label: t("profile.recent") }, { value: "recent", label: t("profile.recent") },
{ value: "top_rated", label: t("profile.topRated") }, { value: "top_rated", label: t("profile.topRated") },
{ value: "calendar", label: t("profile.calendar", { defaultValue: "Calendar" }) },
{ value: "trends", label: t("profile.trends") }, { value: "trends", label: t("profile.trends") },
] as const ] as const
@@ -128,6 +130,7 @@ export function ProfileView({
search={search} search={search}
/> />
)} )}
{tab === "calendar" && <CalendarTab userId={userId} />}
{tab === "trends" && <TrendsView data={data} />} {tab === "trends" && <TrendsView data={data} />}
</> </>
)} )}
@@ -192,6 +195,45 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
) )
} }
function CalendarTab({ userId }: { userId?: string }) {
const { data, isPending, hasNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: "date_desc", user_id: userId })
const items = data?.pages.flatMap((p) => p.items) ?? []
const [detailEntries, setDetailEntries] = useState<DiaryEntryDto[] | null>(null)
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
if (isPending) return <Skeleton className="h-80 w-full rounded-xl" />
if (hasNextPage) fetchNextPage()
return (
<>
<DiaryCalendar
entries={items}
onSelectDate={(entries) => {
if (entries.length === 1) setDetailEntry(entries[0]!)
else setDetailEntries(entries)
}}
/>
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
/>
)}
{detailEntries && (
<ReviewDetailSheet
open={!!detailEntries}
onOpenChange={(open) => !open && setDetailEntries(null)}
movie={detailEntries[0]!.movie}
review={detailEntries[0]!.review}
/>
)}
</>
)
}
const trendChartConfig = { const trendChartConfig = {
count: { label: "Movies", color: "var(--primary)" }, count: { label: "Movies", color: "var(--primary)" },
} satisfies ChartConfig } satisfies ChartConfig