Skip to main content

ns-ui

Clock Card Rack

A catch-up digest as a punch-clock card rack: one bordered card per collaborator, unread bursts holding the card proud of a shared top datum by up to 6px until a real 'Mark caught up' button settles it flush.

Use when the morning 'what happened while I was away' panel on a shared project, where every entry is a time-punched row grouped on its author's own card so one person's rhythm stays intact — pick this over a single interleaved activity feed ('3 people made 12 edits'), which forces per-line avatar decoding this rack is built to avoid. Differs from memory-ledger-decay (patina-ledger): that one ages a record's ink color the longer it sits unreferenced; this one never recolors anything — unread is a physical position (proud vs. flush) against a datum line, cleared by a button, not a decay curve read off elapsed time.

Install

npx shadcn add https://design.helpmarq.com/r/clock-card.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.

Source
registry/core/clock-card/component.tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// ClockCardRack — the "what happened while I was away" digest, built as a
// punch-clock card rack: one radius-12 bordered card per collaborator, each
// holding their own work bursts as real punched rows ("edited section 2 —
// 14:10 to 14:32"). No interleaved anonymous feed — every row lives on its
// author's card, so the rhythm of one person's morning reads at a glance
// instead of needing per-line avatar decoding.
//
// Time is the only governing scalar. It drives three things, never set
// independently: (1) row order inside a card — latest punch first; (2) rack
// order across cards — the card with the most recent punch anywhere on it
// sits on top, recomputed on every render, never a stored index; (3) how
// PROUD an unread card sits. Every card shares one top datum (the hairline
// at the head of the rack) and cards holding unread bursts float up to 6px
// above their flush resting position — `2px + 1px per 12 unread hours`,
// clamped at 6px so a two-week vacation's backlog can't shove a card off the
// rack the way an unclamped scalar would. The clamp is the point: proudness
// reads as "there is unread work here", not "here is how much".
//
// Marking a card read is a real per-card "Mark caught up" button, never a
// passive read-on-view — settle is a pure transform transition (300ms
// ease-out-expo) off the offset above, so it costs no layout. A brand new
// punch row plays a single vertical clip reveal on the render it first
// appears (detected by diffing burst ids against the previous commit in a
// layout effect, so the reveal starts on the very first paint rather than
// flashing open then replaying) — like a card dropped into the slot.
//
// Fully controlled: `collaborators` is the only model, rows carry their own
// `unread` flag, the rack derives every sort/offset from it and holds no
// copy of the model itself. "Mark caught up" is reported via a callback,
// the caller flips the flags. Local state is transient UI only: which
// burst ids are still mid-reveal-animation and the aria-live announcement
// text. Colors are CSS custom properties only (--background, --foreground,
// --ns-muted, --border, --ns-accent via the bg-ns-accent/text-white utility
// pair already standard across this registry's buttons) — never a literal —
// so both themes render correctly. Differs from memory-ledger-decay
// (patina-ledger): that component ages a record by tarnishing its ink on a
// color ramp; this one never recolors anything for unread state — unread is
// a physical position along a datum line, settled by a button, not a decay
// curve read off elapsed time. Pure DOM + CSS + real <time> elements, no
// canvas. prefers-reduced-motion swaps the proud/flush states directly with
// no 300ms settle and skips the row-reveal animation entirely; nothing
// becomes unreachable, it just stops moving.
// ---------------------------------------------------------------------------

export interface ClockCardBurst {
  /** stable id, unique within its collaborator */
  id: string;
  /** what happened, e.g. "edited section 2" */
  section: string;
  /** ISO datetime the burst started */
  start: string;
  /** ISO datetime the burst ended */
  end: string;
  /** unread bursts hold their card proud of the rack. default false */
  unread?: boolean;
}

export interface ClockCardCollaborator {
  /** stable id */
  id: string;
  /** person's name — becomes the card's accessible name */
  name: string;
  /** work bursts for this person, any order — the component sorts them */
  bursts: ClockCardBurst[];
}

