Skip to main content

ns-ui

Schedule ASCII Free/Busy

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.

Usage

registry/core/schedule-ascii-freebusy/demo.tsx
"use client";

import { ScheduleAsciiFreebusy, type Attendee, type SlotStatus } from "./component";

// One design-sync calendar: six attendees, 18 half-hour slots (09:00–18:00).

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

const 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] },
];

export default function ScheduleAsciiFreebusyDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / schedule-ascii-freebusy
      </p>
      <ScheduleAsciiFreebusy attendees={ATTENDEES} title="Design sync — Thu 16 Apr" />
      <p className="max-w-md text-center text-xs text-ns-muted">
        The accent window is the earliest slot everyone required is free.
      </p>
    </div>
  );
}

Component

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 and their free/busy blocks, rendered as one row each */
  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;
  /** offered meeting-length options, in minutes */
  durations?: number[];
  /** heading above the grid */
  title?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

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-ns-muted/50";
const RULE_INK = "text-ns-muted/55";
const OPTIONAL_INK = "text-ns-muted/30";

const GLYPH_CLASS: Record<SlotStatus, string> = {
  free: FREE_INK,
  tentative: "text-ns-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,
  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-ns-accent/[0.10]" : inBest(k) ? "bg-ns-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-ns-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-ns-accent ${
                  on ? "bg-ns-accent/[0.14] text-foreground" : "text-ns-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-ns-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-ns-accent ${
                    off ? "text-ns-muted/60 hover:text-ns-muted" : "text-foreground hover:text-ns-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-ns-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-ns-accent" : "text-ns-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-ns-accent" : "text-ns-muted"
            }`}
          >
            {answer}
          </div>
        </div>
      </div>

      {/* readout: hover detail crossfading with the resting hint */}
      <div className="relative h-[1.4em] text-[11px] text-ns-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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/schedule-ascii-freebusy.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/schedule-ascii-freebusy.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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-ns-muted/50, tentative is ▒ in text-ns-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-ns-muted (a length-1 run degenerates to ┴); the BEST run — earliest of the longest — is drawn in text-ns-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-ns-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-ns-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-ns-accent ring. COLOR: token-only (--foreground / --ns-muted / --border / --surface / --background / --ns-accent through Tailwind utilities and bg-ns-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.

Props

PropTypeDefaultDescription
attendeesAttendee[]—attendees and their free/busy blocks, rendered as one row each
slots?number18number of half-hour columns (default 18 = 09:00–18:00)
dayStartMinutes?number9 * 60minutes at the first column (default 540 = 09:00)
defaultDuration?number60initial meeting length in minutes; must be one of `durations`
durations?number[][30, 60, 90]offered meeting-length options, in minutes
title?string"Design sync — find a time"heading above the grid
className?string—extra classes merged onto the rendered root element