feat: improve rewind page (#130)

* add timeframe selectors for rewind

* alter rewind nav to default to monthly rewind

* fix rewind default page

* remove superfluous parameters
This commit is contained in:
Gabe Farrell 2026-01-12 23:22:29 -05:00 committed by GitHub
parent ddb0becc0f
commit 62267652ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 197 additions and 38 deletions

View file

@ -8,9 +8,16 @@ interface Props {
} }
export default function Rewind(props: Props) { export default function Rewind(props: Props) {
const artistimg = props.stats.top_artists[0].image; const artistimg = props.stats.top_artists[0]?.image;
const albumimg = props.stats.top_albums[0].image; const albumimg = props.stats.top_albums[0]?.image;
const trackimg = props.stats.top_tracks[0].image; const trackimg = props.stats.top_tracks[0]?.image;
if (
!props.stats.top_artists[0] ||
!props.stats.top_albums[0] ||
!props.stats.top_tracks[0]
) {
return <p>Not enough data exists to create a Rewind for this period :(</p>;
}
return ( return (
<div className="flex flex-col gap-7"> <div className="flex flex-col gap-7">
<h2>{props.stats.title}</h2> <h2>{props.stats.title}</h2>

View file

@ -2,7 +2,7 @@ import { ExternalLink, History, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch"; import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem"; import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings"; import SidebarSettings from "./SidebarSettings";
import { getRewindYear } from "~/utils/utils"; import { getRewindParams, getRewindYear } from "~/utils/utils";
export default function Sidebar() { export default function Sidebar() {
const iconSize = 20; const iconSize = 20;
@ -45,7 +45,7 @@ export default function Sidebar() {
<SidebarSearch size={iconSize} /> <SidebarSearch size={iconSize} />
<SidebarItem <SidebarItem
space={10} space={10}
to={`/rewind?year=${getRewindYear()}`} to="/rewind"
name="Rewind" name="Rewind"
onClick={() => {}} onClick={() => {}}
modal={<></>} modal={<></>}

View file

@ -1,52 +1,201 @@
import Rewind from "~/components/rewind/Rewind"; import Rewind from "~/components/rewind/Rewind";
import type { Route } from "./+types/Home"; import type { Route } from "./+types/Home";
import { type RewindStats } from "api/api"; import { imageUrl, type RewindStats } from "api/api";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { LoaderFunctionArgs } from "react-router"; import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router"; import { useLoaderData } from "react-router";
import { getRewindYear } from "~/utils/utils"; import { getRewindParams, getRewindYear } from "~/utils/utils";
import { useNavigate } from "react-router";
import { average } from "color.js";
import { ChevronLeft, ChevronRight } from "lucide-react";
// TODO: Bind year and month selectors to what data actually exists
const months = [
"Full Year",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export async function clientLoader({ request }: LoaderFunctionArgs) { export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url); const url = new URL(request.url);
const year = url.searchParams.get("year") || getRewindYear(); const year =
parseInt(url.searchParams.get("year") || "0") || getRewindParams().year;
const month =
parseInt(url.searchParams.get("month") || "0") || getRewindParams().month;
const res = await fetch(`/apis/web/v1/summary?year=${year}`); const res = await fetch(`/apis/web/v1/summary?year=${year}&month=${month}`);
if (!res.ok) { if (!res.ok) {
throw new Response("Failed to load summary", { status: 500 }); throw new Response("Failed to load summary", { status: 500 });
} }
const stats: RewindStats = await res.json(); const stats: RewindStats = await res.json();
stats.title = `Your ${year} Rewind`; stats.title = `Your ${month === 0 ? "" : months[month]} ${year} Rewind`;
return { stats }; return { stats };
} }
export function meta({}: Route.MetaArgs) {
return [
{ title: `Rewind - Koito` },
{ name: "description", content: "Rewind - Koito" },
];
}
export default function RewindPage() { export default function RewindPage() {
const currentParams = new URLSearchParams(location.search);
let year = parseInt(currentParams.get("year") || "0");
let month = parseInt(currentParams.get("month") || "0");
const navigate = useNavigate();
const [showTime, setShowTime] = useState(false); const [showTime, setShowTime] = useState(false);
const { stats: stats } = useLoaderData<{ stats: RewindStats }>(); const { stats: stats } = useLoaderData<{ stats: RewindStats }>();
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
useEffect(() => {
if (!stats.top_artists[0]) return;
const img = (stats.top_artists[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)`);
});
}, [stats]);
const updateParams = (params: Record<string, string | null>) => {
const nextParams = new URLSearchParams(location.search);
for (const key in params) {
const val = params[key];
if (val !== null && val !== "0") {
nextParams.set(key, val);
} else {
nextParams.delete(key);
}
}
const url = `/rewind?${nextParams.toString()}`;
navigate(url, { replace: false });
};
const navigateMonth = (direction: "prev" | "next") => {
if (direction === "next") {
if (month === 12) {
month = 0;
} else {
month += 1;
}
} else {
if (month === 0) {
month = 12;
} else {
month -= 1;
}
}
updateParams({
year: year.toString(),
month: month.toString(),
});
};
const navigateYear = (direction: "prev" | "next") => {
if (direction === "next") {
year += 1;
} else {
year -= 1;
}
updateParams({
year: year.toString(),
month: month.toString(),
});
};
const pgTitle = `${stats.title} - Koito`;
return ( return (
<main className="w-18/20"> <div
<title>{stats.title} - Koito</title> className="w-full min-h-screen"
<meta property="og:title" content={`${stats.title} - Koito`} /> style={{
<meta name="description" content={`${stats.title} - Koito`} /> background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
<div className="flex flex-col items-start mt-20 gap-10"> transition: "1000",
<div className="flex items-center gap-3"> }}
<label htmlFor="show-time-checkbox">Show time listened?</label> >
<input <div className="flex flex-col items-start md:flex-row sm:items-center gap-4">
type="checkbox" <title>{pgTitle}</title>
name="show-time-checkbox" <meta property="og:title" content={pgTitle} />
checked={showTime} <meta name="description" content={pgTitle} />
onChange={(e) => setShowTime(!showTime)} <div className="flex flex-col items-start mt-20 gap-10 w-19/20 px-20">
></input> {stats !== undefined && (
<Rewind stats={stats} includeTime={showTime} />
)}
<div className="flex flex-col items-center gap-4 py-8">
<div className="flex items-center gap-6 justify-around">
<button
onClick={() => navigateMonth("prev")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={
// Previous month is in the future OR
new Date(year, month - 2) > new Date() ||
// We are looking at current year and prev would take us to full year
(new Date().getFullYear() === year && month === 1)
}
>
<ChevronLeft size={20} />
</button>
<p className="font-medium text-xl text-center w-30">
{months[month]}
</p>
<button
onClick={() => navigateMonth("next")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={new Date(year, month) > new Date()}
>
<ChevronRight size={20} />
</button>
</div>
<div className="flex items-center gap-6 justify-around">
<button
onClick={() => navigateYear("prev")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={new Date(year - 1, month) > new Date()}
>
<ChevronLeft size={20} />
</button>
<p className="font-medium text-xl text-center w-30">{year}</p>
<button
onClick={() => navigateYear("next")}
className="p-2 disabled:text-(--color-fg-tertiary)"
disabled={
// Next year date is in the future OR
new Date(year + 1, month - 1) > new Date() ||
// Next year date is current full year OR
(month == 0 && new Date().getFullYear() === year + 1) ||
// Next year date is current month
(new Date().getMonth() === month - 1 &&
new Date().getFullYear() === year + 1)
}
>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<label htmlFor="show-time-checkbox">Show time listened?</label>
<input
type="checkbox"
name="show-time-checkbox"
checked={showTime}
onChange={(e) => setShowTime(!showTime)}
></input>
</div>
</div> </div>
{stats !== undefined && <Rewind stats={stats} includeTime={showTime} />}
</div> </div>
</main> </div>
); );
} }

View file

@ -16,12 +16,15 @@ const timeframeToInterval = (timeframe: Timeframe): string => {
}; };
const getRewindYear = (): number => { const getRewindYear = (): number => {
return new Date().getFullYear() - 1;
};
const getRewindParams = (): { month: number; year: number } => {
const today = new Date(); const today = new Date();
if (today.getMonth() > 10 && today.getDate() >= 30) { if (today.getMonth() == 0) {
// if we are in december 30/31, just serve current year return { month: 0, year: today.getFullYear() - 1 };
return today.getFullYear();
} else { } else {
return today.getFullYear() - 1; return { month: today.getMonth(), year: today.getFullYear() };
} }
}; };
@ -114,5 +117,5 @@ const timeListenedString = (seconds: number) => {
return `${minutes} minutes listened`; return `${minutes} minutes listened`;
}; };
export { hexToHSL, timeListenedString, getRewindYear }; export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
export type { hsl }; export type { hsl };