{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-timeline-core",
  "title": "Timeline core primitive",
  "description": "Events and timelines for matter histories, ledgers, deploy logs and activity feeds: a typed event model, Intl day grouping (Today, Yesterday, weekday), relative time on a shared hydration-safe clock, live append with a once-only capped enter stagger, and lane packing with a time axis for span timelines.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-thinking-core.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/timeline-core/group.ts",
      "content": "import { toMs, type TimeInput, type TimelineEvent } from \"./types\";\n\nexport type SortOrder = \"desc\" | \"asc\";\n\n/** A copy sorted by time (newest first by default); ties keep their input order. */\nexport function sortEvents<E extends TimelineEvent>(events: readonly E[], order: SortOrder = \"desc\"): E[] {\n  const k = order === \"desc\" ? -1 : 1;\n  return events\n    .map((e, i) => ({ e, i, t: toMs(e.at) }))\n    .sort((a, b) => (a.t - b.t) * k || a.i - b.i)\n    .map((x) => x.e);\n}\n\nconst keyFormatters = new Map<string, Intl.DateTimeFormat>();\n/**\n * The calendar day of `t` as `YYYY-MM-DD`, in `timeZone` (default: the runtime's). Pass a\n * `timeZone` when rendering on the server, so the server and the browser agree.\n */\nexport function dayKey(t: TimeInput, timeZone?: string): string {\n  const id = timeZone ?? \"\";\n  let fmt = keyFormatters.get(id);\n  if (!fmt) {\n    fmt = new Intl.DateTimeFormat(\"en-CA\", { timeZone, year: \"numeric\", month: \"2-digit\", day: \"2-digit\" });\n    keyFormatters.set(id, fmt);\n  }\n  const parts = fmt.formatToParts(toMs(t));\n  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? \"\";\n  return `${get(\"year\")}-${get(\"month\")}-${get(\"day\")}`;\n}\n\n/** Whole calendar days from day key `a` to `b` (DST-proof: counted on UTC midnights). */\nexport function daysBetweenKeys(a: string, b: string): number {\n  return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000);\n}\n\nexport type DayLabelOptions = {\n  /** Reference time for \"Today\" / \"Yesterday\". Pass a fixed one for deterministic demos. */\n  now: TimeInput;\n  locale?: string;\n  timeZone?: string;\n};\n\nconst cap = (s: string, locale?: string) => s.charAt(0).toLocaleUpperCase(locale) + s.slice(1);\n\n/**\n * A day header: \"Today\", \"Yesterday\", the weekday within the last week (\"Monday\"), else a\n * short date (\"Mon, 12 Oct\"), with the year when it isn't the current one. Localised by Intl.\n */\nexport function dayLabel(key: string, { now, locale, timeZone }: DayLabelOptions): string {\n  const today = dayKey(now, timeZone);\n  const diff = daysBetweenKeys(key, today);\n  // Noon UTC of the key, formatted in UTC, is the key's own calendar day in every zone.\n  const date = Date.parse(`${key}T12:00:00Z`);\n  if (diff === 0 || diff === 1 || diff === -1) {\n    return cap(new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(-diff, \"day\"), locale);\n  }\n  if (diff > 1 && diff < 7) return new Intl.DateTimeFormat(locale, { weekday: \"long\", timeZone: \"UTC\" }).format(date);\n  const sameYear = key.slice(0, 4) === today.slice(0, 4);\n  return new Intl.DateTimeFormat(locale, {\n    weekday: \"short\",\n    day: \"numeric\",\n    month: \"short\",\n    year: sameYear ? undefined : \"numeric\",\n    timeZone: \"UTC\",\n  }).format(date);\n}\n\nexport type DayGroup<E> = {\n  /** `YYYY-MM-DD`. Key the sticky header by it. */\n  key: string;\n  label: string;\n  events: E[];\n};\n\n/**\n * Events grouped by calendar day, with localised headers, newest day first by default; events\n * inside a day follow the same order. Pure: call it in render or a `useMemo`.\n *\n *   groupByDay(transactions, { now: FIXTURE_NOW, timeZone: \"Europe/London\" })\n *   // [{ key: \"2026-10-18\", label: \"Today\", events: [...] }, { key: \"2026-10-17\", label: \"Yesterday\", ... }]\n */\nexport function groupByDay<E extends TimelineEvent>(events: readonly E[], { order = \"desc\", ...label }: DayLabelOptions & { order?: SortOrder }): DayGroup<E>[] {\n  const groups: DayGroup<E>[] = [];\n  const byKey = new Map<string, DayGroup<E>>();\n  for (const e of sortEvents(events, order)) {\n    const key = dayKey(e.at, label.timeZone);\n    let g = byKey.get(key);\n    if (!g) {\n      g = { key, label: dayLabel(key, label), events: [] };\n      byKey.set(key, g);\n      groups.push(g);\n    }\n    g.events.push(e);\n  }\n  return groups;\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/timeline-core/group.ts"
    },
    {
      "path": "src/registry/eased/_primitives/timeline-core/lanes.ts",
      "content": "import { toMs, type TimeInput } from \"./types\";\n\nexport type Span = { id: string; at: TimeInput; end?: TimeInput };\n\n/**\n * Packs overlapping spans into the fewest lanes (greedy, earliest start first), so a lease,\n * matter or trace timeline stacks only what actually overlaps. Points (no `end`) take `minMs`.\n *\n *   const { laneOf, count } = packLanes(leases);   // laneOf.get(\"l-3\") → 1\n */\nexport function packLanes(spans: readonly Span[], { gapMs = 0, minMs = 0 }: { gapMs?: number; minMs?: number } = {}) {\n  const items = spans\n    .map((s, i) => {\n      const start = toMs(s.at);\n      return { id: s.id, i, start, end: Math.max(start + minMs, s.end === undefined ? start : toMs(s.end)) };\n    })\n    .sort((a, b) => a.start - b.start || a.i - b.i);\n  const laneEnds: number[] = [];\n  const laneOf = new Map<string, number>();\n  for (const it of items) {\n    let lane = laneEnds.findIndex((end) => end + gapMs <= it.start);\n    if (lane === -1) lane = laneEnds.push(0) - 1;\n    laneEnds[lane] = it.end;\n    laneOf.set(it.id, lane);\n  }\n  return { laneOf, count: laneEnds.length };\n}\n\nconst MIN = 60_000;\nconst HOUR = 60 * MIN;\nconst DAY = 24 * HOUR;\nconst STEPS = [MIN, 5 * MIN, 15 * MIN, 30 * MIN, HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY, 91 * DAY, 365 * DAY];\n\n/**\n * A linear time axis for lane timelines: `x(t)` in px, the today line's x, and evenly stepped\n * ticks (1 min … 1 year) with at most one per `minGap` px. Ticks under a day are on whole\n * minutes/hours of UTC; day and longer ticks are on UTC midnights (format them with your zone).\n */\nexport function timeAxis(start: TimeInput, end: TimeInput, width: number, { minGap = 72, now }: { minGap?: number; now?: TimeInput } = {}) {\n  const s = toMs(start);\n  const e = Math.max(s + 1, toMs(end));\n  const k = width / (e - s);\n  const x = (t: TimeInput) => (toMs(t) - s) * k;\n  const step = STEPS.find((st) => st * k >= minGap) ?? STEPS[STEPS.length - 1];\n  const ticks: number[] = [];\n  for (let t = Math.ceil(s / step) * step; t <= e; t += step) ticks.push(t);\n  return { x, ticks, step, nowX: now === undefined ? undefined : x(now) };\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/timeline-core/lanes.ts"
    },
    {
      "path": "src/registry/eased/_primitives/timeline-core/live.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { duration, ease, reducedFade, stagger as staggers } from \"../../lib/motion\";\nimport { sortEvents, type SortOrder } from \"./group\";\nimport type { TimelineEvent } from \"./types\";\n\nexport type LiveTimelineOptions = {\n  /** Newest first (feeds, logs) or oldest first (a matter's history). Default `desc`. */\n  order?: SortOrder;\n  /** Keep at most this many events, dropping the oldest. */\n  max?: number;\n  /** Seconds between rows of one appended batch. Default `stagger.item` (35ms); the batch total is capped at 0.4s (rule 83). */\n  stagger?: number;\n  /** Stagger the initial events in once on mount too. Default false (they are simply there). */\n  animateInitial?: boolean;\n};\n\nexport type LiveTimeline<E> = {\n  /** Sorted events, ready to render (or to `groupByDay`). */\n  events: E[];\n  /** Adds one or more events; each enters with its slot in the batch's stagger. Existing ids are replaced in place, without re-entering. */\n  append(events: E | readonly E[]): void;\n  /** Patches one event by id (a pending payment settling); it doesn't re-enter. */\n  update(id: string, patch: Partial<E>): void;\n  remove(id: string): void;\n  /** Replaces everything without entrances (a filter change, a reload). */\n  reset(events: readonly E[]): void;\n  /** The entrance delay (s) for an id that is entering now, or null if it should just be there. */\n  entering(id: string): number | null;\n};\n\n/** Max total stagger for a batch, seconds (rule 83). */\nconst STAGGER_CAP = 0.4;\n\n/**\n * A live event list: append, update and remove by id, kept sorted, with an entrance stagger\n * only for rows that arrived after mount (each enters once; a re-render never replays it).\n * Rows animate with `enterMotion(timeline.entering(id), reduced)`.\n *\n *   const feed = useLiveTimeline(fixtures.events, { max: 50 });\n *   useEffect(() => socket.on(\"event\", feed.append), [feed.append]);\n */\nexport function useLiveTimeline<E extends TimelineEvent>(initial: readonly E[], { order = \"desc\", max, stagger = staggers.item, animateInitial = false }: LiveTimelineOptions = {}): LiveTimeline<E> {\n  const [state, setState] = useState(() => {\n    const sorted = sortEvents(initial, order);\n    return { list: trim(sorted, max, order), entering: animateInitial ? delays(sorted.map((e) => e.id), stagger) : new Map<string, number>() };\n  });\n  const clearTimer = useRef(0);\n\n  const scheduleClear = useCallback(() => {\n    clearTimeout(clearTimer.current);\n    // After the longest entrance has run, forget it: a row moved to another day group re-mounts and must not replay.\n    clearTimer.current = window.setTimeout(() => setState((s) => ({ ...s, entering: new Map() })), (STAGGER_CAP + duration.slow) * 1000 + 100);\n  }, []);\n  useEffect(() => {\n    if (animateInitial) scheduleClear();\n    return () => clearTimeout(clearTimer.current);\n  }, [animateInitial, scheduleClear]);\n\n  const append = useCallback(\n    (input: E | readonly E[]) => {\n      const batch = (Array.isArray(input) ? input : [input]) as E[];\n      if (!batch.length) return;\n      setState(({ list, entering }) => {\n        const byId = new Map(batch.map((e) => [e.id, e]));\n        const known = new Set(list.map((e) => e.id));\n        const fresh = batch.filter((e) => !known.has(e.id));\n        // Newest first in a desc feed, so the top row lands first.\n        const arrival = sortEvents(fresh, order).map((e) => e.id);\n        const replaced = list.map((e) => byId.get(e.id) ?? e);\n        return {\n          list: trim(sortEvents([...replaced, ...fresh], order), max, order),\n          entering: fresh.length ? new Map([...entering, ...delays(arrival, stagger)]) : entering,\n        };\n      });\n      scheduleClear();\n    },\n    [order, max, stagger, scheduleClear],\n  );\n\n  const update = useCallback(\n    (id: string, patch: Partial<E>) => setState((s) => ({ ...s, list: sortEvents(s.list.map((e) => (e.id === id ? { ...e, ...patch } : e)), order) })),\n    [order],\n  );\n  const remove = useCallback((id: string) => setState((s) => ({ ...s, list: s.list.filter((e) => e.id !== id) })), []);\n  const reset = useCallback((events: readonly E[]) => setState({ list: trim(sortEvents(events, order), max, order), entering: new Map() }), [order, max]);\n  const { list, entering: enteringMap } = state;\n  const entering = useCallback((id: string) => enteringMap.get(id) ?? null, [enteringMap]);\n\n  return useMemo(() => ({ events: list, append, update, remove, reset, entering }), [list, append, update, remove, reset, entering]);\n}\n\nfunction delays(ids: string[], step: number) {\n  const each = ids.length > 1 ? Math.min(step, STAGGER_CAP / (ids.length - 1)) : 0;\n  return new Map(ids.map((id, i) => [id, i * each]));\n}\n\nfunction trim<E extends TimelineEvent>(list: E[], max: number | undefined, order: SortOrder) {\n  if (max === undefined || list.length <= max) return list;\n  return order === \"desc\" ? list.slice(0, max) : list.slice(list.length - max);\n}\n\n/**\n * Motion props for a timeline row: rises `--enter-offset` (6px) and fades in after `delay`, or\n * renders in place when `delay` is null. Reduced motion: opacity only, instant.\n *\n *   <motion.li key={e.id} {...enterMotion(feed.entering(e.id), reduced)}>\n */\nexport function enterMotion(delay: number | null, reduced = false) {\n  if (delay === null) return { initial: false as const };\n  if (reduced) return { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { ...reducedFade, delay } };\n  return {\n    initial: { opacity: 0, y: 6 },\n    animate: { opacity: 1, y: 0 },\n    transition: { duration: duration.slow, ease: ease.enter, delay },\n  };\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/timeline-core/live.ts"
    },
    {
      "path": "src/registry/eased/_primitives/timeline-core/relative.ts",
      "content": "\"use client\";\n\nimport { useCallback, useSyncExternalStore } from \"react\";\nimport { toMs, type TimeInput } from \"./types\";\n\nexport type RelativeOptions = {\n  locale?: string;\n  /** Intl.RelativeTimeFormat style. `short` gives \"5 min. ago\". Default `long`. */\n  style?: Intl.RelativeTimeFormatStyle;\n};\n\nconst UNITS: [Intl.RelativeTimeFormatUnit, number, number][] = [\n  // unit, ms per unit, use it while |diff| < this many ms\n  [\"second\", 1_000, 45_000],\n  [\"minute\", 60_000, 45 * 60_000],\n  [\"hour\", 3_600_000, 22 * 3_600_000],\n  [\"day\", 86_400_000, 6.5 * 86_400_000],\n  [\"week\", 7 * 86_400_000, 26 * 86_400_000],\n  [\"month\", 30.44 * 86_400_000, 320 * 86_400_000],\n  [\"year\", 365.25 * 86_400_000, Infinity],\n];\n\nconst rtfCache = new Map<string, Intl.RelativeTimeFormat>();\nconst rtf = (locale: string | undefined, style: Intl.RelativeTimeFormatStyle) => {\n  const id = `${locale}|${style}`;\n  let f = rtfCache.get(id);\n  if (!f) rtfCache.set(id, (f = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\", style })));\n  return f;\n};\n\n/**\n * \"now\", \"5 minutes ago\", \"yesterday\", \"in 3 days\", \"last week\": the closest unit, rounded,\n * localised by `Intl.RelativeTimeFormat` with `numeric: \"auto\"`. Under 45 seconds reads \"now\".\n */\nexport function formatRelative(at: TimeInput, now: TimeInput, { locale, style = \"long\" }: RelativeOptions = {}): string {\n  const diff = toMs(at) - toMs(now);\n  if (!Number.isFinite(diff)) return \"\";\n  const f = rtf(locale, style);\n  if (Math.abs(diff) < 45_000) return f.format(0, \"second\");\n  for (const [unit, size, limit] of UNITS) {\n    if (Math.abs(diff) < limit) return f.format(Math.round(diff / size), unit);\n  }\n  return \"\";\n}\n\n/** How often a relative label needs re-rendering at this distance: 10s under a minute, 30s under an hour, then 5 min. */\nexport function relativeRefreshMs(at: TimeInput, now: number): number {\n  const d = Math.abs(toMs(at) - now);\n  return d < 60_000 ? 10_000 : d < 3_600_000 ? 30_000 : 300_000;\n}\n\ntype NowStore = { now: number | undefined; listeners: Set<() => void>; timer: number };\nconst stores = new Map<number, NowStore>();\nconst storeFor = (interval: number) => {\n  let s = stores.get(interval);\n  if (!s) stores.set(interval, (s = { now: undefined, listeners: new Set(), timer: 0 }));\n  return s;\n};\n\n/**\n * The current time, shared by every caller with the same `intervalMs` (one timer per\n * interval, not per row) and paused while nobody is subscribed. Undefined on the server and\n * during hydration, so a relative label never mismatches the server HTML.\n */\nexport function useNow(intervalMs = 30_000): number | undefined {\n  const subscribe = useCallback(\n    (onChange: () => void) => {\n      const s = storeFor(intervalMs);\n      s.listeners.add(onChange);\n      if (s.listeners.size === 1) {\n        s.now = Date.now();\n        s.timer = window.setInterval(() => {\n          s.now = Date.now();\n          s.listeners.forEach((l) => l());\n        }, intervalMs);\n        queueMicrotask(onChange);\n      }\n      return () => {\n        s.listeners.delete(onChange);\n        if (s.listeners.size === 0) clearInterval(s.timer);\n      };\n    },\n    [intervalMs],\n  );\n  return useSyncExternalStore(\n    subscribe,\n    () => {\n      const s = storeFor(intervalMs);\n      return (s.now ??= Date.now());\n    },\n    () => undefined,\n  );\n}\n\n/**\n * A live relative label for `at` that refreshes at the cadence its distance needs. Pass `now`\n * to pin it (fixtures, screenshots). Undefined until mounted: render the absolute time, or\n * nothing, in its place.\n *\n *   const ago = useRelativeTime(event.at);   // \"3 minutes ago\"\n *   <time dateTime={iso}>{ago ?? time}</time>\n */\nexport function useRelativeTime(at: TimeInput, { now: pinned, ...options }: RelativeOptions & { now?: TimeInput } = {}): string | undefined {\n  // Pick the cadence from the event's age at the last known time.\n  const coarse = useNow(300_000);\n  const interval = coarse === undefined ? 300_000 : relativeRefreshMs(at, coarse);\n  const live = useNow(interval);\n  const now = pinned ?? live;\n  return now === undefined ? undefined : formatRelative(at, now, options);\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/timeline-core/relative.ts"
    },
    {
      "path": "src/registry/eased/_primitives/timeline-core/timeline-core.ts",
      "content": "/**\n * timeline-core: events and timelines for matter histories, transaction ledgers, deploy logs\n * and activity feeds. A typed event model, Intl day grouping, relative time on a shared\n * clock, live append with a once-only entrance stagger, and lane packing for span timelines.\n * Import from here; the files behind it may move.\n */\nexport { toMs, type EventTone, type TimeInput, type TimelineEvent } from \"./types\";\nexport { dayKey, dayLabel, daysBetweenKeys, groupByDay, sortEvents, type DayGroup, type DayLabelOptions, type SortOrder } from \"./group\";\nexport { formatRelative, relativeRefreshMs, useNow, useRelativeTime, type RelativeOptions } from \"./relative\";\nexport { enterMotion, useLiveTimeline, type LiveTimeline, type LiveTimelineOptions } from \"./live\";\nexport { packLanes, timeAxis, type Span } from \"./lanes\";\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/timeline-core/timeline-core.ts"
    },
    {
      "path": "src/registry/eased/_primitives/timeline-core/types.ts",
      "content": "import type { StepStatus } from \"../thinking-core/types\";\n\n/** A time: epoch ms, an ISO string, or a Date. */\nexport type TimeInput = number | string | Date;\n\n/** The colour role of an event; renderers map it to `*-subtle` / `*-text` tokens. */\nexport type EventTone = \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\";\n\n/**\n * One thing that happened: a filing, a payment, a deploy log line, a comment, a page.\n * Spans (a lease, a hearing, a trace span) set `end` too.\n */\nexport type TimelineEvent<D = unknown> = {\n  /** Stable id: rows are keyed by it, so live appends never re-mount older rows. */\n  id: string;\n  at: TimeInput;\n  /** Spans only. */\n  end?: TimeInput;\n  title: string;\n  detail?: string;\n  /** A free-form category the block filters on: \"filing\", \"deploy\", \"refund\". */\n  kind?: string;\n  tone?: EventTone;\n  /** For runs and deploys: thinking-core's frozen status. */\n  status?: StepStatus;\n  /** Who did it. */\n  actor?: { name: string; avatarUrl?: string };\n  data?: D;\n};\n\n/** Epoch ms from any `TimeInput`. Invalid input reads as NaN. */\nexport function toMs(t: TimeInput): number {\n  return typeof t === \"number\" ? t : t instanceof Date ? t.getTime() : Date.parse(t);\n}\n",
      "type": "registry:lib",
      "target": "@components/eased/_primitives/timeline-core/types.ts"
    }
  ],
  "type": "registry:lib"
}