Meeting Planner
Find a time across time zones — participant rows over one shared 24-hour track, a draggable column that reads the same instant in every zone, hour snapping with Shift for half hours, and working hours derived from Intl.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/meeting-planner.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MeetingPlanner" component: one row per
participant, one shared horizontal day, and a draggable column that reads the
same instant in every zone at once. lucide-react for the icons,
Intl.DateTimeFormat for every string and every number, no date library and no
slider library.
Contract
- forwardRef<HTMLDivElement> — the ref and every native div prop land on the
card, className merges into it.
- Props extend Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" |
"onChange"> and add:
participants: { timeZone: string; label?: string; meta?: string;
workingHours?: { start: number; end: number } }[],
now: Date | number (REQUIRED),
value?: Date | number (controlled) / defaultValue?: Date | number,
onChange?: (selection: MeetingSelection) => void,
referenceTimeZone?: string, durationMinutes?: number (default 60, clamped to
15…1440), workingHours?: { start; end } (default 9-18, in each zone's own
local hours), locale?: string (default "en-US"), hour12?: boolean (default
false), showBestSlot?: boolean (default true), label?: string.
- `now` is required on purpose. It decides which day the track spans and where
the "now" hairline sits, and making it a prop is what keeps render free of
Date.now(): the server and the browser can never disagree about the day, and
a screenshot is reproducible. A page that wants it live owns the ticking.
- MeetingSelection is the emitted payload: { start: Date, end: Date,
durationMinutes, inHoursCount, locals: { timeZone, label, startText, endText,
dayOffset, offsetMinutes, availability }[] }. The instant AND that instant
written in every zone, because the caller usually needs both and re-deriving
the second from the first is exactly the work this component exists to do.
- The reference zone decides whose day the track spans; day markers are
measured against it. An id that is missing, unknown to this runtime or simply
not in `participants` falls back to the first resolvable row instead of
blanking the card.
- `label` defaults to the last segment of the id with underscores replaced —
"America/New_York" reads as "New York".
- No zod contract and no mock: `participants` is the whole input, and a
participant list is configuration, not fetched data. There is no loading or
error state to render — the meaningful states are ready, refusal (nobody
overlaps), an unresolvable zone, and empty.
Behavior — the day
- The track is one reference day: 24 cells, one hour of absolute time each.
- The anchor is the reference zone's local midnight, found in two passes, not
by subtracting the wall time elapsed since midnight. One pass is wrong twice
a year: on a spring-forward day only 23 real hours have passed by 23:00 local,
so the naive answer lands on 23:00 the day before. Re-interpret the local
Y/M/D as UTC, shift it by the offset *at that guess* rather than at `now`,
then check the result — where a zone skips midnight itself (Santiago, Havana)
the day starts at 01:00 and that hour is the honest answer.
- Every cell is read from Intl at its own instant instead of adding hours to a
single offset. A day with a DST transition then bends with it: Berlin reads
00, 01, 03, … and its 24-hour window reaches 00:00 of the next local day,
which is the truth rather than a row sheared by 60 minutes.
- Offsets are never stored. Read a zone's wall clock back with an en-US / h23
formatToParts, re-interpret those parts as if they were UTC, and subtract the
real instant floored to whole seconds (formatToParts has no milliseconds).
Deriving it is what keeps DST, the half-hour zones (India, Adelaide) and the
quarter-hour ones (Nepal, Chatham) correct, and correct again when the rules
change.
- Cache Intl.DateTimeFormat instances at module scope keyed by zone + locale;
the constructor throws a RangeError on an id this runtime has never heard of,
so cache `null` for those too and treat "cannot build a formatter" as
"unknown zone" everywhere.
Behavior — the column
- The selection is a start instant plus `durationMinutes`, rendered as a column
positioned by percentage (startMinutes / 1440) and sized by
(duration / 1440). It spans every row, because "the same instant in every
zone" is the whole point.
- The column is not per-row. Lay the card out as a two-column CSS grid (labels,
track) with EXPLICIT grid-template-rows, and place one overlay at
grid-row: 1 / -1 — `-1` only reaches the end of the *explicit* grid, so an
implicit one silently collapses the column onto a single participant.
- Pointer: pointerdown outside the column puts the meeting's start under the
pointer; pointerdown *inside* it is a grab rather than a move — it only
remembers the offset you took hold of, so a half-hour column never snaps out
from under the finger that was picking it up, and the drag keeps that spot.
setPointerCapture, then keep the maths on the overlay's own rect;
pointerup / pointercancel / lostpointercapture all just clear the flag.
Nothing is added to `document`, so there is no listener to clean up, and the
drag ends by itself if the card unmounts mid-gesture.
- One shot: read AND write the "dragging" ref inside the same synchronous block
of pointerdown, so a second finger cannot open a second drag over the first.
- Snapping: to the whole hour, to half hours while Shift is held — read
event.shiftKey on every move, so the precision can change mid-drag. Every
target is clamped into the day; a `value` handed in from outside is clamped
too, because the track *is* one day and there is nowhere else to stand.
- Press the track and it also takes focus, so the arrow keys continue the
gesture the pointer started.
- touch-action: pan-y — vertical scrolling still belongs to the page, a
horizontal drag belongs to the planner.
Behavior — availability
- For each participant, ask whether the WHOLE meeting is inside their working
window by sampling it every 30 minutes plus its final minute, re-reading the
local hour from Intl at each sample. Three answers: in / edge ("Partly") /
out. Sampling is what makes a 17:30 start against an 18:00 close, a window
that wraps past midnight (end <= start = night shift) and a meeting that
straddles a DST jump all come out right without special cases.
- The header counts only resolvable zones: "3 of 4 in hours".
- "Best slot" walks the day in 30-minute candidates and scores each one as
2 × in + 1 × edge, ties going to the earlier slot so the answer is stable. It
then reports honestly: everyone in hours, or "Best on this day: 2 of 3 …" and
who is left outside, or "no slot on this day is fully inside anyone's working
hours". It never silently picks a bad time and calls it good, and when the
column is already on the best slot it says so instead of doing nothing.
- Reports live in a transient line that clears itself after a few seconds,
which is what lets the same refusal be announced twice; clear the timer on
unmount.
Behavior — keyboard and ARIA
- The column is the control: role="slider", aria-orientation="horizontal",
tabIndex 0, aria-valuemin 0 / aria-valuemax (1440 - duration) / aria-valuenow
in minutes from the day's start, plus aria-describedby pointing at the visible
keyboard hint.
- aria-valuetext is the feature spoken out loud: the reference zone's start and
end, then each other zone's local start with its day marker, then "N of M
inside working hours" — capped at ~5 zones plus "and N more zones" so a big
list stays a sentence rather than a paragraph.
- Keys: ArrowRight / ArrowUp +1 hour, ArrowLeft / ArrowDown -1 hour, Shift for
half hours, PageUp / PageDown ±3 hours, Home / End the ends of the day. Every
move lands back on the step grid, so arrows normalise a dragged position
instead of inheriting its offset. Bind the handler on the card, run the
consumer's onKeyDown first, bail on defaultPrevented, and act only while the
slider owns focus so the buttons keep their own keys.
- Two nudge buttons (±30 minutes) and Best slot give the pointer a path that
needs no dragging at all. They go inert with aria-disabled + a guard in the
handler, NEVER the native disabled attribute: they go inert underneath the
user the moment the column reaches an end of the day, and the browser blurs a
node the instant it becomes disabled, dropping focus on <body>. Nothing the
component does to itself unmounts a focusable node either — the column is
never removed by its own action — so no move can leave focus nowhere.
- A zone id Intl rejects is a refusal, not a crash: the row stays, inert, with
the id spelled out and a reason, no track behind it, and out of the count — a
zone with no offset cannot be measured against anything.
- The hour cells are aria-hidden decoration; everything they show reaches
assistive tech as text in the label column and through aria-valuetext.
- A permanently mounted polite live region (role="status", aria-atomic) carries
only the reports. It never repeats the selection, because the slider already
announces that itself.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, bg-muted
for off-hours cells and bg-primary/25 for working ones, bg-primary/10 with
border-x-2 border-primary for the column, bg-primary + text-primary-foreground
for the grip and the "In hours" chip, bg-accent for "Partly",
bg-foreground/40 for the now hairline, text-muted-foreground for every
secondary line, text-destructive for an unresolvable id.
- cn() merges every className; focus-visible:ring-2 ring-ring (inset on the
slider, since it sits over the rows); tabular-nums everywhere digits line up.
- Motion is decoration: one transition on the column's `left`, disabled while
dragging (it would lag a finger) and behind motion-reduce:transition-none,
plus motion-reduce:transition-none on the button colour transitions. With
motion off the column simply appears where it was put.
- Cells are equal-width flex children (grow basis-0) with a hairline border, so
a percentage position lands exactly on an hour boundary; the track keeps a
constant height so the column never reflows a row.
Customization levers
- Density: the label column is an 11rem grid track and cells carry text-[10px]
hour numbers — widen the first column for longer names, or drop the numbers
entirely for a pure heat strip.
- Sub-blocks are independent: delete the header chip, the nudge buttons
(showBestSlot={false} already drops the third), the day markers or the hint
line without touching the maths.
- Track shape: 24 one-hour cells is a constant. Halve it for a 12-hour working
window, or split each hour into two cells to visualise half-hour zones — only
the anchor, the cell count and the snap step change.
- Working windows: one default for the card, per-row overrides for shifts, and
a wrapping window for night work. To colour by team rather than by
availability, swap the two cell classes for var(--chart-1..5).
- Duration: durationMinutes sizes the column and the availability sampling
together; pair it with a select above the card to offer 30 / 60 / 90.
- Scoring: `2 × in + edge` is one policy. Weight a named participant higher, or
require a specific zone to be `in`, and the Best slot button changes meaning
without touching the search.
- The day: pass a `now` on another date to plan another day, or wire two
buttons that hand the component `now ± 1 day`. Everything else follows,
including DST.
- Time text: `hour12` and `locale` decide the readable times and the cell
numbers (12-hour cells read 8a / 3p, which fits a 20px column); keep locale
explicit rather than undefined, or the server's locale and the visitor's will
render different strings into the same HTML.Concepts
- Injected instant — the day being planned arrives as a prop, never from a clock read during render, which is what makes the card server-renderable, screenshot-stable and free of the hydration mismatch a
Date.now()in render guarantees. - One anchor, one day — everything hangs off the reference zone's local midnight, derived in two passes so a spring-forward day does not start at 23:00 the evening before; each hour cell is then re-read from
Intlat its own instant, so a DST jump shows up as a repeated or missing local hour instead of shearing the row. - Snap with an escape hatch — the gesture lands on whole hours, and holding Shift drops it to half hours mid-drag; the keyboard has the exact same pair, so the fine grain is never a mouse-only privilege.
- Sampled availability — "does this fit your day?" is answered by walking the meeting in 30-minute steps rather than by comparing two numbers, which is why a night shift that wraps past midnight, a 17:30 start against an 18:00 close and a meeting that straddles a DST change all come out right with no special cases.
- One slider, many rows — a single
role="slider"overlay spans the whole grid, so there is one tab stop, one value, and anaria-valuetextthat says the chosen moment in every zone at once — the visual feature and the spoken one are the same feature. - Refusal over optimism — when no hour of the day suits everyone, Best slot moves to the honest maximum and names who is left outside instead of quietly picking something and calling it a match.
Coordinates Input
A latitude/longitude pair that reads decimal degrees or DMS in either field, re-prints between the two on a toggle, names the field that leaves ±90 / ±180, and splits a pasted pair across both inputs.
Char Counter
A character budget for any input or textarea — grapheme-accurate counting, a warning band, an explicit over-limit state, and a hard cap that trims the paste instead of swallowing it.