Day Schedule
Data Display

Day Schedule

A vertical day timeline with exact hour gridlines, overlap-aware event blocks that tile into computed columns, and a ticking now-line that pauses offscreen and in hidden tabs.

Install

npx shadcn@latest add @paragon/day-schedule

Also installs: calendar

day-schedule.tsx

"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { sameDay, startOfDay } from "@/registry/paragon/ui/calendar";
import { cn } from "@/lib/utils";

export type ScheduleTone = "default" | "primary" | "success" | "warning" | "destructive";

export interface ScheduleEvent {
  id: string;
  start: Date;
  end: Date;
  title: string;
  location?: string;
  tone?: ScheduleTone;
}

const TONE_BLOCK: Record<ScheduleTone, string> = {
  default:
    "border-muted-foreground/50 bg-secondary/80 hover:bg-secondary text-foreground",
  primary: "border-primary bg-primary/8 hover:bg-primary/12 text-foreground",
  success: "border-success bg-success/10 hover:bg-success/15 text-foreground",
  warning: "border-warning bg-warning/12 hover:bg-warning/18 text-foreground",
  destructive:
    "border-destructive bg-destructive/8 hover:bg-destructive/12 text-foreground",
};

const minutesOf = (d: Date) => d.getHours() * 60 + d.getMinutes();

const hourLabel = (h: number) =>
  h === 0 || h === 24
    ? "12 AM"
    : h === 12
      ? "12 PM"
      : h < 12
        ? `${h} AM`
        : `${h - 12} PM`;

const timeLabel = (d: Date) => {
  const h = d.getHours();
  const m = d.getMinutes();
  const hr = ((h + 11) % 12) + 1;
  return `${hr}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h < 12 ? "AM" : "PM"}`;
};

interface Positioned {
  event: ScheduleEvent;
  top: number;
  height: number;
  col: number;
  cols: number;
}

/**
 * Overlap layout: cluster transitively-overlapping events, then assign
 * each to the first column whose previous occupant has ended. Every
 * member of a cluster shares the cluster's column count, so widths tile
 * the row exactly.
 */
function layoutEvents(
  events: ScheduleEvent[],
  startMin: number,
  endMin: number,
  pxPerMin: number,
): Positioned[] {
  const sorted = events
    .filter((e) => minutesOf(e.end) > startMin && minutesOf(e.start) < endMin)
    .sort(
      (a, b) =>
        minutesOf(a.start) - minutesOf(b.start) ||
        minutesOf(b.end) - minutesOf(a.end),
    );

  const out: Positioned[] = [];
  let cluster: { item: Positioned; endMin: number }[] = [];
  let clusterEnd = -1;
  let columns: number[] = []; // end minute occupying each column

  const flush = () => {
    for (const { item } of cluster) item.cols = columns.length;
    cluster = [];
    columns = [];
  };

  for (const ev of sorted) {
    const s = Math.max(minutesOf(ev.start), startMin);
    const e = Math.min(minutesOf(ev.end), endMin);
    if (s >= clusterEnd && cluster.length > 0) flush();

    let col = columns.findIndex((end) => end <= s);
    if (col === -1) {
      col = columns.length;
      columns.push(e);
    } else {
      columns[col] = e;
    }

    const item: Positioned = {
      event: ev,
      top: (s - startMin) * pxPerMin,
      height: Math.max(24, (e - s) * pxPerMin - 2),
      col,
      cols: 1,
    };
    out.push(item);
    cluster.push({ item, endMin: e });
    clusterEnd = Math.max(clusterEnd, e);
  }
  flush();
  return out;
}

export interface DayScheduleProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  /** The day being displayed. */
  date?: Date;
  events?: ScheduleEvent[];
  /** Visible range, in whole hours (24h clock). */
  startHour?: number;
  endHour?: number;
  /** Pixels per hour. */
  hourHeight?: number;
  /** Base "now" for the ticking line. Pass a fixed date for
   * deterministic renders; the line still advances from it. */
  now?: Date;
  onEventSelect?: (event: ScheduleEvent) => void;
  /** Disables the mount stagger. */
  static?: boolean;
}

/**
 * A vertical day timeline: exact hour gridlines, overlap-aware event
 * blocks that tile shared columns, and a now-line that ticks every half
 * minute — pausing while the tab is hidden or the timeline offscreen.
 */
