Skip to content
Star on GitHub
DatesEvent calendar
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Event calendar

A week view where the agent finds a free slot for you. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add event-calendar
Oct 19 – 23, 2026
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add event-calendar

React

$npx designme add event-calendar --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

A work-week calendar where people drag events to a new time or day, click free time to add one, and open any event for its details. An agent button in the header asks your agent to find a slot, and the slots it suggests appear as outlined blocks with Book and Other. It shows scheduling help from an agent right inside the calendar people already use.

  • When an agent schedules meetings or finds free time for a team.
  • When people need to move and add events in a simple week view without a full calendar app.
  • When the agent can prepare something for a meeting, such as prep notes, from the event itself.
03

Usage

Import it and drive it from your own state.

Import

import { EventCalendar } from '@/components/designme/event-calendar/EventCalendar';

Example

The demo above as a React component, 56 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useCallback, useRef, useState } from 'react';
import { EventCalendar, type EventCalendarEvent, type EventCalendarPerson, type EventCalendarSuggestion, type EventCalendarTone } from './EventCalendar';
import { useStory, useWait } from '../lib/useScript';

const WEEK = new Date(2026, 9, 19);
const at = (day: number, hour: number) => new Date(2026, 9, 19 + day, Math.floor(hour), Math.round((hour % 1) * 60));
const WHO: EventCalendarPerson[] = [{ initials: 'MK', color: 'var(--dm-av-l)' }, { initials: 'JO', color: 'var(--dm-av-m)' }, { initials: 'DA', color: 'var(--dm-av-b)' }, { initials: 'AR', color: 'var(--dm-av-p)' }];
// two to four people, the way the site picks them
const people = (title: string) => WHO.slice(0, 2 + title.length % 3);
const EVENTS: [number, number, number, string, EventCalendarTone][] = [
  [0, 9, 1, 'Standup', 'blue'], [0, 13, 1.5, 'Pricing workshop', 'orange'], [1, 10, 1, '1:1 with Maya', 'purple'], [1, 14, 2, 'Focus: checkout', 'teal'],
  [2, 9, 1, 'Standup', 'blue'], [2, 11.5, 1, 'Agent demo', 'yellow'], [3, 9, 1, 'Standup', 'blue'], [3, 12, 1, 'Lunch & learn', 'green'],
  [3, 15, 1.5, 'Launch review', 'red'], [4, 10, 1.5, 'Planning', 'orange']
];
const START: EventCalendarEvent[] = EVENTS.map(([d, h, len, title, tone], i) => ({ id: 'e' + i, title, start: at(d, h), end: at(d, h + len), tone, people: people(title) }));
const SLOTS: EventCalendarSuggestion[] = [
  { id: 'wed', title: 'Design review', start: at(2, 13.5), end: at(2, 14.25) },
  { id: 'fri', title: 'Design review', start: at(4, 13), end: at(4, 13.75) }
];

export default function EventCalendarDemo() {
  const [events, setEvents] = useState(START);
  const [suggestions, setSuggestions] = useState<EventCalendarSuggestion[]>([]);
  const [finding, setFinding] = useState(false);
  const [label, setLabel] = useState('Find 45 min for a design review');
  const skipped = useRef(false);
  const wait = useWait();

  // the agent checks everyone's calendar, then suggests the best slot with one more behind it
  const ask = useCallback(async () => {
    setSuggestions([]); skipped.current = false;
    setFinding(true); setLabel('Checking 4 calendars');
    await wait(1300);
    setSuggestions(SLOTS); setFinding(false); setLabel('Everyone is free Wed 1:30');
  }, [wait]);
  // the preview asks the agent for a slot once on its own, unless you take over first
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async s => { await s.wait(1200); await ask(); });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <EventCalendar week={WEEK} now={new Date(2026, 9, 19, 10, 42)} events={events} onEventsChange={setEvents}
        newEvent={{ tone: 'pink', people: people('New event') }}
        onPrepNotes={() => wait(1200).then(() => "Notes ready: last week's decisions and 3 open questions.")} onJoin={() => {}}
        askLabel={label} onAsk={ask} finding={finding} suggestions={suggestions}
        onBook={s => {
          setEvents(es => [...es, { id: s.id + '-booked', title: s.title, start: s.start, end: s.end, tone: 'blue', people: people(s.title) }]);
          setSuggestions([]); setLabel(skipped.current ? 'Booked Fri 1:00, invites sent' : 'Booked, invites sent to 4 people');
        }}
        onSkip={() => { skipped.current = true; setSuggestions(ss => ss.slice(1)); setLabel('Next best: Fri 1:00'); }} />
    </div>
  );
}
04

Props

Everything EventCalendar takes.

PropTypeWhat it does
eventsEventCalendarEvent[]The events. Dragging moves them, clicking free time adds one, the details card names or removes it
defaultEventsEventCalendarEvent[]
onEventsChange(events: EventCalendarEvent[]) => void
weekrequiredDateThe first day shown, like Monday Oct 19. Its time of day is ignored
daysnumberHow many days to show, from `week`. Defaults to 5
startHournumberThe first hour on the grid, 9 for 9am
hoursnumberHow many hours the grid shows. Defaults to 8 (9am to 5pm)
titlestringThe heading. Defaults to the dates shown, like "Oct 19 – 23, 2026"
nowDateMarks today and draws the now line. Leave it out for neither
selectedstring | nullThe event whose details card is open
defaultSelectedstring | null
onSelectedChange(id: string | null) => void
newEvent{ tone?: EventCalendarTone; people?: EventCalendarPerson[]; hours?: number }What an event added by clicking free time starts with. Defaults to a pink, one hour event
onPrepNotes(event: EventCalendarEvent) => ReactNode | Promise<ReactNode>Adds Prep notes to the details card. Return the notes, or a promise of them; "Writing" shows until it settles
onJoin(event: EventCalendarEvent) => voidAdds Join to the details card
askLabelstringAdds the agent's button to the header, like "Find 45 min for a design review"
onAsk() => void
findingbooleanThe agent is looking: its button thinks and a scan line sweeps the week
suggestionsEventCalendarSuggestion[]Slots the agent suggests. The first is the pick, the rest wait dimmed behind it
onBook(suggestion: EventCalendarSuggestion) => voidBook on the picked suggestion. Add the event and clear the suggestions
onSkip(suggestion: EventCalendarSuggestion) => voidOther on the picked suggestion, shown while there are more. Drop it and the next one becomes the pick
statusstringThe quiet line in the header. Defaults to what the person last did, like "Moved Standup to Wed 1:15"
daynumberThe day a narrow calendar shows, as its place in the week (0 for the first day). A calendar under 520px wide shows one day at a time, picked from a row of days; it opens on today, and moves to the day of the agent's pick when one comes
defaultDaynumber
onDayChange(day: number) => void
classNamestring
05

Code

The full source, as the CLI installs it.

EventCalendar.tsxReact · 361 lines
'use client';
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, enter, Swap, Think, Tip } from '../lib/motion';
import '../designme.css';
import './event-calendar.css';

export type EventCalendarTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';

export interface EventCalendarPerson {
  /** Like "MK" */
  initials: string;
  /** The avatar's colour, like 'var(--dm-av-l)' */
  color: string;
}

export interface EventCalendarEvent {
  id: string;
  /** Like "Pricing workshop". An empty title reads "New event" and opens with a box to name it */
  title: string;
  /** Read in the browser's local time */
  start: Date;
  end: Date;
  /** The block's colour. Defaults to blue */
  tone?: EventCalendarTone;
  /** Who is invited, shown in the details card */
  people?: EventCalendarPerson[];
}

