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.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add badge button cardInstall the glue
Install the block with
npx better-supabase add calendar. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the screen
React and shadcn
View in the exampletask-calendar.tsx const 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> ); }