export function DaySchedule({
  date,
  events = [],
  startHour = 7,
  endHour = 19,
  hourHeight = 56,
  now,
  onEventSelect,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel,
  ...props
}: DayScheduleProps) {
  const reduced = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { amount: 0.05 });

  const [fallbackNow] = React.useState(() => new Date());
  const base = now ?? fallbackNow;
  const baseTime = base.getTime();
  const day = date ? startOfDay(date) : startOfDay(base);

  // Ticks from the base instant: elapsed wall time is added in an
  // effect (never during render), and the interval only runs while the
  // component is in view and the tab visible.
  const [elapsed, setElapsed] = React.useState(0);
  React.useEffect(() => {
    if (!inView) return;
    const mountedAt = Date.now();
    const initial = elapsed; // eslint-disable-line react-hooks/exhaustive-deps
    const update = () => setElapsed(initial + (Date.now() - mountedAt));
    const id = setInterval(() => {
      if (!document.hidden) update();
    }, 30_000);
    const onVisible = () => {
      if (!document.hidden) update();
    };
    document.addEventListener("visibilitychange", onVisible);
    return () => {
      clearInterval(id);
      document.removeEventListener("visibilitychange", onVisible);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [inView, baseTime]);

  const current = new Date(baseTime + elapsed);
  const startMin = startHour * 60;
  const endMin = endHour * 60;
  const pxPerMin = hourHeight / 60;
  const totalHeight = (endMin - startMin) * pxPerMin;

  const positioned = React.useMemo(
    () => layoutEvents(events, startMin, endMin, pxPerMin),
    [events, startMin, endMin, pxPerMin],
  );

  const nowMin = minutesOf(current);
  const showNowLine =
    sameDay(current, day) && nowMin >= startMin && nowMin <= endMin;
  const hours = Array.from(
    { length: endHour - startHour + 1 },
    (_, i) => startHour + i,
  );

  return (
    <div
      ref={rootRef}
      className={cn("flex w-full min-w-0 select-none", className)}
      aria-label={ariaLabel ?? "Day schedule"}
      {...props}
    >
      {/* Hour gutter */}
      <div
        className="relative w-12 shrink-0"
        style={{ height: totalHeight }}
        aria-hidden
      >
        {hours.map((h, i) => (
          <span
            key={h}
            className="absolute right-2 -translate-y-1/2 text-[10px] text-muted-foreground tabular-nums"
            style={{ top: i * hourHeight }}
          >
            {hourLabel(h)}
          </span>
        ))}
      </div>

      {/* Timeline */}
      <div className="relative min-w-0 flex-1" style={{ height: totalHeight }}>
        {hours.map((h, i) => (
          <div
            key={h}
            aria-hidden
            className={cn(
              "absolute inset-x-0 border-t",
              i === 0 || i === hours.length - 1
                ? "border-border"
                : "border-border/70",
            )}
            style={{ top: i * hourHeight }}
          />
        ))}

        <div role="list" aria-label="Events">
          {positioned.map(({ event, top, height, col, cols }, i) => {
            const tone = event.tone ?? "default";
            return (
              <motion.button
                key={event.id}
                role="listitem"
                type="button"
                initial={
                  isStatic || reduced
                    ? false
                    : { opacity: 0, y: 8, filter: "blur(4px)" }
                }
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                transition={{
                  duration: 0.25,
                  ease: [0.22, 1, 0.36, 1],
                  delay: Math.min(i * 0.04, 0.24),
                }}
                onClick={() => onEventSelect?.(event)}
                aria-label={`${event.title}, ${timeLabel(event.start)} to ${timeLabel(event.end)}${event.location ? `, ${event.location}` : ""}`}
                className={cn(
                  "absolute overflow-hidden rounded-md border-l-2 px-2 py-1 text-left outline-none",
                  "transition-[background-color,box-shadow,scale] duration-150 ease-out focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-ring active:not-disabled:scale-[0.99]",
                  TONE_BLOCK[tone],
                )}
                style={{
                  top,
                  height,
                  left: `calc(${(col / cols) * 100}% + ${col === 0 ? 6 : 2}px)`,
                  width: `calc(${(1 / cols) * 100}% - ${col === 0 ? 8 : 4}px)`,
                }}
              >
                <span className="block truncate text-xs leading-4 font-medium">
                  {event.title}
                </span>
                <span className="block truncate text-[10px] leading-3.5 text-muted-foreground tabular-nums">
                  {timeLabel(event.start)}{timeLabel(event.end)}
                  {event.location ? ` · ${event.location}` : ""}
                </span>
              </motion.button>
            );
          })}
        </div>

        {showNowLine && (
          <div
            aria-hidden
            className="pointer-events-none absolute inset-x-0 top-0 z-10"
            style={{
              transform: `translateY(${(nowMin - startMin) * pxPerMin}px)`,
            }}
          >
            <div className="relative">
              <span className="absolute top-1/2 -left-1 size-2 -translate-y-1/2 rounded-full bg-destructive" />
              <div className="border-t border-destructive" />
            </div>
          </div>
        )}
      </div>
    </div>
  );
}