/** A free slot the agent found, drawn as an outlined block with Book (and Other while there are more) */
export interface EventCalendarSuggestion {
  id: string;
  title: string;
  start: Date;
  end: Date;
}

export interface EventCalendarProps {
  /** The events. Dragging moves them, clicking free time adds one, the details card names or removes it */
  events?: EventCalendarEvent[];
  defaultEvents?: EventCalendarEvent[];
  onEventsChange?: (events: EventCalendarEvent[]) => void;
  /** The first day shown, like Monday Oct 19. Its time of day is ignored */
  week: Date;
  /** How many days to show, from `week`. Defaults to 5 */
  days?: number;
  /** The first hour on the grid, 9 for 9am */
  startHour?: number;
  /** How many hours the grid shows. Defaults to 8 (9am to 5pm) */
  hours?: number;
  /** The heading. Defaults to the dates shown, like "Oct 19 – 23, 2026" */
  title?: string;
  /** Marks today and draws the now line. Leave it out for neither */
  now?: Date;
  /** The event whose details card is open */
  selected?: string | null;
  defaultSelected?: string | null;
  onSelectedChange?: (id: string | null) => void;
  /** What an event added by clicking free time starts with. Defaults to a pink, one hour event */
  newEvent?: { tone?: EventCalendarTone; people?: EventCalendarPerson[]; hours?: number };
  /** Adds Prep notes to the details card. Return the notes, or a promise of them; "Writing" shows until it settles */
  onPrepNotes?: (event: EventCalendarEvent) => ReactNode | Promise<ReactNode>;
  /** Adds Join to the details card */
  onJoin?: (event: EventCalendarEvent) => void;
  /** Adds the agent's button to the header, like "Find 45 min for a design review" */
  askLabel?: string;
  onAsk?: () => void;
  /** The agent is looking: its button thinks and a scan line sweeps the week */
  finding?: boolean;
  /** Slots the agent suggests. The first is the pick, the rest wait dimmed behind it */
  suggestions?: EventCalendarSuggestion[];
  /** Book on the picked suggestion. Add the event and clear the suggestions */
  onBook?: (suggestion: EventCalendarSuggestion) => void;
  /** Other on the picked suggestion, shown while there are more. Drop it and the next one becomes the pick */
  onSkip?: (suggestion: EventCalendarSuggestion) => void;
  /** The quiet line in the header. Defaults to what the person last did, like "Moved Standup to Wed 1:15" */
  status?: string;
  /** The day a narrow calendar shows, as its place in the week (0 for the first day). A calendar under 520px wide shows one
      day at a time, picked from a row of days; it opens on today, and moves to the day of the agent's pick when one comes */
  day?: number;
  defaultDay?: number;
  onDayChange?: (day: number) => void;
  className?: string;
}

const PX = 44;
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DAYN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const TONE: Record<EventCalendarTone, string> = { blue: 'pb', purple: 'pl', green: 'pm', orange: 'pp', red: 'pr', yellow: 'py', pink: 'pk', teal: 'pt' };

const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const dayIndex = (week: Date, d: Date) => Math.round((addDays(d, 0).getTime() - addDays(week, 0).getTime()) / 864e5);
const hourOf = (d: Date) => d.getHours() + d.getMinutes() / 60;
const at = (week: Date, day: number, hour: number) => new Date(week.getFullYear(), week.getMonth(), week.getDate() + day, Math.floor(hour), Math.round((hour % 1) * 60));
/** 13.5 → "1:30", the way the site writes times */
const tm = (h: number) => { const hh = Math.floor(h), mm = Math.round((h - hh) * 60); return `${hh > 12 ? hh - 12 : hh}:${String(mm).padStart(2, '0')}`; };
const tint = (tone: EventCalendarTone = 'blue'): CSSProperties => ({ background: `var(--dm-${TONE[tone]}-bg)`, color: `var(--dm-${TONE[tone]})` });
const named = (e: EventCalendarEvent) => e.title || 'New event';

/** The details card: who is coming and what to do, or a box to name a new event */
function Details({ event, onClose, onSave, onDiscard, onPrepNotes, onJoin }: {
  event: EventCalendarEvent; onClose: () => void; onSave: (title: string) => void; onDiscard: () => void;
  onPrepNotes?: EventCalendarProps['onPrepNotes']; onJoin?: EventCalendarProps['onJoin'];
}) {
  const fresh = !event.title;
  const input = useRef<HTMLInputElement>(null);
  const [prep, setPrep] = useState<'idle' | 'writing' | 'done'>('idle');
  const [note, setNote] = useState<ReactNode>(null);
  useEffect(() => { if (fresh) input.current?.focus({ preventScroll: true }); }, [fresh]);
  const write = async () => {
    if (!onPrepNotes || prep !== 'idle') return;
    setPrep('writing');
    const n = await onPrepNotes(event);
    setNote(n); setPrep('done');
  };
  const key = (k: KeyboardEvent<HTMLInputElement>) => { if (k.key === 'Enter') onSave(k.currentTarget.value); if (k.key === 'Escape') onDiscard(); };
  const h = hourOf(event.start), end = hourOf(event.end), people = event.people ?? [];
  return (
    <>
      <div className="dm-ecx-ph">
        <i style={{ background: `var(--dm-${TONE[event.tone ?? 'blue']})` }} />
        {fresh ? <input ref={input} className="dm-ecx-ti" placeholder="Add a title" aria-label="Event title" onKeyDown={key} /> : <b>{event.title}</b>}
        <Tip label="Close"><button type="button" className="dm-ib" aria-label="Close" onClick={onClose}><Icon name="x" size="xs" /></button></Tip>
      </div>
      <div className="dm-ecx-pt">{DAYN[event.start.getDay()]} {event.start.getDate()} {MONTHS[event.start.getMonth()]} · {tm(h)} – {tm(end)}</div>
      {people.length > 0 && (
        <div className="dm-ecx-pw">
          {people.map((p, i) => <Avatar key={i} initials={p.initials} color={p.color} />)}
          <span className="dm-muted">{people.length === 1 ? '1 person' : `${people.length} people`}</span>
        </div>
      )}
      {prep === 'done' && <div className={cx('dm-ecx-note', enter().className)}><Icon name="spark" size="xs" /><span>{note}</span></div>}
      {fresh ? (
        <div className="dm-ecx-pa">
          <button type="button" className="dm-pill dm-ghost dm-sm" onClick={onDiscard}>Discard</button>
          <button type="button" className="dm-pill dm-primary dm-sm" onClick={() => onSave(input.current?.value ?? '')}>Save</button>
        </div>
      ) : (onPrepNotes || onJoin) && (
        <div className="dm-ecx-pa">
          {onPrepNotes && prep !== 'done' && <button type="button" className="dm-pill dm-soft dm-sm" onClick={write}>{prep === 'writing' ? <><Think />Writing</> : <><Icon name="spark" size="xs" />Prep notes</>}</button>}
          {onJoin && <button type="button" className="dm-pill dm-primary dm-sm" onClick={() => onJoin(event)}>Join</button>}
        </div>
      )}
    </>
  );
}

/** A work week of events you can drag to another time or day, add to by clicking free time, and open for details.
    The agent can look for a free slot and suggest it. */
