Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CalendarClock, ChevronLeft, ChevronRight, GripVertical, Sparkles } from "lucide-react"

import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Time math
 *
 * Nothing here stores an offset and nothing ever adds hours to one. Every
 * number — the GMT label, the hour written into a cell, whether an hour is
 * inside someone's working window — is read back out of `Intl` at its own
 * instant. That is the only way a day containing a DST transition bends with

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/meeting-planner.json

Prompt

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 Intl at 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 an aria-valuetext that 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.

On This Page