From f51771bc342435b854f891af26a8cf1f3403b573 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:15:46 -0500 Subject: [PATCH] feat: add ranks to top items charts (#122) --- client/app/components/TopItemList.tsx | 264 +++++++----- client/app/routes/Charts/AlbumChart.tsx | 33 +- client/app/routes/Charts/ArtistChart.tsx | 33 +- client/app/routes/Charts/ChartLayout.tsx | 494 ++++++++++++----------- client/app/routes/Charts/TrackChart.tsx | 33 +- 5 files changed, 491 insertions(+), 366 deletions(-) diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 5b20d39..adb60ce 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -1,102 +1,186 @@ import { Link, useNavigate } from "react-router"; import ArtistLinks from "./ArtistLinks"; -import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api"; +import { + imageUrl, + type Album, + type Artist, + type Track, + type PaginatedResponse, +} from "api/api"; type Item = Album | Track | Artist; interface Props { - data: PaginatedResponse - separators?: ConstrainBoolean - type: "album" | "track" | "artist"; - className?: string, + data: PaginatedResponse; + separators?: ConstrainBoolean; + ranked?: boolean; + type: "album" | "track" | "artist"; + className?: string; } -export default function TopItemList({ data, separators, type, className }: Props) { +export default function TopItemList({ + data, + separators, + type, + className, + ranked, +}: Props) { + const currentParams = new URLSearchParams(location.search); + const page = Math.max(parseInt(currentParams.get("page") || "1"), 1); - return ( -
- {data.items.map((item, index) => { - const key = `${type}-${item.id}`; - return ( -
- -
- ); - })} -
- ); -} + let lastRank = 0; -function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) { - - const itemClasses = `flex items-center gap-2` - - switch (type) { - case "album": { - const album = item as Album; - - return ( -
- - {album.title} - -
- - {album.title} - -
- {album.is_various_artists ? - Various Artists - : -
- -
- } -
{album.listen_count} plays
-
-
- ); - } - case "track": { - const track = item as Track; - - return ( -
- - {track.title} - -
- - {track.title} - -
-
- -
-
{track.listen_count} plays
-
-
- ); - } - case "artist": { - const artist = item as Artist; - return ( -
- - {artist.name} -
- {artist.name} -
{artist.listen_count} plays
-
- -
- ); - } + const calculateRank = (data: Item[], page: number, index: number): number => { + if ( + index === 0 || + data[index] == undefined || + !(data[index].listen_count === data[index - 1].listen_count) + ) { + lastRank = index + 1 + (page - 1) * 100; } + return lastRank; + }; + + return ( +
+ {data.items.map((item, index) => { + const key = `${type}-${item.id}`; + return ( +
+ +
+ ); + })} +
+ ); +} + +function ItemCard({ + item, + type, + rank, + ranked, +}: { + item: Item; + type: "album" | "track" | "artist"; + rank: number; + ranked?: boolean; +}) { + const itemClasses = `flex items-center gap-2`; + + switch (type) { + case "album": { + const album = item as Album; + + return ( +
+ {ranked &&
{rank}
} + + {album.title} + +
+ + {album.title} + +
+ {album.is_various_artists ? ( + Various Artists + ) : ( +
+ +
+ )} +
{album.listen_count} plays
+
+
+ ); + } + case "track": { + const track = item as Track; + + return ( +
+ {ranked &&
{rank}
} + + {track.title} + +
+ + {track.title} + +
+
+ +
+
{track.listen_count} plays
+
+
+ ); + } + case "artist": { + const artist = item as Artist; + return ( +
+ {ranked &&
{rank}
} + + {artist.name} +
+ {artist.name} +
+ {artist.listen_count} plays +
+
+ +
+ ); + } + } } diff --git a/client/app/routes/Charts/AlbumChart.tsx b/client/app/routes/Charts/AlbumChart.tsx index ba323bf..96370a9 100644 --- a/client/app/routes/Charts/AlbumChart.tsx +++ b/client/app/routes/Charts/AlbumChart.tsx @@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api"; export async function clientLoader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const page = url.searchParams.get("page") || "0"; - url.searchParams.set('page', page) + url.searchParams.set("page", page); const res = await fetch( `/apis/web/v1/top-albums?${url.searchParams.toString()}` @@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) { } export default function AlbumChart() { - const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse }>(); + const { top_albums: initialData } = useLoaderData<{ + top_albums: PaginatedResponse; + }>(); return ( (
-
- - -
+
+ + +
Prev -
diff --git a/client/app/routes/Charts/ArtistChart.tsx b/client/app/routes/Charts/ArtistChart.tsx index ec3dfd8..676700d 100644 --- a/client/app/routes/Charts/ArtistChart.tsx +++ b/client/app/routes/Charts/ArtistChart.tsx @@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api"; export async function clientLoader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const page = url.searchParams.get("page") || "0"; - url.searchParams.set('page', page) + url.searchParams.set("page", page); const res = await fetch( `/apis/web/v1/top-artists?${url.searchParams.toString()}` @@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) { } export default function Artist() { - const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse }>(); + const { top_artists: initialData } = useLoaderData<{ + top_artists: PaginatedResponse; + }>(); return ( (
-
- - -
+
+ + +
Prev -
diff --git a/client/app/routes/Charts/ChartLayout.tsx b/client/app/routes/Charts/ChartLayout.tsx index ee5ef59..02ee9bd 100644 --- a/client/app/routes/Charts/ChartLayout.tsx +++ b/client/app/routes/Charts/ChartLayout.tsx @@ -1,264 +1,272 @@ -import { - useFetcher, - useLocation, - useNavigate, -} from "react-router" -import { useEffect, useState } from "react" -import { average } from "color.js" -import { imageUrl, type PaginatedResponse } from "api/api" -import PeriodSelector from "~/components/PeriodSelector" +import { useFetcher, useLocation, useNavigate } from "react-router"; +import { useEffect, useState } from "react"; +import { average } from "color.js"; +import { imageUrl, type PaginatedResponse } from "api/api"; +import PeriodSelector from "~/components/PeriodSelector"; interface ChartLayoutProps { - title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played" - initialData: PaginatedResponse - endpoint: string - render: (opts: { - data: PaginatedResponse - page: number - onNext: () => void - onPrev: () => void - }) => React.ReactNode + title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"; + initialData: PaginatedResponse; + endpoint: string; + render: (opts: { + data: PaginatedResponse; + page: number; + onNext: () => void; + onPrev: () => void; + }) => React.ReactNode; } export default function ChartLayout({ - title, - initialData, - endpoint, - render, + title, + initialData, + endpoint, + render, }: ChartLayoutProps) { - const pgTitle = `${title} - Koito` + const pgTitle = `${title} - Koito`; - const fetcher = useFetcher() - const location = useLocation() - const navigate = useNavigate() + const fetcher = useFetcher(); + const location = useLocation(); + const navigate = useNavigate(); - const currentParams = new URLSearchParams(location.search) - const currentPage = parseInt(currentParams.get("page") || "1", 10) + const currentParams = new URLSearchParams(location.search); + const currentPage = parseInt(currentParams.get("page") || "1", 10); - const data: PaginatedResponse = fetcher.data?.[endpoint] - ? fetcher.data[endpoint] - : initialData + const data: PaginatedResponse = fetcher.data?.[endpoint] + ? fetcher.data[endpoint] + : initialData; - const [bgColor, setBgColor] = useState("(--color-bg)") + const [bgColor, setBgColor] = useState("(--color-bg)"); - useEffect(() => { - if ((data?.items?.length ?? 0) === 0) return + useEffect(() => { + if ((data?.items?.length ?? 0) === 0) return; - const img = (data.items[0] as any)?.image - if (!img) return + const img = (data.items[0] as any)?.image; + if (!img) return; - average(imageUrl(img, "small"), { amount: 1 }).then((color) => { - setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`) - }) - }, [data]) + average(imageUrl(img, "small"), { amount: 1 }).then((color) => { + setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`); + }); + }, [data]); - const period = currentParams.get("period") ?? "day" - const year = currentParams.get("year") - const month = currentParams.get("month") - const week = currentParams.get("week") + const period = currentParams.get("period") ?? "day"; + const year = currentParams.get("year"); + const month = currentParams.get("month"); + const week = currentParams.get("week"); - const updateParams = (params: Record) => { - const nextParams = new URLSearchParams(location.search) - - for (const key in params) { - const val = params[key] - if (val !== null) { - nextParams.set(key, val) - } else { - nextParams.delete(key) - } - } - - const url = `/${endpoint}?${nextParams.toString()}` - navigate(url, { replace: false }) + const updateParams = (params: Record) => { + const nextParams = new URLSearchParams(location.search); + + for (const key in params) { + const val = params[key]; + if (val !== null) { + nextParams.set(key, val); + } else { + nextParams.delete(key); + } } - - const handleSetPeriod = (p: string) => { - updateParams({ - period: p, - page: "1", - year: null, - month: null, - week: null, - }) - } - const handleSetYear = (val: string) => { - if (val == "") { - updateParams({ - period: period, - page: "1", - year: null, - month: null, - week: null - }) - return - } - updateParams({ - period: null, - page: "1", - year: val, - }) - } - const handleSetMonth = (val: string) => { - updateParams({ - period: null, - page: "1", - year: year ?? new Date().getFullYear().toString(), - month: val, - }) - } - const handleSetWeek = (val: string) => { - updateParams({ - period: null, - page: "1", - year: year ?? new Date().getFullYear().toString(), - month: null, - week: val, - }) - } - useEffect(() => { - fetcher.load(`/${endpoint}?${currentParams.toString()}`) - }, [location.search]) + const url = `/${endpoint}?${nextParams.toString()}`; + navigate(url, { replace: false }); + }; - const setPage = (nextPage: number) => { - const nextParams = new URLSearchParams(location.search) - nextParams.set("page", String(nextPage)) - const url = `/${endpoint}?${nextParams.toString()}` - fetcher.load(url) - navigate(url, { replace: false }) - } - - const handleNextPage = () => setPage(currentPage + 1) - const handlePrevPage = () => setPage(currentPage - 1) - - const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`) - const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`) - const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`) - - const getDateRange = (): string => { - let from: Date - let to: Date - - const now = new Date() - const currentYear = now.getFullYear() - const currentMonth = now.getMonth() // 0-indexed - const currentDate = now.getDate() - - if (year && month) { - from = new Date(parseInt(year), parseInt(month) - 1, 1) - to = new Date(from) - to.setMonth(from.getMonth() + 1) - to.setDate(0) - } else if (year && week) { - const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year - const weekNumber = parseInt(week) - from = new Date(base) - from.setDate(base.getDate() + (weekNumber - 1) * 7) - to = new Date(from) - to.setDate(from.getDate() + 6) - } else if (year) { - from = new Date(parseInt(year), 0, 1) - to = new Date(parseInt(year), 11, 31) - } else { - switch (period) { - case "day": - from = new Date(now) - to = new Date(now) - break - case "week": - to = new Date(now) - from = new Date(now) - from.setDate(to.getDate() - 6) - break - case "month": - to = new Date(now) - from = new Date(now) - if (currentMonth === 0) { - from = new Date(currentYear - 1, 11, currentDate) - } else { - from = new Date(currentYear, currentMonth - 1, currentDate) - } - break - case "year": - to = new Date(now) - from = new Date(currentYear - 1, currentMonth, currentDate) - break - case "all_time": - return "All Time" - default: - return "" - } - } - - const formatter = new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }) - - return `${formatter.format(from)} - ${formatter.format(to)}` + const handleSetPeriod = (p: string) => { + updateParams({ + period: p, + page: "1", + year: null, + month: null, + week: null, + }); + }; + const handleSetYear = (val: string) => { + if (val == "") { + updateParams({ + period: period, + page: "1", + year: null, + month: null, + week: null, + }); + return; } - + updateParams({ + period: null, + page: "1", + year: val, + }); + }; + const handleSetMonth = (val: string) => { + updateParams({ + period: null, + page: "1", + year: year ?? new Date().getFullYear().toString(), + month: val, + }); + }; + const handleSetWeek = (val: string) => { + updateParams({ + period: null, + page: "1", + year: year ?? new Date().getFullYear().toString(), + month: null, + week: val, + }); + }; - return ( -
- {pgTitle} - - -
-

{title}

-
- -
- - - -
-
-

{getDateRange()}

-
- {render({ - data, - page: currentPage, - onNext: handleNextPage, - onPrev: handlePrevPage, - })} -
-
-
- ) + useEffect(() => { + fetcher.load(`/${endpoint}?${currentParams.toString()}`); + }, [location.search]); + + const setPage = (nextPage: number) => { + const nextParams = new URLSearchParams(location.search); + nextParams.set("page", String(nextPage)); + const url = `/${endpoint}?${nextParams.toString()}`; + fetcher.load(url); + navigate(url, { replace: false }); + }; + + const handleNextPage = () => setPage(currentPage + 1); + const handlePrevPage = () => setPage(currentPage - 1); + + const yearOptions = Array.from( + { length: 10 }, + (_, i) => `${new Date().getFullYear() - i}` + ); + const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`); + const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`); + + const getDateRange = (): string => { + let from: Date; + let to: Date; + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); // 0-indexed + const currentDate = now.getDate(); + + if (year && month) { + from = new Date(parseInt(year), parseInt(month) - 1, 1); + to = new Date(from); + to.setMonth(from.getMonth() + 1); + to.setDate(0); + } else if (year && week) { + const base = new Date(parseInt(year), 0, 1); // Jan 1 of the year + const weekNumber = parseInt(week); + from = new Date(base); + from.setDate(base.getDate() + (weekNumber - 1) * 7); + to = new Date(from); + to.setDate(from.getDate() + 6); + } else if (year) { + from = new Date(parseInt(year), 0, 1); + to = new Date(parseInt(year), 11, 31); + } else { + switch (period) { + case "day": + from = new Date(now); + to = new Date(now); + break; + case "week": + to = new Date(now); + from = new Date(now); + from.setDate(to.getDate() - 6); + break; + case "month": + to = new Date(now); + from = new Date(now); + if (currentMonth === 0) { + from = new Date(currentYear - 1, 11, currentDate); + } else { + from = new Date(currentYear, currentMonth - 1, currentDate); + } + break; + case "year": + to = new Date(now); + from = new Date(currentYear - 1, currentMonth, currentDate); + break; + case "all_time": + return "All Time"; + default: + return ""; + } + } + + const formatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return `${formatter.format(from)} - ${formatter.format(to)}`; + }; + + return ( +
+ {pgTitle} + + +
+

{title}

+
+ +
+ + + +
+
+

{getDateRange()}

+
+ {render({ + data, + page: currentPage, + onNext: handleNextPage, + onPrev: handlePrevPage, + })} +
+
+
+ ); } diff --git a/client/app/routes/Charts/TrackChart.tsx b/client/app/routes/Charts/TrackChart.tsx index eeeb145..9e8ee08 100644 --- a/client/app/routes/Charts/TrackChart.tsx +++ b/client/app/routes/Charts/TrackChart.tsx @@ -6,7 +6,7 @@ import { type Album, type PaginatedResponse } from "api/api"; export async function clientLoader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const page = url.searchParams.get("page") || "0"; - url.searchParams.set('page', page) + url.searchParams.set("page", page); const res = await fetch( `/apis/web/v1/top-tracks?${url.searchParams.toString()}` @@ -20,7 +20,9 @@ export async function clientLoader({ request }: LoaderFunctionArgs) { } export default function TrackChart() { - const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse }>(); + const { top_tracks: initialData } = useLoaderData<{ + top_tracks: PaginatedResponse; + }>(); return ( (
-
- - -
+
+ + +
Prev -