export function EventCalendar({
  events, defaultEvents = [], onEventsChange, week, days = 5, startHour = 9, hours = 8, title, now,
  selected, defaultSelected = null, onSelectedChange, newEvent, onPrepNotes, onJoin,
  askLabel = 'Find a time', onAsk, finding = false, suggestions = [], onBook, onSkip, status, day, defaultDay, onDayChange, className
}: EventCalendarProps) {
  const [list, setList] = useControllable(events, defaultEvents, onEventsChange);
  const [openId, setOpenId] = useControllable(selected, defaultSelected, onSelectedChange);
  const [msg, setMsg] = useState('');
  const uid = useId();
  const made = useRef(0);

  const root = useRef<HTMLDivElement>(null);
  const pop = useRef<HTMLDivElement>(null);
  const cols = useRef<(HTMLDivElement | null)[]>([]);
  const blocks = useRef(new Map<string, HTMLElement>());

  // the first events stagger in; an event added later, or dropped after a drag, plays the entrance again
  const delays = useRef<Map<string, number> | null>(null);
  if (!delays.current) delays.current = new Map(list.map((e, i) => [e.id, i * 30]));
  // a dropped event goes last in its day, so it sits on top, as on the site
  const [moves, setMoves] = useState<Record<string, number>>({});
  const drops = useRef(0);

  const end = startHour + hours;
  const inView = (d: Date) => { const i = dayIndex(week, d); return i >= 0 && i < days; };
  const shown = list.filter(e => inView(e.start));
  const open = openId != null ? list.find(e => e.id === openId) ?? null : null;
  // the card keeps what it showed while it fades out, and starts afresh each time it opens
  const [card, setCard] = useState(open);
  if (open && open !== card) setCard(open);
  const [runs, setRuns] = useState(0);

  const select = (id: string | null) => { if (id !== null) setRuns(n => n + 1); setOpenId(id); };
  const close = () => setOpenId(null);
  // opened with Enter or Space, the card takes the focus; closed with the focus in it, its event gets it back
  const keyOpen = useRef(false), inPop = useRef(false), lastOpen = useRef<string | null>(null);
  useIsoLayoutEffect(() => {
    if (openId != null) {
      lastOpen.current = openId;
      if (keyOpen.current) { keyOpen.current = false; pop.current?.querySelector<HTMLElement>('input,button')?.focus({ preventScroll: true }); }
      return;
    }
    if (inPop.current) { inPop.current = false; const b = lastOpen.current && blocks.current.get(lastOpen.current); if (b && b.isConnected) b.focus({ preventScroll: true }); }
  }, [openId]);

  /* ---------- the details card: beside its event, inside the calendar ---------- */
  const [place, setPlace] = useState<CSSProperties>({});
  useIsoLayoutEffect(() => {
    const el = open && blocks.current.get(open.id), p = pop.current, panel = root.current;
    if (!el || !p || !panel) return;
    const pr = panel.getBoundingClientRect(), er = el.getBoundingClientRect(), w = 248;
    let x = er.right - pr.left + 8; if (x + w > pr.width - 8) x = er.left - pr.left - w - 8; x = Math.max(8, x);
    const y = Math.max(8, Math.min(er.top - pr.top, pr.height - p.offsetHeight - 8));
    setPlace({ left: x, top: y });
  }, [open?.id, runs]);
  useEffect(() => {
    if (!open) return;
    const down = (e: globalThis.PointerEvent) => { const t = e.target as Node; if (!pop.current?.contains(t) && !blocks.current.get(open.id)?.contains(t)) close(); };
    const key = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') close(); };
    document.addEventListener('pointerdown', down); document.addEventListener('keydown', key);
    return () => { document.removeEventListener('pointerdown', down); document.removeEventListener('keydown', key); };
  }); // eslint-disable-line react-hooks/exhaustive-deps

  const update = (id: string, change: Partial<EventCalendarEvent>) => setList(list.map(e => e.id === id ? { ...e, ...change } : e));
  const remove = (id: string) => { setList(list.filter(e => e.id !== id)); close(); };
  const save = (e: EventCalendarEvent, value: string) => {
    const v = value.trim() || 'Untitled';
    update(e.id, { title: v }); close();
    setMsg(`Added ${v}, ${DAYN[e.start.getDay()]} ${tm(hourOf(e.start))}`);
  };

  /* ---------- dragging: to another time or day, in 15 minute steps; a click without moving opens the card ---------- */
  const drag = useRef<{ id: string; x: number; y: number; moved: boolean; day: number; hour: number } | null>(null);
  const [dragging, setDragging] = useState<{ id: string; day: number; hour: number; dx: number } | null>(null);
  const colX = (i: number) => cols.current[i]?.getBoundingClientRect().left ?? 0;
  const down = (p: PointerEvent<HTMLElement>, e: EventCalendarEvent) => {
    if (p.button) return;
    p.currentTarget.setPointerCapture(p.pointerId);
    drag.current = { id: e.id, x: p.clientX, y: p.clientY, moved: false, day: dayIndex(week, e.start), hour: hourOf(e.start) };
  };
  const move = (p: PointerEvent<HTMLElement>, e: EventCalendarEvent) => {
    const d = drag.current; if (!d || d.id !== e.id) return;
    const dx = p.clientX - d.x, dy = p.clientY - d.y;
    if (!d.moved && Math.hypot(dx, dy) < 5) return;
    if (!d.moved) { d.moved = true; if (openId != null) close(); setGhost(null); }
    const from = dayIndex(week, e.start), len = hourOf(e.end) - hourOf(e.start);
    d.hour = Math.max(startHour, Math.min(end - len, hourOf(e.start) + Math.round(dy / PX * 4) / 4));
    const under = document.elementsFromPoint(p.clientX, p.clientY).find(x => x.classList?.contains('dm-ecx-col')) as HTMLElement | undefined;
    if (under) d.day = +(under.dataset.d ?? d.day);
    setDragging({ id: e.id, day: d.day, hour: d.hour, dx: colX(d.day) - colX(from) });
  };
  const up = (e: EventCalendarEvent) => {
    const d = drag.current; drag.current = null; if (!d || d.id !== e.id) return;
    if (!d.moved) { select(openId === e.id ? null : e.id); return; }
    setDragging(null);
    const n = ++drops.current; setMoves(m => ({ ...m, [e.id]: n }));
    const from = dayIndex(week, e.start), h = hourOf(e.start), len = hourOf(e.end) - h;
    if (d.day === from && d.hour === h) return;
    update(e.id, { start: at(week, d.day, d.hour), end: at(week, d.day, d.hour + len) });
    setMsg(`Moved ${named(e)} to ${DAYN[addDays(week, d.day).getDay()]} ${tm(d.hour)}`);
  };

  /* ---------- free time: a dashed slot follows the pointer, a click adds an event there ---------- */
  const [ghost, setGhost] = useState<{ day: number; hour: number } | null>(null);
  const hover = (p: PointerEvent<HTMLDivElement>, day: number) => {
    if (p.target !== p.currentTarget || dragging) { if (ghost) setGhost(null); return; }
    const hour = startHour + Math.min(hours * 2 - 1, Math.floor((p.clientY - p.currentTarget.getBoundingClientRect().top) / PX * 2)) / 2;
    if (ghost?.day !== day || ghost.hour !== hour) setGhost({ day, hour });
  };
  const add = (p: MouseEvent<HTMLDivElement>, day: number) => {
    if (p.target !== p.currentTarget || !ghost || ghost.day !== day) return;
    const len = newEvent?.hours ?? 1, id = `${uid}-new-${++made.current}`;
    setList([...list, { id, title: '', start: at(week, day, ghost.hour), end: at(week, day, ghost.hour + len), tone: newEvent?.tone ?? 'pink', people: newEvent?.people }]);
    setGhost(null); select(id);
  };

  /* ---------- the agent's scan ---------- */
  const [scans, setScans] = useState(0);
  useEffect(() => { if (finding) setScans(n => n + 1); }, [finding]);

  const heading = title ?? (() => { const a = week, b = addDays(week, days - 1); return `${MONTHS[a.getMonth()]} ${a.getDate()} – ${b.getMonth() !== a.getMonth() ? MONTHS[b.getMonth()] + ' ' : ''}${b.getDate()}, ${b.getFullYear()}`; })();
  const today = now && inView(now) ? dayIndex(week, now) : -1;
  const sizes: CSSProperties | undefined = days !== 5 ? { ['--dm-days' as string]: days } : undefined;
  /* ---------- a narrow calendar: one day at a time ---------- */
  const [shownDay, setShownDay] = useControllable(day, defaultDay ?? Math.max(0, today), onDayChange);
  const daysRow = useRef<HTMLDivElement>(null);
  const narrow = () => !!daysRow.current?.getClientRects().length;
  const [turns, setTurns] = useState(0);
  const pickDay = (d: number) => { if (d === shownDay) return; if (narrow()) { close(); setTurns(n => n + 1); } setShownDay(d); };
  // the agent's pick opens on its day
  const pick = suggestions[0], pickDayOf = pick && inView(pick.start) ? dayIndex(week, pick.start) : -1;
  useEffect(() => { if (pickDayOf >= 0) pickDay(pickDayOf); }, [pick?.id]); // eslint-disable-line react-hooks/exhaustive-deps
  const tall: CSSProperties | undefined = hours !== 8 ? { height: hours * PX } : undefined;
  const top = (h: number) => (h - startHour) * PX + 1;

  return (
    <div ref={root} data-dm="" className={cx('dm-ecx dm-panel', className)}>
      <div className="dm-ecx-h">
        <b>{heading}</b><span className="dm-ecx-sp" />
        <Swap className="dm-ecx-msg dm-muted">{status ?? msg}</Swap>
        {onAsk && <button type="button" className="dm-pill dm-soft dm-sm dm-ecx-ask" onClick={onAsk}>{finding ? <Think /> : <Icon name="spark" size="xs" />}<Swap key={finding ? 'f' : 'i'}>{askLabel}</Swap></button>}
      </div>
      <div ref={daysRow} className="dm-ecx-days" role="group" aria-label="Day">
        {Array.from({ length: days }, (_, d) => {
          const date = addDays(week, d);
          return (
            <button key={d} type="button" className={d === today ? 'dm-t' : undefined} aria-pressed={d === shownDay} onClick={() => pickDay(d)}
              aria-label={date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}>
              <span>{DAYN[date.getDay()]}</span><b>{date.getDate()}</b>
            </button>
          );
        })}
      </div>
      <div className="dm-ecx-sc">
        <div className="dm-ecx-grid" style={sizes}>
          <div className="dm-ecx-tm">{Array.from({ length: hours }, (_, i) => { const h = startHour + i; return <span key={h}>{h > 12 ? h - 12 : h}{h >= 12 ? 'pm' : 'am'}</span>; })}</div>
          {Array.from({ length: days }, (_, d) => {
            const date = addDays(week, d);
            return (
              <div key={d === shownDay ? `${d}:${turns}` : d} className={cx('dm-ecx-day', d === shownDay && 'dm-on', d === shownDay && turns > 0 && 'dm-enter')}>
                <div className={cx('dm-ecx-dh', d === today && 'dm-t')}>{DAYN[date.getDay()]} <b>{date.getDate()}</b></div>
                <div ref={el => { cols.current[d] = el; }} className="dm-ecx-col" data-d={d} style={tall}
                  onPointerMove={p => hover(p, d)} onPointerLeave={() => setGhost(null)} onClick={p => add(p, d)}>
                  {shown.filter(e => dayIndex(week, e.start) === d).sort((a, b) => (moves[a.id] ?? 0) - (moves[b.id] ?? 0)).map(e => {
                    const h = hourOf(e.start), len = hourOf(e.end) - h, live = dragging?.id === e.id ? dragging : null, at_ = live?.hour ?? h;
                    if (h + len <= startHour || h >= end) return null;
                    // an hour or more keeps its exact height; anything shorter grows to show its time, like a booked suggestion on the site
                    return (
                      <Tip key={`${e.id}:${moves[e.id] ?? 0}`} label={`${named(e)}, ${tm(at_)}`} off={!!dragging || openId === e.id}>
                        <button type="button" ref={el => { if (el) blocks.current.set(e.id, el); else blocks.current.delete(e.id); }}
                          className={cx('dm-ecx-e dm-enter', live && 'dm-drag')}
                          style={{ ...tint(e.tone), top: top(at_), [len < 1 ? 'minHeight' : 'height']: len * PX - 3, transform: live ? `translateX(${live.dx}px)` : undefined, animationDelay: (delays.current!.get(e.id) ?? 0) + 'ms' }}
                          onPointerDown={p => down(p, e)} onPointerMove={p => move(p, e)} onPointerUp={() => up(e)} onPointerCancel={() => up(e)}
                          onClick={c => { if (c.detail !== 0) return; keyOpen.current = openId !== e.id; select(openId === e.id ? null : e.id); }}>
                          <b>{named(e)}</b><span>{tm(at_)} – {tm(at_ + len)}</span>
                        </button>
                      </Tip>
                    );
                  })}
                  {suggestions.filter(s => dayIndex(week, s.start) === d).map(s => {
                    const i = suggestions.indexOf(s), h = hourOf(s.start), len = hourOf(s.end) - h;
                    return (
                      <div key={s.id} className={cx('dm-ecx-e dm-sug', i > 0 && 'dm-alt')} style={{ ...tint('blue'), top: top(h), height: 'auto', minHeight: len * PX - 3, animationDelay: i * 120 + 'ms' }}>
                        <b>{s.title}</b><span>{tm(h)} – {tm(h + len)}</span>
                        {i === 0 && (onBook || onSkip) && (
                          <div className="dm-ecx-sg">
                            {onBook && <button type="button" onClick={() => onBook(s)}>Book</button>}
                            {onSkip && suggestions.length > 1 && <button type="button" className="dm-n" onClick={() => onSkip(s)}>Other</button>}
                          </div>
                        )}
                      </div>
                    );
                  })}
                  {d === today && now && <Tip label={`Now, ${tm(hourOf(now))}`}><div className="dm-ecx-now" style={{ top: (hourOf(now) - startHour) * PX }} /></Tip>}
                  {ghost?.day === d && <div className="dm-ecx-ghost" style={{ top: top(ghost.hour) }}>+ New event</div>}
                </div>
              </div>
            );
          })}
          <div key={scans} className={cx('dm-ecx-scan', scans > 0 && 'dm-go')} />
        </div>
      </div>
      <div ref={pop} className={cx('dm-ecx-pop', open && 'dm-open')} role="dialog" aria-label="Event" inert={!open} style={place}
        onFocus={() => { inPop.current = true; }} onBlur={f => { if (!pop.current?.contains(f.relatedTarget as Node)) inPop.current = false; }}>
        {card && <Details key={`${card.id}:${runs}`} event={card} onClose={close} onSave={v => save(card, v)} onDiscard={() => remove(card.id)} onPrepNotes={onPrepNotes} onJoin={onJoin} />}
      </div>
    </div>
  );
}
event-calendar.cssReact · 79 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Event calendar ---------- */
.dm-ecx{overflow:hidden;position:relative;container:ecx/inline-size}
/* the Ask pill crossfades its words but stays a flex row, so the spark keeps its gap */
.dm-ecx-ask.dm-sw{display:inline-flex}
/* the Ask pill is an accent tint, like a picked preset in Date range picker */
.dm-pill.dm-soft.dm-ecx-ask{background:var(--dm-sel);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.dm-pill.dm-soft.dm-ecx-ask:hover{background:color-mix(in srgb,var(--dm-accent) 14%,transparent)}}
.dm-ecx-h{display:flex;align-items:center;gap:var(--dm-s-2);padding:10px var(--dm-s-3);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13.5px;flex-wrap:wrap}
.dm-ecx-h b{font-weight:500}
.dm-ecx-sp{flex:1}
.dm-ecx-sc{overflow-x:auto}
.dm-ecx-grid{display:grid;grid-template-columns:44px repeat(var(--dm-days,5),minmax(76px,1fr));min-width:424px;position:relative}
.dm-ecx-tm{grid-row:1/3;display:flex;flex-direction:column;padding-top:var(--dm-s-8)}
.dm-ecx-tm span{height:44px;font-size:10.5px;color:var(--dm-muted);text-align:right;padding-right:var(--dm-s-2);transform:translateY(-6px)}
.dm-ecx-day{border-left:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ecx-dh{height:32px;display:flex;align-items:center;justify-content:center;gap:5px;font-size:12px;color:var(--dm-fg2);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ecx-dh b{font-weight:500;color:var(--dm-fg)}
.dm-ecx-dh.dm-t b{width:22px;height:22px;border-radius:50%;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;font-size:11.5px}
.dm-ecx-col{position:relative;height:352px;background-image:linear-gradient(color-mix(in srgb,var(--dm-line) 50%,transparent) 1px,transparent 1px);background-size:100% 44px}
.dm-ecx-e{position:absolute;left:3px;right:3px;border-radius:var(--dm-r-sm);padding:var(--dm-s-1) 7px;font-size:11.5px;line-height:1.3;text-align:left;overflow:hidden;transition:transform .25s var(--dm-ease),box-shadow .25s}
@media (hover:hover) and (pointer:fine){.dm-ecx-e:hover{transform:translateY(-1px);box-shadow:var(--dm-shadow)}}
/* events sit in the scrolling grid, which would cut an outside ring: theirs is drawn inside */
.dm-ecx-e:focus-visible{outline-offset:-2px}
.dm-ecx-e{cursor:grab;touch-action:none}
.dm-ecx-e.dm-drag{transition:none;z-index:6;box-shadow:var(--dm-shadow-pop);cursor:grabbing}
.dm-ecx-col{cursor:copy}
.dm-ecx-ghost{position:absolute;left:3px;right:3px;height:41px;border-radius:var(--dm-r-sm);border:1.5px dashed color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 45%,transparent);background:color-mix(in srgb,var(--dm-accent) 5%,transparent);color:var(--dm-accent-fg,var(--dm-accent));font-size:11px;font-weight:500;padding:var(--dm-s-1) 7px;pointer-events:none}
.dm-ecx-now{position:absolute;left:0;right:0;height:2px;margin-top:-1px;background:var(--dm-bad);z-index:3}
.dm-ecx-now::before{content:"";position:absolute;left:-4px;top:-3px;width:8px;height:8px;border-radius:50%;background:var(--dm-bad)}
.dm-ecx-msg{font-size:12.5px;margin-right:var(--dm-s-2)}
.dm-ecx-pop{position:absolute;z-index:20;width:248px;padding:var(--dm-s-3);background:var(--dm-raised);border-radius:14px;box-shadow:var(--dm-shadow-pop);opacity:0;visibility:hidden;transform:translateY(6px);filter:blur(3px);pointer-events:none;transition:opacity .2s var(--dm-ease),transform .35s var(--dm-ease),filter .2s,visibility 0s .2s}
.dm-ecx-pop.dm-open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter}
.dm-ecx-ph{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13.5px}
.dm-ecx-ph>i{width:10px;height:10px;border-radius:3px;flex:none}
.dm-ecx-ph b{font-weight:500;flex:1;min-width:0}
.dm-ecx-ph .dm-ib{width:24px;height:24px;margin:-4px -4px -4px auto}
.dm-ecx-ti{flex:1;min-width:0;border:0;outline:0;background:none;font:inherit;font-weight:500;color:var(--dm-fg)}
.dm-ecx-ti::placeholder{color:var(--dm-muted)}
.dm-ecx-ti:focus{box-shadow:0 1px 0 color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-ecx-pt{font-size:12.5px;color:var(--dm-fg2);margin:var(--dm-s-1) 0 0 18px}
.dm-ecx-pw{display:flex;align-items:center;margin:10px 0 0 18px}
.dm-ecx-pw .dm-av{width:22px;height:22px;font-size:9px;margin-right:-4px;box-shadow:0 0 0 2px var(--dm-raised)}
.dm-ecx-pw .dm-muted{margin-left:10px;font-size:12px}
.dm-ecx-note{display:flex;gap:6px;align-items:flex-start;margin:10px 0 0 18px;font-size:12px;line-height:1.45;color:var(--dm-fg2)}
.dm-ecx-note .dm-i{color:var(--dm-accent-fg,var(--dm-accent));margin-top:2px}
.dm-ecx-pa{display:flex;justify-content:flex-end;gap:6px;margin-top:var(--dm-s-3)}
.dm-ecx-e b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-ecx-e span{font-size:10.5px}
.dm-ecx-e.dm-sug{background:var(--dm-surface)!important;color:var(--dm-accent-fg,var(--dm-accent))!important;box-shadow:inset 0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent));animation:dm-ecs .6s var(--dm-ease) both}
.dm-ecx-e.dm-sug.dm-alt{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 40%,transparent)}
@keyframes dm-ecs{from{opacity:0;transform:scale(.92);filter:blur(3px)}}
.dm-ecx-sg{display:flex;gap:var(--dm-s-1);margin-top:var(--dm-s-1)}
.dm-ecx-sg button{font-size:10.5px;padding:2px 6px;border-radius:5px;background:var(--dm-accent);color:var(--dm-on-accent)}
.dm-ecx-sg button.dm-n{background:transparent;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-ecx-scan{position:absolute;left:44px;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--dm-accent),transparent);top:32px;opacity:0;pointer-events:none}
.dm-ecx-scan.dm-go{animation:dm-ecsc 1.2s var(--dm-ease-io) both}
@keyframes dm-ecsc{0%{opacity:0;top:32px}20%{opacity:1}100%{opacity:0;top:384px}}
/* a narrow calendar, on a phone or in a narrow card: one day at a time, picked from a row of days, so every event has room
   for its whole title and time */