export interface ClockCardRackProps {
  /** one card per collaborator — fully controlled, the rack derives everything else */
  collaborators: ClockCardCollaborator[];
  /** fires when a card's "Mark caught up" button is pressed; caller clears that card's unread flags */
  onMarkCaughtUp?: (collaboratorId: string) => void;
  /** accessible name for the rack region. default "Catch-up digest" */
  ariaLabel?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const MAX_PROUD_PX = 6;
const BASE_PROUD_PX = 2;
const HOUR_MS = 3_600_000;

function safeTime(iso: string): number {
  const t = Date.parse(iso);
  return Number.isFinite(t) ? t : 0;
}

function formatClock(iso: string): string {
  const t = safeTime(iso);
  if (!t) return "--:--";
  return new Date(t).toLocaleTimeString(undefined, {
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  });
}

function proudOffsetPx(bursts: ClockCardBurst[]): number {
  let unreadHours = 0;
  let unreadCount = 0;
  for (const b of bursts) {
    if (!b.unread) continue;
    unreadCount++;
    unreadHours += Math.max(0, safeTime(b.end) - safeTime(b.start)) / HOUR_MS;
  }
  if (unreadCount === 0) return 0;
  return Math.min(MAX_PROUD_PX, BASE_PROUD_PX + unreadHours / 12);
}

function latestPunch(bursts: ClockCardBurst[]): number {
  let latest = -Infinity;
  for (const b of bursts) latest = Math.max(latest, safeTime(b.end));
  return latest;
}

export function ClockCardRack({
  collaborators,
  onMarkCaughtUp,
  ariaLabel = "Catch-up digest",
  className = "",
}: ClockCardRackProps) {
  const uid = useId();
  const [reducedMotion, setReducedMotion] = useState(false);
  const [enteringIds, setEnteringIds] = useState<Set<string>>(() => new Set());
  const [liveMsg, setLiveMsg] = useState("");
  const seenIdsRef = useRef<Set<string> | null>(null);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReducedMotion(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  // Detect brand-new burst rows synchronously before paint, so the very
  // first frame already renders them clipped-closed rather than flashing
  // open once, then replaying closed->open a frame later.
  useLayoutEffect(() => {
    const allIds = new Set<string>();
    for (const c of collaborators) for (const b of c.bursts) allIds.add(b.id);

    const seen = seenIdsRef.current;
    if (seen === null) {
      // first mount: nothing "enters", it's just the initial render
      seenIdsRef.current = allIds;
      return;
    }

    const fresh = new Set<string>();
    for (const id of allIds) if (!seen.has(id)) fresh.add(id);
    seenIdsRef.current = allIds;

    if (fresh.size === 0 || reducedMotion) return;
    setEnteringIds((prev) => new Set([...prev, ...fresh]));
    const t = window.setTimeout(() => {
      setEnteringIds((prev) => {
        const next = new Set(prev);
        for (const id of fresh) next.delete(id);
        return next;
      });
    }, 360);
    return () => window.clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [collaborators, reducedMotion]);

  const sorted = [...collaborators].sort(
    (a, b) => latestPunch(b.bursts) - latestPunch(a.bursts)
  );

  function markCaughtUp(c: ClockCardCollaborator) {
    onMarkCaughtUp?.(c.id);
    setLiveMsg(`Caught up with ${c.name}`);
  }

  return (
    <div
      role="region"
      aria-label={ariaLabel}
      className={["ns-clockcard-rack", className].join(" ")}
    >
      <style>{`
.ns-clockcard-datum{height:1px;background:var(--border);margin-bottom:10px}
.ns-clockcard-slots{display:flex;flex-direction:column;gap:10px;padding-top:${MAX_PROUD_PX}px}
.ns-clockcard-card{border-radius:12px;border:1px solid var(--border);background:var(--background);transform:translateY(calc(-1 * var(--nsc-offset, 0px)));transition:transform 300ms cubic-bezier(.16,1,.3,1)}
.ns-clockcard-head{display:flex;align-items:baseline;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border)}
.ns-clockcard-name{margin:0;font-size:13px;font-weight:600;color:var(--foreground)}
.ns-clockcard-count{font-family:var(--font-mono, ui-monospace, monospace);font-size:11px;color:var(--ns-muted)}
.ns-clockcard-caughtup{margin-left:auto;flex-shrink:0;border-radius:6px;background:var(--ns-accent);color:var(--background);font-size:11px;font-weight:500;padding:5px 10px;transition:opacity 150ms ease-out}
.ns-clockcard-caughtup:hover:not(:disabled){opacity:0.82}
.ns-clockcard-caughtup:focus-visible{outline:2px solid var(--ns-accent);outline-offset:2px}
.ns-clockcard-caughtup:disabled{opacity:0.4;pointer-events:none;cursor:not-allowed}
.ns-clockcard-rows{list-style:none;margin:0;padding:0}
.ns-clockcard-row{overflow:hidden}
.ns-clockcard-row + .ns-clockcard-row{border-top:1px solid var(--border)}
.ns-clockcard-row-clip{padding:7px 12px;display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 8px}
.ns-clockcard-row[data-entering="true"] .ns-clockcard-row-clip{animation:ns-clockcard-reveal 340ms cubic-bezier(.16,1,.3,1) both}
@keyframes ns-clockcard-reveal{from{clip-path:inset(0 0 100% 0);opacity:0}to{clip-path:inset(0 0 0% 0);opacity:1}}
.ns-clockcard-section{font-size:12.5px;color:var(--foreground);min-width:0}
.ns-clockcard-times{font-family:var(--font-mono, ui-monospace, monospace);font-size:11.5px;color:var(--ns-muted);white-space:nowrap;margin-left:auto}
.ns-clockcard-empty{padding:10px 12px;font-size:12px;color:var(--ns-muted)}
@media (prefers-reduced-motion: reduce){
  .ns-clockcard-card{transition:none!important}
  .ns-clockcard-row[data-entering="true"] .ns-clockcard-row-clip{animation:none!important}
  .ns-clockcard-caughtup{transition:none!important}
}
`}</style>

      <div aria-hidden="true" className="ns-clockcard-datum" />

      <div className="ns-clockcard-slots">
        {sorted.map((c) => {
          const bursts = [...c.bursts].sort(
            (a, b) => safeTime(b.start) - safeTime(a.start)
          );
          const unreadCount = bursts.filter((b) => b.unread).length;
          const offset = proudOffsetPx(bursts);
          const headingId = `${uid}-${c.id}-name`;
          const countText =
            unreadCount > 0
              ? `${unreadCount} unread burst${unreadCount === 1 ? "" : "s"}`
              : "Caught up";

          return (
            <section
              key={c.id}
              aria-labelledby={headingId}
              data-proud={unreadCount > 0}
              className="ns-clockcard-card"
              style={{ "--nsc-offset": `${offset}px` } as React.CSSProperties}
            >
              <header className="ns-clockcard-head">
                <h3 id={headingId} className="ns-clockcard-name">
                  {c.name}
                </h3>
                <span className="ns-clockcard-count">{countText}</span>
                <button
                  type="button"
                  onClick={() => markCaughtUp(c)}
                  disabled={unreadCount === 0}
                  aria-label={`Mark ${c.name} caught up`}
                  className="ns-clockcard-caughtup"
                >
                  Mark caught up
                </button>
              </header>

              {bursts.length === 0 ? (
                <p className="ns-clockcard-empty">No activity yet.</p>
              ) : (
                <ul role="list" className="ns-clockcard-rows">
                  {bursts.map((b) => (
                    <li
                      key={b.id}
                      data-entering={enteringIds.has(b.id)}
                      className="ns-clockcard-row"
                    >
                      <div className="ns-clockcard-row-clip">
                        <span className="ns-clockcard-section">{b.section}</span>
                        <span className="ns-clockcard-times">
                          <time dateTime={b.start}>{formatClock(b.start)}</time>
                          {" – "}
                          <time dateTime={b.end}>{formatClock(b.end)}</time>
                        </span>
                      </div>
                    </li>
                  ))}
                </ul>
              )}
            </section>
          );
        })}
      </div>

      <div aria-live="polite" className="sr-only">
        {liveMsg}
      </div>
    </div>
  );
}
Build spec

A punch-clock card rack for a 'what happened while I was away' digest: one radius-12 bordered card per collaborator (rounded-xl border border-border bg-background), stacked in a rail under one shared hairline datum. Every entry is a time-punched row rendered on its author's own card ('edited section 2 — 14:10 to 14:32', real <time dateTime> elements, Geist Mono) — the interleaved anonymous feed pattern is structurally impossible here, not just avoided by convention, because rows only ever exist inside a collaborator's own <ul>. Time is the single governing scalar and nothing else is: it sorts rows within a card (latest punch first), sorts cards across the rack (the card whose most recent burst end-time is newest sits on top, recomputed fresh every render from `Math.max` over each card's bursts, never a stored index — so the rack keeps itself sorted for free as new punches land), and sets how far a card sits proud of the shared top datum when it holds unread work: `2px + 1px per 12 cumulative unread hours` (summed duration of that card's unread bursts), clamped at 6px so a two-week backlog cannot eject a card off the physical range the rack renders in — the clamp is what keeps 'proud' legible as a binary-ish 'there is unread work here' signal rather than a magnitude gauge. A card with zero unread bursts sits at exactly 0 offset (flush), never at the clamped floor. Settling is a single CSS custom property (--nsc-offset) driving `transform: translateY()`, transitioned 300ms cubic-bezier(.16,1,.3,1) (ease-out-expo) — a pure transform, no layout is touched, so marking a card caught up costs one composited animation. Reading is opt-in and explicit: each card has a real, per-card 'Mark caught up' button (disabled once nothing is unread, guarded with disabled:pointer-events-none so the disabled state never fires its own hover style) that reports via `onMarkCaughtUp(collaboratorId)` — the component holds no model of its own, `collaborators` is the only prop and every derived value (row order, rack order, proud offset, unread count) is recomputed from it on every render, so the caller flipping `unread` flags is the only way state changes. Completion is announced through a polite aria-live region reading 'Caught up with <name>'. A brand new punch row (a burst id not present in the previous commit, detected by diffing id sets against a ref updated inside a layout effect so the diff runs and the new row already renders clipped-shut on the very first paint, never flashing open and then replaying) plays a single 340ms clip-path reveal, like a card being dropped into its slot — a genuinely new punch, not merely 'this data changed'. A11y: each card is a `<section aria-labelledby>` pointing at a real `<h3>` holding the person's name, with a plain-text unread count immediately after it ('4 unread bursts' / 'Caught up') so the state is legible without inferring anything from a color or a pixel offset; rows are `<li>` inside a `<ul>`, each holding two real `<time dateTime>` elements, never a styled `<span>` standing in for one; rack re-sorting is a pure reorder of `<section>` elements keyed by collaborator id, so React moves the existing DOM nodes rather than remounting them and focus survives a re-sort for free. Props: `collaborators` (`{id, name, bursts: {id, section, start, end, unread?}[]}[]`), `onMarkCaughtUp`, `ariaLabel` (default 'Catch-up digest'), `className`. prefers-reduced-motion drops the 300ms settle transition and the row-reveal animation entirely — proud/flush and new/existing rows just render in their resting state directly, every control stays fully operable. Pure DOM + CSS + inline `<time>` — no canvas, no SVG. Demo: three collaborators at different proud depths (one two-burst backlog, one single fresh punch, one already caught up) plus a 'Simulate new punch' control that appends a new unread burst and shows the reveal-and-possible-resort together.

Props

PropTypeDefaultDescription
collaboratorsClockCardCollaborator[]one card per collaborator — fully controlled, the rack derives everything else
onMarkCaughtUp?(collaboratorId: string) => voidfires when a card's "Mark caught up" button is pressed; caller clears that card's unread flags
ariaLabel?string"Catch-up digest"accessible name for the rack region. default "Catch-up digest"
className?stringextra classes merged onto the rendered root element