Calendar
8 blocksCalendar 01
Preview
npx nateui@latest add CalendarUpcomingActivitiesDark
import { useState } from 'react'
import { PiClipboard, PiUser } from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar'
import Calendar from '@/components/ui/Calendar'
import Card from '@/components/ui/Card'
import Scroll from '@/components/ui/Scroll'
import Segment from '@/components/ui/Segment'
import Tag from '@/components/ui/Tag'
import UsersAvatarGroup from '@/components/composites/UsersAvatarGroup'
const assetBase = 'https://statics.nateui.com/img'
const meetingPlatformLogos = {
'Google Meet': `${assetBase}/thumbs/brands/google-meet.png`,
Zoom: `${assetBase}/thumbs/brands/zoom.png`,
Slack: `${assetBase}/thumbs/brands/slack.png`,
'Phone call': `${assetBase}/thumbs/brands/whatsapp.png`,
}
const tabs = [
{ value: 'meeting', label: 'Meetings', icon: <PiUser aria-hidden="true" /> },
{ value: 'task', label: 'Tasks', icon: <PiClipboard aria-hidden="true" /> },
]
const meetings = [
{
id: 'meeting-1',
title: 'Meeting with Acme Corp',
time: '9:00 - 9:30 AM',
location: 'Slack',
attendees: [
{ name: 'Angelina', img: `${assetBase}/avatars/thumb-1.jpg` },
{ name: 'Max', img: `${assetBase}/avatars/thumb-2.jpg` },
{ name: 'Eugene', img: `${assetBase}/avatars/thumb-3.jpg` },
{ name: 'Arlene', img: `${assetBase}/avatars/thumb-4.jpg` },
{ name: 'Steve', img: `${assetBase}/avatars/thumb-5.jpg` },
],
},
{
id: 'meeting-2',
title: 'Strategy call with Nexera',
time: '10:30 - 11:15 AM',
location: 'Zoom',
attendees: [
{ name: 'Jeremiah', img: `${assetBase}/avatars/thumb-6.jpg` },
{ name: 'Shannon', img: `${assetBase}/avatars/thumb-7.jpg` },
{ name: 'Camila', img: `${assetBase}/avatars/thumb-8.jpg` },
],
},
{
id: 'meeting-3',
title: 'Follow-up call with Cloudora',
time: '2:30 - 2:50 PM',
location: 'Phone call',
attendees: [
{ name: 'Jessica', img: `${assetBase}/avatars/thumb-9.jpg` },
{ name: 'Camila', img: `${assetBase}/avatars/thumb-8.jpg` },
],
},
{
id: 'meeting-4',
title: 'Product review with Acme Corp',
time: '4:00 - 4:30 PM',
location: 'Google Meet',
attendees: [
{ name: 'Jackie', img: `${assetBase}/avatars/thumb-10.jpg` },
{ name: 'Miriam', img: `${assetBase}/avatars/thumb-11.jpg` },
{ name: 'Cassandra', img: `${assetBase}/avatars/thumb-12.jpg` },
{ name: 'Earl', img: `${assetBase}/avatars/thumb-13.jpg` },
],
},
{
id: 'meeting-5',
title: 'Renewal call with Nexera',
time: '5:00 - 5:30 PM',
location: 'Zoom',
attendees: [{ name: 'Alvin', img: `${assetBase}/avatars/thumb-14.jpg` }],
},
]
const tasks = [
{
id: 'task-1',
title: 'Send proposal to Acme Corp',
time: 'Due 4:00 PM',
location: 'Sales pipeline',
subject: { name: 'Eugene', img: `${assetBase}/avatars/thumb-3.jpg` },
},
{
id: 'task-2',
title: 'Update Nexera records',
time: 'Due 5:00 PM',
location: 'Workspace',
subject: { name: 'Jackie', img: `${assetBase}/avatars/thumb-10.jpg` },
},
{
id: 'task-3',
title: 'Review Cloudora forecast',
time: 'Due 6:00 PM',
location: 'Workspace',
subject: { name: 'Miriam', img: `${assetBase}/avatars/thumb-11.jpg` },
},
{
id: 'task-4',
title: 'Prepare Acme Corp demo',
time: 'Due 6:30 PM',
location: 'Sales pipeline',
subject: { name: 'Max', img: `${assetBase}/avatars/thumb-2.jpg` },
},
]
export default function CalendarUpcomingActivities() {
const [activeTab, setActiveTab] = useState<'meeting' | 'task'>('meeting')
return (
<Card bodyClass="p-0">
<div className="space-y-4 py-4">
<div className="px-4">
<h5 className="text-lg font-semibold text-card-foreground">
Upcoming Activities
</h5>
</div>
<Calendar.Mini
visibleCount={7}
showHeader={false}
showStripNavigation={false}
className="border-b px-4 py-2"
/>
<div className="space-y-4 px-4">
<Segment
className="w-full"
value={activeTab}
onChange={(value) =>
setActiveTab(value as 'meeting' | 'task')
}
>
{tabs.map((tab) => (
<Segment.Item
key={tab.value}
value={tab.value}
className="gap-2"
>
{tab.icon}
<span>{tab.label}</span>
</Segment.Item>
))}
</Segment>
<Scroll className="h-80" scrollbars="vertical">
<div className="space-y-2 pe-1">
{activeTab === 'meeting'
? meetings.map((activity) => {
const platformLogo =
meetingPlatformLogos[
activity.location as keyof typeof meetingPlatformLogos
]
return (
<button
key={activity.id}
type="button"
className="block w-full space-y-2 rounded-card border bg-accent/40 p-3 text-left transition-colors hover:bg-accent"
>
<div className="flex flex-col justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-card-foreground">
{activity.title}
</div>
<div className="mt-1 text-xs font-medium text-muted-foreground">
{activity.time}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<UsersAvatarGroup
users={activity.attendees}
maxCount={3}
avatarProps={{
size: 24,
className:
'bg-accent text-accent-foreground',
}}
/>
<Tag
prefix={
<Avatar
src={platformLogo}
alt=""
size={16}
shape="square"
className="mr-1 border-0 bg-transparent"
/>
}
>
{activity.location}
</Tag>
</div>
</div>
</button>
)
})
: tasks.map((activity) => (
<button
key={activity.id}
type="button"
className="block w-full space-y-2 rounded-card border bg-accent/40 p-3 text-left transition-colors hover:bg-accent"
>
<div className="flex flex-col justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-card-foreground">
{activity.title}
</div>
<div className="mt-1 text-xs font-medium text-muted-foreground">
{activity.time}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar
src={activity.subject.img}
alt=""
size={24}
className="bg-accent text-accent-foreground"
/>
<span className="truncate text-sm font-medium text-card-foreground">
{activity.subject.name}
</span>
</div>
<Tag>{activity.location}</Tag>
</div>
</div>
</button>
))}
</div>
</Scroll>
</div>
</div>
</Card>
)
}
Calendar 02
Preview
npx nateui@latest add CalendarRecurrencePanelDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
type EndsMode = 'never' | 'on' | 'after'
const frequencyOptions = [
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
]
const panelTitleId = 'calendar-recurrence-panel-title'
const getStartOfToday = () => {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date
}
const getInitialEndDate = () => {
const date = getStartOfToday()
date.setDate(date.getDate() + 7)
return date
}
const formatDate = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export default function CalendarRecurrencePanel() {
const [frequency, setFrequency] = useState('daily')
const [skipWeekends, setSkipWeekends] = useState(false)
const [endsMode, setEndsMode] = useState<EndsMode>('on')
const [endDate, setEndDate] = useState(() => getInitialEndDate())
const [endDateValue, setEndDateValue] = useState(() =>
formatDate(getInitialEndDate()),
)
const [occurrences, setOccurrences] = useState('10')
const today = getStartOfToday()
const handleCalendarChange = (date: Date | null) => {
if (!date) {
return
}
setEndDate(date)
setEndDateValue(formatDate(date))
setEndsMode('on')
}
return (
<section aria-labelledby={panelTitleId}>
<Card className="overflow-hidden" bodyClass="p-0">
<div className="flex flex-col sm:flex-row">
<div className="flex min-w-0 flex-col flex-1">
<header className="flex items-center justify-between gap-4 p-4">
<h5 id={panelTitleId}>
Recurrence settings
</h5>
</header>
<Form
className="flex flex-1 flex-col"
containerClassName="flex flex-1 flex-col"
onSubmit={(event) => event.preventDefault()}
>
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Form.Field
className="mb-0"
htmlFor="calendar-recurrence-frequency"
label="Frequency"
>
<Select
className="mt-2"
inputId="calendar-recurrence-frequency"
options={frequencyOptions}
value={
frequencyOptions.find(
(option) =>
option.value === frequency,
) ?? frequencyOptions[0]
}
onChange={(option) =>
setFrequency(option.value as string)
}
/>
</Form.Field>
<div className="flex items-center sm:pt-8">
<Checkbox
checked={skipWeekends}
onChange={(checked) =>
setSkipWeekends(checked)
}
>
Skip weekends
</Checkbox>
</div>
</div>
<fieldset>
<legend className="sr-only">Ends</legend>
<Form.Field
className="mb-0"
label="Ends"
labelId="calendar-recurrence-ends-label"
>
<Radio.Group
aria-labelledby="calendar-recurrence-ends-label"
className="grid w-full grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-2 gap-y-2 mt-2"
name="calendar-recurrence-ends"
value={endsMode}
onChange={(value) =>
setEndsMode(value as EndsMode)
}
>
<Radio value="never">Never</Radio>
<div aria-hidden="true" />
<Radio value="on">On date</Radio>
<Input
aria-label="Recurrence end date"
disabled={endsMode !== 'on'}
placeholder="YYYY-MM-DD"
type="text"
value={endDateValue}
onChange={(event) =>
setEndDateValue(
event.target.value,
)
}
/>
<Radio value="after">After</Radio>
<Input
aria-label="Number of occurrences"
disabled={endsMode !== 'after'}
min={1}
suffix="occurrences"
type="number"
value={occurrences}
onChange={(event) =>
setOccurrences(
event.target.value,
)
}
/>
</Radio.Group>
</Form.Field>
</fieldset>
</div>
<footer className="flex flex-wrap justify-end gap-2 p-4">
<Button
type="button"
variant="default"
>
Cancel
</Button>
<Button
type="button"
variant="solid"
>
Save
</Button>
</footer>
</Form>
</div>
<div className="flex justify-center items-center min-w-0 flex-col bg-muted p-4 sm:max-w-[283px]">
<Calendar
aria-label="Select recurrence end date"
className="w-full"
firstDayOfWeek="sunday"
minDate={today}
value={endDate}
onChange={handleCalendarChange}
/>
</div>
</div>
</Card>
</section>
)
}
Calendar 03
Preview
npx nateui@latest add CalendarBookingSchedulerDark
import { useState } from 'react'
import { PiClockFill, PiVideoCameraFill, PiTranslateBold, PiUserFill } from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Card from '@/components/ui/Card'
import Notification from '@/components/ui/Notification'
import Scroll from '@/components/ui/Scroll'
import toast from '@/components/ui/toast'
const eventTitleId = 'calendar-booking-scheduler-event-title'
const availableDays = new Set([10, 11, 15, 17, 22, 24, 29])
const fixtureMonth = new Date(2026, 8, 1)
const initialDate = new Date(2026, 8, 10)
const timezoneOptions = [
{ label: 'UTC-05:00 Eastern Time', value: 'utc-05' },
{ label: 'UTC+00:00 Coordinated Universal Time', value: 'utc-00' },
{ label: 'UTC+08:00 Kuala Lumpur', value: 'utc+08' },
]
const timeSlots = [
'09:00 AM',
'09:30 AM',
'10:00 AM',
'10:30 AM',
'11:00 AM',
'01:00 PM',
'01:30 PM',
'02:00 PM',
'02:30 PM',
'03:00 PM',
]
const eventDetails = [
{ value: '30 minutes', Icon: PiClockFill },
{ value: 'Video call', Icon: PiVideoCameraFill },
{ value: 'English', Icon: PiTranslateBold },
] as const
const isAvailableDate = (date: Date) =>
date.getFullYear() === fixtureMonth.getFullYear() &&
date.getMonth() === fixtureMonth.getMonth() &&
availableDays.has(date.getDate())
const formatSelectedDate = (date: Date) =>
new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
}).format(date)
export default function CalendarBookingScheduler() {
const [selectedDate, setSelectedDate] = useState(initialDate)
const [selectedSlot, setSelectedSlot] = useState<string | null>(null)
const handleDateChange = (date: Date | null) => {
if (!date) {
return
}
setSelectedDate(date)
setSelectedSlot(null)
}
const handleConfirm = () => {
toast.push(
<Notification type="success" title="Book successful" />,
{ placement: 'top-center' },
)
}
return (
<section className="py-16 mx-auto max-w-240" aria-labelledby={eventTitleId}>
<Card className="overflow-hidden" bodyClass="p-0">
<div className="flex flex-col md:flex-row md:flex-wrap lg:flex-nowrap">
<div className="flex min-w-0 flex-col gap-4 border-b p-4 md:flex-1 md:border-b-0 md:border-r lg:basis-0">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 mb-4">
<Avatar
aria-hidden="true"
size="sm"
icon={
<PiUserFill className="text-muted-foreground" />
}
/>
<p className="text-medium">
Avery Lin
</p>
</div>
<h4 id={eventTitleId}>Planning session</h4>
</div>
<dl className="flex flex-col gap-4 mt-2">
{eventDetails.map(({ value, Icon }) => (
<div
key={value}
className="flex items-center gap-2"
>
<Icon
aria-hidden="true"
className="shrink-0 text-lg text-muted-foreground"
/>
<dd className="font-medium">{value}</dd>
</div>
))}
</dl>
<div className="flex flex-col gap-2 text-muted-foreground">
<p>A focused slot for aligning the next project step. Bring the open questions you want to resolve.</p>
</div>
</div>
<div className="flex min-w-0 flex-col gap-4 border-b p-4 md:flex-1 md:border-b-0 lg:max-w-[300px] lg:border-r">
<h5 id="calendar-booking-scheduler-date-heading">
Choose a date
</h5>
<Calendar
aria-label="Select an appointment date"
className="w-full"
defaultMonth={fixtureMonth}
disableDate={(date) => !isAvailableDate(date)}
firstDayOfWeek="sunday"
value={selectedDate}
onChange={handleDateChange}
/>
</div>
<div className="flex min-h-96 min-w-0 flex-col gap-4 p-4 md:basis-full md:min-h-0 md:border-t lg:basis-0 lg:flex-1 lg:border-t-0 lg:max-w-[300px]">
<h5 id="calendar-booking-scheduler-slots-heading">
{formatSelectedDate(selectedDate)}
</h5>
<Scroll
className="min-h-0 flex-1"
scrollbars="vertical"
>
<div className="flex flex-col gap-2">
{timeSlots.map((slot) => {
const isSelected = selectedSlot === slot
return (
<div
key={slot}
className={
isSelected
? 'grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2'
: 'min-w-0'
}
>
{isSelected ? (
<div className="flex h-control-md min-w-0 items-center justify-center rounded-control bg-secondary px-4 font-medium text-foreground">
{slot}
</div>
) : (
<Button
className="w-full min-w-0"
type="button"
variant="default"
onClick={() =>
setSelectedSlot(slot)
}
>
{slot}
</Button>
)}
{isSelected && (
<Button
aria-label={`Confirm ${slot} time`}
type="button"
variant="solid"
onClick={handleConfirm}
>
Confirm
</Button>
)}
</div>
)
})}
</div>
</Scroll>
</div>
</div>
</Card>
</section>
)
}
Calendar 04
Preview
npx nateui@latest add CalendarTaskPlannerDark
import { useState } from 'react'
import { PiClipboardTextFill, PiFlagFill, PiPlus, PiVideoCameraFill } from 'react-icons/pi'
import Tag from '@/components/ui/Tag'
import IconFrame from '@/components/composites/IconFrame'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Card from '@/components/ui/Card'
const headingId = 'calendar-task-planner-heading'
const taskTypes = {
meeting: {
label: 'Meeting',
Icon: PiVideoCameraFill,
dotClass: 'bg-palette-purple',
frameClass: 'bg-palette-purple-soft',
iconClass: 'text-palette-purple-soft-foreground',
},
review: {
label: 'Review',
Icon: PiClipboardTextFill,
dotClass: 'bg-palette-emerald',
frameClass: 'bg-palette-emerald-soft',
iconClass: 'text-palette-emerald-soft-foreground',
},
deadline: {
label: 'Deadline',
Icon: PiFlagFill,
dotClass: 'bg-palette-rose',
frameClass: 'bg-palette-rose-soft',
iconClass: 'text-palette-rose-soft-foreground',
},
}
type TaskType = keyof typeof taskTypes
const anchorMonth = new Date(2026, 7, 1)
const anchorDate = new Date(2026, 7, 12)
const monthMarkers: Record<number, TaskType> = {
4: 'review',
7: 'meeting',
12: 'meeting',
15: 'deadline',
19: 'review',
22: 'meeting',
27: 'deadline',
}
const tasks: { id: string; type: TaskType; title: string; time: string }[] = [
{
id: 'task-1',
type: 'meeting',
title: 'Weekly platform sync',
time: '9:30 - 10:15 AM',
},
{
id: 'task-2',
type: 'review',
title: 'Design walkthrough',
time: '11:00 - 11:45 AM',
},
{
id: 'task-3',
type: 'deadline',
title: 'Vendor invoice cutoff',
time: '1:00 - 1:30 PM',
},
{
id: 'task-4',
type: 'meeting',
title: 'New hire introduction',
time: '3:00 - 3:45 PM',
},
]
export default function CalendarTaskPlanner() {
const [selectedDate, setSelectedDate] = useState(anchorDate)
const [visibleMonth, setVisibleMonth] = useState(anchorMonth)
const renderDay = (date: Date) => {
const inVisibleMonth =
date.getMonth() === visibleMonth.getMonth() &&
date.getFullYear() === visibleMonth.getFullYear()
const marker = inVisibleMonth ? monthMarkers[date.getDate()] : undefined
const isSelected =
date.getDate() === selectedDate.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear()
const dotClass = isSelected
? 'bg-primary-foreground'
: marker && taskTypes[marker].dotClass
return (
<span className="relative flex h-full w-full items-center justify-center">
<span>{date.getDate()}</span>
{marker && (
<span
aria-hidden="true"
className={`absolute inset-x-0 bottom-1.5 mx-auto h-1 w-1 rounded-full ${dotClass}`}
/>
)}
</span>
)
}
return (
<section aria-labelledby={headingId}>
<Card className="overflow-hidden" bodyClass="p-0">
<div className="p-4">
<h5 id={headingId}>Task planner</h5>
<Calendar
className="mt-4"
defaultMonth={anchorMonth}
firstDayOfWeek="sunday"
renderDay={renderDay}
value={selectedDate}
onChange={(date) => date && setSelectedDate(date)}
onMonthChange={setVisibleMonth}
/>
</div>
<div className="flex items-center justify-between gap-2 border-t px-4 py-3">
<h6 className="min-w-0 truncate">
Today events
</h6>
<Button
aria-label="Add task"
className="shrink-0"
icon={<PiPlus />}
size="sm"
variant="subtle"
/>
</div>
<ul className="divide-y border-t">
{tasks.map((task) => {
const { label, Icon, frameClass, iconClass } =
taskTypes[task.type]
return (
<li
key={task.id}
className="flex items-center justify-between px-4 py-4"
>
<div className="flex items-center gap-2">
<IconFrame
className={`shrink-0 ${frameClass}`}
size={36}
variant="muted"
>
<Icon
aria-hidden="true"
className={`text-lg ${iconClass}`}
/>
</IconFrame>
<div className="min-w-0">
<div className="font-semibold">
{task.title}
</div>
<div className="text-xs text-muted-foreground">
{task.time}
</div>
</div>
</div>
<Tag >
{label}
</Tag>
</li>
)
})}
</ul>
</Card>
</section>
)
}
Calendar 05
Preview
npx nateui@latest add CalendarEventWorkspaceDark
import { useState } from 'react'
import { PiCheck, PiSlidersHorizontal, PiPlus } from 'react-icons/pi'
import FullCalendar from '@/components/composites/FullCalendar'
import Button from '@/components/ui/Button'
import DatePicker from '@/components/ui/DatePicker'
import Dropdown from '@/components/ui/Dropdown'
import Drawer from '@/components/ui/Drawer'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Switcher from '@/components/ui/Switcher'
import TimeInput from '@/components/ui/TimeInput'
import type {
CalendarView,
FullCalendarEvent,
} from '@/components/composites/FullCalendar'
const headingId = 'calendar-event-workspace-heading'
const createEventFormId = 'calendar-event-form'
const swatchClass: Record<string, string> = {
blue: 'bg-palette-blue',
purple: 'bg-palette-purple',
green: 'bg-palette-emerald',
red: 'bg-palette-red',
}
const calendars = [
{ id: 'product', label: 'Product', color: 'blue' },
{ id: 'marketing', label: 'Marketing', color: 'purple' },
{ id: 'team', label: 'Team', color: 'green' },
{ id: 'deadlines', label: 'Deadlines', color: 'red' },
]
const viewOptions: { label: string; value: CalendarView }[] = [
{ label: 'Month', value: 'month' },
{ label: 'Week', value: 'week' },
{ label: 'Day', value: 'day' },
{ label: 'Agenda', value: 'agenda' },
]
const events = [
{
id: 1,
type: 'product',
color: 'blue',
title: 'Roadmap sync',
description: 'Quarterly roadmap review with the product team.',
startDate: '2026-08-03T09:00:00',
endDate: '2026-08-03T09:45:00',
},
{
id: 2,
type: 'marketing',
color: 'purple',
title: 'Content review',
description: 'Review drafts before the newsletter goes out.',
startDate: '2026-08-05T13:00:00',
endDate: '2026-08-05T13:30:00',
},
{
id: 3,
type: 'team',
color: 'green',
title: 'Design critique',
description: 'Walkthrough of the latest screens with feedback.',
startDate: '2026-08-12T10:00:00',
endDate: '2026-08-12T10:30:00',
},
{
id: 4,
type: 'deadlines',
color: 'red',
title: 'Invoice due',
description: 'Vendor invoice payment window closes.',
startDate: '2026-08-12T17:00:00',
endDate: '2026-08-12T17:30:00',
},
{
id: 5,
type: 'product',
color: 'blue',
title: 'API review',
description: 'Walk through the proposed endpoint changes.',
startDate: '2026-08-21T11:00:00',
endDate: '2026-08-21T11:30:00',
},
{
id: 6,
type: 'marketing',
color: 'purple',
title: 'Newsletter draft',
description: 'First pass on next month’s newsletter copy.',
startDate: '2026-08-26T15:00:00',
endDate: '2026-08-26T15:30:00',
},
{
id: 7,
type: 'product',
color: 'blue',
title: 'Onsite workshop',
description: 'Cross-team planning workshop at the office.',
startDate: '2026-08-14T00:00:00',
endDate: '2026-08-17T00:00:00',
},
{
id: 8,
type: 'team',
color: 'green',
title: 'Team offsite',
description: 'Annual offsite for the wider team.',
startDate: '2026-08-24T00:00:00',
endDate: '2026-08-26T00:00:00',
},
{
id: 9,
type: 'product',
color: 'blue',
title: 'Standup',
description: 'Daily standup with the product squad.',
startDate: '2026-08-19T08:30:00',
endDate: '2026-08-19T09:00:00',
},
{
id: 10,
type: 'marketing',
color: 'purple',
title: 'Campaign review',
description: 'Check performance on the current campaign.',
startDate: '2026-08-19T09:30:00',
endDate: '2026-08-19T10:15:00',
},
{
id: 11,
type: 'team',
color: 'green',
title: '1:1 check-in',
description: 'Regular check-in with a direct report.',
startDate: '2026-08-19T11:00:00',
endDate: '2026-08-19T11:30:00',
},
{
id: 12,
type: 'deadlines',
color: 'red',
title: 'Contract signature',
description: 'Countersign the vendor renewal contract.',
startDate: '2026-08-19T12:00:00',
endDate: '2026-08-19T12:15:00',
},
{
id: 13,
type: 'product',
color: 'blue',
title: 'Sprint planning',
description: 'Plan scope for the upcoming sprint.',
startDate: '2026-08-19T14:00:00',
endDate: '2026-08-19T14:45:00',
},
{
id: 14,
type: 'marketing',
color: 'purple',
title: 'Retro',
description: 'Retrospective on the last campaign push.',
startDate: '2026-08-19T16:00:00',
endDate: '2026-08-19T16:30:00',
},
]
type EventDraft = {
title: string
calendarId: string
allDay: boolean
startDate: Date
endDate: Date
startTime: Date
endTime: Date
description: string
}
const defaultCreateDate = new Date(2026, 7, 19)
const createDraft = (date: Date = defaultCreateDate): EventDraft => {
const startDate = new Date(date)
startDate.setHours(0, 0, 0, 0)
const startTime = new Date(startDate)
if (startTime.getHours() === 0 && startTime.getMinutes() === 0) {
startTime.setHours(9, 0, 0, 0)
}
const endTime = new Date(startTime)
endTime.setMinutes(endTime.getMinutes() + 30)
return {
title: '',
calendarId: calendars[0].id,
allDay: false,
startDate,
endDate: new Date(startDate),
startTime,
endTime,
description: '',
}
}
const isAllDayEvent = (event: FullCalendarEvent) => {
const start = new Date(event.startDate)
const end = new Date(event.endDate)
const startsAtMidnight =
start.getHours() === 0 && start.getMinutes() === 0
const endsAtMidnight = end.getHours() === 0 && end.getMinutes() === 0
const endsAtDayClose =
end.getHours() === 23 &&
end.getMinutes() === 59 &&
end.getSeconds() === 59
return (
startsAtMidnight &&
((endsAtMidnight &&
end.getTime() - start.getTime() >= 24 * 60 * 60 * 1000) ||
endsAtDayClose)
)
}
const createDraftFromEvent = (event: FullCalendarEvent): EventDraft => {
const start = new Date(event.startDate)
const end = new Date(event.endDate)
const startDate = new Date(start)
const endDate = new Date(end)
const allDay = isAllDayEvent(event)
startDate.setHours(0, 0, 0, 0)
endDate.setHours(0, 0, 0, 0)
return {
title: event.title,
calendarId: event.type,
allDay,
startDate,
endDate,
startTime: start,
endTime: end,
description: event.description,
}
}
const combineDateAndTime = (date: Date, time: Date) => {
const combined = new Date(date)
combined.setHours(time.getHours(), time.getMinutes(), 0, 0)
return combined
}
const endOfDay = (date: Date) => {
const end = new Date(date)
end.setHours(23, 59, 59, 999)
return end
}
export default function CalendarEventWorkspace() {
const [calendarEvents, setCalendarEvents] = useState(events)
const [isEventDrawerOpen, setEventDrawerOpen] = useState(false)
const [editingEventId, setEditingEventId] = useState<number | null>(null)
const [eventDraft, setEventDraft] = useState<EventDraft>(() =>
createDraft(),
)
const [createError, setCreateError] = useState<string | null>(null)
const [visibleCalendarIds, setVisibleCalendarIds] = useState(
() => new Set(calendars.map((calendar) => calendar.id)),
)
const isEditingEvent = editingEventId !== null
const openCreateDrawer = (date?: Date) => {
setEventDraft(createDraft(date))
setCreateError(null)
setEditingEventId(null)
setEventDrawerOpen(true)
}
const openEditDrawer = (event: FullCalendarEvent) => {
setEventDraft(createDraftFromEvent(event))
setCreateError(null)
setEditingEventId(event.id)
setEventDrawerOpen(true)
}
const closeEventDrawer = () => {
setEventDrawerOpen(false)
setEditingEventId(null)
setCreateError(null)
}
const updateEventDraft = <Key extends keyof EventDraft>(
key: Key,
value: EventDraft[Key],
) => {
setEventDraft((currentDraft) => ({
...currentDraft,
[key]: value,
}))
setCreateError(null)
}
const handleSaveEvent = () => {
const title = eventDraft.title.trim()
const calendar = calendars.find(
(item) => item.id === eventDraft.calendarId,
)
const startDateTime = combineDateAndTime(
eventDraft.startDate,
eventDraft.startTime,
)
const endDateTime = eventDraft.allDay
? endOfDay(eventDraft.endDate)
: combineDateAndTime(eventDraft.endDate, eventDraft.endTime)
if (!title) {
setCreateError('Add a title before saving the event.')
return
}
if (!calendar) {
setCreateError('Choose a calendar before creating the event.')
return
}
if (endDateTime <= startDateTime) {
setCreateError('The end must be after the start.')
return
}
setCalendarEvents((currentEvents) => {
if (editingEventId !== null) {
return currentEvents.map((event) =>
event.id === editingEventId
? {
...event,
type: calendar.id,
color: calendar.color,
title,
description: eventDraft.description.trim(),
startDate: startDateTime.toISOString(),
endDate: endDateTime.toISOString(),
}
: event,
)
}
return [
...currentEvents,
{
id:
Math.max(
0,
...currentEvents.map(
(currentEvent) => currentEvent.id,
),
) + 1,
type: calendar.id,
color: calendar.color,
title,
description: eventDraft.description.trim(),
startDate: startDateTime.toISOString(),
endDate: endDateTime.toISOString(),
},
]
})
setVisibleCalendarIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.add(calendar.id)
return nextIds
})
closeEventDrawer()
}
const toggleCalendar = (id: string) => {
setVisibleCalendarIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const visibleEvents = calendarEvents.filter((event) =>
visibleCalendarIds.has(event.type),
)
return (
<section aria-labelledby={headingId}>
<div className="w-full h-225 xl:h-screen">
<FullCalendar
fillHeight
events={visibleEvents}
view="month"
onCellClick={openCreateDrawer}
onEventClick={openEditDrawer}
onChange={(_, updatedEvent) =>
setCalendarEvents((currentEvents) =>
currentEvents.map((event) =>
event.id === updatedEvent.id
? updatedEvent
: event,
),
)
}
renderMoreContent={({ overflowedEvents }) => (
<ul className="w-56 space-y-1">
{overflowedEvents.map((event) => (
<li
key={event.id}
className="flex items-center gap-2 rounded-control-sm px-2 py-1.5 hover:bg-accent"
>
<span
aria-hidden="true"
className={`size-2 shrink-0 rounded-full ${swatchClass[event.color] ?? 'bg-palette-gray'}`}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{event.title}
</span>
</li>
))}
</ul>
)}
renderHeaderEnd={({ view, setView }) => (
<div className="flex items-center gap-2">
<Dropdown
placement="bottom-end"
renderTitle={
<Button
variant="default"
size="sm"
icon={<PiSlidersHorizontal aria-hidden="true" />}
>
Filters
</Button>
}
>
{calendars.map((calendar) => (
<Dropdown.Item
key={calendar.id}
closeOnClick={false}
onClick={() =>
toggleCalendar(calendar.id)
}
>
<span className="flex w-full items-center gap-2">
<span
aria-hidden="true"
className={`size-2 shrink-0 rounded-full ${swatchClass[calendar.color]}`}
/>
<span className="flex-1 truncate">
{calendar.label}
</span>
{visibleCalendarIds.has(
calendar.id,
) && (
<PiCheck
aria-hidden="true"
className="shrink-0 text-primary"
/>
)}
</span>
</Dropdown.Item>
))}
</Dropdown>
<Button
variant="solid"
size="sm"
icon={<PiPlus aria-hidden="true" />}
onClick={() => openCreateDrawer()}
>
Add event
</Button>
<Select
className="w-32"
options={viewOptions}
value={viewOptions.find(
(option) => option.value === view,
)}
onChange={(selected) => {
if (selected && 'value' in selected) {
setView(
selected.value as CalendarView,
)
}
}}
/>
</div>
)}
/>
</div>
<Drawer
isOpen={isEventDrawerOpen}
placement="right"
title={isEditingEvent ? 'Edit event' : 'New event'}
onClose={closeEventDrawer}
footerClass="flex justify-end gap-2"
footer={
<>
<Button
type="button"
onClick={closeEventDrawer}
>
Cancel
</Button>
<Button
variant="solid"
form={createEventFormId}
type="submit"
>
{isEditingEvent ? 'Save changes' : 'Create event'}
</Button>
</>
}
>
<Form
id={createEventFormId}
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
handleSaveEvent()
}}
>
{createError && (
<p
className="rounded-control bg-destructive-soft px-3 py-2 text-sm text-destructive"
role="alert"
>
{createError}
</p>
)}
<Form.Field
htmlFor="calendar-event-title"
label="Title"
>
<Input
id="calendar-event-title"
value={eventDraft.title}
placeholder="Add event title"
onChange={(event) =>
updateEventDraft('title', event.target.value)
}
/>
</Form.Field>
<Form.Field
htmlFor="calendar-event-calendar"
label="Calendar"
>
<Select
inputId="calendar-event-calendar"
options={calendars.map((calendar) => ({
value: calendar.id,
label: calendar.label,
}))}
value={
calendars
.map((calendar) => ({
value: calendar.id,
label: calendar.label,
}))
.find(
(option) =>
option.value ===
eventDraft.calendarId,
)
}
customInputDisplay={(selected) => {
const calendar = calendars.find(
(item) => item.id === selected?.value,
)
return calendar ? (
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`size-2 rounded-full ${swatchClass[calendar.color]}`}
/>
<span>{calendar.label}</span>
</span>
) : null
}}
customOption={({ option, selected, CheckIcon }) => {
const calendar = calendars.find(
(item) => item.id === option.value,
)
return (
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
{calendar && (
<span
aria-hidden="true"
className={`size-2 shrink-0 rounded-full ${swatchClass[calendar.color]}`}
/>
)}
<span className="truncate">
{option.label}
</span>
</span>
{selected && CheckIcon}
</span>
)
}}
onChange={(selected) => {
if (selected && 'value' in selected) {
updateEventDraft(
'calendarId',
selected.value,
)
}
}}
/>
</Form.Field>
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-foreground">
All day
</span>
<Switcher
aria-label="All day"
checked={eventDraft.allDay}
onChange={(checked) =>
updateEventDraft('allDay', checked)
}
/>
</div>
<Form.Field
htmlFor="calendar-event-start-date"
label="Starts"
>
<DatePicker
id="calendar-event-start-date"
inputFormat="MMMM D, YYYY"
value={eventDraft.startDate}
clearable={false}
onChange={(date) => {
if (date) {
updateEventDraft('startDate', date)
}
}}
/>
</Form.Field>
<Form.Field
htmlFor="calendar-event-end-date"
label="Ends"
>
<DatePicker
id="calendar-event-end-date"
inputFormat="MMMM D, YYYY"
value={eventDraft.endDate}
clearable={false}
onChange={(date) => {
if (date) {
updateEventDraft('endDate', date)
}
}}
/>
</Form.Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Form.Field
htmlFor="calendar-event-start-time"
label="From"
>
<TimeInput
id="calendar-event-start-time"
value={eventDraft.startTime}
format="12"
clearable={false}
disabled={eventDraft.allDay}
onChange={(time) => {
if (time) {
updateEventDraft('startTime', time)
}
}}
/>
</Form.Field>
<Form.Field
htmlFor="calendar-event-end-time"
label="To"
>
<TimeInput
id="calendar-event-end-time"
value={eventDraft.endTime}
format="12"
clearable={false}
disabled={eventDraft.allDay}
onChange={(time) => {
if (time) {
updateEventDraft('endTime', time)
}
}}
/>
</Form.Field>
</div>
<Form.Field
htmlFor="calendar-event-description"
label="Description"
>
<Input
id="calendar-event-description"
textArea
rows={4}
value={eventDraft.description}
placeholder="Add notes or an agenda"
onChange={(event) =>
updateEventDraft(
'description',
event.target.value,
)
}
/>
</Form.Field>
</Form>
</Drawer>
</section>
)
}
Calendar 06
Preview
npx nateui@latest add CalendarWeekScheduleDark
import { useState } from 'react'
import {
PiArrowUpRight,
PiCalendarBlank,
PiCaretLeft,
PiCaretRight,
PiClock,
PiEnvelopeSimple,
PiMagnifyingGlass,
PiMapPin,
PiPhone,
PiVideoCamera,
} from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import FullCalendar from '@/components/composites/FullCalendar'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import Segment from '@/components/ui/Segment'
import Select from '@/components/ui/Select'
import Tag from '@/components/ui/Tag'
import classNames from '@/utils/classNames'
import type { CalendarView } from '@/components/composites/FullCalendar'
const headingId = 'calendar-week-schedule-heading'
const assetBase = 'https://statics.nateui.com/img'
const legend = [
{ id: 'onsite', label: 'Onsite Interview', dotClass: 'bg-palette-blue' },
{ id: 'remote', label: 'Remote Interview', dotClass: 'bg-palette-purple' },
]
const tintClass: Record<string, string> = {
blue: 'bg-palette-blue-soft/60 hover:bg-palette-blue-soft text-palette-blue-soft-foreground border-palette-blue-soft',
purple: 'bg-palette-purple-soft/60 hover:bg-palette-purple-soft text-palette-purple-soft-foreground border-palette-purple-soft',
}
const modeLabel: Record<string, string> = {
onsite: 'Onsite',
remote: 'Remote',
}
const statusOptions = [
{ label: 'All Status', value: 'all' },
{ label: 'Confirmed', value: 'Confirmed' },
{ label: 'Pending', value: 'Pending' },
]
function addDays(date: Date, amount: number) {
const result = new Date(date)
result.setDate(result.getDate() + amount)
return result
}
function getWeekStart(date: Date) {
const start = new Date(date)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - start.getDay())
return start
}
function toLocalIso(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`
}
function eventTime(weekStart: Date, dayOffset: number, hour: number, minute: number) {
const date = addDays(weekStart, dayOffset)
date.setHours(hour, minute, 0, 0)
return toLocalIso(date)
}
const today = new Date()
const currentWeekStart = getWeekStart(today)
const previousWeekStart = addDays(currentWeekStart, -7)
const nextWeekStart = addDays(currentWeekStart, 7)
function timeAround(offsetMinutes: number, durationMinutes: number) {
const start = new Date(today)
start.setMinutes(start.getMinutes() + offsetMinutes)
const end = new Date(start)
end.setMinutes(end.getMinutes() + durationMinutes)
return {
start: [start.getHours(), start.getMinutes()] as [number, number],
end: [end.getHours(), end.getMinutes()] as [number, number],
}
}
type EventSeed = {
id: number
type: string
color: string
title: string
description: string
weekStart: Date
dayOffset: number
start: [number, number]
end: [number, number]
}
function buildEvent(seed: EventSeed) {
return {
id: seed.id,
type: seed.type,
color: seed.color,
title: seed.title,
description: seed.description,
startDate: eventTime(seed.weekStart, seed.dayOffset, seed.start[0], seed.start[1]),
endDate: eventTime(seed.weekStart, seed.dayOffset, seed.end[0], seed.end[1]),
}
}
const initialEvents = [
buildEvent({ id: 1, type: 'remote', color: 'purple', title: 'Elena Ruiz', description: 'Phone Screen · Product Designer', weekStart: currentWeekStart, dayOffset: 1, ...timeAround(140, 40) }),
buildEvent({ id: 2, type: 'remote', color: 'purple', title: 'Nadia Farouk', description: 'Hiring Manager Chat · Product Manager', weekStart: currentWeekStart, dayOffset: 2, ...timeAround(-140, 45) }),
buildEvent({ id: 3, type: 'onsite', color: 'blue', title: 'Connor Blake', description: 'Technical Round · DevOps Engineer', weekStart: currentWeekStart, dayOffset: 3, ...timeAround(20, 45) }),
buildEvent({ id: 4, type: 'remote', color: 'purple', title: 'Renee Dupont', description: 'Final Round · Marketing Lead', weekStart: currentWeekStart, dayOffset: 4, ...timeAround(-80, 45) }),
buildEvent({ id: 5, type: 'onsite', color: 'blue', title: 'Marcus Webb', description: 'Panel Interview · Sales Engineer', weekStart: currentWeekStart, dayOffset: 5, ...timeAround(-110, 40) }),
buildEvent({ id: 6, type: 'remote', color: 'purple', title: 'Samuel Osei', description: 'Final Round · Data Analyst', weekStart: currentWeekStart, dayOffset: 6, ...timeAround(60, 35) }),
buildEvent({ id: 7, type: 'remote', color: 'purple', title: 'Tobias Kane', description: 'Technical Round · Backend Engineer', weekStart: currentWeekStart, dayOffset: 0, ...timeAround(90, 45) }),
buildEvent({ id: 8, type: 'onsite', color: 'blue', title: 'Priya Nair', description: 'Intro Call · Frontend Engineer', weekStart: currentWeekStart, dayOffset: 0, ...timeAround(-20, 45) }),
buildEvent({ id: 9, type: 'remote', color: 'purple', title: 'Yuki Tanaka', description: 'Phone Screen · UX Researcher', weekStart: previousWeekStart, dayOffset: 2, start: [5, 30], end: [6, 15] }),
buildEvent({ id: 10, type: 'remote', color: 'purple', title: 'Aisha Bello', description: 'Technical Round · Content Strategist', weekStart: previousWeekStart, dayOffset: 4, start: [12, 0], end: [12, 45] }),
buildEvent({ id: 11, type: 'onsite', color: 'blue', title: 'Elena Ruiz', description: 'Final Round · Product Designer', weekStart: previousWeekStart, dayOffset: 5, start: [17, 0], end: [17, 45] }),
buildEvent({ id: 12, type: 'remote', color: 'purple', title: 'Tobias Kane', description: 'Panel Interview · Backend Engineer', weekStart: nextWeekStart, dayOffset: 1, start: [19, 0], end: [19, 45] }),
buildEvent({ id: 13, type: 'onsite', color: 'blue', title: 'Connor Blake', description: 'Final Round · DevOps Engineer', weekStart: nextWeekStart, dayOffset: 3, start: [12, 30], end: [13, 15] }),
buildEvent({ id: 14, type: 'remote', color: 'purple', title: 'Nadia Farouk', description: 'Final Round · Product Manager', weekStart: nextWeekStart, dayOffset: 5, start: [16, 30], end: [17, 15] }),
buildEvent({ id: 15, type: 'onsite', color: 'blue', title: 'Samuel Osei', description: 'Culture Fit · Data Analyst', weekStart: currentWeekStart, dayOffset: 4, ...timeAround(70, 30) }),
]
const candidateDetails: Record<
number,
{ avatar: string; phone: string; email: string; location: string; status: string }
> = {
1: { avatar: `${assetBase}/avatars/thumb-1.jpg`, phone: '+1 415 555 0132', email: 'elena.ruiz@examplemail.com', location: 'Austin, TX', status: 'Confirmed' },
2: { avatar: `${assetBase}/avatars/thumb-5.jpg`, phone: '+1 213 555 0187', email: 'nadia.farouk@examplemail.com', location: 'Video call', status: 'Confirmed' },
3: { avatar: `${assetBase}/avatars/thumb-6.jpg`, phone: '+1 720 555 0119', email: 'connor.blake@examplemail.com', location: 'Denver, CO', status: 'Pending' },
4: { avatar: `${assetBase}/avatars/thumb-8.jpg`, phone: '+1 617 555 0152', email: 'renee.dupont@examplemail.com', location: 'Video call', status: 'Confirmed' },
5: { avatar: `${assetBase}/avatars/thumb-9.jpg`, phone: '+1 469 555 0176', email: 'marcus.webb@examplemail.com', location: 'Austin, TX', status: 'Confirmed' },
6: { avatar: `${assetBase}/avatars/thumb-4.jpg`, phone: '+1 312 555 0143', email: 'samuel.osei@examplemail.com', location: 'Chicago, IL', status: 'Confirmed' },
7: { avatar: `${assetBase}/avatars/thumb-2.jpg`, phone: '+1 512 555 0198', email: 'tobias.kane@examplemail.com', location: 'Video call', status: 'Confirmed' },
8: { avatar: `${assetBase}/avatars/thumb-3.jpg`, phone: '+1 646 555 0176', email: 'priya.nair@examplemail.com', location: 'Video call', status: 'Pending' },
9: { avatar: `${assetBase}/avatars/thumb-7.jpg`, phone: '+1 206 555 0164', email: 'yuki.tanaka@examplemail.com', location: 'Seattle, WA', status: 'Confirmed' },
10: { avatar: `${assetBase}/avatars/thumb-10.jpg`, phone: '+1 305 555 0128', email: 'aisha.bello@examplemail.com', location: 'Video call', status: 'Pending' },
11: { avatar: `${assetBase}/avatars/thumb-1.jpg`, phone: '+1 415 555 0132', email: 'elena.ruiz@examplemail.com', location: 'Austin, TX', status: 'Confirmed' },
12: { avatar: `${assetBase}/avatars/thumb-2.jpg`, phone: '+1 512 555 0198', email: 'tobias.kane@examplemail.com', location: 'Toronto, ON', status: 'Pending' },
13: { avatar: `${assetBase}/avatars/thumb-6.jpg`, phone: '+1 720 555 0119', email: 'connor.blake@examplemail.com', location: 'Denver, CO', status: 'Confirmed' },
14: { avatar: `${assetBase}/avatars/thumb-5.jpg`, phone: '+1 213 555 0187', email: 'nadia.farouk@examplemail.com', location: 'Video call', status: 'Confirmed' },
15: { avatar: `${assetBase}/avatars/thumb-4.jpg`, phone: '+1 312 555 0143', email: 'samuel.osei@examplemail.com', location: 'Chicago, IL', status: 'Pending' },
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})
}
function formatDateTime(iso: string) {
const date = new Date(iso)
return `${date.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' })} · ${formatTime(iso)}`
}
type EventContentProps = {
id: number
title: string
color: string
type: string
description: string
startDate: string
endDate: string
isOpen: boolean
onOpenChange: (open: boolean) => void
view: 'week' | 'month'
}
function buildEventContent({
id,
title,
color,
type,
description,
startDate,
endDate,
isOpen,
view,
onOpenChange,
}: EventContentProps) {
const durationMinutes = Math.round(
(new Date(endDate).getTime() - new Date(startDate).getTime()) / 60000,
)
const isCompact = durationMinutes < 35
const details = candidateDetails[id]
const tint = tintClass[color] ?? tintClass.blue
return {
className: classNames(tint, 'border-transparent p-0'),
content: (
<Popover
open={isOpen}
onOpenChange={onOpenChange}
trigger="click"
placement="right-start"
className="p-0"
renderTrigger={
<div
className={classNames(
'flex h-full w-full min-w-0 outline-none',
view === 'month'
? 'items-center gap-1 px-2'
: 'items-start gap-2 p-2',
)}
>
<Avatar
src={details.avatar}
alt=""
size={view === 'month' ? 20 : 16}
className="shrink-0"
/>
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{view === 'week' && !isCompact && (
<p className="truncate">
{formatTime(startDate)} - {formatTime(endDate)}
</p>
)}
</div>
</div>
}
>
<div className="p-4">
<div className="flex items-center gap-2">
<Avatar
src={details.avatar}
alt=""
/>
<div className="min-w-0">
<div className="truncate font-semibold text-popover-foreground">
{title}
</div>
<div className="truncate text-xs font-medium text-muted-foreground">
{description}
</div>
</div>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-popover-foreground">
<PiVideoCamera aria-hidden="true" className="shrink-0 text-base text-muted-foreground" />
{modeLabel[type] ?? type}
</div>
<div className="flex items-center gap-2 text-popover-foreground">
<PiClock aria-hidden="true" className="shrink-0 text-base text-muted-foreground" />
{durationMinutes} min
</div>
<div className="flex items-center gap-2 text-popover-foreground">
<PiPhone aria-hidden="true" className="shrink-0 text-base text-muted-foreground" />
<span className="truncate">{details.phone}</span>
</div>
<div className="flex items-center gap-2 text-popover-foreground">
<PiEnvelopeSimple aria-hidden="true" className="shrink-0 text-base text-muted-foreground" />
<span className="truncate">{details.email}</span>
</div>
<div className="flex items-center gap-2 text-popover-foreground">
<PiCalendarBlank aria-hidden="true" className="shrink-0 text-base text-muted-foreground" />
{formatDateTime(startDate)}
</div>
<div className="flex items-center gap-2 text-popover-foreground">
<PiMapPin aria-hidden="true" className="shrink-0 text-base text-muted-foreground" />
{details.location}
</div>
</div>
<Button className="mt-4 w-full" icon={<PiArrowUpRight aria-hidden="true" />}>
View details
</Button>
</div>
</Popover>
),
}
}
export default function CalendarWeekSchedule() {
const [events, setEvents] = useState(initialEvents)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [openEventId, setOpenEventId] = useState<number | null>(null)
const query = searchQuery.trim().toLowerCase()
const filteredEvents = events.filter((event) => {
const matchesQuery = !query || event.title.toLowerCase().includes(query)
const matchesStatus =
statusFilter === 'all' || candidateDetails[event.id].status === statusFilter
return matchesQuery && matchesStatus
})
return (
<section aria-labelledby={headingId}>
<h5 id={headingId} className="sr-only">
Interview week schedule
</h5>
<div>
<div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-3">
<div>
<span className="text-lg font-semibold">
{filteredEvents.length}
</span>{' '}
<span className="text-muted-foreground">Interviews</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
prefix={
<PiMagnifyingGlass
aria-hidden="true"
className="text-base text-muted-foreground"
/>
}
placeholder="Search candidates"
value={searchQuery}
onChange={(event) =>
setSearchQuery(event.target.value)
}
className="w-48"
/>
<Select
className="w-36"
options={statusOptions}
value={statusOptions.find(
(option) => option.value === statusFilter,
)}
onChange={(selected) => {
if (selected && 'value' in selected) {
setStatusFilter(selected.value as string)
}
}}
/>
</div>
</div>
<div className="w-full h-225 xl:h-screen ">
<FullCalendar
fillHeight
events={filteredEvents}
view="week"
onChange={(_filteredEvents, updatedEvent) => {
setEvents((prev) =>
prev.map((event) =>
event.id === updatedEvent.id
? updatedEvent
: event,
),
)
}}
renderEvent={({ event, view }) =>
buildEventContent({
id: event.id,
title: event.title,
color: event.color,
type: event.type,
description: event.description,
startDate: event.startDate,
endDate: event.endDate,
isOpen: openEventId === event.id,
view: view as 'week' | 'month',
onOpenChange: (open) =>
setOpenEventId(open ? event.id : null),
})
}
renderHeaderStart={({
handlePrevious,
selectedDate,
handleNext,
}) => (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Button
aria-label="Previous period"
icon={<PiCaretLeft aria-hidden="true" />}
variant="ghost"
size="sm"
onClick={handlePrevious}
/>
<Button
aria-label="Next period"
icon={<PiCaretRight aria-hidden="true" />}
variant="ghost"
size="sm"
onClick={handleNext}
/>
<h5 className="ml-1">
{selectedDate.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})}
</h5>
</div>
<div className="flex items-center gap-4">
{legend.map((item) => (
<Tag
key={item.id}
className="gap-1"
>
<span
aria-hidden="true"
className={classNames(
'size-2 shrink-0 rounded-full',
item.dotClass,
)}
/>
{item.label}
</Tag>
))}
</div>
</div>
)}
renderHeaderEnd={({
view,
setView,
}) => (
<div className="flex flex-wrap items-center gap-2">
<Segment
value={view}
onChange={(value) =>
setView(value as CalendarView)
}
>
<Segment.Item value="week">Week</Segment.Item>
<Segment.Item value="month">Month</Segment.Item>
</Segment>
</div>
)}
/>
</div>
</div>
</section>
)
}
Calendar 07
Preview
npx nateui@latest add CalendarMonthSideRailDark
import { useEffect, useRef, useState } from 'react'
import {
PiCaretLeft,
PiCaretRight,
PiPlus
} from 'react-icons/pi'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Checkbox from '@/components/ui/Checkbox'
import DatePicker from '@/components/ui/DatePicker'
import Drawer from '@/components/ui/Drawer'
import Form from '@/components/ui/Form'
import InputGroup from '@/components/ui/InputGroup'
import FullCalendar from '@/components/composites/FullCalendar'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import TimeInput from '@/components/ui/TimeInput'
import classNames from '@/utils/classNames'
import type { RefObject } from 'react'
const headingId = 'calendar-month-side-rail-heading'
const eventFormId = 'calendar-month-side-rail-event-form'
const calendarSources = [
{ id: 'personal', label: 'Personal', color: 'blue', dotClass: 'bg-palette-blue' },
{ id: 'work', label: 'Work', color: 'green', dotClass: 'bg-palette-emerald' },
{ id: 'clients', label: 'Clients', color: 'orange', dotClass: 'bg-palette-orange' },
{ id: 'projects', label: 'Projects', color: 'purple', dotClass: 'bg-palette-purple' },
] as const
const tintClass: Record<string, string> = {
blue: 'bg-palette-blue-soft text-palette-blue',
green: 'bg-palette-emerald-soft text-palette-emerald',
orange: 'bg-palette-orange-soft text-palette-orange',
purple: 'bg-palette-purple-soft text-palette-purple',
}
function toLocalIso(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`
}
const monthAnchor = new Date()
function monthEventTime(day: number, hour: number, minute: number) {
const date = new Date(
monthAnchor.getFullYear(),
monthAnchor.getMonth(),
day,
hour,
minute,
0,
0,
)
return toLocalIso(date)
}
function dateKey(date: Date) {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
}
function isSameDay(a: Date, b: Date) {
return dateKey(a) === dateKey(b)
}
type EventSeed = {
id: number
sourceId: string
title: string
description: string
day: number
start: [number, number]
end: [number, number]
}
function buildEvent(seed: EventSeed) {
const source = calendarSources.find((item) => item.id === seed.sourceId)
const color: string = source ? source.color : 'gray'
return {
id: seed.id,
type: seed.sourceId,
color,
title: seed.title,
description: seed.description,
startDate: monthEventTime(seed.day, seed.start[0], seed.start[1]),
endDate: monthEventTime(seed.day, seed.end[0], seed.end[1]),
}
}
// Irregular on purpose: clusters, empty stretches, varied durations/times,
// and day 14 with one more event than the grid's visible capacity so the
// month view's "+N more" affordance actually appears.
const initialEvents = [
buildEvent({ id: 1, sourceId: 'personal', title: 'Design review', description: 'Walkthrough of the latest screens with feedback.', day: 1, start: [9, 15], end: [10, 0] }),
buildEvent({ id: 2, sourceId: 'work', title: 'Client call', description: 'Weekly check-in with a client contact.', day: 3, start: [8, 30], end: [9, 0] }),
buildEvent({ id: 4, sourceId: 'projects', title: 'Release checklist', description: 'Confirm the checklist before the release window.', day: 3, start: [15, 45], end: [16, 30] }),
buildEvent({ id: 5, sourceId: 'personal', title: 'Quarterly planning', description: 'Set scope and priorities for the quarter.', day: 7, start: [10, 0], end: [12, 0] }),
buildEvent({ id: 6, sourceId: 'work', title: 'Standup', description: 'Daily standup with the immediate team.', day: 8, start: [9, 0], end: [9, 15] }),
buildEvent({ id: 7, sourceId: 'clients', title: 'Onboarding session', description: 'Walk a new teammate through the current setup.', day: 12, start: [13, 30], end: [14, 0] }),
buildEvent({ id: 8, sourceId: 'projects', title: 'Interview slot', description: 'Screening interview for an open role.', day: 12, start: [16, 0], end: [16, 45] }),
buildEvent({ id: 9, sourceId: 'personal', title: 'Vendor sync', description: 'Status update call with a vendor contact.', day: 14, start: [8, 0], end: [8, 30] }),
buildEvent({ id: 10, sourceId: 'work', title: 'Content draft', description: 'First pass review on a draft in progress.', day: 14, start: [9, 0], end: [9, 20] }),
buildEvent({ id: 11, sourceId: 'clients', title: 'Support rotation', description: 'Start of the weekly support rotation shift.', day: 14, start: [10, 0], end: [10, 30] }),
buildEvent({ id: 12, sourceId: 'projects', title: 'Team lunch', description: 'Informal lunch with the immediate team.', day: 14, start: [12, 0], end: [12, 30] }),
buildEvent({ id: 15, sourceId: 'clients', title: 'Roadmap sync', description: 'Align on roadmap priorities with the client.', day: 15, start: [9, 30], end: [10, 0] }),
buildEvent({ id: 16, sourceId: 'personal', title: 'Portfolio review', description: 'Review current project portfolio status.', day: 19, start: [8, 45], end: [9, 30] }),
buildEvent({ id: 18, sourceId: 'projects', title: 'Contract review', description: 'Review terms before countersigning.', day: 19, start: [14, 0], end: [14, 30] }),
buildEvent({ id: 19, sourceId: 'clients', title: 'Travel booking', description: 'Confirm travel arrangements for the client visit.', day: 22, start: [7, 30], end: [8, 0] }),
buildEvent({ id: 20, sourceId: 'personal', title: 'Expense review', description: 'Review and approve outstanding expenses.', day: 23, start: [18, 30], end: [19, 0] }),
buildEvent({ id: 21, sourceId: 'work', title: 'Client workshop', description: 'Facilitate a working session with a client.', day: 28, start: [10, 15], end: [10, 45] }),
buildEvent({ id: 22, sourceId: 'projects', title: 'Workshop prep', description: 'Prepare materials for the client workshop.', day: 28, start: [14, 15], end: [15, 0] }),
]
type EventDraft = {
title: string
sourceId: string
date: Date
startTime: Date
endTime: Date
description: string
}
function defaultDraftDate() {
const today = new Date()
today.setHours(0, 0, 0, 0)
return today
}
function createDraft(date: Date = defaultDraftDate()): EventDraft {
const start = new Date(date)
start.setHours(9, 0, 0, 0)
const end = new Date(start)
end.setMinutes(end.getMinutes() + 30)
return {
title: '',
sourceId: calendarSources[0].id,
date: new Date(date),
startTime: start,
endTime: end,
description: '',
}
}
function createDraftFromEvent(event: {
type: string
title: string
description: string
startDate: string
endDate: string
}): EventDraft {
const startTime = new Date(event.startDate)
const endTime = new Date(event.endDate)
const date = new Date(startTime)
date.setHours(0, 0, 0, 0)
return {
title: event.title,
sourceId: event.type,
date,
startTime,
endTime,
description: event.description,
}
}
function combineDateAndTime(date: Date, time: Date) {
const combined = new Date(date)
combined.setHours(time.getHours(), time.getMinutes(), 0, 0)
return combined
}
function GridDateSync({
bind,
selectedDate,
onDateChange,
setSelectedDate,
}: {
bind: RefObject<((date: Date) => void) | null>
selectedDate: Date
onDateChange: (date: Date) => void
setSelectedDate: (date: Date) => void
}) {
useEffect(() => {
bind.current = setSelectedDate
return () => {
bind.current = null
}
}, [bind, setSelectedDate])
useEffect(() => {
onDateChange(selectedDate)
}, [selectedDate, onDateChange])
return null
}
export default function CalendarMonthSideRail() {
const [events, setEvents] = useState(initialEvents)
const [enabledSourceIds, setEnabledSourceIds] = useState<Set<string>>(
() => new Set(calendarSources.map((source) => source.id)),
)
const [activeDate, setActiveDate] = useState(() => new Date())
const setGridDate = useRef<((date: Date) => void) | null>(null)
const [isDrawerOpen, setDrawerOpen] = useState(false)
const [editingEventId, setEditingEventId] = useState<number | null>(null)
const [draft, setDraft] = useState<EventDraft>(() => createDraft())
const [formError, setFormError] = useState<string | null>(null)
const filteredEvents = events.filter((event) =>
enabledSourceIds.has(event.type),
)
const sourceIdByDay = new Map<string, string>()
filteredEvents.forEach((event) => {
const key = dateKey(new Date(event.startDate))
if (!sourceIdByDay.has(key)) {
sourceIdByDay.set(key, event.type)
}
})
const toggleSource = (id: string, checked: boolean) => {
setEnabledSourceIds((prev) => {
const next = new Set(prev)
if (checked) {
next.add(id)
} else {
next.delete(id)
}
return next
})
}
const openCreateDrawer = (date?: Date) => {
setDraft(createDraft(date))
setEditingEventId(null)
setFormError(null)
setDrawerOpen(true)
}
const openEditDrawer = (event: {
id: number
type: string
title: string
description: string
startDate: string
endDate: string
}) => {
setDraft(createDraftFromEvent(event))
setEditingEventId(event.id)
setFormError(null)
setDrawerOpen(true)
}
const closeDrawer = () => {
setDrawerOpen(false)
setEditingEventId(null)
setFormError(null)
}
const updateDraft = <Key extends keyof EventDraft>(
key: Key,
value: EventDraft[Key],
) => {
setDraft((prev) => ({ ...prev, [key]: value }))
setFormError(null)
}
const handleSubmitEvent = () => {
const title = draft.title.trim()
if (!title) {
setFormError('Add a title before saving.')
return
}
const startDate = combineDateAndTime(draft.date, draft.startTime)
const endDate = combineDateAndTime(draft.date, draft.endTime)
if (endDate <= startDate) {
setFormError('End time must be after start time.')
return
}
const source =
calendarSources.find((item) => item.id === draft.sourceId) ??
calendarSources[0]
const description = draft.description.trim()
if (editingEventId !== null) {
setEvents((prev) =>
prev.map((event) =>
event.id === editingEventId
? {
...event,
type: source.id,
color: source.color,
title,
description,
startDate: toLocalIso(startDate),
endDate: toLocalIso(endDate),
}
: event,
),
)
} else {
const nextId =
Math.max(0, ...events.map((event) => event.id)) + 1
setEvents((prev) => [
...prev,
{
id: nextId,
type: source.id,
color: source.color,
title,
description,
startDate: toLocalIso(startDate),
endDate: toLocalIso(endDate),
},
])
}
setEnabledSourceIds((prev) => {
const next = new Set(prev)
next.add(source.id)
return next
})
closeDrawer()
}
const renderMiniDay = (date: Date) => {
const key = dateKey(date)
const sourceId = sourceIdByDay.get(key)
const source = sourceId
? calendarSources.find((item) => item.id === sourceId)
: undefined
const isSelected = isSameDay(date, activeDate)
return (
<span className="relative flex h-full w-full items-center justify-center">
<span>{date.getDate()}</span>
{source && (
<span
aria-hidden="true"
className={classNames(
'absolute inset-x-0 bottom-0.5 mx-auto h-1 w-1 rounded-full',
isSelected ? 'bg-primary-foreground' : source.dotClass,
)}
/>
)}
</span>
)
}
return (
<section
aria-labelledby={headingId}
className="grid min-h-0 grid-cols-1 lg:grid-cols-[minmax(280px,290px)_minmax(0,1fr)] h-225 xl:h-screen"
>
<aside className="order-2 flex min-h-0 min-w-0 flex-col gap-4 overflow-y-auto border-t px-4 py-4 lg:order-1 lg:border-t-0 lg:border-r lg:px-4">
<Calendar
firstDayOfWeek="sunday"
value={activeDate}
onChange={(date) => {
if (!date) return
setActiveDate(date)
setGridDate.current?.(date)
}}
renderDay={renderMiniDay}
/>
<div>
<h6 className="text-base font-semibold">
Calendars
</h6>
<div className="mt-2 flex flex-col">
{calendarSources.map((source) => (
<Checkbox
key={source.id}
checked={enabledSourceIds.has(source.id)}
onChange={(checked) =>
toggleSource(source.id, checked)
}
className="flex-row-reverse justify-between p-2 rounded-md hover:bg-muted"
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={classNames(
'size-2 shrink-0 rounded-full',
source.dotClass,
)}
/>
{source.label}
</span>
</Checkbox>
))}
</div>
</div>
</aside>
<div className="order-1 flex min-h-0 min-w-0 flex-col lg:order-2">
<FullCalendar
fillHeight
view="month"
events={filteredEvents}
onCellClick={openCreateDrawer}
onEventClick={openEditDrawer}
onChange={(_filteredEvents, updatedEvent) => {
setEvents((prev) =>
prev.map((event) =>
event.id === updatedEvent.id
? updatedEvent
: event,
),
)
}}
renderEvent={({ event }) => {
const tint = tintClass[event.color] ?? tintClass.blue
const source = calendarSources.find(
(item) => item.id === event.type,
)
return {
className: 'bg-card border',
content: (
<div className="flex min-w-0 items-center gap-1.5 truncate">
<span
aria-hidden="true"
className={classNames(
'size-1.5 shrink-0 rounded-full',
source?.dotClass ?? 'bg-palette-gray',
)}
/>
<span className="font-medium">
{event.title}
</span>
<span className="shrink-0 text-muted-foreground">
{new Date(event.startDate).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</span>
</div>
),
}
}}
renderHeaderStart={({
selectedDate,
setSelectedDate,
}) => (
<>
<GridDateSync
bind={setGridDate}
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
onDateChange={setActiveDate}
/>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
</div>
<h5 id={headingId} className="text-lg font-semibold">
{selectedDate.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
})}
</h5>
</div>
</>
)}
renderHeaderEnd={({
setSelectedDate,
handlePrevious,
handleNext,
}) => (
<div className="flex items-center gap-2">
<InputGroup>
<Button
aria-label="Previous month"
icon={<PiCaretLeft aria-hidden="true" />}
size="sm"
onClick={handlePrevious}
/>
<Button
variant="default"
size="sm"
onClick={() => setSelectedDate(new Date())}
>
Today
</Button>
<Button
aria-label="Next month"
icon={<PiCaretRight aria-hidden="true" />}
size="sm"
onClick={handleNext}
/>
</InputGroup>
<Button
variant="solid"
size="sm"
icon={<PiPlus aria-hidden="true" />}
onClick={() => openCreateDrawer()}
>
Add event
</Button>
</div>
)}
/>
</div>
<Drawer
isOpen={isDrawerOpen}
placement="right"
title={editingEventId !== null ? 'Edit event' : 'New event'}
onClose={closeDrawer}
footerClass="flex justify-end gap-2"
footer={
<>
<Button type="button" onClick={closeDrawer}>
Cancel
</Button>
<Button
variant="solid"
form={eventFormId}
type="submit"
>
{editingEventId !== null ? 'Save changes' : 'Create event'}
</Button>
</>
}
>
<Form
id={eventFormId}
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
handleSubmitEvent()
}}
>
{formError && (
<p
className="rounded-control bg-destructive-soft px-3 py-2 text-sm text-destructive"
role="alert"
>
{formError}
</p>
)}
<Form.Field htmlFor="calendar-month-event-title" label="Title">
<Input
id="calendar-month-event-title"
value={draft.title}
placeholder="Add event title"
onChange={(event) =>
updateDraft('title', event.target.value)
}
/>
</Form.Field>
<Form.Field htmlFor="calendar-month-event-source" label="Calendar">
<Select
inputId="calendar-month-event-source"
options={calendarSources.map((source) => ({
value: source.id,
label: source.label,
}))}
value={calendarSources
.map((source) => ({
value: source.id,
label: source.label,
}))
.find((option) => option.value === draft.sourceId)}
customOption={({ option, selected, CheckIcon }) => {
const source = calendarSources.find(
(item) => item.id === option.value,
)
return (
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
{source && (
<span
aria-hidden="true"
className={classNames(
'size-2 shrink-0 rounded-full',
source.dotClass,
)}
/>
)}
<span className="truncate">{option.label}</span>
</span>
{selected && CheckIcon}
</span>
)
}}
onChange={(selected) => {
if (selected && 'value' in selected) {
updateDraft('sourceId', selected.value as string)
}
}}
/>
</Form.Field>
<Form.Field htmlFor="calendar-month-event-date" label="Date">
<DatePicker
id="calendar-month-event-date"
inputFormat="MMMM D, YYYY"
value={draft.date}
clearable={false}
onChange={(date) => {
if (date) {
updateDraft('date', date)
}
}}
/>
</Form.Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Form.Field htmlFor="calendar-month-event-start" label="From">
<TimeInput
id="calendar-month-event-start"
value={draft.startTime}
format="12"
clearable={false}
onChange={(time) => {
if (time) {
updateDraft('startTime', time)
}
}}
/>
</Form.Field>
<Form.Field htmlFor="calendar-month-event-end" label="To">
<TimeInput
id="calendar-month-event-end"
value={draft.endTime}
format="12"
clearable={false}
onChange={(time) => {
if (time) {
updateDraft('endTime', time)
}
}}
/>
</Form.Field>
</div>
<Form.Field htmlFor="calendar-month-event-description" label="Description">
<Input
id="calendar-month-event-description"
textArea
rows={4}
value={draft.description}
placeholder="Add notes or an agenda"
onChange={(event) =>
updateDraft('description', event.target.value)
}
/>
</Form.Field>
</Form>
</Drawer>
</section>
)
}
Calendar 08
Preview
npx nateui@latest add CalendarDayFocusRailDark
import { useEffect, useRef, useState } from 'react'
import {
PiCheck,
PiCalendarBlank,
PiCaretLeft,
PiCaretRight,
PiClock,
PiPencilSimple,
PiPlus,
PiTrash,
} from 'react-icons/pi'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import DatePicker from '@/components/ui/DatePicker'
import Dialog from '@/components/ui/Dialog'
import Drawer from '@/components/ui/Drawer'
import Form from '@/components/ui/Form'
import EmptyState from '@/components/composites/EmptyState'
import FullCalendar from '@/components/composites/FullCalendar'
import IconFrame from '@/components/composites/IconFrame'
import Input from '@/components/ui/Input'
import Popover from '@/components/ui/Popover'
import InputGroup from '@/components/ui/InputGroup'
import Scroll from '@/components/ui/Scroll'
import Select from '@/components/ui/Select'
import Switcher from '@/components/ui/Switcher'
import TimeInput from '@/components/ui/TimeInput'
import type { RefObject } from 'react'
import type {
CalendarView,
FullCalendarEvent,
} from '@/components/composites/FullCalendar'
const headingId = 'calendar-day-focus-rail-heading'
const eventFormId = 'calendar-day-focus-rail-event-form'
const assetBase = 'https://statics.nateui.com/img'
const calendars = [
{ id: 'fieldwork', label: 'Fieldwork', color: 'blue' },
{ id: 'planning', label: 'Planning', color: 'purple' },
{ id: 'operations', label: 'Operations', color: 'green' },
]
const calendarOptions = calendars.map((calendar) => ({
value: calendar.id,
label: calendar.label,
}))
const swatchClass: Record<string, string> = {
blue: 'bg-palette-blue',
purple: 'bg-palette-purple',
green: 'bg-palette-emerald',
}
const viewOptions: { label: string; value: CalendarView }[] = [
{ label: 'Month', value: 'month' },
{ label: 'Week', value: 'week' },
{ label: 'Day', value: 'day' },
{ label: 'Agenda', value: 'agenda' },
]
const guestOptions = [
{ id: 'mira', name: 'Mira Chen', avatar: `${assetBase}/avatars/thumb-2.jpg` },
{ id: 'noah', name: 'Noah Patel', avatar: `${assetBase}/avatars/thumb-4.jpg` },
{ id: 'rhea', name: 'Rhea Morgan', avatar: `${assetBase}/avatars/thumb-6.jpg` },
{ id: 'aiko', name: 'Aiko Tan', avatar: `${assetBase}/avatars/thumb-8.jpg` },
{ id: 'jules', name: 'Jules Martin', avatar: `${assetBase}/avatars/thumb-10.jpg` },
]
const initialGuestIds: Record<number, string[]> = {
1: ['mira', 'noah', 'rhea'],
2: ['aiko', 'mira'],
3: ['noah', 'rhea', 'aiko', 'jules'],
4: ['rhea', 'jules'],
5: ['mira', 'aiko', 'jules'],
}
const eventTintClass: Record<string, string> = {
blue: 'bg-palette-blue-soft/60 hover:bg-palette-blue-soft text-palette-blue-soft-foreground border-palette-blue-soft',
green: 'bg-palette-emerald-soft/60 hover:bg-palette-emerald-soft text-palette-emerald-soft-foreground border-palette-emerald-soft',
purple: 'bg-palette-purple-soft/60 hover:bg-palette-purple-soft text-palette-purple-soft-foreground border-palette-purple-soft',
}
const startOfToday = () => {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date
}
const todayAnchor = startOfToday()
const addDays = (date: Date, amount: number) => {
const result = new Date(date)
result.setDate(result.getDate() + amount)
return result
}
const addMinutes = (date: Date, amount: number) => {
const result = new Date(date)
result.setMinutes(result.getMinutes() + amount)
return result
}
const createFixtureEvent = (
id: number,
type: string,
color: string,
title: string,
description: string,
dayOffset: number,
hour: number,
minute: number,
duration: number,
) => {
const start = addDays(todayAnchor, dayOffset)
start.setHours(hour, minute, 0, 0)
const end = addMinutes(start, duration)
return {
id,
type,
color,
title,
description,
startDate: start.toISOString(),
endDate: end.toISOString(),
}
}
const initialEvents = [
createFixtureEvent(
1,
'fieldwork',
'blue',
'Site survey',
'Walk the north lot and record the latest measurements.',
0,
8,
20,
45,
),
createFixtureEvent(
2,
'planning',
'purple',
'Material review',
'Compare the sample set and mark the next round of revisions.',
0,
8,
45,
90,
),
createFixtureEvent(
3,
'operations',
'green',
'Print run planning',
'Confirm quantities, paper stock, and the handoff sequence.',
0,
11,
30,
90,
),
createFixtureEvent(
4,
'fieldwork',
'blue',
'Inventory count',
'Reconcile the storage count before the afternoon dispatch.',
0,
15,
0,
20,
),
createFixtureEvent(
5,
'planning',
'purple',
'Bench calibration',
'Review calibration notes and approve the next test window.',
0,
16,
10,
50,
),
createFixtureEvent(
6,
'operations',
'green',
'Route check',
'Check the delivery route against the updated site notes.',
-23,
10,
0,
50,
),
createFixtureEvent(
7,
'fieldwork',
'blue',
'Sample pickup',
'Collect the prepared sample cases from the staging room.',
-17,
13,
15,
35,
),
createFixtureEvent(
8,
'planning',
'purple',
'Layout review',
'Review the second layout pass and leave focused notes.',
-11,
9,
30,
75,
),
createFixtureEvent(
9,
'operations',
'green',
'Storage reset',
'Reset the storage map after the incoming materials arrive.',
-6,
14,
0,
30,
),
createFixtureEvent(
10,
'planning',
'purple',
'Quarter outline',
'Shape the next quarter outline from the current field notes.',
-2,
16,
30,
45,
),
createFixtureEvent(
11,
'fieldwork',
'blue',
'Workshop setup',
'Prepare the tools and printed plans for the working session.',
1,
9,
0,
60,
),
createFixtureEvent(
12,
'operations',
'green',
'Dispatch review',
'Review the open dispatch list and close completed handoffs.',
4,
12,
40,
40,
),
createFixtureEvent(
13,
'planning',
'purple',
'Archive planning',
'Decide which notes should move into the project archive.',
8,
15,
20,
55,
),
createFixtureEvent(
14,
'fieldwork',
'blue',
'Field notes handoff',
'Hand off the annotated field notes for final review.',
13,
10,
15,
30,
),
createFixtureEvent(
15,
'operations',
'green',
'Supply check',
'Check the replenishment list and flag anything below threshold.',
19,
13,
0,
45,
),
]
type EventDraft = {
title: string
calendarId: string
allDay: boolean
startDate: Date
endDate: Date
startTime: Date
endTime: Date
description: string
}
const startOfDate = (date: Date) => {
const result = new Date(date)
result.setHours(0, 0, 0, 0)
return result
}
const endOfDate = (date: Date) => {
const result = new Date(date)
result.setHours(23, 59, 59, 999)
return result
}
const createDraft = (date: Date = new Date()): EventDraft => {
const startDate = startOfDate(date)
const startTime = new Date(date)
if (startTime.getHours() === 0 && startTime.getMinutes() === 0) {
startTime.setHours(9, 0, 0, 0)
} else {
startTime.setSeconds(0, 0)
}
const endTime = addMinutes(startTime, 30)
return {
title: '',
calendarId: calendars[0].id,
allDay: false,
startDate,
endDate: startOfDate(endTime),
startTime,
endTime,
description: '',
}
}
const isAllDayEvent = (event: FullCalendarEvent) => {
const start = new Date(event.startDate)
const end = new Date(event.endDate)
const startsAtMidnight =
start.getHours() === 0 && start.getMinutes() === 0
const endsAtDayClose =
end.getHours() === 23 &&
end.getMinutes() === 59 &&
end.getSeconds() === 59
return startsAtMidnight && endsAtDayClose
}
const createDraftFromEvent = (event: FullCalendarEvent): EventDraft => {
const start = new Date(event.startDate)
const end = new Date(event.endDate)
const allDay = isAllDayEvent(event)
return {
title: event.title,
calendarId: event.type,
allDay,
startDate: startOfDate(start),
endDate: startOfDate(end),
startTime: start,
endTime: end,
description: event.description,
}
}
const combineDateAndTime = (date: Date, time: Date) => {
const combined = new Date(date)
combined.setHours(time.getHours(), time.getMinutes(), 0, 0)
return combined
}
const isSameDay = (left: Date | string, right: Date | string) => {
const first = new Date(left)
const second = new Date(right)
return (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
)
}
const formatDate = (date: Date | string) =>
new Date(date).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
const formatWeekday = (date: Date | string) =>
new Date(date).toLocaleDateString('en-US', { weekday: 'long' })
const formatTime = (date: Date | string) =>
new Date(date).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const getEventsForDay = (
events: FullCalendarEvent[],
selectedDate: Date,
) => {
return events
.filter((event) => isSameDay(event.startDate, selectedDate))
.sort(
(left, right) =>
new Date(left.startDate).getTime() -
new Date(right.startDate).getTime(),
)
}
const getFirstEventForDay = (
events: FullCalendarEvent[],
selectedDate: Date,
) => getEventsForDay(events, selectedDate)[0] || null
const getNextEventForDay = (
events: FullCalendarEvent[],
selectedDate: Date,
) => {
const dayEvents = getEventsForDay(events, selectedDate)
return (
dayEvents.find((event) => new Date(event.endDate) >= new Date()) ||
dayEvents[0] ||
null
)
}
const renderMiniDay = (
date: Date,
selectedDate: Date,
events: FullCalendarEvent[],
) => {
const hasEvents = events.some((event) => isSameDay(event.startDate, date))
const isSelected = isSameDay(date, selectedDate)
return (
<span className="relative flex h-full w-full items-center justify-center">
<span>{date.getDate()}</span>
{hasEvents && (
<span
aria-hidden="true"
className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${isSelected ? 'bg-primary-foreground' : 'bg-primary'}`}
/>
)}
</span>
)
}
function GridDateSync({
bind,
selectedDate,
setSelectedDate,
onDateChange,
view,
onViewChange,
}: {
bind: RefObject<((date: Date) => void) | null>
selectedDate: Date
setSelectedDate: (date: Date) => void
onDateChange: (date: Date) => void
view: CalendarView
onViewChange: (view: CalendarView) => void
}) {
useEffect(() => {
bind.current = setSelectedDate
return () => {
bind.current = null
}
}, [bind, setSelectedDate])
useEffect(() => {
onDateChange(selectedDate)
}, [onDateChange, selectedDate])
useEffect(() => {
onViewChange(view)
}, [onViewChange, view])
return null
}
export default function CalendarDayFocusRail() {
const [calendarEvents, setCalendarEvents] = useState(initialEvents)
const [selectedEventId, setSelectedEventId] = useState<number | null>(null)
const [guestIdsByEvent, setGuestIdsByEvent] =
useState<Record<number, string[]>>(initialGuestIds)
const [isEventDrawerOpen, setEventDrawerOpen] = useState(false)
const [editingEventId, setEditingEventId] = useState<number | null>(null)
const [eventToDelete, setEventToDelete] =
useState<FullCalendarEvent | null>(null)
const [activeDate, setActiveDate] = useState(todayAnchor)
const [activeView, setActiveView] = useState<CalendarView>('day')
const [eventDraft, setEventDraft] = useState<EventDraft>(() =>
createDraft(todayAnchor),
)
const [formError, setFormError] = useState<string | null>(null)
const setCalendarDateRef = useRef<((date: Date) => void) | null>(null)
const calendarEventsRef = useRef(calendarEvents)
useEffect(() => {
calendarEventsRef.current = calendarEvents
}, [calendarEvents])
useEffect(() => {
if (activeView !== 'day') return
// DayView has no initial-scroll prop, so mirror WeekView's one-hour
// lookback against its Scroll viewport from this single DOM bridge.
const frame = window.requestAnimationFrame(() => {
const viewport = document.querySelector<HTMLElement>(
'section .scroll-viewport',
)
if (!viewport) return
const firstEvent = getFirstEventForDay(
calendarEventsRef.current,
activeDate,
)
const anchor = firstEvent
? new Date(firstEvent.startDate)
: new Date()
const anchorMinutes =
anchor.getHours() * 60 + anchor.getMinutes()
const targetMinutes = Math.max(0, anchorMinutes - 60)
const targetTop = (targetMinutes / 60) * 96
const maxScrollTop = Math.max(
0,
viewport.scrollHeight - viewport.clientHeight,
)
viewport.scrollTop = Math.min(targetTop, maxScrollTop)
})
return () => window.cancelAnimationFrame(frame)
}, [activeDate, activeView])
const isEditingEvent = editingEventId !== null
const openCreateDrawer = (date: Date = new Date()) => {
setEventDraft(createDraft(date))
setFormError(null)
setEditingEventId(null)
setEventDrawerOpen(true)
}
const openEditDrawer = (event: FullCalendarEvent) => {
setEventDraft(createDraftFromEvent(event))
setFormError(null)
setEditingEventId(event.id)
setEventDrawerOpen(true)
}
const closeEventDrawer = () => {
setEventDrawerOpen(false)
setEditingEventId(null)
setFormError(null)
}
const updateEventDraft = <Key extends keyof EventDraft>(
key: Key,
value: EventDraft[Key],
) => {
setEventDraft((currentDraft) => ({
...currentDraft,
[key]: value,
}))
setFormError(null)
}
const handleSaveEvent = () => {
const title = eventDraft.title.trim()
const calendar = calendars.find(
(item) => item.id === eventDraft.calendarId,
)
const startDateTime = combineDateAndTime(
eventDraft.startDate,
eventDraft.startTime,
)
const endDateTime = eventDraft.allDay
? endOfDate(eventDraft.endDate)
: combineDateAndTime(eventDraft.endDate, eventDraft.endTime)
if (!title) {
setFormError('Add a title before saving the event.')
return
}
if (!calendar) {
setFormError('Choose a calendar before saving the event.')
return
}
if (endDateTime <= startDateTime) {
setFormError('The end must be after the start.')
return
}
const nextEventId =
editingEventId ??
Math.max(0, ...calendarEvents.map((event) => event.id)) + 1
setCalendarEvents((currentEvents) => {
if (editingEventId !== null) {
return currentEvents.map((event) =>
event.id === editingEventId
? {
...event,
type: calendar.id,
color: calendar.color,
title,
description: eventDraft.description.trim(),
startDate: startDateTime.toISOString(),
endDate: endDateTime.toISOString(),
}
: event,
)
}
return [
...currentEvents,
{
id: nextEventId,
type: calendar.id,
color: calendar.color,
title,
description: eventDraft.description.trim(),
startDate: startDateTime.toISOString(),
endDate: endDateTime.toISOString(),
},
]
})
if (editingEventId === null) {
setGuestIdsByEvent((currentGuests) => ({
...currentGuests,
[nextEventId]: [],
}))
}
setSelectedEventId(nextEventId)
closeEventDrawer()
}
const handleCalendarDateChange = (
date: Date,
setSelectedDate: (date: Date) => void,
) => {
setSelectedEventId(null)
setSelectedDate(date)
}
const handleCellClick = (date: Date) => {
setCalendarDateRef.current?.(date)
setSelectedEventId(null)
openCreateDrawer(date)
}
const handleCalendarChange = (
_events: FullCalendarEvent[],
updatedEvent: FullCalendarEvent,
) => {
setCalendarEvents((currentEvents) =>
currentEvents.map((event) =>
event.id === updatedEvent.id ? updatedEvent : event,
),
)
}
const handleDelete = () => {
if (!eventToDelete) return
const eventId = eventToDelete.id
setCalendarEvents((currentEvents) =>
currentEvents.filter((event) => event.id !== eventId),
)
setGuestIdsByEvent((currentGuests) => {
const nextGuests = { ...currentGuests }
delete nextGuests[eventId]
return nextGuests
})
setSelectedEventId(null)
setEventToDelete(null)
}
const getGuestIds = (eventId: number) => guestIdsByEvent[eventId] ?? []
const toggleGuest = (
eventId: number,
guestId: string,
checked: boolean,
) => {
setGuestIdsByEvent((currentGuests) => {
const currentIds = currentGuests[eventId] ?? []
const nextIds = checked
? Array.from(new Set([...currentIds, guestId]))
: currentIds.filter((id) => id !== guestId)
return {
...currentGuests,
[eventId]: nextIds,
}
})
}
const renderHeaderStart = ({
selectedDate,
setSelectedDate,
view,
}: {
selectedDate: Date
setSelectedDate: (date: Date) => void
view: CalendarView
}) => {
return (
<>
<GridDateSync
bind={setCalendarDateRef}
selectedDate={selectedDate}
setSelectedDate={setSelectedDate}
onDateChange={setActiveDate}
view={view}
onViewChange={setActiveView}
/>
<div className="flex min-w-0 items-center gap-4">
<div className="flex h-12 w-12 shrink-0 flex-col items-center justify-between rounded-control border bg-card text-card-foreground overflow-hidden">
<span className="text-[10px] font-medium uppercase bg-muted w-full h-4 text-center items-center">
{selectedDate.toLocaleDateString('en-US', {
month: 'short',
})}
</span>
<span className="font-semibold flex-1 flex items-center">
{selectedDate.getDate()}
</span>
</div>
<div className="min-w-0">
<h5 id={headingId} className="truncate">
{formatDate(selectedDate)}
</h5>
<p className="text-xs text-muted-foreground">
{formatWeekday(selectedDate)}
</p>
</div>
</div>
</>
)
}
const renderHeaderEnd = ({
handlePrevious,
handleNext,
selectedDate,
setSelectedDate,
view,
setView,
}: {
handlePrevious: () => void
handleNext: () => void
selectedDate: Date
setSelectedDate: (date: Date) => void
view: CalendarView
setView: (view: CalendarView) => void
}) => {
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
<InputGroup>
<Button
aria-label="Previous period"
icon={<PiCaretLeft aria-hidden="true" />}
size="sm"
onClick={() => {
setSelectedEventId(null)
handlePrevious()
}}
/>
<Button
size="sm"
variant="default"
onClick={() => {
setSelectedEventId(null)
setSelectedDate(new Date())
}}
>
Today
</Button>
<Button
aria-label="Next period"
icon={<PiCaretRight aria-hidden="true" />}
size="sm"
onClick={() => {
setSelectedEventId(null)
handleNext()
}}
/>
</InputGroup>
</div>
<Select
className="w-28"
options={viewOptions}
size="sm"
value={viewOptions.find((option) => option.value === view)}
onChange={(selected) => {
if (selected && 'value' in selected) {
setView(selected.value as CalendarView)
}
}}
/>
<Button
icon={<PiPlus aria-hidden="true" />}
size="sm"
variant="solid"
onClick={() => openCreateDrawer(selectedDate)}
>
Add event
</Button>
</div>
)
}
return (
<section
aria-labelledby={headingId}
className="flex h-full min-h-0 max-h-dvh min-w-0 flex-col overflow-hidden"
>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<FullCalendar
fillHeight
events={calendarEvents}
view="day"
onCellClick={handleCellClick}
onEventClick={(event) => setSelectedEventId(event.id)}
onChange={handleCalendarChange}
renderEvent={({ event }) => {
const start = new Date(event.startDate)
const end = new Date(event.endDate)
const durationMinutes = Math.round(
(end.getTime() - start.getTime()) / 60000,
)
return {
className:
eventTintClass[event.color] ??
eventTintClass.blue,
content: (
<>
<div className="flex items-center gap-2 truncate">
<p className="truncate font-medium">
{event.title}
</p>
</div>
{durationMinutes > 25 && (
<p>
{formatTime(start)} - {formatTime(end)}
</p>
)}
</>
),
}
}}
renderHeaderStart={renderHeaderStart}
renderHeaderEnd={renderHeaderEnd}
renderDayViewSidebar={({ selectedDate, setSelectedDate }) => {
const activeEvent = getNextEventForDay(
calendarEvents,
selectedDate,
)
const selectedEvent = calendarEvents.find(
(event) =>
event.id === selectedEventId &&
isSameDay(event.startDate, selectedDate),
)
const detailEvent = selectedEvent || activeEvent
const selectedGuestIds = detailEvent
? getGuestIds(detailEvent.id)
: []
const guests = guestOptions.filter((guest) =>
selectedGuestIds.includes(guest.id),
)
return (
<div className="hidden min-h-0 min-w-0 w-72 shrink-0 flex-col border-l bg-card md:flex">
<div className="shrink-0 border-b p-4">
<Calendar
className="w-full min-w-0"
firstDayOfWeek="monday"
labelFormat={{
month: 'MMMM',
year: 'YYYY',
}}
renderDay={(date) =>
renderMiniDay(
date,
selectedDate,
calendarEvents,
)
}
value={selectedDate}
onChange={(date) => {
if (date) {
handleCalendarDateChange(
date,
setSelectedDate,
)
}
}}
/>
</div>
<Scroll
className="min-h-0 flex-1"
scrollbars="vertical"
type="always"
>
<div className="p-4">
{detailEvent ? (
<div>
<div className="flex items-start justify-between gap-2">
<h5 className="text-lg font-sembold min-w-0 truncate">
{detailEvent.title}
</h5>
<div className="flex shrink-0 items-center gap-1">
<Button
aria-label="Delete event"
destructive
icon={<PiTrash aria-hidden="true" />}
size="sm"
variant="ghost"
onClick={() =>
setEventToDelete(
detailEvent,
)
}
/>
<Button
aria-label="Edit event"
icon={<PiPencilSimple aria-hidden="true" />}
size="sm"
variant="ghost"
onClick={() =>
openEditDrawer(
detailEvent,
)
}
/>
</div>
</div>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2">
<PiCalendarBlank
aria-hidden="true"
className="shrink-0 text-lg text-muted-foreground"
/>
<span>
{formatDate(
detailEvent.startDate,
)}
</span>
</div>
<div className="flex items-center gap-2">
<PiClock
aria-hidden="true"
className="shrink-0 text-lg text-muted-foreground"
/>
<span>
{isAllDayEvent(
detailEvent,
)
? 'All day'
: `${formatTime(detailEvent.startDate)} - ${formatTime(detailEvent.endDate)}`}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-2 border-t pt-4">
<div className="flex min-w-0 items-center">
<div className="flex shrink-0 items-center">
{guests.map(
(guest, index) => (
<Avatar
key={guest.id}
alt=""
className={
index >
0
? '-ml-2'
: ''
}
size={28}
src={guest.avatar}
/>
),
)}
</div>
<Popover
placement="top-end"
trigger="click"
width={260}
className="p-2"
renderTrigger={
<Button
aria-label="Manage guests"
className="ml-2"
icon={<PiPlus aria-hidden="true" />}
shape="circle"
size="sm"
variant="subtle"
/>
}
>
<div>
{guestOptions.map(
(guest) => {
const checked = selectedGuestIds.includes(
guest.id,
)
return (
<button
aria-pressed={checked}
key={guest.id}
type="button"
className="flex cursor-pointer items-center gap-2 rounded-control-sm px-2 py-2 hover:bg-accent w-full"
onClick={() =>
toggleGuest(
detailEvent.id,
guest.id,
!checked,
)
}
>
<span className="flex items-center gap-2">
<Avatar
alt=""
size={24}
src={guest.avatar}
/>
<span className="min-w-0 truncate">
{guest.name}
</span>
</span>
{checked && (
<PiCheck
aria-hidden="true"
className="ml-auto shrink-0 text-lg text-primary"
/>
)}
</button>
)
},
)}
</div>
</Popover>
</div>
<span className="shrink-0 text-muted-foreground">
{selectedGuestIds.length}{' '}
guests
</span>
</div>
<div className="mt-4 border-t pt-4">
<div className="font-semibold">
Description
</div>
<p className="mt-2 text-muted-foreground">
{detailEvent.description ||
'No description yet.'}
</p>
</div>
</div>
) : (
<div className="flex items-center justify-center py-4">
<EmptyState
illustration={
<IconFrame>
<PiCalendarBlank
aria-hidden="true"
className="text-2xl"
/>
</IconFrame>
}
size={180}
offset={-40}
variant="dots"
>
<div className="flex max-w-52 flex-col items-center text-center">
<h6 className="font-semibold text-base">
Nothing scheduled
</h6>
<p className="text-muted-foreground mt-1">
Add an event to start
planning this day.
</p>
<Button
className="mt-4"
icon={<PiPlus aria-hidden="true" />}
onClick={() =>
openCreateDrawer(
selectedDate,
)
}
>
Add event
</Button>
</div>
</EmptyState>
</div>
)}
</div>
</Scroll>
</div>
)
}}
/>
</div>
</div>
<Drawer
isOpen={isEventDrawerOpen}
placement="right"
title={isEditingEvent ? 'Edit event' : 'New event'}
onClose={closeEventDrawer}
footerClass="flex justify-end gap-2"
footer={
<>
<Button type="button" onClick={closeEventDrawer}>
Cancel
</Button>
<Button
form={eventFormId}
type="submit"
variant="solid"
>
{isEditingEvent ? 'Save changes' : 'Create event'}
</Button>
</>
}
>
<Form
id={eventFormId}
containerClassName="space-y-4"
onSubmit={(event) => {
event.preventDefault()
handleSaveEvent()
}}
>
{formError && (
<p
className="rounded-control bg-destructive-soft px-3 py-2 text-destructive"
role="alert"
>
{formError}
</p>
)}
<div>
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-title"
label="Title"
>
<Input
id="calendar-day-focus-title"
placeholder="Add event title"
value={eventDraft.title}
onChange={(event) =>
updateEventDraft('title', event.target.value)
}
/>
</Form.Field>
</div>
<div>
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-calendar"
label="Calendar"
>
<Select
inputId="calendar-day-focus-calendar"
options={calendarOptions}
value={calendarOptions.find(
(option) =>
option.value === eventDraft.calendarId,
)}
customInputDisplay={(selected) => {
const calendar = calendars.find(
(item) => item.id === selected?.value,
)
return calendar ? (
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`size-2 rounded-full ${swatchClass[calendar.color]}`}
/>
<span>{calendar.label}</span>
</span>
) : null
}}
customOption={({ option, selected, CheckIcon }) => {
const calendar = calendars.find(
(item) => item.id === option.value,
)
return (
<span className="flex w-full items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
{calendar && (
<span
aria-hidden="true"
className={`size-2 shrink-0 rounded-full ${swatchClass[calendar.color]}`}
/>
)}
<span className="truncate">
{option.label}
</span>
</span>
{selected && CheckIcon}
</span>
)
}}
onChange={(selected) => {
if (selected && 'value' in selected) {
updateEventDraft(
'calendarId',
selected.value as string,
)
}
}}
/>
</Form.Field>
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground">All day</span>
<Switcher
aria-label="All day"
checked={eventDraft.allDay}
onChange={(checked) =>
updateEventDraft('allDay', checked)
}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-start-date"
label="Starts"
>
<DatePicker
id="calendar-day-focus-start-date"
inputFormat="MMMM D, YYYY"
value={eventDraft.startDate}
clearable={false}
onChange={(date) => {
if (date) {
updateEventDraft('startDate', date)
}
}}
/>
</Form.Field>
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-end-date"
label="Ends"
>
<DatePicker
id="calendar-day-focus-end-date"
inputFormat="MMMM D, YYYY"
value={eventDraft.endDate}
clearable={false}
onChange={(date) => {
if (date) {
updateEventDraft('endDate', date)
}
}}
/>
</Form.Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-start-time"
label="From"
>
<TimeInput
id="calendar-day-focus-start-time"
value={eventDraft.startTime}
format="24"
clearable={false}
disabled={eventDraft.allDay}
onChange={(time) => {
if (time) {
updateEventDraft('startTime', time)
}
}}
/>
</Form.Field>
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-end-time"
label="To"
>
<TimeInput
id="calendar-day-focus-end-time"
value={eventDraft.endTime}
format="24"
clearable={false}
disabled={eventDraft.allDay}
onChange={(time) => {
if (time) {
updateEventDraft('endTime', time)
}
}}
/>
</Form.Field>
</div>
<div>
<Form.Field
className="mb-0"
htmlFor="calendar-day-focus-description"
label="Description"
>
<Input
id="calendar-day-focus-description"
placeholder="Add notes or an agenda"
rows={4}
textArea
value={eventDraft.description}
onChange={(event) =>
updateEventDraft(
'description',
event.target.value,
)
}
/>
</Form.Field>
</div>
</Form>
</Drawer>
<Dialog
isOpen={eventToDelete !== null}
aria-describedby="calendar-day-focus-delete-description"
aria-labelledby="calendar-day-focus-delete-title"
width={420}
onClose={() => setEventToDelete(null)}
>
<div className="space-y-4">
<div>
<h5 id="calendar-day-focus-delete-title">
Delete event
</h5>
<p
id="calendar-day-focus-delete-description"
className="mt-2 text-muted-foreground"
>
Delete "{eventToDelete?.title}"? This cannot
be undone.
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
onClick={() => setEventToDelete(null)}
>
Cancel
</Button>
<Button
destructive
type="button"
variant="solid"
onClick={handleDelete}
>
Delete event
</Button>
</div>
</div>
</Dialog>
</section>
)
}