.dm-ecx-days{display:none}
@container ecx (max-width:519px){
  .dm-ecx-days{display:flex;gap:var(--dm-s-1);padding:var(--dm-s-2) var(--dm-s-3);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
  .dm-ecx-days button{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;padding:var(--dm-s-1) 0;border-radius:var(--dm-r-sm);font-size:11.5px;color:var(--dm-fg2);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
  @media (hover:hover) and (pointer:fine){.dm-ecx-days button:hover{background:var(--dm-row);color:var(--dm-fg)}}
  .dm-ecx-days button[aria-pressed="true"]{background:var(--dm-surface);color:var(--dm-fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-on) 50%,transparent)}
  .dm-ecx-days b{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:13px;font-weight:500;color:var(--dm-fg)}
  .dm-ecx-days .dm-t b{background:var(--dm-accent);color:var(--dm-on-accent)}
  .dm-ecx-sc{overflow:visible}
  .dm-ecx-grid{grid-template-columns:44px minmax(0,1fr);min-width:0;padding-top:var(--dm-s-2)}
  .dm-ecx-day:not(.dm-on){display:none}
  .dm-ecx-dh{display:none}
  .dm-ecx-tm{grid-row:auto;padding-top:0}
  .dm-ecx-scan{top:8px}
  .dm-ecx-scan.dm-go{animation-name:dm-ecscn}
  .dm-ecx-pop{max-width:calc(100% - 16px)}
}
@keyframes dm-ecscn{0%{opacity:0;top:8px}20%{opacity:1}100%{opacity:0;top:360px}}
event-calendar.htmlHTML · 10 lines
<div class="card wide" data-c="event-calendar">
  <div class="stage-in">
    <div class="ecx panel">
      <div class="ecx-h"><b>Oct 19 – 23, 2026</b><span class="ecx-sp"></span><span class="ecx-msg muted"></span><button class="pill soft sm ecx-ask"><svg class="i xs"><use href="#i-spark"/></svg>Find 45 min for a design review</button></div>
      <div class="ecx-days" role="group" aria-label="Day"></div>
      <div class="ecx-sc"><div class="ecx-grid"><div class="ecx-tm"></div></div></div>
    </div>
  </div>
</div>
event-calendar.cssHTML · 78 lines
/* ---------- Event calendar ---------- */
.ecx{overflow:hidden;position:relative;container:ecx/inline-size}
/* the Ask pill crossfades its words but stays a flex row, so the spark keeps its gap */
.ecx-ask.sw{display:inline-flex}
/* the Ask pill is an accent tint, like a picked preset in Date range picker */
.pill.soft.ecx-ask{background:var(--sel);color:var(--accent-fg,var(--accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.pill.soft.ecx-ask:hover{background:color-mix(in srgb,var(--accent) 14%,transparent)}}
.ecx-h{display:flex;align-items:center;gap:var(--s-2);padding:10px var(--s-3);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:13.5px;flex-wrap:wrap}
.ecx-h b{font-weight:500}
.ecx-sp{flex:1}
.ecx-sc{overflow-x:auto}
.ecx-grid{display:grid;grid-template-columns:44px repeat(var(--days,5),minmax(76px,1fr));min-width:424px;position:relative}
.ecx-tm{grid-row:1/3;display:flex;flex-direction:column;padding-top:var(--s-8)}
.ecx-tm span{height:44px;font-size:10.5px;color:var(--muted);text-align:right;padding-right:var(--s-2);transform:translateY(-6px)}
.ecx-day{border-left:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ecx-dh{height:32px;display:flex;align-items:center;justify-content:center;gap:5px;font-size:12px;color:var(--fg2);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ecx-dh b{font-weight:500;color:var(--fg)}
.ecx-dh.t b{width:22px;height:22px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;font-size:11.5px}
.ecx-col{position:relative;height:352px;background-image:linear-gradient(color-mix(in srgb,var(--line) 50%,transparent) 1px,transparent 1px);background-size:100% 44px}
.ecx-e{position:absolute;left:3px;right:3px;border-radius:var(--r-sm);padding:var(--s-1) 7px;font-size:11.5px;line-height:1.3;text-align:left;overflow:hidden;transition:transform .25s var(--ease),box-shadow .25s}
@media (hover:hover) and (pointer:fine){.ecx-e:hover{transform:translateY(-1px);box-shadow:var(--shadow)}}
/* events sit in the scrolling grid, which would cut an outside ring: theirs is drawn inside */
.ecx-e:focus-visible{outline-offset:-2px}
.ecx-e{cursor:grab;touch-action:none}
.ecx-e.drag{transition:none;z-index:6;box-shadow:var(--shadow-pop);cursor:grabbing}
.ecx-col{cursor:copy}
.ecx-ghost{position:absolute;left:3px;right:3px;height:41px;border-radius:var(--r-sm);border:1.5px dashed color-mix(in srgb,var(--accent-mark,var(--accent)) 45%,transparent);background:color-mix(in srgb,var(--accent) 5%,transparent);color:var(--accent-fg,var(--accent));font-size:11px;font-weight:500;padding:var(--s-1) 7px;pointer-events:none}
.ecx-now{position:absolute;left:0;right:0;height:2px;margin-top:-1px;background:var(--bad);z-index:3}
.ecx-now::before{content:"";position:absolute;left:-4px;top:-3px;width:8px;height:8px;border-radius:50%;background:var(--bad)}
.ecx-msg{font-size:12.5px;margin-right:var(--s-2)}
.ecx-pop{position:absolute;z-index:20;width:248px;padding:var(--s-3);background:var(--raised);border-radius:14px;box-shadow:var(--shadow-pop);opacity:0;visibility:hidden;transform:translateY(6px);filter:blur(3px);pointer-events:none;transition:opacity .2s var(--ease),transform .35s var(--ease),filter .2s,visibility 0s .2s}
.ecx-pop.open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter}
.ecx-ph{display:flex;align-items:center;gap:var(--s-2);font-size:13.5px}
.ecx-ph>i{width:10px;height:10px;border-radius:3px;flex:none}
.ecx-ph b{font-weight:500;flex:1;min-width:0}
.ecx-ph .ib{width:24px;height:24px;margin:-4px -4px -4px auto}
.ecx-ti{flex:1;min-width:0;border:0;outline:0;background:none;font:inherit;font-weight:500;color:var(--fg)}
.ecx-ti::placeholder{color:var(--muted)}
.ecx-ti:focus{box-shadow:0 1px 0 color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.ecx-pt{font-size:12.5px;color:var(--fg2);margin:var(--s-1) 0 0 18px}
.ecx-pw{display:flex;align-items:center;margin:10px 0 0 18px}
.ecx-pw .av{width:22px;height:22px;font-size:9px;margin-right:-4px;box-shadow:0 0 0 2px var(--raised)}
.ecx-pw .muted{margin-left:10px;font-size:12px}
.ecx-note{display:flex;gap:6px;align-items:flex-start;margin:10px 0 0 18px;font-size:12px;line-height:1.45;color:var(--fg2)}
.ecx-note .i{color:var(--accent-fg,var(--accent));margin-top:2px}
.ecx-pa{display:flex;justify-content:flex-end;gap:6px;margin-top:var(--s-3)}
.ecx-e b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ecx-e span{font-size:10.5px}
.ecx-e.sug{background:var(--surface)!important;color:var(--accent-fg,var(--accent))!important;box-shadow:inset 0 0 0 1.5px var(--accent-mark,var(--accent));animation:ecs .6s var(--ease) both}
.ecx-e.sug.alt{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent-mark,var(--accent)) 40%,transparent)}
@keyframes ecs{from{opacity:0;transform:scale(.92);filter:blur(3px)}}
.ecx-sg{display:flex;gap:var(--s-1);margin-top:var(--s-1)}
.ecx-sg button{font-size:10.5px;padding:2px 6px;border-radius:5px;background:var(--accent);color:var(--on-accent)}
.ecx-sg button.n{background:transparent;color:var(--accent-fg,var(--accent))}
.ecx-scan{position:absolute;left:44px;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);top:32px;opacity:0;pointer-events:none}
.ecx-scan.go{animation:ecsc 1.2s var(--ease-io) both}
@keyframes ecsc{0%{opacity:0;top:32px}20%{opacity:1}100%{opacity:0;top:384px}}
/* a narrow calendar, on a phone or in a narrow card: one day at a time, picked from a row of days, so every event has room
   for its whole title and time */
.ecx-days{display:none}
@container ecx (max-width:519px){
  .ecx-days{display:flex;gap:var(--s-1);padding:var(--s-2) var(--s-3);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
  .ecx-days button{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;padding:var(--s-1) 0;border-radius:var(--r-sm);font-size:11.5px;color:var(--fg2);transition:background var(--t-fast),color var(--t-fast)}
  @media (hover:hover) and (pointer:fine){.ecx-days button:hover{background:var(--row);color:var(--fg)}}
  .ecx-days button[aria-pressed="true"]{background:var(--surface);color:var(--fg);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-on) 50%,transparent)}
  .ecx-days b{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:13px;font-weight:500;color:var(--fg)}
  .ecx-days .t b{background:var(--accent);color:var(--on-accent)}
  .ecx-sc{overflow:visible}
  .ecx-grid{grid-template-columns:44px minmax(0,1fr);min-width:0;padding-top:var(--s-2)}
  .ecx-day:not(.on){display:none}
  .ecx-dh{display:none}
  .ecx-tm{grid-row:auto;padding-top:0}
  .ecx-scan{top:8px}
  .ecx-scan.go{animation-name:ecscn}
  .ecx-pop{max-width:calc(100% - 16px)}
}
@keyframes ecscn{0%{opacity:0;top:8px}20%{opacity:1}100%{opacity:0;top:360px}}
event-calendar.jsHTML · 89 lines
DM.def('event-calendar', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{spark}', I('spark','xs'));
  const DAYS = ['Mon 19','Tue 20','Wed 21','Thu 22','Fri 23'], H0 = 9, PX = 44;
  const EV = [[0,9,1,'Standup','b'],[0,13,1.5,'Pricing workshop','p'],[1,10,1,'1:1 with Maya','l'],[1,14,2,'Focus: checkout','t'],[2,9,1,'Standup','b'],[2,11.5,1,'Agent demo','y'],[3,9,1,'Standup','b'],[3,12,1,'Lunch & learn','m'],[3,15,1.5,'Launch review','r'],[4,10,1.5,'Planning','p']];
  const grid = $('.ecx-grid', r);
  $('.ecx-tm', r).innerHTML = Array.from({ length:8 }, (_, i) => { const h = H0 + i; return `<span>${h > 12 ? h - 12 : h}${h >= 12 ? 'pm' : 'am'}</span>`; }).join('');
  grid.insertAdjacentHTML('beforeend', DAYS.map((d, i) => `<div class="ecx-day"><div class="ecx-dh${i === 0 ? ' t' : ''}">${d.split(' ')[0]} <b>${d.split(' ')[1]}</b></div><div class="ecx-col" data-d="${i}"></div></div>`).join('') + '<div class="ecx-scan"></div>');
  const col = i => $(`.ecx-col[data-d="${i}"]`, grid);
  /* a narrow calendar shows one day: the row of days picks it, and an answer from the agent opens the day it found */
  const days = $('.ecx-days', r);
  days.innerHTML = DAYS.map((d, i) => `<button aria-pressed="false" data-d="${i}"${i === 0 ? ' class="t"' : ''} aria-label="${['Monday','Tuesday','Wednesday','Thursday','Friday'][i]} ${d.split(' ')[1]} October"><span>${d.split(' ')[0]}</span><b>${d.split(' ')[1]}</b></button>`).join('');
  let day = -1;
  const narrow = () => !!days.getClientRects().length;
  function showDay(i){
    if (i === day) return; const was = day; day = i;
    $$('button', days).forEach(b => b.setAttribute('aria-pressed', +b.dataset.d === i));
    $$('.ecx-day', grid).forEach((d, k) => d.classList.toggle('on', k === i));
    if (was >= 0 && narrow()){ closePop(); enter(col(i).parentElement); }
  }
  $$('button', days).forEach(b => b.onclick = () => showDay(+b.dataset.d));
  const tm = h => { const hh = Math.floor(h), mm = Math.round((h - hh) * 60); return `${hh > 12 ? hh - 12 : hh}:${String(mm).padStart(2, '0')}`; };
  const ev = (e, cls = '') => html(`<button class="ecx-e ${cls}" style="top:${(e[1] - H0) * PX + 1}px;height:${e[2] * PX - 3}px;background:var(--p${e[4]}-bg);color:var(--p${e[4]})"><b>${e[3]}</b><span>${tm(e[1])} – ${tm(e[1] + e[2])}</span></button>`);
  const panel = $('.ecx', r), msg = $('.ecx-msg', r), pop = html('<div class="ecx-pop" role="dialog" aria-label="Event"></div>'); panel.appendChild(pop);
  const WHO = [['MK','var(--av-l)'],['JO','var(--av-m)'],['DA','var(--av-b)'],['AR','var(--av-p)']], DAYN = ['Mon','Tue','Wed','Thu','Fri'];
  let openFor = null;
  const say = t => swap(msg, t);
  // closing gives the focus back to the event when it was in the card
  const closePop = () => { const back = pop.contains(document.activeElement) && openFor; pop.classList.remove('open'); openFor = null; if (back && back.isConnected) back.focus({ preventScroll:true }); };
  /* the details card opens beside the event and stays inside the calendar */
  function openPop(el){
    const e = el._e; if (openFor === el){ closePop(); return; } openFor = el; const n = 2 + e[3].length % 3, fresh = e[3] === 'New event';
    pop.innerHTML = `<div class="ecx-ph"><i style="background:var(--p${e[4]})"></i>${fresh ? '<input class="ecx-ti" placeholder="Add a title" aria-label="Event title">' : `<b>${e[3]}</b>`}<button class="ib ecx-px" aria-label="Close" data-tip="Close">${I('x','xs')}</button></div><div class="ecx-pt">${DAYN[e[0]]} ${DAYS[e[0]].split(' ')[1]} Oct · ${tm(e[1])} – ${tm(e[1] + e[2])}</div><div class="ecx-pw">${WHO.slice(0, n).map(w => AV(w[0], w[1])).join('')}<span class="muted">${n} people</span></div><div class="ecx-pa">${fresh ? '<button class="pill ghost sm ecx-del">Discard</button><button class="pill primary sm ecx-save">Save</button>' : `<button class="pill soft sm ecx-prep">${I('spark','xs')}Prep notes</button><button class="pill primary sm">Join</button>`}</div>`;
    const pr = panel.getBoundingClientRect(), er = el.getBoundingClientRect(), w = 248;
    let x = er.right - pr.left + 8; if (x + w > pr.width - 8) x = er.left - pr.left - w - 8; x = Math.max(8, x);
    let y = er.top - pr.top; y = Math.max(8, Math.min(y, pr.height - pop.offsetHeight - 8));
    pop.style.left = x + 'px'; pop.style.top = y + 'px'; pop.classList.remove('open'); void pop.offsetWidth; pop.classList.add('open'); hideTip();
    $('.ecx-px', pop).onclick = closePop;
    const prep = $('.ecx-prep', pop); if (prep) prep.onclick = () => { prep.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span>Writing'; c.later(1200, () => { if (!prep.isConnected) return; const nt = html(`<div class="ecx-note">${I('spark','xs')}<span>Notes ready: last week's decisions and 3 open questions.</span></div>`); prep.closest('.ecx-pa').before(nt); enter(nt); prep.remove(); }); };
    const ti = $('.ecx-ti', pop); if (ti){ ti.focus({ preventScroll:true });
      const save = () => { const v = ti.value.trim() || 'Untitled'; e[3] = v; $('b', el).textContent = v; el.dataset.tip = v + ', ' + tm(e[1]); closePop(); say('Added ' + v + ', ' + DAYN[e[0]] + ' ' + tm(e[1])); };
      ti.onkeydown = k => { if (k.key === 'Enter') save(); if (k.key === 'Escape'){ el.remove(); closePop(); } };
      $('.ecx-save', pop).onclick = save; $('.ecx-del', pop).onclick = () => { el.remove(); closePop(); }; }
  }
  /* drag to another time or day, snapping to 15 minutes; a click without moving opens the details */
  const colX = i => col(i).getBoundingClientRect().left;
  function bind(el, e){ el._e = e; el.dataset.tip = e[3] + ', ' + tm(e[1]);
    // Enter or Space opens its card with the focus in it (a pointer opens it on release, below)
    el.onclick = k => { if (!k.isTrusted || k.detail) return; openPop(el); if (openFor === el){ const f = $('.ecx-ti', pop) || $('button', pop); if (f) f.focus({ preventScroll:true }); } };
    el.onpointerdown = d => { if (d.button || d.target.closest('.ecx-sg')) return; const sx = d.clientX, sy = d.clientY; let moved = false, nd = e[0], nh = e[1]; el.setPointerCapture(d.pointerId);
      el.onpointermove = m => { const dx = m.clientX - sx, dy = m.clientY - sy; if (!moved && Math.hypot(dx, dy) < 5) return; if (!moved){ moved = true; el.classList.add('drag'); closePop(); hideTip(); }
        nh = Math.max(H0, Math.min(H0 + 8 - e[2], e[1] + Math.round(dy / PX * 4) / 4));
        const under = document.elementsFromPoint(m.clientX, m.clientY).find(x => x.classList && x.classList.contains('ecx-col')); if (under) nd = +under.dataset.d;
        el.style.transform = `translateX(${colX(nd) - colX(e[0])}px)`; el.style.top = (nh - H0) * PX + 1 + 'px'; $('span', el).textContent = `${tm(nh)} – ${tm(nh + e[2])}`; };
      el.onpointerup = el.onpointercancel = () => { el.onpointermove = el.onpointerup = el.onpointercancel = null; if (!moved){ openPop(el); return; }
        el.classList.remove('drag'); el.style.transform = ''; const was = e[0] !== nd || e[1] !== nh; e[0] = nd; e[1] = nh; col(nd).appendChild(el); el.dataset.tip = e[3] + ', ' + tm(nh);
        if (was) say(`Moved ${e[3]} to ${DAYN[nd]} ${tm(nh)}`); }; }; }
  EV.forEach((e, i) => { const el = ev(e); col(e[0]).appendChild(el); enter(el, i * 30); bind(el, e); });
  showDay(0);
  /* now line on today's column */
  col(0).insertAdjacentHTML('beforeend', `<div class="ecx-now" style="top:${(10.7 - H0) * PX}px" data-tip="Now, 10:42"></div>`);
  /* a dashed slot follows the pointer over free time; click it to add an event there */
  const ghost = html('<div class="ecx-ghost">+ New event</div>');
  $$('.ecx-col', grid).forEach(cl => {
    cl.onpointermove = m => { if (m.target !== cl || $('.ecx-e.drag', grid)){ ghost.remove(); return; } const h = H0 + Math.min(15, Math.floor((m.clientY - cl.getBoundingClientRect().top) / PX * 2)) / 2;
      ghost.style.top = (h - H0) * PX + 1 + 'px'; ghost._h = h; if (ghost.parentElement !== cl) cl.appendChild(ghost); };
    cl.onpointerleave = () => ghost.remove();
    cl.onclick = m => { if (m.target !== cl || ghost.parentElement !== cl) return; const e = [+cl.dataset.d, ghost._h, 1, 'New event', 'k']; ghost.remove(); const el = ev(e); cl.appendChild(el); enter(el); bind(el, e); openPop(el); };
  });
  c.on(document, 'pointerdown', d => { if (openFor && !pop.contains(d.target) && !openFor.contains(d.target)) closePop(); });
  c.on(document, 'keydown', k => { if (k.key === 'Escape') closePop(); });
  const ask = $('.ecx-ask', r);
  // the button keeps its spark, only the words crossfade
  const tell = t => swap(ask, I('spark','xs') + t, true);
  ask.onclick = () => c.run(async () => {
    $$('.ecx-e.sug', grid).forEach(x => x.remove());
    ask.innerHTML = '<span class="think on"><i></i><i></i><i></i><i></i></span>Checking 4 calendars'; const sc = $('.ecx-scan', grid); sc.classList.remove('go'); void sc.offsetWidth; sc.classList.add('go');
    await c.wait(1300);
    const S = [[2,13.5,.75,'Design review','b'],[4,13,.75,'Design review','b']];
    S.forEach((s, i) => { const el = ev(s, 'sug' + (i ? ' alt' : '')); if (!i) el.insertAdjacentHTML('beforeend', '<div class="ecx-sg"><button class="y">Book</button><button class="n">Other</button></div>'); col(s[0]).appendChild(el); el.style.animationDelay = i * 120 + 'ms'; el.style.height = 'auto'; el.style.minHeight = s[2] * PX - 3 + 'px'; });
    ask.innerHTML = I('spark','xs') + 'Everyone is free Wed 1:30';
    showDay(S[0][0]);
    const best = $('.ecx-e.sug:not(.alt)', grid);
    $('.y', best).onclick = e => { e.stopPropagation(); best.classList.remove('sug'); best.style.background = 'var(--pb-bg)'; best.style.color = 'var(--pb)'; best.style.boxShadow = ''; $('.ecx-sg', best).remove(); enter(best); bind(best, S[0]); $$('.ecx-e.sug', grid).forEach(x => x.remove()); tell('Booked, invites sent to 4 people'); };
    $('.n', best).onclick = e => { e.stopPropagation(); const alt = $('.ecx-e.sug.alt', grid); best.remove(); alt.classList.remove('alt'); alt.insertAdjacentHTML('beforeend', '<div class="ecx-sg"><button class="y">Book</button></div>'); $('.y', alt).onclick = ev2 => { ev2.stopPropagation(); alt.classList.remove('sug'); alt.style.background = 'var(--pb-bg)'; alt.style.color = 'var(--pb)'; $('.ecx-sg', alt).remove(); bind(alt, S[1]); tell('Booked Fri 1:00, invites sent'); }; tell('Next best: Fri 1:00'); showDay(S[1][0]); };
  });
  // the preview asks the agent for a slot once on its own, unless you take over first
  c.auto(1200, () => ask.click());
});
06

Questions

What people ask about it.

Does the calendar find free slots by itself?
No. onAsk tells you the person pressed the agent's button. Set finding to true while your agent looks, then pass what it found as suggestions and set finding back to false.
What should Book and Other do?
onBook gets the picked suggestion: add it to events and clear suggestions. onSkip gets the one being skipped: drop it from suggestions and the next one becomes the pick.
How do I save events after someone drags or adds one?
Pass events and onEventsChange. Every move, new event, rename and removal comes back as the full updated list, with times as local Dates.
Does it work on a phone?
Yes. Below 520px wide the week turns into a day view with a row of days to switch between, and dragging an event moves it within that day. The width of the calendar decides, not the screen, so a narrow card on a wide screen gets the day view too.
Can I show a full week or longer hours?
Yes. Set days to 7 for a full week, and startHour and hours for the time range, like startHour={8} and hours={10} for 8am to 6pm.
07

More in Dates

Pickers for single dates and date ranges.

Date pickerA field that opens a calendar, with quick picks and natural language.
Segmented controlSwitch between views of the same thing, with a sliding indicator.
Date range pickerPick a start and end across two months, or use a preset.
AgendaToday's schedule with a live now line and agent prep notes.