RegistryUI recipes
Record calendar
Rows on a month grid by a date column, with the month in the URL and a count of rows without a date.
It loads its data on the server through the block's client; the calendar block must be installed (npx better-supabase add calendar). Its page on bettersupabase.com is /recipes/record-calendar.
Web, with shadcn components:
npx shadcn add badge button cardconst SHOWN_PER_DAY = 3;
/**
* Tasks on a month grid by their due date. The month lives in the URL
* (`?month=YYYY-MM`), so the arrows are links and the page stays a server
* component.
*/
export async function TaskCalendar({
rows,
first,
today,
monthHref,
}: {
rows: readonly TaskRow[];
first: Temporal.PlainDate;
today: Temporal.PlainDate;
monthHref: (month: string) => string;
}) {
const [t, format] = await Promise.all([
getExtracted("projects"),
getFormatter(),
]);
const weeks = weeksOf(first);
const byDay = new Map<string, TaskRow[]>();
for (const row of rows) {
if (row.dueOn !== null) {
byDay.set(row.dueOn, [...(byDay.get(row.dueOn) ?? []), row]);
}
}
const label = (date: Temporal.PlainDate, options: DateTimeFormatOptions) =>
format.dateTime(new Date(`${date.toString()}T12:00:00Z`), {
...options,
timeZone: "UTC",
});
const unscheduled = rows.filter((row) => row.dueOn === null).length;
const monthOf = (date: Temporal.PlainDate) =>
date.toPlainYearMonth().toString();
return (
<Card data-recipe="record-calendar">
<CardHeader>
<CardTitle>
{label(first, { month: "long", year: "numeric" })}
</CardTitle>
<CardDescription>
{t(
"{count, plural, =0 {Every task has a due date.} one {# task has no due date.} other {# tasks have no due date.}}",
{ count: unscheduled },
)}
</CardDescription>
<CardAction className="flex items-center gap-2">
<Link
href={monthHref(monthOf(first.subtract({ months: 1 })))}
aria-label={t("Previous month")}
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
>
<ChevronLeftIcon />
</Link>
<Link
href={monthHref(monthOf(today))}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
{t("Today")}
</Link>
<Link
href={monthHref(monthOf(first.add({ months: 1 })))}
aria-label={t("Next month")}
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
>
<ChevronRightIcon />
</Link>
</CardAction>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[42rem] overflow-hidden rounded-lg border">
<div
aria-hidden="true"
className="grid grid-cols-7 border-b bg-muted/50"
>
{weeks[0]?.map((day) => (
<span
key={day.toString()}
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
{label(day, { weekday: "short" })}
</span>
))}
</div>
<ol
aria-label={label(first, { month: "long", year: "numeric" })}
className="grid grid-cols-7"
>
{weeks.flat().map((day) => {
const key = day.toString();
const tasks = byDay.get(key) ?? [];
const outside = day.month !== first.month;
return (
<li
key={key}
className={cn(
"flex min-h-24 flex-col gap-1 border-e border-b p-1.5 [&:nth-child(7n)]:border-e-0",
outside && "bg-muted/30",
)}
>
<span className="sr-only">
{label(day, { dateStyle: "full" })}
</span>
<span
aria-hidden="true"
className={cn(
"text-xs",
outside ? "text-muted-foreground" : "font-medium",
)}
>
{day.equals(today) ? (
<Badge variant="default">{day.day}</Badge>
) : (
day.day
)}
</span>
{tasks.slice(0, SHOWN_PER_DAY).map((task) => (
<Link
key={task.id}
href={`/projects/${task.projectId}`}
title={task.title}
className={cn(
"truncate rounded-sm bg-primary/10 px-1.5 py-0.5 text-xs text-foreground transition-colors hover:bg-primary/20",
task.status === "done" &&
"text-muted-foreground line-through",
)}
>
{task.title}
</Link>
))}
{tasks.length > SHOWN_PER_DAY ? (
<span className="text-xs text-muted-foreground">
{t("{count} more", {
count: String(tasks.length - SHOWN_PER_DAY),
})}
</span>
) : null}
</li>
);
})}
</ol>
</div>
</div>
</CardContent>
</Card>
);
}Last updated on