feat(calendar): preset selector with responsive pills, persistence, empty-state nudge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-05-29 14:13:51 -04:00
co-authored by Claude Opus 4.8
parent f859cbfd7a
commit 6cebbb3cd9
2 changed files with 100 additions and 22 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ describe("Calendar page", () => {
renderCalendar("/calendar?week=2026-04-06&library=7");
expect(mockUseCalendarWeek).toHaveBeenCalledWith("2026-04-06", {
filter: "all",
filter: "following",
libraryId: 7,
});
});
+99 -21
View File
@@ -17,25 +17,52 @@ import WeekNavigator from "@/components/calendar/WeekNavigator";
import DayGroup from "@/components/calendar/DayGroup";
import { addWeeks, formatDayHeading, getWeekDays, getWeekStart } from "@/lib/calendarWeek";
type CalendarFilter = "all" | "favorites" | "watchlist";
type CalendarFilter = "following" | "popular" | "trending" | "everything";
const FILTER_OPTIONS: { value: CalendarFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "favorites", label: "Favorites" },
{ value: "watchlist", label: "Watchlist" },
const PRESET_OPTIONS: { value: CalendarFilter; label: string }[] = [
{ value: "following", label: "Following" },
{ value: "popular", label: "Popular" },
{ value: "trending", label: "Trending" },
{ value: "everything", label: "All" },
];
const DEFAULT_PRESET: CalendarFilter = "following";
const PRESET_STORAGE_KEY = "calendar:preset";
// Accept the four presets plus legacy server values so old shared links keep working.
const KNOWN_FILTERS = new Set<string>([
"following",
"popular",
"trending",
"everything",
"all",
"favorites",
"watchlist",
]);
/** Skeleton rows mirror a full week; enough slides per row to fill wide viewports. */
const CALENDAR_SKELETON_DAY_ROWS = 7;
const CALENDAR_SKELETON_ITEMS_PER_ROW = 18;
function readStoredPreset(): CalendarFilter {
if (typeof window === "undefined") return DEFAULT_PRESET;
const stored = window.localStorage.getItem(PRESET_STORAGE_KEY);
return stored && PRESET_OPTIONS.some((o) => o.value === stored)
? (stored as CalendarFilter)
: DEFAULT_PRESET;
}
function writeStoredPreset(value: string) {
if (typeof window === "undefined") return;
window.localStorage.setItem(PRESET_STORAGE_KEY, value);
}
function parseCalendarParams(searchParams: URLSearchParams) {
const weekRaw = searchParams.get("week");
const weekStart =
weekRaw && /^\d{4}-\d{2}-\d{2}$/.test(weekRaw) ? weekRaw : getWeekStart(new Date());
const rawFilter = searchParams.get("filter");
const filter: CalendarFilter =
rawFilter === "favorites" || rawFilter === "watchlist" ? rawFilter : "all";
const filter = rawFilter && KNOWN_FILTERS.has(rawFilter) ? rawFilter : readStoredPreset();
const libraryIdRaw = searchParams.get("library");
const libraryId = libraryIdRaw ? Number(libraryIdRaw) : undefined;
return { weekStart, filter, libraryId };
@@ -77,7 +104,10 @@ export default function Calendar() {
}
const goToWeek = (w: string) => setParams({ week: w });
const setFilter = (f: string) => setParams({ filter: f === "all" ? undefined : f });
const setFilter = (f: string) => {
writeStoredPreset(f);
setParams({ filter: f === DEFAULT_PRESET ? undefined : f });
};
const setLibrary = (id: string) => setParams({ library: id === "all" ? undefined : id });
const onSelectDay = (date: string) => {
@@ -101,13 +131,13 @@ export default function Calendar() {
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Calendar</h1>
<div className="flex flex-wrap items-center gap-2">
{/* Filter toggle */}
{/* Preset pills (desktop) */}
<div
role="group"
aria-label="Filter"
className="surface-panel-subtle flex items-center gap-0.5 rounded-full p-1"
aria-label="Calendar preset"
className="surface-panel-subtle hidden items-center gap-0.5 rounded-full p-1 lg:flex"
>
{FILTER_OPTIONS.map((opt) => (
{PRESET_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
@@ -124,6 +154,22 @@ export default function Calendar() {
))}
</div>
{/* Preset dropdown (smaller displays) */}
<div className="lg:hidden">
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="border-border/50 bg-surface/60 h-9 w-auto min-w-[130px] rounded-full text-[12px] font-semibold backdrop-blur-sm sm:text-[13px]">
<SelectValue placeholder="Following" />
</SelectTrigger>
<SelectContent>
{PRESET_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Library filter */}
{libraries.data && libraries.data.length > 1 && (
<Select value={libraryId ? String(libraryId) : "all"} onValueChange={setLibrary}>
@@ -186,7 +232,7 @@ export default function Calendar() {
</div>
) : (
<div className="px-4 sm:px-6 lg:px-10 xl:px-12">
<CalendarEmpty filter={filter} onClearFilter={() => setFilter("all")} />
<CalendarEmpty filter={filter} onSelectPreset={setFilter} />
</div>
)}
</div>
@@ -210,19 +256,51 @@ function CalendarSkeleton() {
);
}
function CalendarEmpty({ filter, onClearFilter }: { filter: string; onClearFilter: () => void }) {
function CalendarEmpty({
filter,
onSelectPreset,
}: {
filter: string;
onSelectPreset: (f: string) => void;
}) {
const isEverything = filter === "everything" || filter === "all";
return (
<div className="surface-panel flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-[1.8rem] border-0 px-6 py-16 text-center">
<CalendarDays className="text-muted-foreground h-10 w-10" strokeWidth={1.5} />
<p className="text-muted-foreground text-sm">
{filter !== "all"
? `No events this week matching your ${filter} filter.`
: "Nothing scheduled this week."}
{filter === "following"
? "Nothing upcoming from shows you follow this week."
: isEverything
? "Nothing scheduled this week."
: "No events this week for this view."}
</p>
{filter !== "all" && (
<Button variant="link" size="sm" className="text-primary text-sm" onClick={onClearFilter}>
Show all
</Button>
{!isEverything && (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
variant="link"
size="sm"
className="text-primary text-sm"
onClick={() => onSelectPreset("popular")}
>
Popular
</Button>
<Button
variant="link"
size="sm"
className="text-primary text-sm"
onClick={() => onSelectPreset("trending")}
>
Trending
</Button>
<Button
variant="link"
size="sm"
className="text-primary text-sm"
onClick={() => onSelectPreset("everything")}
>
Show everything
</Button>
</div>
)}
</div>
);