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:
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user