Date pickers
A date, a range or a preset period, picked from a calendar in a popover.
Due date that rules out the past, and says why
date-pickers-01A task's due date in the kit's DatePicker: days before today are unavailable and say so, days already holding the assignee's work carry a dot, and the usual moves sit beside the calendar.
import * as React from "react"
import { CalendarCheckIcon } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { DueDateField } from "./date-pickers-01.client"
import { dueDateContext, setDueDate, type DueTask, type Result } from "./date-pickers-01.data"
export type DatePickers01Props = {
/** Opens the calendar on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A task's due date, moved with the kit's DatePicker: past days are ruled out
* and say why, the days that already hold the assignee's work carry a dot,
* and the usual moves — next Monday, a week, two, the end of the month — sit
* beside the calendar. Beside the field, what else is due for the same person.
*
* A server component. It reads the task through `./date-pickers-01.data` and
* hands the island `moveDueDate`, a server function defined here, which
* refuses a day in the past and answers with a `Result`. With no open task to
* move, the card says so.
*/
export function DatePickers01({ defaultOpen = false }: DatePickers01Props) {
const titleId = React.useId()
const context = dueDateContext()
async function moveDueDate(taskId: string, day: string): Promise<Result<DueTask>> {
"use server"
return setDueDate(taskId, day)
}
return (
<section
data-section="date-pickers-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Due date</h2>
</CardTitle>
<CardDescription>Move a task to a day that can still be met</CardDescription>
</CardHeader>
{context ? (
<DueDateField context={context} defaultOpen={defaultOpen} onSave={moveDueDate} />
) : (
<CardContent>
<EmptyState
icon={<CalendarCheckIcon aria-hidden="true" />}
title="Nothing to reschedule"
description="Your open tasks show here, with the calendar to move a due date, once you have one."
/>
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default DatePickers01"use client"
import * as React from "react"
import { labelDayButton } from "react-day-picker"
import { CircleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Callout } from "@/components/ui/callout"
import { CardContent, CardFooter } from "@/components/ui/card"
import { DatePicker } from "@/components/ui/date-picker"
import { Label } from "@/components/ui/label"
import { PriorityBadge } from "@/components/ui/priority-badge"
import type { DueDateContext, DueTask, Result } from "./date-pickers-01.data"
/** Moves the due date: the section's server action. */
export type SetDueDate = (taskId: string, day: string) => Promise<Result<DueTask>>
const DAY_MS = 86_400_000
/** A day spelled out. Every day here is a calendar day kept as midnight UTC, so the format reads it in UTC. */
const LONG = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** The same with its year: what the field shows, and what its name says. */
const FULL = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
/**
* "2026-09-08" → its midnight in UTC, and back. A due date is a day, kept as
* UTC midnight in the store; the kit's DatePicker counts days in UTC too, so
* a day reads as itself and a click hands back the day clicked, whatever zone
* the reader is in.
*/
const utc = (day: string) => new Date(`${day}T00:00:00.000Z`)
const dayOf = (date: Date) => new Date(date.getTime()).toISOString().slice(0, 10)
const daysFrom = (from: string, to: string) => Math.round((utc(to).getTime() - utc(from).getTime()) / DAY_MS)
const tasks = (count: number) => `${count} other ${count === 1 ? "task" : "tasks"}`
/** How far a day is from today, in words. */
function distance(day: string, today: string): string {
const days = daysFrom(today, day)
if (days === 0) return "today"
if (days === 1) return "tomorrow"
return `in ${days} days`
}
/**
* The due date field. The calendar opens on the reader's pick of the day and
* rules out every day before today, and each of those days says why in its
* name; a day that already has the assignee's work due carries a dot and says
* how much. Picking a day saves it at once through the section's server
* action: the line under the field says what the new date means, a refusal
* says why beside the field, and a save is confirmed in the footer's status
* line.
*/
export function DueDateField({
context,
defaultOpen = false,
onSave,
}: {
context: DueDateContext
defaultOpen?: boolean
onSave: SetDueDate
}) {
const id = React.useId()
const part = (name: string) => `${id}-${name}`
const { task, today, others } = context
const first = task.assignee.name.split(" ")[0]
const [due, setDue] = React.useState(task.due)
const [saving, setSaving] = React.useState(false)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [status, setStatus] = React.useState("")
// The day the server last said it keeps, and the newest pick: an older
// pick's answer that comes back late decides nothing.
const saved = React.useRef(task.due)
const latest = React.useRef(0)
const load = React.useMemo(() => {
const counts = new Map<string, number>()
for (const other of others) counts.set(other.due, (counts.get(other.due) ?? 0) + 1)
return counts
}, [others])
const overdue = daysFrom(due, today)
const sameDay = load.get(due) ?? 0
const line =
overdue > 0
? `Overdue by ${overdue} ${overdue === 1 ? "day" : "days"} · it was due ${LONG.format(utc(due))}`
: `Due ${LONG.format(utc(due))}, ${distance(due, today)}${sameDay > 0 ? ` · ${tasks(sameDay)} due that day` : ""}`
async function pick(date: Date | undefined) {
if (!date) return
const day = dayOf(date)
const ticket = ++latest.current
setDue(day)
setSaving(true)
setRefusal(null)
let result: Result<DueTask>
try {
result = await onSave(task.id, day)
} catch {
result = { ok: false, error: { code: "network", message: "The due date was not saved. Check the connection and try again." } }
}
if (ticket !== latest.current) return
setSaving(false)
if (result.ok) {
saved.current = result.data.due
setDue(result.data.due)
setStatus(`Saved · ${task.title} is due ${result.data.dueLabel}.`)
return
}
setDue(saved.current)
setRefusal(result.error.message)
setStatus("")
}
return (
<>
<CardContent className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-8">
<div className="flex min-w-0 flex-col gap-5">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<PriorityBadge priority={task.priority} />
<span className="text-xs text-muted-foreground">{task.status}</span>
</div>
<p className="text-sm font-semibold text-pretty">{task.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar size="sm">
<AvatarImage src={task.assignee.face} alt="" />
<AvatarFallback className="bg-foreground text-2xs text-background">{first.charAt(0)}</AvatarFallback>
</Avatar>
<span className="truncate">
{task.assignee.name} · {task.project}
</span>
</div>
</div>
<div className="flex max-w-sm flex-col gap-2">
<Label id={part("label")} htmlFor={part("trigger")}>
Due date
</Label>
{/* The date the field shows, for its name: the label and the value, as one. */}
<span id={part("value")} hidden>
{FULL.format(utc(due))}
</span>
<DatePicker
id={part("trigger")}
aria-labelledby={`${part("label")} ${part("value")}`}
aria-describedby={`${part("hint")} ${part("line")}`}
aria-busy={saving || undefined}
value={utc(due)}
onValueChange={(date) => void pick(date)}
min={utc(today)}
max={utc(context.last)}
format="EEEE, MMMM d, yyyy"
defaultOpen={defaultOpen}
presets={context.presets.map((preset) => ({ label: preset.label, date: utc(preset.day) }))}
calendar={{
today: utc(today),
weekStartsOn: 1,
modifiers: { busy: [...load.keys()].map((day) => utc(day)) },
modifiersClassNames: {
busy: "[&>button]:after:absolute [&>button]:after:bottom-1 [&>button]:after:size-1 [&>button]:after:rounded-full [&>button]:after:bg-current [&>button]:after:opacity-70",
},
labels: {
labelDayButton: (date, modifiers, options, dateLib) => {
const name = labelDayButton(date, modifiers, options, dateLib)
if (modifiers.disabled) return `${name}, unavailable: ${dayOf(date) < today ? "in the past" : "too far ahead"}`
const count = load.get(dayOf(date)) ?? 0
return count > 0 ? `${name}, ${first} has ${tasks(count)} due` : name
},
},
footer: `Days before today are in the past. A dot marks a day ${first} already has work due.`,
// Wraps inside the grid's width rather than widening the calendar to fit one line.
classNames: { footer: "w-0 min-w-full px-1 pt-3 text-xs text-pretty text-muted-foreground" },
}}
/>
<p id={part("hint")} className="text-xs text-muted-foreground">
A due date can’t be in the past: the earliest is today, {context.todayLabel}.
</p>
<p
id={part("line")}
className={cn("flex items-start gap-1.5 text-sm text-pretty tabular-nums", overdue > 0 ? "text-danger" : "text-foreground")}
>
{overdue > 0 ? <CircleAlertIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" /> : null}
{line}
</p>
</div>
{refusal ? (
<Callout variant="danger" role="alert" title="The due date was not changed" className="max-w-sm">
{refusal}
</Callout>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-3 md:border-s md:border-border md:ps-8">
<h3 id={part("others")} className="text-xs font-medium text-muted-foreground">
Also due for {first}
</h3>
{others.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing else is due.</p>
) : (
<ul aria-labelledby={part("others")} className="flex flex-col divide-y divide-border">
{others.map((other) => (
<li key={other.id} className={cn("flex min-w-0 items-center gap-3 py-2 first:pt-0", other.due === due && "font-medium")}>
<time dateTime={other.due} className="w-24 shrink-0 text-xs text-muted-foreground tabular-nums">
{other.dueShort}
</time>
<span className="min-w-0 flex-1 truncate text-sm">{other.title}</span>
<PriorityBadge priority={other.priority} size="sm" />
</li>
))}
</ul>
)}
</div>
</CardContent>
<CardFooter className="gap-0 text-sm">
<p role="status" aria-live="polite" className="min-h-5 text-foreground">
{status}
</p>
{status ? null : <p className="text-muted-foreground">A day you pick is saved at once.</p>}
</CardFooter>
</>
)
}/**
* What `date-pickers-01` reads and the one thing it changes: the signed-in
* member's most overdue open task in `db.tasks`, the other work already due
* for them from today on (so the calendar can say which days are full), the
* shortcuts a due date is usually moved to, and `setDueDate`, the section's
* server action.
*
* A task's due date is a calendar day — midnight UTC in the store — so days
* travel as `yyyy-mm-dd` strings and every date is written here, on the
* server, in UTC. "Today" is `REFERENCE_DATE`'s day. The island takes the
* rows as props and never imports this module.
*/
import { avatarFor } from "@/lib/avatars"
import { MEMBERS, REFERENCE_DATE, db, type Result, type Task } from "@/lib/sample-data"
export type { Result }
const DAY_MS = 86_400_000
const LONG = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
const SHORT = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
/** The statuses of work still to do: a board's backlog is not scheduled yet. */
const OPEN: readonly Task["status"][] = ["todo", "in_progress", "review"]
const STATUS_LABEL: Record<Task["status"], string> = {
backlog: "Backlog",
todo: "To do",
in_progress: "In progress",
review: "In review",
done: "Done",
}
const isoDay = (at: Date) => at.toISOString().slice(0, 10)
const midnight = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
const dayFromIso = (day: string) => new Date(`${day}T00:00:00.000Z`)
/** How far ahead a due date can be set, in days: the calendar's last day. */
export const HORIZON_DAYS = 730
/**
* A calendar day written `yyyy-mm-dd`, and a day that exists: it has to come
* back the same from a round trip through UTC, so 2026-02-30 is refused
* rather than read as March 2.
*/
function isCalendarDay(day: unknown): day is string {
if (typeof day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return false
const at = dayFromIso(day)
return !Number.isNaN(at.getTime()) && isoDay(at) === day
}
/** A task that is due, as the section shows it. */
export type DueTask = {
id: string
title: string
project: string
priority: Task["priority"]
status: string
due: string
/** "Tuesday, September 1". */
dueLabel: string
}
export type DueDateContext = {
task: DueTask & { assignee: { name: string; face: string } }
/** `REFERENCE_DATE`'s day. */
today: string
todayLabel: string
/** The last day a due date can be set to: `HORIZON_DAYS` from today. */
last: string
/** The assignee's other open work from today on, soonest first. */
others: (DueTask & { dueShort: string })[]
/** The days a due date is usually moved to, from today. */
presets: { label: string; day: string }[]
}
function dueTask(task: Task): DueTask {
const project = db.projects.all().find((entry) => entry.id === task.projectId)
return {
id: task.id,
title: task.title,
project: project?.name ?? "No project",
priority: task.priority,
status: STATUS_LABEL[task.status],
due: isoDay(task.dueAt),
dueLabel: LONG.format(task.dueAt),
}
}
/**
* The task to move: the signed-in member's open task that is most overdue, or
* — when nothing is overdue — the one due soonest. None when they have no open
* work.
*/
function taskToMove(): Task | undefined {
const me = MEMBERS[0].id
const mine = db.tasks
.all()
.filter((task) => task.assignee === me && OPEN.includes(task.status))
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime())
return mine[0]
}
/**
* The task, the day it is now, and what else is due for the same person. Null
* when the member has no open task: the section then says so rather than
* failing.
*/
export function dueDateContext(): DueDateContext | null {
const task = taskToMove()
if (!task) return null
const today = midnight(REFERENCE_DATE)
const assignee = MEMBERS.find((member) => member.id === task.assignee) ?? MEMBERS[0]
const others = db.tasks
.all()
.filter((entry) => entry.id !== task.id && entry.assignee === task.assignee && OPEN.includes(entry.status) && entry.dueAt.getTime() >= today)
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime())
const inDays = (count: number) => isoDay(new Date(today + count * DAY_MS))
const toMonday = (8 - new Date(today).getUTCDay()) % 7 || 7
const monthEnd = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0)
return {
task: { ...dueTask(task), assignee: { name: assignee.name, face: avatarFor(assignee.name) } },
today: isoDay(REFERENCE_DATE),
todayLabel: LONG.format(REFERENCE_DATE),
last: inDays(HORIZON_DAYS),
others: others.map((entry) => ({ ...dueTask(entry), dueShort: SHORT.format(entry.dueAt) })),
presets: [
{ label: "Today", day: inDays(0) },
{ label: "Next Monday", day: inDays(toMonday) },
{ label: "In a week", day: inDays(7) },
{ label: "In two weeks", day: inDays(14) },
{ label: "End of the month", day: isoDay(new Date(monthEnd)) },
],
}
}
/**
* Moves a task's due date. The day must be a real day written `yyyy-mm-dd`,
* not in the past and not past the calendar's horizon — the server decides
* that against `REFERENCE_DATE`, whatever the calendar allowed — and the task
* is looked up by an id that is a string.
*/
export async function setDueDate(taskId: unknown, day: unknown): Promise<Result<DueTask>> {
if (!isCalendarDay(day)) return { ok: false, error: { code: "invalid", message: "Pick a day from the calendar." } }
if (dayFromIso(day).getTime() < midnight(REFERENCE_DATE))
return { ok: false, error: { code: "past", message: `A due date can't be in the past: today is ${LONG.format(REFERENCE_DATE)}.` } }
if (dayFromIso(day).getTime() > midnight(REFERENCE_DATE) + HORIZON_DAYS * DAY_MS)
return { ok: false, error: { code: "horizon", message: "Pick a day in the next two years." } }
const task = typeof taskId === "string" ? db.tasks.all().find((entry) => entry.id === taskId) : undefined
if (!task) return { ok: false, error: { code: "not_found", message: "That task no longer exists." } }
const saved = await db.tasks.update(task.id, { dueAt: dayFromIso(day), updatedAt: REFERENCE_DATE })
return saved.ok ? { ok: true, data: dueTask(saved.data) } : saved
}npx shadcn@latest add @vibra/date-pickers-01Report period with presets from the page's own today
date-pickers-02A sales report's period in the kit's DateRangePicker — today, the last 7 or 30 days, this month, last month, this year — with what it took against the days just before.
import * as React from "react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ReportPeriod } from "./date-pickers-02.client"
import { takingsSeries } from "./date-pickers-02.data"
export type DatePickers02Props = {
/** Opens the calendar on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A sales report's period, picked with the kit's DateRangePicker: the usual
* windows — today, the last 7 or 30 days, this month, last month, this year —
* counted from the page's own today, and a two-month calendar for anything
* else. Under it, what the period took against the same number of days just
* before, and the takings a day.
*
* A server component. It reads the takings through `./date-pickers-02.data`
* and hands the island the series; every total is added up from it there.
*/
export function DatePickers02({ defaultOpen = false }: DatePickers02Props) {
const titleId = React.useId()
return (
<section
data-section="date-pickers-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sales report</h2>
</CardTitle>
<CardDescription>Paid and fulfilled orders, by the day they were placed, in UTC</CardDescription>
</CardHeader>
<ReportPeriod series={takingsSeries()} defaultOpen={defaultOpen} />
</Card>
</div>
</section>
)
}
export default DatePickers02"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatDate, formatNumber } from "@/lib/format"
import { CardContent } from "@/components/ui/card"
import { DateRangePicker, getPresetRange, type DateRange } from "@/components/ui/date-range-picker"
import { Label } from "@/components/ui/label"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import type { TakingsSeries } from "./date-pickers-02.data"
const DAY_MS = 86_400_000
/** A day as a reader writes it. Every day here is a calendar day kept as midnight UTC, so the format reads it in UTC. */
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** "2026-09-08" → the reader's own midnight that day: the calendar counts the reader's days. */
function localDay(day: string): Date {
const [year, month, date] = day.split("-").map(Number)
return new Date(year, month - 1, date)
}
/** A calendar day back to "2026-09-08". */
function isoOf(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}
const utc = (day: string) => new Date(`${day}T00:00:00.000Z`)
const shift = (day: string, days: number) => new Date(utc(day).getTime() + days * DAY_MS).toISOString().slice(0, 10)
const length = (from: string, to: string) => Math.round((utc(to).getTime() - utc(from).getTime()) / DAY_MS) + 1
const dollars = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* "Aug 6 – Sep 4, 2026": both ends in UTC, joined by a fixed en dash. Never
* Intl's formatRange, which Node and the browsers space differently — a
* label the server writes one way and the browser another breaks hydration.
*/
export function spanLabel(from: string, to: string): string {
const year = (day: string) => day.slice(0, 4)
if (from === to) return `${SHORT.format(utc(from))}, ${year(from)}`
if (year(from) === year(to)) return `${SHORT.format(utc(from))} – ${SHORT.format(utc(to))}, ${year(to)}`
return `${SHORT.format(utc(from))}, ${year(from)} – ${SHORT.format(utc(to))}, ${year(to)}`
}
/**
* A reporting period and what it took. The kit's DateRangePicker holds the
* period, its presets counted from the page's own today rather than the
* clock's, and the calendar rules out the days before the first sale and
* after today. Under it, the period's revenue, orders and average order, each
* against the same number of days just before, and the takings a day as bars.
* A change of period is read out in the status line.
*/
export function ReportPeriod({ series, defaultOpen = false }: { series: TakingsSeries; defaultOpen?: boolean }) {
const id = React.useId()
const today = series.today
// Noon on today: the presets count whole days around it, and noon is the same day in every zone.
const now = React.useMemo(() => {
const day = localDay(today)
day.setHours(12)
return day
}, [today])
const [range, setRange] = React.useState<DateRange | undefined>(() => getPresetRange("last30", now))
// Running totals, so any period adds up in two lookups.
const totals = React.useMemo(() => {
const index = new Map<string, number>()
const cents = [0]
const orders = [0]
series.days.forEach((entry, position) => {
index.set(entry.day, position)
cents.push(cents[position] + entry.cents)
orders.push(orders[position] + entry.orders)
})
return (from: string, to: string) => {
const start = index.get(from < series.first ? series.first : from)
const end = index.get(to > today ? today : to)
if (start === undefined || end === undefined || end < start) return { cents: 0, orders: 0 }
return { cents: cents[end + 1] - cents[start], orders: orders[end + 1] - orders[start] }
}
}, [series, today])
const from = range?.from ? isoOf(range.from) : today
const to = range?.to ? isoOf(range.to) : from
const days = length(from, to)
const current = totals(from, to)
const before = { from: shift(from, -days), to: shift(from, -1) }
const comparable = before.from >= series.first
const previous = comparable ? totals(before.from, before.to) : undefined
const change = (value: number, was: number | undefined) => (was ? (value - was) / was : undefined)
const average = current.orders ? current.cents / current.orders : 0
const previousAverage = previous?.orders ? previous.cents / previous.orders : undefined
const against = comparable ? `the ${days === 1 ? "day" : `${days} days`} before, ${spanLabel(before.from, before.to)}` : ""
const bars = series.days.filter((entry) => entry.day >= from && entry.day <= to)
const highest = bars.reduce((best, entry) => (entry.cents > best.cents ? entry : best), bars[0] ?? { day: from, cents: 0, orders: 0 })
function choose(next: DateRange | undefined) {
// A report always has a period: a click that would leave none — the
// calendar lets go of a one-day range handed back to it — keeps this one.
if (!next?.from) return
// A preset may reach back past the first sale ("This year"); the period starts where the sales do.
const first = localDay(series.first)
setRange(next.from < first ? { from: first, to: next.to } : next)
}
return (
<CardContent className="flex flex-col gap-5 p-0">
<div className="flex flex-col gap-3 px-(--card-spacing) pt-(--card-spacing) sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 flex-col gap-2 sm:w-80">
<Label id={`${id}-label`} htmlFor={`${id}-trigger`}>
Report period
</Label>
{/* The period the field shows, written as the picker writes it, for its name: the label and the value, as one. */}
<span id={`${id}-value`} hidden>
{range?.from ? (range.to ? `${formatDate(range.from)} – ${formatDate(range.to)}` : formatDate(range.from)) : ""}
</span>
<DateRangePicker
id={`${id}-trigger`}
aria-labelledby={`${id}-label ${id}-value`}
value={range}
onValueChange={choose}
now={now}
min={localDay(series.first)}
max={localDay(today)}
defaultOpen={defaultOpen}
calendar={{ today: localDay(today), weekStartsOn: 1 }}
/>
</div>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{days} {days === 1 ? "day" : "days"} · {dollars(current.cents)} from {formatNumber(current.orders)} {current.orders === 1 ? "order" : "orders"}
</p>
</div>
<StatCardGroup columns={3} divided className="border-t border-border">
<StatCard
label="Revenue"
value={dollars(current.cents)}
delta={change(current.cents, previous?.cents)}
deltaFormat="percent"
description={comparable ? `vs ${dollars(previous?.cents ?? 0)} ${against}` : "No earlier sales to compare with"}
/>
<StatCard
label="Orders"
value={formatNumber(current.orders)}
delta={change(current.orders, previous?.orders)}
deltaFormat="percent"
description={comparable ? `vs ${formatNumber(previous?.orders ?? 0)} ${against}` : "No earlier sales to compare with"}
/>
<StatCard
label="Average order"
value={current.orders ? formatCurrency(average / 100, "USD") : "No orders"}
delta={previousAverage && current.orders ? (average - previousAverage) / previousAverage : undefined}
deltaFormat="percent"
description={comparable && previousAverage ? `vs ${formatCurrency(previousAverage / 100, "USD")} before` : "No earlier orders to compare with"}
/>
</StatCardGroup>
<figure className="flex flex-col gap-2 border-t border-border px-(--card-spacing) pb-(--card-spacing) pt-4">
<figcaption className="flex justify-between gap-3 text-xs text-muted-foreground tabular-nums">
<span>Takings a day, {spanLabel(from, to)}</span>
{highest.cents > 0 ? (
<span>
Best day {SHORT.format(utc(highest.day))}, {dollars(highest.cents)}
</span>
) : null}
</figcaption>
<div aria-hidden="true" className={cn("flex h-16 items-end", bars.length > 31 ? "gap-px" : "gap-1")}>
{bars.map((entry) => (
<span
key={entry.day}
data-day={entry.day}
className="min-w-px flex-1 rounded-t-xs bg-chart-1"
style={{ height: `${highest.cents ? Math.max(2, (entry.cents / highest.cents) * 100) : 2}%` }}
/>
))}
</div>
</figure>
</CardContent>
)
}/**
* What `date-pickers-02` reads: the store's takings a day — every paid or
* fulfilled order in `db.orders`, summed by the UTC day it was placed — from
* the first day the store took an order to today, with the days it took
* nothing kept as zeros, so any range the reader picks adds up from here.
*
* Read inside a function, on the server; "today" is `REFERENCE_DATE`'s day.
* The island takes the series as props and never imports this module.
*/
import { REFERENCE_DATE, db, type Order } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** The orders that count as a sale: paid for and not handed back. */
const SOLD: readonly Order["status"][] = ["paid", "fulfilled"]
/** One day's takings. */
export type Takings = {
/** `yyyy-mm-dd`, in UTC. */
day: string
/** Minor units. */
cents: number
orders: number
}
export type TakingsSeries = {
/** The first day with an order: nothing earlier can be reported. */
first: string
/** `REFERENCE_DATE`'s day: nothing later has happened. */
today: string
/** Every day from `first` to `today`, oldest first. */
days: Takings[]
}
const isoDay = (at: number) => new Date(at).toISOString().slice(0, 10)
const midnight = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
/** The takings a day, from the first order to today. */
export function takingsSeries(): TakingsSeries {
const today = midnight(REFERENCE_DATE)
const sales = db.orders.all().filter((order) => SOLD.includes(order.status) && order.placedAt.getTime() <= REFERENCE_DATE.getTime())
const first = Math.min(...db.orders.all().map((order) => midnight(order.placedAt)), today)
const days = new Map<string, Takings>()
for (let at = first; at <= today; at += DAY_MS) days.set(isoDay(at), { day: isoDay(at), cents: 0, orders: 0 })
for (const order of sales) {
const entry = days.get(isoDay(midnight(order.placedAt)))
if (!entry) continue
entry.cents += order.totalCents
entry.orders += 1
}
return { first: isoDay(first), today: isoDay(today), days: [...days.values()] }
}npx shadcn@latest add @vibra/date-pickers-02Date and time, with the time zone stated
date-pickers-03When the release email goes out: a day from the kit's DatePicker, a half-hour and a time zone, stated back with the offset and the same instant in UTC, beside the clock it lands on where the customers are.
import * as React from "react"
import { MegaphoneIcon } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { ReleaseSend } from "./date-pickers-03.client"
import { scheduleReleaseEmail, sendContext, type Result } from "./date-pickers-03.data"
export type DatePickers03Props = {
/** Opens the calendar on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* When the release email goes out: a day, a half-hour and the time zone they
* are read in, stated back in words with the zone's offset and the same
* instant in UTC — and beside it, the clock it lands on where the customers
* are. A time already past there says so and cannot be scheduled.
*
* A server component. It reads the release, the customers and the zones'
* offsets through `./date-pickers-03.data`, and hands the island
* `schedule`, a server function defined here, which works the instant out
* again and answers with a `Result`. With no release to announce, the card
* says so.
*/
export function DatePickers03({ defaultOpen = false }: DatePickers03Props) {
const titleId = React.useId()
const context = sendContext()
async function schedule(request: { day: string; time: string; zone: string }): Promise<Result<{ at: string }>> {
"use server"
return scheduleReleaseEmail(request)
}
return (
<section
data-section="date-pickers-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Release email</h2>
</CardTitle>
{context ? (
<CardDescription>
Announce <span className="font-mono">{context.release.version}</span> · {context.release.title}
</CardDescription>
) : null}
</CardHeader>
{context ? (
<ReleaseSend context={context} defaultOpen={defaultOpen} onSchedule={schedule} />
) : (
<CardContent>
<EmptyState
icon={<MegaphoneIcon aria-hidden="true" />}
title="No release to announce"
description="The newest release is announced from here, at a day, a time and a time zone, once there is one."
/>
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default DatePickers03"use client"
import * as React from "react"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { CardContent, CardFooter } from "@/components/ui/card"
import { DatePicker } from "@/components/ui/date-picker"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { Result, SendContext } from "./date-pickers-03.data"
/** Schedules the send: the section's server action. */
export type ScheduleSend = (request: { day: string; time: string; zone: string }) => Promise<Result<{ at: string }>>
const DAY_MS = 86_400_000
const MINUTE_MS = 60_000
/** Wall-clock days and times are kept as UTC fields, so every format here reads them in UTC. */
const LONG = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
const SHORT = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
/** What the day field shows, "Mon, Sep 7, 2026", and so what its name says. */
const PICKED = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/**
* "2026-09-08" → its midnight in UTC, and back: the day a send goes out on is
* a calendar day, and the kit's DatePicker counts days in UTC, so a day reads
* as itself and a click hands back the day clicked, whatever zone the reader
* is in. The time and the zone are the fields beside it.
*/
const utc = (day: string) => new Date(`${day}T00:00:00.000Z`)
const dayOf = (date: Date) => new Date(date.getTime()).toISOString().slice(0, 10)
const shift = (day: string, days: number) => new Date(utc(day).getTime() + days * DAY_MS).toISOString().slice(0, 10)
const hhmm = (at: Date) => `${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")}`
/** "UTC+2", "UTC−4", "UTC+5:30", and plain "UTC" for none. */
function offsetLabel(minutes: number): string {
if (minutes === 0) return "UTC"
const sign = minutes > 0 ? "+" : "−"
const hours = Math.floor(Math.abs(minutes) / 60)
const rest = Math.abs(minutes) % 60
return `UTC${sign}${hours}${rest ? `:${String(rest).padStart(2, "0")}` : ""}`
}
/**
* The release email's send time: a day from the kit's DatePicker, a half-hour
* and the zone they are read in, each a named field. The line under them
* states the time in that zone, its offset and the same instant in UTC, and
* beside them the clock the email lands on where the customers are. A time
* that has already passed there says so, with the clock there now, and
* cannot be scheduled; the server checks again. A save is confirmed in the
* footer's status line, a refusal beside the button.
*/
export function ReleaseSend({
context,
defaultOpen = false,
onSchedule,
}: {
context: SendContext
defaultOpen?: boolean
onSchedule: ScheduleSend
}) {
const id = React.useId()
const part = (name: string) => `${id}-${name}`
const busiest = [...context.zones].sort((a, b) => b.customers - a.customers)[0]
// The next Monday: an announcement goes out at the start of a week.
const monday = shift(context.today, (8 - utc(context.today).getUTCDay()) % 7 || 7)
const [day, setDay] = React.useState(monday)
const [time, setTime] = React.useState("09:00")
const [zoneId, setZoneId] = React.useState(busiest.id)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [status, setStatus] = React.useState("")
const slotItems = context.slots.map((slot) => ({ value: slot, label: slot }))
const zone = context.zones.find((entry) => entry.id === zoneId) ?? busiest
const offset = zone.offsets[day] ?? 0
const [hours, minutes] = time.split(":").map(Number)
const instant = new Date(utc(day).getTime() + (hours * 60 + minutes - offset) * MINUTE_MS)
const past = instant.getTime() <= Date.parse(context.now)
const where = zone.id === "UTC" ? "UTC" : `in ${zone.city} (${offsetLabel(offset)})`
const summary = `Sends ${LONG.format(utc(day))} at ${time} ${where}${zone.id === "UTC" ? "" : ` · ${hhmm(instant)} UTC`}`
const landing = context.zones
.filter((entry) => entry.customers > 0)
.sort((a, b) => b.customers - a.customers)
.map((entry) => {
const there = new Date(instant.getTime() + (entry.offsets[day] ?? 0) * MINUTE_MS)
return { ...entry, clock: hhmm(there), day: SHORT.format(there), share: Math.round((entry.customers / context.customers) * 100) }
})
async function schedule() {
setRefusal(null)
let result: Result<{ at: string }>
try {
result = await onSchedule({ day, time, zone: zone.id })
} catch {
result = { ok: false, error: { code: "network", message: "The email was not scheduled. Check the connection and try again." } }
}
if (result.ok) {
setStatus(`Scheduled · the ${context.release.version} email ${summary.charAt(0).toLowerCase()}${summary.slice(1)}.`)
return
}
setRefusal(result.error.message)
setStatus("")
}
return (
<>
<CardContent className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-8">
<div className="flex min-w-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="flex min-w-0 flex-col gap-2">
<Label id={part("day-label")} htmlFor={part("day")}>
Send on
</Label>
{/* The day the field shows, for its name: the label and the value, as one. */}
<span id={part("day-value")} hidden>
{PICKED.format(utc(day))}
</span>
<DatePicker
id={part("day")}
aria-labelledby={`${part("day-label")} ${part("day-value")}`}
value={utc(day)}
onValueChange={(date) => date && setDay(dayOf(date))}
min={utc(context.today)}
max={utc(shift(context.today, Object.keys(zone.offsets).length - 1))}
format="EEE, MMM d, yyyy"
defaultOpen={defaultOpen}
calendar={{
today: utc(context.today),
weekStartsOn: 1,
footer: "Days before today are in the past.",
classNames: { footer: "w-0 min-w-full px-1 pt-3 text-xs text-pretty text-muted-foreground" },
}}
/>
</div>
<div className="flex min-w-0 flex-col gap-2">
<Label id={part("time-label")} htmlFor={part("time")}>
At
</Label>
<Select items={slotItems} value={time} onValueChange={(next) => next && setTime(String(next))}>
<SelectTrigger id={part("time")} aria-labelledby={part("time-label")} className="w-full tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent>
{slotItems.map((slot) => (
<SelectItem key={slot.value} value={slot.value} className="tabular-nums">
{slot.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex min-w-0 flex-col gap-2">
<Label id={part("zone-label")} htmlFor={part("zone")}>
Time zone
</Label>
<Select
items={context.zones.map((entry) => ({ value: entry.id, label: zoneLabel(entry.city, entry.offsets[day] ?? 0) }))}
value={zone.id}
onValueChange={(next) => next && setZoneId(String(next))}
>
<SelectTrigger id={part("zone")} aria-labelledby={part("zone-label")} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{context.zones.map((entry) => (
<SelectItem key={entry.id} value={entry.id}>
{zoneLabel(entry.city, entry.offsets[day] ?? 0)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p id={part("summary")} className="text-sm text-pretty tabular-nums">
{summary}
</p>
{past ? (
<Callout id={part("past")} variant="warning" title="That time has already passed">
It is {zone.nowThere} {zone.id === "UTC" ? "in UTC" : `in ${zone.city}`}. Pick a later day or time.
</Callout>
) : null}
<div className="flex flex-col gap-3">
<AsyncButton
onClick={schedule}
disabled={past}
focusableWhenDisabled
// Named here as well: AsyncButton writes the caller's aria-disabled over Base UI's own.
aria-disabled={past || undefined}
aria-describedby={past ? `${part("summary")} ${part("past")}` : part("summary")}
className="w-fit"
>
Schedule the email
</AsyncButton>
{refusal ? (
<Callout variant="danger" role="alert" title="The email was not scheduled">
{refusal}
</Callout>
) : null}
</div>
</div>
<div className="flex min-w-0 flex-col gap-3 lg:border-s lg:border-border lg:ps-8">
<h3 id={part("landing")} className="text-xs font-medium text-muted-foreground">
Where your customers read it
</h3>
<ul aria-labelledby={part("landing")} className="flex flex-col divide-y divide-border">
{landing.map((entry) => (
<li key={entry.id} className="flex items-baseline justify-between gap-3 py-2 first:pt-0">
<span className="min-w-0">
<span className="text-sm">{entry.city}</span>
<span className="block text-xs text-muted-foreground tabular-nums">{entry.share}% of customers</span>
</span>
<span className="shrink-0 text-end tabular-nums">
<span className="text-sm font-medium">{entry.clock}</span>
<span className="block text-xs text-muted-foreground">{entry.day}</span>
</span>
</li>
))}
</ul>
</div>
</CardContent>
<CardFooter className="gap-0 text-sm">
<p role="status" aria-live="polite" className="min-h-5 text-foreground">
{status}
</p>
{status ? null : <p className="text-muted-foreground">Nothing is scheduled yet.</p>}
</CardFooter>
</>
)
}
/** "Berlin · UTC+2". */
function zoneLabel(city: string, offset: number): string {
return city === "UTC" ? "UTC" : `${city} · ${offsetLabel(offset)}`
}/**
* What `date-pickers-03` reads and the one thing it does: the release the
* email announces (the newest in `db.releases`), where the store's customers
* are (`db.customers`, by country, gathered into the zones they keep time
* in), each zone's offset from UTC day by day for the next three months, and
* `scheduleReleaseEmail`, the section's server action.
*
* Every offset and every clock reading is worked out here, on the server,
* against `REFERENCE_DATE`, so the island only adds minutes and never asks the
* runtime what time it is anywhere. It takes the rows as props and never
* imports this module.
*/
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"
export type { Result }
const DAY_MS = 86_400_000
const MINUTE_MS = 60_000
/** How far ahead a send can be set, in days. */
export const HORIZON_DAYS = 90
/** The zones a send can be set in, and the countries each keeps time for. */
const ZONES: { id: string; city: string; countries: string[] }[] = [
{ id: "UTC", city: "UTC", countries: [] },
{ id: "America/New_York", city: "New York", countries: ["United States", "Canada"] },
{ id: "America/Sao_Paulo", city: "São Paulo", countries: ["Brazil"] },
{ id: "Europe/London", city: "London", countries: ["United Kingdom", "Ireland", "Portugal"] },
{ id: "Europe/Berlin", city: "Berlin", countries: ["Germany", "France", "Netherlands", "Sweden", "Spain"] },
{ id: "Asia/Kolkata", city: "Kolkata", countries: ["India"] },
{ id: "Asia/Tokyo", city: "Tokyo", countries: ["Japan"] },
{ id: "Australia/Sydney", city: "Sydney", countries: ["Australia"] },
]
/** The half-hours a send can go out at, 06:00 to 21:30. */
export const SLOTS: string[] = Array.from({ length: 32 }, (_, index) => {
const minutes = 6 * 60 + index * 30
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`
})
export type Zone = {
id: string
city: string
/** How many customers keep this zone's time. */
customers: number
/** Minutes ahead of UTC on each day from today, keyed `yyyy-mm-dd`: summer time is in here. */
offsets: Record<string, number>
/** The clock there at `REFERENCE_DATE`, "17:40 on Fri, Sep 4". */
nowThere: string
}
export type SendContext = {
release: { version: string; title: string }
/** `REFERENCE_DATE`'s day, in UTC. */
today: string
/** `REFERENCE_DATE` itself, as an ISO instant: a send has to come after it. */
now: string
customers: number
zones: Zone[]
/** The half-hours offered. */
slots: string[]
}
const isoDay = (at: number) => new Date(at).toISOString().slice(0, 10)
/**
* The formatters a zone is read with — its wall clock in parts, its time and
* its day — made once per zone and kept: making one is the slow part of
* reading an offset, and a context reads each zone's on 91 days. Only the
* offered zones are ever asked for, so the list stays as short as `ZONES`.
*/
const FORMATS = {
wall: { hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" },
clock: { hour: "2-digit", minute: "2-digit", hourCycle: "h23" },
day: { weekday: "short", month: "short", day: "numeric" },
} satisfies Record<string, Intl.DateTimeFormatOptions>
const formatters = new Map<string, Intl.DateTimeFormat>()
function formatter(zone: string, use: keyof typeof FORMATS): Intl.DateTimeFormat {
const key = `${use} ${zone}`
let made = formatters.get(key)
if (!made) {
made = new Intl.DateTimeFormat(use === "clock" ? "en-GB" : "en-US", { timeZone: zone, ...FORMATS[use] })
formatters.set(key, made)
}
return made
}
/** The clock in a zone at `REFERENCE_DATE`: "17:40 on Fri, Sep 4". */
const nowThere = (zone: string) => `${formatter(zone, "clock").format(REFERENCE_DATE)} on ${formatter(zone, "day").format(REFERENCE_DATE)}`
/** Minutes a zone is ahead of UTC at an instant. */
function offsetAt(zone: string, at: Date): number {
const parts = formatter(zone, "wall").formatToParts(at)
const part = (type: string) => Number(parts.find((entry) => entry.type === type)?.value)
const wall = Date.UTC(part("year"), part("month") - 1, part("day"), part("hour") % 24, part("minute"))
return Math.round((wall - Math.floor(at.getTime() / MINUTE_MS) * MINUTE_MS) / MINUTE_MS)
}
/** The instant a wall-clock time in a zone stands for. */
function instantOf(day: string, time: string, zone: string): Date {
const [year, month, date] = day.split("-").map(Number)
const [hours, minutes] = time.split(":").map(Number)
const wall = Date.UTC(year, month - 1, date, hours, minutes)
const first = wall - offsetAt(zone, new Date(wall)) * MINUTE_MS
return new Date(wall - offsetAt(zone, new Date(first)) * MINUTE_MS)
}
/**
* The release, the zones with their customers and offsets, and now. Null when
* the store has no release to announce: the section then says so rather than
* failing.
*/
export function sendContext(): SendContext | null {
const [release] = [...db.releases.all()].sort((a, b) => b.date.getTime() - a.date.getTime())
if (!release) return null
const customers = db.customers.all()
const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
return {
release: { version: release.version, title: release.title },
today: isoDay(today),
now: REFERENCE_DATE.toISOString(),
customers: customers.length,
zones: ZONES.map((zone) => ({
id: zone.id,
city: zone.city,
customers: customers.filter((customer) => zone.countries.includes(customer.country)).length,
offsets: Object.fromEntries(
Array.from({ length: HORIZON_DAYS + 1 }, (_, index) => {
const noon = today + index * DAY_MS + 12 * 60 * MINUTE_MS
return [isoDay(today + index * DAY_MS), offsetAt(zone.id, new Date(noon))]
})
),
nowThere: nowThere(zone.id),
})),
slots: SLOTS,
}
}
/**
* A calendar day written `yyyy-mm-dd`, and a day that exists: it has to come
* back the same from a round trip through UTC, so 2026-13-45 is refused
* rather than read as February 14 of the year after.
*/
function isCalendarDay(day: unknown): day is string {
if (typeof day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return false
const at = new Date(`${day}T00:00:00.000Z`)
return !Number.isNaN(at.getTime()) && isoDay(at.getTime()) === day
}
/**
* Schedules the release email for a day, a half-hour and a zone. The server
* works the instant out itself and refuses anything but a plain request of
* three strings: a zone or a time it does not offer, a day that does not
* exist or lies past the calendar's horizon, and an instant that is not after
* `REFERENCE_DATE` — whatever the form allowed.
*/
export async function scheduleReleaseEmail(request: unknown): Promise<Result<{ at: string }>> {
if (typeof request !== "object" || request === null || Array.isArray(request))
return { ok: false, error: { code: "invalid", message: "Pick a day, a time and a time zone." } }
const { day, time, zone: zoneId } = request as Record<string, unknown>
const zone = typeof zoneId === "string" ? ZONES.find((entry) => entry.id === zoneId) : undefined
if (!zone) return { ok: false, error: { code: "zone", message: "Pick one of the time zones offered." } }
if (typeof time !== "string" || !SLOTS.includes(time))
return { ok: false, error: { code: "time", message: "Pick a time on the half hour, between 06:00 and 21:30." } }
if (!isCalendarDay(day)) return { ok: false, error: { code: "day", message: "Pick a day from the calendar." } }
const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
if (Date.parse(`${day}T00:00:00.000Z`) > today + HORIZON_DAYS * DAY_MS)
return { ok: false, error: { code: "horizon", message: `Pick a day in the next ${HORIZON_DAYS} days.` } }
const at = instantOf(day, time, zone.id)
if (Number.isNaN(at.getTime())) return { ok: false, error: { code: "day", message: "Pick a day from the calendar." } }
if (at.getTime() <= REFERENCE_DATE.getTime())
return {
ok: false,
error: { code: "past", message: `That time has already passed in ${zone.city}: it is ${nowThere(zone.id)} there.` },
}
return { ok: true, data: { at: at.toISOString() } }
}npx shadcn@latest add @vibra/date-pickers-03Compact month picker for a report
date-pickers-04An invoicing report's month picked inline from a compact grid of the year, beside what the month billed, what was paid and what is still owed.
import * as React from "react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatementMonth } from "./date-pickers-04.client"
import { invoiceMonths } from "./date-pickers-04.data"
/**
* An invoicing report with its month picked inline: a compact grid of the
* year's months beside the chosen month's billing, what was paid and what is
* still owed. Months with no invoicing are held back, with the reason.
*
* A server component. It files the invoices by month through
* `./date-pickers-04.data` — each month's dates written there, on the server
* — and hands the island the months.
*/
export function DatePickers04() {
const titleId = React.useId()
return (
<section
data-section="date-pickers-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Monthly statement</h2>
</CardTitle>
<CardDescription>Invoices by the month they were issued, in UTC</CardDescription>
</CardHeader>
<StatementMonth data={invoiceMonths()} />
</Card>
</div>
</section>
)
}
export default DatePickers04"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { CardContent } from "@/components/ui/card"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import type { InvoiceMonth, InvoiceMonths } from "./date-pickers-04.data"
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
const NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
const COLUMNS = 4
const dollars = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const invoices = (count: number) => `${formatNumber(count)} ${count === 1 ? "invoice" : "invoices"}`
const keyOf = (year: number, index: number) => `${year}-${String(index + 1).padStart(2, "0")}`
/**
* An invoicing report's month, picked inline: the year's twelve months as a
* compact grid, the months with no invoicing yet — before the first and after
* this one — held back with the reason under the grid, and the chosen month
* pressed on the accent's tint. The arrows walk the grid (up and down by a
* row), Page Up and Page Down change the year, and Enter or Space picks. The
* report beside it is the chosen month's billing, what of it was paid and
* what is still owed, against the month before.
*/
export function StatementMonth({ data }: { data: InvoiceMonths }) {
const id = React.useId()
const byKey = React.useMemo(() => new Map(data.months.map((entry, index) => [entry.month, { entry, index }])), [data])
const first = data.months[0].month
const current = data.months[data.months.length - 1].month
// The last whole month: this one is still running.
const [chosen, setChosen] = React.useState(data.months[Math.max(0, data.months.length - 2)].month)
const [year, setYear] = React.useState(Number(chosen.slice(0, 4)))
const gridRef = React.useRef<HTMLDivElement>(null)
const firstYear = Number(first.slice(0, 4))
const lastYear = Number(current.slice(0, 4))
const picked = byKey.get(chosen)!
const before: InvoiceMonth | undefined = data.months[picked.index - 1]
const held = [
year === firstYear && first.slice(5) !== "01" ? `Invoicing starts in ${NAMES[Number(first.slice(5)) - 1]} ${firstYear}.` : "",
year === lastYear && current.slice(5) !== "12" ? `${NAMES[Number(current.slice(5))]} onwards has not started yet.` : "",
]
.filter(Boolean)
.join(" ")
/** Arrow up and down move a row; Page Up and Page Down move a year. Left, right, Home and End are the group's own. */
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const items = [...(gridRef.current?.querySelectorAll<HTMLButtonElement>("[data-month]") ?? [])]
const at = items.findIndex((item) => item === document.activeElement)
if (event.key === "PageUp" || event.key === "PageDown") {
const next = year + (event.key === "PageUp" ? -1 : 1)
if (next >= firstYear && next <= lastYear) setYear(next)
event.preventDefault()
return
}
if (at === -1 || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return
event.preventDefault()
const step = event.key === "ArrowUp" ? -COLUMNS : COLUMNS
for (let target = at + step; target >= 0 && target < items.length; target += step) {
if (!items[target].disabled) {
items[target].focus()
return
}
}
}
const paidShare = picked.entry.billed.cents ? picked.entry.paid.cents / picked.entry.billed.cents : 0
const change = (value: number, was: number | undefined) => (was ? (value - was) / was : undefined)
return (
<CardContent className="grid gap-6 md:grid-cols-[17rem_minmax(0,1fr)] md:gap-8">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p id={`${id}-year`} className="text-sm font-semibold tabular-nums" aria-live="polite">
{year}
</p>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Previous year"
disabled={year <= firstYear}
onClick={() => setYear(year - 1)}
>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Next year" disabled={year >= lastYear} onClick={() => setYear(year + 1)}>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
</div>
<div ref={gridRef} onKeyDown={onKeyDown}>
<ToggleGroup
aria-label={`Statement month, ${year}`}
aria-describedby={held ? `${id}-held` : undefined}
variant="outline"
spacing={1}
value={chosen.startsWith(`${year}-`) ? [chosen] : []}
onValueChange={(next) => {
const [value] = next
if (value) setChosen(String(value))
}}
className="grid w-full grid-cols-4 gap-1"
>
{MONTHS.map((name, index) => {
const key = keyOf(year, index)
const known = byKey.has(key)
return (
<ToggleGroupItem
key={key}
value={key}
data-month={key}
disabled={!known}
aria-label={`${NAMES[index]} ${year}`}
className="w-full border-border tabular-nums aria-pressed:border-transparent aria-pressed:bg-brand-muted aria-pressed:font-semibold"
>
{name}
{key === current ? <span aria-hidden="true" className="size-1 rounded-full bg-foreground" /> : null}
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
<p id={`${id}-held`} className="min-h-4 text-xs text-muted-foreground">
{held}
</p>
</div>
<div className="flex min-w-0 flex-col gap-3" aria-labelledby={`${id}-report`} role="region">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 id={`${id}-report`} className="text-sm font-semibold">
{picked.entry.label}
{picked.entry.partial ? " so far" : ""}
</h3>
<p className="text-xs text-muted-foreground tabular-nums">{picked.entry.span}</p>
</div>
<StatCardGroup columns={3} className="gap-3">
<StatCard
size="sm"
label="Billed"
value={dollars(picked.entry.billed.cents)}
delta={change(picked.entry.billed.cents, before?.billed.cents)}
deltaFormat="percent"
description={`${invoices(picked.entry.billed.count)}${before ? `, vs ${dollars(before.billed.cents)} in ${before.label.split(" ")[0]}` : ""}`}
/>
<StatCard
size="sm"
label="Paid"
value={dollars(picked.entry.paid.cents)}
description={`${formatPercent(paidShare, { maximumFractionDigits: 0 })} of billed · ${invoices(picked.entry.paid.count)}`}
/>
<StatCard
size="sm"
label="Still owed"
value={dollars(picked.entry.owed.cents)}
positiveIsGood={false}
description={
picked.entry.overdue.count > 0
? `${invoices(picked.entry.owed.count)}, ${formatNumber(picked.entry.overdue.count)} overdue`
: `${invoices(picked.entry.owed.count)}, none overdue`
}
/>
</StatCardGroup>
</div>
</CardContent>
)
}/**
* What `date-pickers-04` reads: the store's invoicing, month by month — every
* `db.invoices` row filed under the UTC month it was issued in, from the first
* month anything was issued to the month `REFERENCE_DATE` falls in — with what
* was billed, what of it has been paid and what is still owed.
*
* The dates a month covers are written here, on the server, with
* `formatRange` pinned to UTC: Node and the browsers space a range's dash
* differently, so a range is only ever formatted in one place. The island
* takes the months as props and never imports this module.
*/
import { REFERENCE_DATE, db } from "@/lib/sample-data"
const DAY_MS = 86_400_000
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
const SPAN = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/** A sum of invoices: how many, and their total in minor units. */
export type Tally = { count: number; cents: number }
/** One month of invoicing. */
export type InvoiceMonth = {
/** `yyyy-mm`. */
month: string
/** "August 2026". */
label: string
/** The days it covers, "Aug 1 – 31, 2026"; the current month stops at today. */
span: string
/** True for the month still running. */
partial: boolean
billed: Tally
paid: Tally
/** Open or overdue: sent and not yet paid. */
owed: Tally
overdue: Tally
}
export type InvoiceMonths = {
/** Oldest first, with a zero month for any the store issued nothing in. */
months: InvoiceMonth[]
}
const monthKey = (at: Date) => at.toISOString().slice(0, 7)
/** Every month from the first invoice to this one, with its tallies. */
export function invoiceMonths(): InvoiceMonths {
const invoices = db.invoices.all().filter((invoice) => invoice.status !== "draft" && invoice.issuedAt.getTime() <= REFERENCE_DATE.getTime())
const firstIssued = Math.min(...invoices.map((invoice) => invoice.issuedAt.getTime()), REFERENCE_DATE.getTime())
const start = new Date(firstIssued)
const months: InvoiceMonth[] = []
for (
let cursor = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1);
cursor <= REFERENCE_DATE.getTime();
cursor = Date.UTC(new Date(cursor).getUTCFullYear(), new Date(cursor).getUTCMonth() + 1, 1)
) {
const from = new Date(cursor)
const next = Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1)
const partial = next > REFERENCE_DATE.getTime()
const last = partial ? REFERENCE_DATE : new Date(next - DAY_MS)
const inMonth = invoices.filter((invoice) => monthKey(invoice.issuedAt) === monthKey(from))
const tally = (rows: typeof inMonth): Tally => ({ count: rows.length, cents: rows.reduce((total, row) => total + row.amountCents, 0) })
months.push({
month: monthKey(from),
label: MONTH.format(from),
span: SPAN.formatRange(from, last),
partial,
billed: tally(inMonth.filter((invoice) => invoice.status !== "void")),
paid: tally(inMonth.filter((invoice) => invoice.status === "paid")),
owed: tally(inMonth.filter((invoice) => invoice.status === "open" || invoice.status === "overdue")),
overdue: tally(inMonth.filter((invoice) => invoice.status === "overdue")),
})
}
return { months }
}npx shadcn@latest add @vibra/date-pickers-04