Schedule ASCII Free/Busy

Scheduling

A meeting-time finder: one row of block glyphs per attendee and an underlined accent window marking the earliest slot where everyone required is free — toggling a person optional recomputes the answer instantly.

Install
npx shadcn add https://design.helpmarq.com/r/schedule-ascii-freebusy.json
Source
registry/core/schedule-ascii-freebusy/component.tsx
"use client";

import { useCallback, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// ScheduleAsciiFreebusy — a meeting-time finder that shows its own work.
//
// One row of block glyphs per attendee over 18 half-hour slots (09:00–18:00)
// and an underlined accent window marking the earliest slot where everyone
// required is actually free. The solver is a bitwise AND of the required
// attendees' inverted busy masks followed by a single left-to-right
// run-length scan — dropping one person to optional removes their mask from
// the AND and the answer visibly moves, which is the whole argument of the
// component.
//
// Status is glyph-encoded, never color-encoded: free ·, tentative ▒, busy █,
// outside working hours blank. Pure DOM + tokens: no canvas, no rAF loop.
// ---------------------------------------------------------------------------

export type SlotStatus = "free" | "tentative" | "busy" | "out";

export interface Attendee {
  /** stable lowercase id, also used for [data-attendee-toggle] */
  id: string;
  name: string;
  /** one status per slot; length must equal `slots` */
  day: SlotStatus[];
}

export interface ScheduleAsciiFreebusyProps {
  attendees?: Attendee[];
  /** number of half-hour columns (default 18 = 09:00–18:00) */
  slots?: number;
  /** minutes at the first column (default 540 = 09:00) */
  dayStartMinutes?: number;
  /** initial meeting length in minutes; must be one of `durations` */
  defaultDuration?: number;
  durations?: number[];
  title?: string;
  className?: string;
}

const F: SlotStatus = "free";
const T: SlotStatus = "tentative";
const B: SlotStatus = "busy";
const O: SlotStatus = "out";

const DEFAULT_ATTENDEES: Attendee[] = [
  { id: "dana", name: "Dana", day: [F, B, B, F, F, F, B, B, F, F, B, B, F, F, F, F, O, O] },
  { id: "priya", name: "Priya", day: [B, F, F, F, F, B, B, F, F, T, B, B, B, F, F, F, F, F] },
  { id: "marcus", name: "Marcus", day: [B, F, F, F, F, F, B, B, B, F, F, F, B, B, F, T, F, F] },
  { id: "ines", name: "Inés", day: [F, F, T, F, F, B, F, B, B, F, B, F, F, B, F, F, F, F] },
  { id: "tobias", name: "Tobias", day: [B, F, F, F, F, F, B, F, F, B, F, B, B, F, F, F, B, B] },
  { id: "wei", name: "Wei", day: [F, F, F, F, F, B, B, B, F, B, F, F, F, B, T, F, F, O] },
];

const GLYPH: Record<SlotStatus, string> = {
  free: "·",
  tentative: "▒",
  busy: "█",
  out: " ",
};

// Resting ink. NOT `text-border` — --border is tuned for 1px hairlines and is
// invisible as type on the light theme (#ebebeb on #fafafa), which would leave
// the free cells and the rule blank at rest. Muted tints read in both themes
// and still sit below the busy blocks in the hierarchy.
const FREE_INK = "text-muted/50";
const RULE_INK = "text-muted/55";
const OPTIONAL_INK = "text-muted/30";

const GLYPH_CLASS: Record<SlotStatus, string> = {
  free: FREE_INK,
  tentative: "text-muted",
  busy: "text-foreground",
  out: FREE_INK,
};

const STATUS_WORD: Record<SlotStatus, string> = {
  free: "free",
  tentative: "tentative",
  busy: "busy",
  out: "outside working hours",
};

/** busy for solving purposes: a hard conflict or a slot outside working hours */
const isBlocked = (s: SlotStatus) => s === "busy" || s === "out";

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

type Run = { start: number; end: number };

/** every maximal run of set bits in `mask` whose length is at least `min` */
function maximalRuns(mask: boolean[], min: number): Run[] {
  const runs: Run[] = [];
  let start = -1;
  for (let k = 0; k <= mask.length; k++) {
    if (k < mask.length && mask[k]) {
      if (start === -1) start = k;
    } else if (start !== -1) {
      if (k - start >= min) runs.push({ start, end: k - 1 });
      start = -1;
    }
  }
  return runs;
}

export function ScheduleAsciiFreebusy({
  attendees = DEFAULT_ATTENDEES,
  slots = 18,
  dayStartMinutes = 9 * 60,
  defaultDuration = 60,
  durations = [30, 60, 90],
  title = "Design sync — find a time",
  className = "",
}: ScheduleAsciiFreebusyProps) {
  const [duration, setDuration] = useState(defaultDuration);
  const [optional, setOptional] = useState<Record<string, boolean>>({});
  const [hoverCol, setHoverCol] = useState<number | null>(null);
  const [active, setActive] = useState<{ r: number; c: number }>({ r: 0, c: 0 });
  const gridRef = useRef<HTMLDivElement>(null);
  const durationRef = useRef<HTMLDivElement>(null);

  const durationSlots = Math.max(1, Math.round(duration / 30));

  const timeAt = useCallback(
    (k: number) => {
      const m = dayStartMinutes + k * 30;
      const hh = String(Math.floor(m / 60)).padStart(2, "0");
      const mm = String(m % 60).padStart(2, "0");
      return `${hh}:${mm}`;
    },
    [dayStartMinutes]
  );

  const required = useMemo(() => attendees.filter((a) => !optional[a.id]), [attendees, optional]);

  /** freeMask[k] = AND over required attendees of NOT blocked */
  const freeMask = useMemo(() => {
    const mask: boolean[] = [];
    for (let k = 0; k < slots; k++) {
      mask.push(required.length > 0 && required.every((a) => !isBlocked(a.day[k] ?? "out")));
    }
    return mask;
  }, [required, slots]);

  const runs = useMemo(() => maximalRuns(freeMask, durationSlots), [freeMask, durationSlots]);

  /** earliest of the longest qualifying runs */
  const best = useMemo(() => {
    let win: Run | null = null;
    for (const r of runs) {
      const len = r.end - r.start + 1;
      if (!win || len > win.end - win.start + 1) win = r;
    }
    return win;
  }, [runs]);

  /** when nothing qualifies: the window of `durationSlots` with the fewest blocked people */
  const nearMiss = useMemo(() => {
    if (best || required.length === 0) return null;
    let winner: { start: number; names: string[] } | null = null;
    for (let s = 0; s + durationSlots <= slots; s++) {
      const names: string[] = [];
      for (const a of required) {
        for (let k = s; k < s + durationSlots; k++) {
          if (isBlocked(a.day[k] ?? "out")) {
            names.push(a.name);
            break;
          }
        }
      }
      if (!winner || names.length < winner.names.length) winner = { start: s, names };
    }
    return winner;
  }, [best, required, durationSlots, slots]);

  const answer = useMemo(() => {
    if (required.length === 0) return "no one required — pick at least one attendee";
    if (best) {
      const who =
        required.length === attendees.length
          ? `all ${required.length} free`
          : `${required.length} required free`;
      return `${timeAt(best.start)} → ${timeAt(best.end + 1)}   ${who}`;
    }
    if (nearMiss) {
      const names = nearMiss.names.join(", ");
      return `no window — best is ${timeAt(nearMiss.start)} → ${timeAt(nearMiss.start + durationSlots)} with ${names} busy`;
    }
    return "no window";
  }, [required, attendees.length, best, nearMiss, timeAt, durationSlots]);

  const hoverLine = useMemo(() => {
    if (hoverCol === null) return null;
    const blocked = required.filter((a) => isBlocked(a.day[hoverCol] ?? "out"));
    if (required.length === 0) return `${timeAt(hoverCol)} — no one required`;
    if (blocked.length === 0) return `${timeAt(hoverCol)} — everyone free`;
    const names = blocked
      .map((a) => (a.day[hoverCol] === "out" ? `${a.name} (off)` : a.name))
      .join(", ");
    return `${timeAt(hoverCol)} — ${blocked.length} of ${required.length} busy: ${names}`;
  }, [hoverCol, required, timeAt]);

  const restLine = `${runs.length} window${runs.length === 1 ? "" : "s"} ≥ ${duration} min · click a name to drop them to optional`;

  const toggle = (id: string) => setOptional((o) => ({ ...o, [id]: !o[id] }));

  const focusCell = (r: number, c: number) => {
    gridRef.current
      ?.querySelector<HTMLDivElement>(`[data-cell-r="${r}"][data-cell-c="${c}"]`)
      ?.focus();
  };

  const onCellKeyDown = (e: React.KeyboardEvent, r: number, c: number) => {
    if (e.key === "Escape") {
      setHoverCol(null);
      return;
    }
    const map: Record<string, [number, number]> = {
      ArrowUp: [-1, 0],
      ArrowDown: [1, 0],
      ArrowLeft: [0, -1],
      ArrowRight: [0, 1],
    };
    const d = map[e.key];
    if (!d) return;
    e.preventDefault();
    const nr = clamp(r + d[0], 0, attendees.length - 1);
    const nc = clamp(c + d[1], 0, slots - 1);
    setActive({ r: nr, c: nc });
    setHoverCol(nc);
    focusCell(nr, nc);
  };

  /** radiogroup semantics: arrows move the selection and the single tab stop */
  const onDurationKeyDown = (e: React.KeyboardEvent, i: number) => {
    const step =
      e.key === "ArrowRight" || e.key === "ArrowDown"
        ? 1
        : e.key === "ArrowLeft" || e.key === "ArrowUp"
          ? -1
          : 0;
    if (!step) return;
    e.preventDefault();
    const next = durations[(i + step + durations.length) % durations.length];
    if (next === undefined) return;
    setDuration(next);
    durationRef.current
      ?.querySelector<HTMLButtonElement>(`[data-duration="${next}"]`)
      ?.focus();
  };

  const inBest = (k: number) => !!best && k >= best.start && k <= best.end;

  const colTint = (k: number) =>
    hoverCol === k ? "bg-accent/[0.10]" : inBest(k) ? "bg-accent/[0.06]" : "";

  /** underline glyph for a window of `len` columns at offset `i` */
  const underline = (len: number, i: number) => {
    if (len === 1) return "┴";
    if (i === 0) return "└";
    if (i === len - 1) return "┘";
    return "─";
  };

  const runAt = (k: number) => runs.find((r) => k >= r.start && k <= r.end) ?? null;

  const cell = "flex h-[1.35em] items-center justify-center select-none";
  const labelColumn = "7em";
  const cellWidth = "1.5em";

  return (
    <div className={`inline-flex flex-col gap-3 font-mono text-[14px] ${className}`}>
      <div className="flex flex-wrap items-center justify-between gap-4">
        <span className="text-[11px] uppercase tracking-[0.18em] text-muted">{title}</span>
        <div
          ref={durationRef}
          role="radiogroup"
          aria-label="Meeting length"
          className="flex items-center rounded-sm border border-border"
        >
          {durations.map((d, i) => {
            const on = d === duration;
            return (
              <button
                key={d}
                type="button"
                role="radio"
                aria-checked={on}
                data-duration={d}
                tabIndex={on || (!durations.includes(duration) && i === 0) ? 0 : -1}
                onClick={() => setDuration(d)}
                onKeyDown={(e) => onDurationKeyDown(e, i)}
                className={`px-2.5 py-1 text-[11px] tabular-nums outline-none transition-colors duration-150 first:rounded-l-sm last:rounded-r-sm motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-accent ${
                  on ? "bg-accent/[0.14] text-foreground" : "text-muted hover:text-foreground"
                }`}
              >
                {d} min
              </button>
            );
          })}
        </div>
      </div>

      <div
        ref={gridRef}
        role="grid"
        aria-label={`Attendee availability, ${timeAt(0)} to ${timeAt(slots)} in half-hour slots`}
        aria-colcount={slots + 1}
        onPointerLeave={() => setHoverCol(null)}
        onKeyDown={(e) => {
          if (e.key === "Escape") setHoverCol(null);
        }}
        className="grid rounded-sm border border-border bg-surface px-3 py-2.5"
        style={{ gridTemplateColumns: `${labelColumn} repeat(${slots}, ${cellWidth})` }}
      >
        {/* hour ruler */}
        <div role="row" className="contents">
          <div role="columnheader" aria-colindex={1} className="h-[1.35em]" />
          {Array.from({ length: slots }, (_, k) =>
            k % 2 === 0 ? (
              <div
                key={k}
                role="columnheader"
                aria-colindex={k + 2}
                aria-colspan={Math.min(2, slots - k)}
                style={{ gridColumn: `span ${Math.min(2, slots - k)}` }}
                className="flex h-[1.35em] items-center text-[10px] tabular-nums text-muted"
              >
                {timeAt(k).slice(0, 2)}
              </div>
            ) : null
          )}
        </div>

        {/* one row per attendee */}
        {attendees.map((a, r) => {
          const off = !!optional[a.id];
          return (
            <div key={a.id} role="row" className="contents">
              <div role="rowheader" className="flex h-[1.35em] items-center pr-2">
                <button
                  type="button"
                  data-attendee-toggle={a.id}
                  aria-pressed={!off}
                  aria-label={`${a.name} — ${off ? "optional, excluded from the search" : "required"}. Toggle.`}
                  onClick={() => toggle(a.id)}
                  className={`w-full truncate rounded-[2px] text-left text-[12px] outline-none transition-colors duration-150 motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-accent ${
                    off ? "text-muted/60 hover:text-muted" : "text-foreground hover:text-accent"
                  }`}
                >
                  {off ? `(${a.name})` : a.name}
                </button>
              </div>
              {Array.from({ length: slots }, (_, k) => {
                const status = a.day[k] ?? "out";
                const isActive = active.r === r && active.c === k;
                const ruled = hoverCol === k && !isBlocked(status);
                return (
                  <div
                    key={k}
                    role="gridcell"
                    data-cell-r={r}
                    data-cell-c={k}
                    tabIndex={isActive ? 0 : -1}
                    aria-selected={!off && inBest(k)}
                    aria-label={`${a.name}, ${timeAt(k)}, ${STATUS_WORD[status]}${off ? ", optional" : ""}`}
                    onPointerEnter={() => setHoverCol(k)}
                    onFocus={() => {
                      setActive({ r, c: k });
                      setHoverCol(k);
                    }}
                    onKeyDown={(e) => onCellKeyDown(e, r, k)}
                    className={`${cell} rounded-[1px] outline-none transition-colors duration-150 motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-accent ${colTint(k)} ${
                      off ? OPTIONAL_INK : GLYPH_CLASS[status]
                    }`}
                  >
                    {ruled ? "┊" : GLYPH[status]}
                  </div>
                );
              })}
            </div>
          );
        })}

        {/* separator rule */}
        <div role="row" aria-hidden className="contents">
          <div className="h-[1.1em]" />
          {Array.from({ length: slots }, (_, k) => (
            <div key={k} className={`flex h-[1.1em] items-center justify-center ${RULE_INK}`}>
              {k === 0 ? "├" : k === slots - 1 ? "┤" : "─"}
            </div>
          ))}
        </div>

        {/* every qualifying window underlined; the best one in accent */}
        <div role="row" aria-hidden className="contents">
          <div className="h-[1.1em]" />
          {Array.from({ length: slots }, (_, k) => {
            const r = runAt(k);
            const isBest = !!best && !!r && r.start === best.start;
            const len = r ? r.end - r.start + 1 : 0;
            return (
              <div
                key={k}
                className={`flex h-[1.1em] items-center justify-center leading-none ${
                  isBest ? "text-accent" : "text-muted"
                }`}
              >
                {r ? underline(len, k - r.start) : ""}
              </div>
            );
          })}
        </div>

        {/* the answer, seated under its own window */}
        <div role="row" className="contents">
          <div role="rowheader" className="h-[1.35em]" />
          <div
            role="gridcell"
            aria-live="polite"
            style={{ gridColumn: `${(best ? Math.min(best.start, 5) : 0) + 2} / -1` }}
            className={`flex h-[1.35em] items-center whitespace-nowrap text-[12px] tabular-nums ${
              best ? "text-accent" : "text-muted"
            }`}
          >
            {answer}
          </div>
        </div>
      </div>

      {/* readout: hover detail crossfading with the resting hint */}
      <div className="relative h-[1.4em] text-[11px] text-muted">
        <span
          className={`absolute inset-0 flex items-center whitespace-nowrap transition-opacity duration-150 motion-reduce:transition-none ${
            hoverLine ? "opacity-0" : "opacity-100"
          }`}
        >
          {restLine}
        </span>
        <span
          aria-live="polite"
          className={`absolute inset-0 flex items-center whitespace-nowrap tabular-nums text-foreground transition-opacity duration-150 motion-reduce:transition-none ${
            hoverLine ? "opacity-100" : "opacity-0"
          }`}
        >
          {hoverLine ?? ""}
        </span>
      </div>
    </div>
  );
}
Use when

showing a group's overlapping availability and the concrete meeting slot that falls out of it — pick this when the point is the ANSWER (the earliest window everyone required is free) and the visible cost of one person's calendar, since making an attendee optional re-runs the solver in front of you. heatmap-year-stipple is the right pick for one series of daily intensity over a year with no solver at all; sheet-ascii-range is for selecting an arbitrary rectangle of cells rather than reading a computed window.

Build spec

Build <ScheduleAsciiFreebusy attendees slots dayStartMinutes defaultDuration durations title className> as a pure-DOM availability matrix over slots=18 half-hour columns spanning 09:00–18:00, one row per attendee (default six: Dana, Priya, Marcus, Inés, Tobias, Wei). GRID: each attendee carries a SlotStatus[] of 'free' | 'tentative' | 'busy' | 'out', rendered strictly glyph-encoded and never color-encoded — free is · in text-muted/50, tentative is ▒ in text-muted, busy is █ in text-foreground (never text-border — --border is a 1px-hairline token and is invisible as type on the light theme, which would leave the resting grid blank), and a slot outside that person's working hours renders as a blank cell. SOLVER (the actual mechanism, not decoration): isBlocked = status is 'busy' or 'out'; freeMask[k] is the AND over every REQUIRED attendee of NOT isBlocked, i.e. the bitwise intersection of the inverted busy masks, computed only over attendees not currently marked optional. A single left-to-right run-length scan over freeMask collects every MAXIMAL run of set bits whose length is >= durationSlots (duration/30). Every qualifying run is drawn on a result row as └ + ─×(len−2) + ┘ in text-muted (a length-1 run degenerates to ┴); the BEST run — earliest of the longest — is drawn in text-accent, and an aria-live answer row seated under that window prints '10:30 → 11:30 all 6 free'. If no run qualifies the component never goes blank: it falls back to a minimum-conflict scan over every window of durationSlots, picking the one blocking the fewest required attendees, and prints 'no window — best is 10:00 → 11:30 with Dana busy'. A single ├────┤ rule separates the attendee rows from the result rows. There is no aggregate or density row of any kind: the six calendars are the only glyph rows. CONTROLS: a segmented role=radiogroup of 30 / 60 / 90 min (1 / 2 / 3 slots) re-runs the scan on click and on ArrowLeft/Right/Up/Down, which wrap around and move the group's single tab stop (roving tabindex parked on the checked radio); each attendee's name is an aria-pressed toggle button (data-attendee-toggle="<id>") that flips them required↔optional — an optional attendee is excluded from the AND, is parenthesised, and their entire row dims to text-muted/30, so with the default data dropping Dana visibly widens the accent window from 10:30 → 11:30 to 09:30 → 11:30. That jump is the component's whole argument and is what the landing-card autoplay presses. HOVER: entering any cell sets the hovered column, which tints that column bg-accent/[0.10] and swaps the glyph to ┊ in every row where that person is not blocked — a dotted plumb line drawn through the free cells of the column, leaving the █ blocks intact — while a readout below crossfades (150ms opacity, motion-reduce:transition-none) from the resting hint to '14:00 — 2 of 6 busy: Dana, Priya', with an out-of-hours attendee listed as 'Wei (off)'. pointerleave on the grid clears it and the readout eases back. A11Y: role=grid on the matrix with role=row per line, role=columnheader on the hour ruler (each hour label carries aria-colspan=2 and an explicit aria-colindex, since it spans two half-hour columns), role=rowheader on the name/label column, role=gridcell on every slot with aria-selected true inside the best window and an aria-label naming attendee + time + status; a roving tabindex (exactly one cell with tabIndex 0) moves with ArrowUp/Down/Left/Right, Escape clears the column hover, and Space on a name button toggles required/optional through native button semantics. Every focusable control — cells, name toggles, duration radios — carries a visible focus-visible:ring-2 focus-visible:ring-accent ring. COLOR: token-only (--foreground / --muted / --border / --surface / --background / --accent through Tailwind utilities and bg-accent/[0.06|0.10|0.14] arbitrary-opacity tints), no hex anywhere, so both themes follow the tokens with nothing to re-derive. There is no canvas, no rAF loop and no animation beyond the 150ms color/opacity transitions, all of which are suppressed under prefers-reduced-motion via motion-reduce:transition-none.

Tags
schedulingcalendaravailabilityasciimonogridkeyboard-navigationmeetings