component — date & time

Timeline

What happened when, as an ordered list: a changelog, a company's history, a CV, an order's tracking events, a roadmap, an audit log. Vertical or horizontal (scrolling, snapping by proximity), with content on one side, alternating, or opposite its date — two-sided layouts collapse to one side under a container query. Markers are dots, rings, icons, avatars, numbers or any node, in neutral, brand, success, warning, danger and muted tones, and the current item pulses (not under reduced motion). Every date carries its own precision — "2019", "2021-Q3", "2022-03", "2023-W12", "2024-06-04", "2024-06-04T14:30" — so a mixed-precision history is just items with different strings, rendered in <time dateTime> at that precision; ranges read "Mar – Jun 2024" and open ones "2023 – present". A `now` prop (never the clock during render) places a Now marker, dashes the connector from the first span that reaches the future, and anchors relative labels. Sorting (newest first without re-sorting), sticky year/month/day group headers, "Show N more", linked and expandable items, compact density, an empty state, and proportional spacing: a real time axis with ticks, range bars in lanes, and long gaps compressed behind a break.

  • timeline
  • history
  • changelog
  • activity
  • audit log
  • roadmap
  • tracking
  • events
  • dates

use a timeline when

  • A history read in order: releases, milestones, a career, a company's story by year.
  • Events on one thing as they happened: an order's tracking, an incident, a record's audit log.
  • A plan by quarter or month where the future is drawn differently from the past.
  • Periods that overlap, placed on a real time axis (spacing="proportional").

don't

  • Resources booked against hours or days — who is where, when. That is a CalendarTimeline.
  • A user's progress through the steps of a flow they are in. That is a Stepper.
  • Branches and merges. That is a CommitGraph.
  • Many rows with the same columns to sort and filter. That is a table, with a date column.

12 examples

Changelog

Vertical, content on one side, newest first. The data is in the order it was written; `order="desc"` sorts it, and `collapseAfter` folds the older releases away.

  1. major

    Two new date components and a rewritten prerender.

  2. minor

    A sheet of this screen's actions, from the header or a floating trigger.

  3. fix

    Plugin rules read tests/ again.

Company history, alternating

Content flips side per item and the year crosses the axis. Narrow the container and it collapses to one side.

  1. Founded in a Hamburg garage
  2. First customer

    A dental practice, still with us.

  3. Seed round

    Twelve people, one product.

  4. Berlin office opens
  5. 100 employees
  6. Four products, 2,000 customers

Career, with ranges

`opposite` puts the dates in a column of their own. Ranges read as one label, and `end="present"` leaves one open.

  1. – present
    Staff engineer, quebi

    Design system and the component library.

  2.  – 
    Senior engineer, Northwind

    Scheduling for 400 clinics.

  3.  – 
    Sabbatical
  4.  – 
    Engineer, Contoso

Order tracking

Status markers, a pulsing current step, and `now`: the connector turns dashed from the first span that reaches the future, and `TimelineNow` sorts itself into place.

  1. Paid
  2. Packed
    Warehouse Hamburg
  3. In transit

    Handed to DHL, tracking 00340434161094042557.

  4. Now
  5. Out for delivery (expected)
  6. Delivered

Roadmap by quarter

Horizontal: the list scrolls when it outgrows its container, is a focusable region the arrow keys scroll, and snaps to items by proximity.

  1. Rules as lint

    The published rules run on this repo.

  2. Calendar views

    Day, week, month and year.

  3. Timeline

    You are reading it.

  4. Charts, second pass
  5. Figma kit
  6. 2.0

Audit log, grouped by day

Avatars as markers, date and time, `relative` labels against a pinned `now`, and sticky day headings from `groupBy`. An item with more to say folds it behind `TimelineDetails`.

  1. 28 Sept 2026

    1. Anna changed the invoice address

    2. Ben approved PR #190
  2. 27 Sept 2026

    1. Anna invited Chris
    2. Deploy failed
      main · d6f8771
  3. 26 Sept 2026

    1. Chris signed in from a new device

Mixed precision

Each date renders at its own precision — a year long ago, a month closer in, a week, a day and a time as it gets near.

  1. Idea
  2. Prototype
  3. First pilot
  4. Security review
  5. General availability
  6. Launch post published

Proportional, with lanes

`spacing="proportional"`: position follows real time, ranges are bars, overlapping items take lanes, and the empty years between 2013 and 2018 are compressed behind a break instead of drawn to scale.

  1.  – 
    Apprenticeship
  2.  – 
    Scheduling
  3.  – 
    Billing
  4. Pilot clinic
  5. – present
    Design system
  6.  – 
    Migration

Compact, in a sidebar

`density="compact"` in a narrow Card. The card is around the timeline; the items inside it are not cards.

recent
  1. Report exported
  2. Filter saved
  3. Dashboard shared
  4. Workspace created

Markers and tones

Dot in three sizes, ring, icon, avatar, number and a custom node. Neutral is ink, brand the action fill, muted the subtle grey; success, warning and danger are for a state that is the point. Markers are decoration — the words carry the state.

  1. Small dot, neutral
  2. Large dot, brand
  3. Ring, success
  4. Icon, warning
  5. Avatar
  6. Number, danger
  7. Custom node, dashed connector on
  8. Muted

Content before the axis

`placement="start"` puts the content before the line and aligns it to it.

  1. Request received
    Support
  2. Answered

Empty

No items renders the empty state, in words — override it with `emptyState`.

Nothing has happened yet.

what to use instead

by job

By what the timeline was doing.

Time on an axis
Steps in order
  • Stepper — Where the user is in a process.

source

timeline.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  Children,
  createContext,
  isValidElement,
  type ReactElement,
  type ReactNode,
  use,
  useEffect,
  useState,
} from "react"
import { useLocale } from "react-aria-components"
import { Avatar } from "@/components/avatar"
import { Disclosure, DisclosurePanel, DisclosureTrigger } from "@/components/disclosure-group"
import { Link } from "@/components/link"
import { ScrollArea } from "@/components/scroll-area"
import { ShowMore } from "@/components/show-more"
import { getDateTimeFormat, getNumberFormat, getRelativeTimeFormat } from "@/lib/intl"
import { cn } from "@/lib/utils"

/**
 * Timeline — quebi design system
 *
 * What happened when: a changelog, a company's story, a CV, an order's
 * tracking events, an audit log. Not `CalendarTimeline` (a resource schedule
 * on an hour axis), not `Stepper` (progress through a process), not
 * `CommitGraph` (branches).
 *
 * ## Shape
 *
 * `Timeline` is an `<ol>` and every `TimelineItem` is an `<li>`, so the order
 * is the list's order for a screen reader too. Items are written as children
 * (`TimelineItem` > `TimelineTitle`, `TimelineDescription`, `TimelineMeta`,
 * anything else), or handed over as data through `items`, which builds the
 * same children. The marker and the connector are decoration — `aria-hidden`
 * — so a state a marker's colour shows ("delivered", "failed") has to be in
 * the item's words as well.
 *
 * ## Dates carry their own precision
 *
 * An item's `date` (or `start` / `end`) is a string whose shape *is* its
 * precision — `"2019"`, `"2021-Q3"`, `"2022-03"`, `"2023-W12"`,
 * `"2024-06-04"`, `"2024-06-04T14:30"` — or `{ date, precision }` for a `Date`
 * or a timestamp. So a mixed-precision history is just items with different
 * strings, and each label renders at its own item's precision ("2019",
 * "Q3 2021", "March 2022", "Week 12, 2023", "4 Jun 2024", "4 Jun 2024,
 * 14:30"). Every date is a `<time dateTime>` with the HTML form of the same
 * precision (a quarter is written as its first month — HTML has no quarter).
 *
 * The strings are read as calendar values, not as instants, and are
 * formatted in UTC, so the label cannot move with the machine's time zone —
 * the site is prerendered, and a label that depended on the zone would be one
 * string in the HTML and another after hydration. A `Date` or a timestamp *is*
 * an instant, and is read on the wall clock of `timeZone`.
 *
 * ## Nothing reads the clock during render
 *
 * `now` is a prop. It places a `TimelineNow`, turns the connector dashed from
 * the first span that reaches into the future, and is the reference for
 * `relative` labels. Without it, relative labels render absolute until mount
 * and pick the clock up in an effect — the same contract as `FormattedDate`.
 *
 * ## Layout
 *
 * Vertical items share one grid through `subgrid`, so the marker column is as
 * wide as the widest marker and an `opposite` date column as wide as the
 * widest date. `alternating` and `opposite` are two-sided only above the
 * `@xl` container width and collapse to one side below it — a container query,
 * so it follows the space the timeline is given rather than the window.
 * Horizontal timelines scroll in a `ScrollArea` that is a focusable region
 * (arrow keys scroll it) and snap to items by proximity, never mandatorily.
 *
 * `spacing="proportional"` is a different drawing of the same items: position
 * along a horizontal axis follows real time, ticks fall at a unit chosen from
 * the span, ranges are bars, overlapping items take lanes, and a long empty
 * stretch is compressed behind a break mark instead of drawn to scale.
 */

// ---------------------------------------------------------------------------
// Date model
// ---------------------------------------------------------------------------

export type TimelinePrecision = "year" | "quarter" | "month" | "week" | "day" | "datetime"

/** A `Date`, timestamp or string with the precision spelled out. */
export interface TimelinePointInput {
  date: Date | string | number
  precision: TimelinePrecision
}

/**
 * A date on the timeline. A string's shape is its precision: `"2019"`,
 * `"2021-Q3"`, `"2022-03"`, `"2023-W12"`, `"2024-06-04"`,
 * `"2024-06-04T14:30"`. Any other string, a `Date` or a timestamp is an
 * instant at `datetime` precision unless wrapped in `{ date, precision }`.
 */
export type TimelineDateInput = string | number | Date | TimelinePointInput

/** The end of a range: a date, or `"present"` for a range that is still open. */
export type TimelineRangeEnd = TimelineDateInput | "present"

/** A date resolved to a calendar period. */
export interface TimelinePoint {
  precision: TimelinePrecision
  year: number
  month: number
  day: number
  hour: number
  minute: number
  /** ISO week-numbering year and week, for `week` precision. */
  weekYear: number
  week: number
  /** First instant of the period, as UTC milliseconds of the wall clock. */
  start: number
  /** First instant after the period. */
  end: number
  /** The value of the `<time dateTime>` attribute. */
  dateTime: string
}

const DAY = 86_400_000
const DEFAULT_TIME_ZONE = "Europe/Berlin"

const PATTERNS: [RegExp, TimelinePrecision][] = [
  [/^(\d{4})$/, "year"],
  [/^(\d{4})-Q([1-4])$/i, "quarter"],
  [/^(\d{4})-(\d{2})$/, "month"],
  [/^(\d{4})-W(\d{2})$/i, "week"],
  [/^(\d{4})-(\d{2})-(\d{2})$/, "day"],
  [/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/, "datetime"],
]

interface Components {
  year: number
  month: number
  day: number
  hour: number
  minute: number
}

const pad = (value: number, length = 2) => String(value).padStart(length, "0")

/** Monday of ISO week 1 of `weekYear`, plus `week - 1` weeks. */
function isoWeekStart(weekYear: number, week: number): number {
  const jan4 = Date.UTC(weekYear, 0, 4)
  const weekday = (new Date(jan4).getUTCDay() + 6) % 7
  return jan4 - weekday * DAY + (week - 1) * 7 * DAY
}

function isoWeekOf(year: number, month: number, day: number): { weekYear: number; week: number } {
  const date = Date.UTC(year, month - 1, day)
  const weekday = (new Date(date).getUTCDay() + 6) % 7
  const thursday = date - weekday * DAY + 3 * DAY
  const weekYear = new Date(thursday).getUTCFullYear()
  return { weekYear, week: 1 + Math.floor((thursday - Date.UTC(weekYear, 0, 1)) / DAY / 7) }
}

function componentsOf(ms: number): Components {
  const date = new Date(ms)
  return {
    year: date.getUTCFullYear(),
    month: date.getUTCMonth() + 1,
    day: date.getUTCDate(),
    hour: date.getUTCHours(),
    minute: date.getUTCMinutes(),
  }
}

/** An instant on the wall clock of `timeZone`. */
function wallClock(instant: number, timeZone: string): Components {
  const parts = getDateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    hourCycle: "h23",
  }).formatToParts(new Date(instant))
  const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0)
  return {
    year: get("year"),
    month: get("month"),
    day: get("day"),
    hour: get("hour") % 24,
    minute: get("minute"),
  }
}

function pointFrom(precision: TimelinePrecision, c: Components, instant?: number): TimelinePoint {
  const { weekYear, week } = isoWeekOf(c.year, c.month, c.day)
  const quarterMonth = Math.floor((c.month - 1) / 3) * 3
  const day = `${pad(c.year, 4)}-${pad(c.month)}-${pad(c.day)}`
  const [start, end, dateTime] = ((): [number, number, string] => {
    switch (precision) {
      case "year":
        return [Date.UTC(c.year, 0, 1), Date.UTC(c.year + 1, 0, 1), pad(c.year, 4)]
      case "quarter":
        return [
          Date.UTC(c.year, quarterMonth, 1),
          Date.UTC(c.year, quarterMonth + 3, 1),
          `${pad(c.year, 4)}-${pad(quarterMonth + 1)}`,
        ]
      case "month":
        return [
          Date.UTC(c.year, c.month - 1, 1),
          Date.UTC(c.year, c.month, 1),
          `${pad(c.year, 4)}-${pad(c.month)}`,
        ]
      case "week": {
        const monday = isoWeekStart(weekYear, week)
        return [monday, monday + 7 * DAY, `${pad(weekYear, 4)}-W${pad(week)}`]
      }
      case "day": {
        const midnight = Date.UTC(c.year, c.month - 1, c.day)
        return [midnight, midnight + DAY, day]
      }
      case "datetime": {
        const at = Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute)
        return [
          at,
          at + 60_000,
          instant === undefined
            ? `${day}T${pad(c.hour)}:${pad(c.minute)}`
            : new Date(instant).toISOString(),
        ]
      }
    }
  })()
  // Named rather than spread: `atPrecision` passes a whole point as `c`, and a
  // spread would carry its old precision, start and end over the new ones.
  const { year, month, day: date, hour, minute } = c
  return { precision, year, month, day: date, hour, minute, weekYear, week, start, end, dateTime }
}

function parseString(value: string): { components: Components; precision: TimelinePrecision } | null {
  for (const [pattern, precision] of PATTERNS) {
    const match = pattern.exec(value.trim())
    if (!match) continue
    const n = match.slice(1).map(Number)
    const year = n[0] ?? 0
    switch (precision) {
      case "year":
        return { precision, components: { year, month: 1, day: 1, hour: 0, minute: 0 } }
      case "quarter":
        return {
          precision,
          components: { year, month: ((n[1] ?? 1) - 1) * 3 + 1, day: 1, hour: 0, minute: 0 },
        }
      case "week":
        return { precision, components: componentsOf(isoWeekStart(year, n[1] ?? 1)) }
      default:
        return {
          precision,
          components: {
            year,
            month: n[1] ?? 1,
            day: n[2] ?? 1,
            hour: n[3] ?? 0,
            minute: n[4] ?? 0,
          },
        }
    }
  }
  return null
}

/** Resolve a timeline date to the calendar period it names. */
export function parseTimelineDate(
  input: TimelineDateInput,
  timeZone: string = DEFAULT_TIME_ZONE,
): TimelinePoint {
  const value = typeof input === "object" && !(input instanceof Date) ? input.date : input
  const requested =
    typeof input === "object" && !(input instanceof Date) ? input.precision : undefined

  if (typeof value === "string") {
    const parsed = parseString(value)
    if (parsed) return pointFrom(requested ?? parsed.precision, parsed.components)
  }
  const instant = new Date(value).getTime()
  if (Number.isNaN(instant)) throw new RangeError(`Timeline: cannot read ${String(value)} as a date`)
  return pointFrom(requested ?? "datetime", wallClock(instant, timeZone), instant)
}

/** The same moment at another precision — the period that contains it. */
function atPrecision(point: TimelinePoint, precision: TimelinePrecision): TimelinePoint {
  return pointFrom(precision, point)
}

// ---------------------------------------------------------------------------
// Labels
// ---------------------------------------------------------------------------

/** The words the timeline says that `Intl` cannot say for it. */
export interface TimelineLabels {
  quarter: (quarter: string, year: string) => string
  week: (week: string, year: string) => string
  /** The end of an open range: "2023 – present". */
  present: string
  /** The label of a `TimelineNow`. */
  now: string
  showMore: (count: string) => string
  showLess: string
  details: string
  empty: string
}

const LABELS: Record<string, TimelineLabels> = {
  en: {
    quarter: (q, y) => `Q${q} ${y}`,
    week: (w, y) => `Week ${w}, ${y}`,
    present: "present",
    now: "Now",
    showMore: (n) => `Show ${n} more`,
    showLess: "Show less",
    details: "Details",
    empty: "Nothing has happened yet.",
  },
  de: {
    quarter: (q, y) => `Q${q} ${y}`,
    week: (w, y) => `KW ${w}/${y}`,
    present: "heute",
    now: "Jetzt",
    showMore: (n) => `${n} weitere anzeigen`,
    showLess: "Weniger anzeigen",
    details: "Details",
    empty: "Bisher ist nichts passiert.",
  },
}

function labelsFor(locale: string, overrides?: Partial<TimelineLabels>): TimelineLabels {
  const base = LABELS[locale.split("-")[0]?.toLowerCase() ?? "en"] ?? (LABELS.en as TimelineLabels)
  return { ...base, ...overrides }
}

const POINT_FORMATS: Partial<Record<TimelinePrecision, Intl.DateTimeFormatOptions>> = {
  year: { year: "numeric" },
  month: { month: "long", year: "numeric" },
  day: { day: "numeric", month: "short", year: "numeric" },
  datetime: { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" },
}

/** In a range the month is short: "Mar – Jun 2024", not "March – June 2024". */
const RANGE_FORMATS: Partial<Record<TimelinePrecision, Intl.DateTimeFormatOptions>> = {
  ...POINT_FORMATS,
  month: { month: "short", year: "numeric" },
}

function dateFormat(locale: string, options: Intl.DateTimeFormatOptions) {
  // UTC because `start` is the wall clock already: see "Dates carry their own precision".
  return getDateTimeFormat(locale, { ...options, timeZone: "UTC" })
}

interface FormatOptions {
  locale: string
  labels?: Partial<TimelineLabels>
  timeZone?: string
}

function formatPoint(point: TimelinePoint, locale: string, labels: TimelineLabels): string {
  const integer = getNumberFormat(locale, { useGrouping: false })
  switch (point.precision) {
    case "quarter":
      return labels.quarter(integer.format(Math.ceil(point.month / 3)), integer.format(point.year))
    case "week":
      return labels.week(integer.format(point.week), integer.format(point.weekYear))
    default:
      return dateFormat(locale, POINT_FORMATS[point.precision] ?? {}).format(point.start)
  }
}

/** A range split where its two dates meet, so each half can be its own `<time>`. */
interface RangeText {
  start: string
  separator: string
  end: string
}

const SEPARATOR = " – "

function rangeText(
  start: TimelinePoint,
  end: TimelinePoint | "present",
  locale: string,
  labels: TimelineLabels,
): RangeText | string {
  const from = formatPoint(start, locale, labels)
  if (end === "present") return { start: from, separator: SEPARATOR, end: labels.present }
  if (end.precision === start.precision && end.start === start.start) return from

  const options = RANGE_FORMATS[start.precision]
  if (end.precision === start.precision && options) {
    // `formatRangeToParts` shares what the two ends have in common ("Mar – Jun
    // 2024"). Everything up to the last part of the start and everything from
    // the first part of the end is kept with its own date.
    const parts = dateFormat(locale, options).formatRangeToParts(start.start, end.start)
    const lastStart = parts.map((part) => part.source).lastIndexOf("startRange")
    const firstEnd = parts.findIndex((part) => part.source === "endRange")
    if (lastStart >= 0 && firstEnd > lastStart) {
      const join = (from: number, to?: number) =>
        parts
          .slice(from, to)
          .map((part) => part.value)
          .join("")
      return {
        start: join(0, lastStart + 1),
        separator: join(lastStart + 1, firstEnd),
        end: join(firstEnd),
      }
    }
  }
  return { start: from, separator: SEPARATOR, end: formatPoint(end, locale, labels) }
}

/** The label a timeline renders for one date, at the date's own precision. */
export function formatTimelineDate(input: TimelineDateInput, options: FormatOptions): string {
  const labels = labelsFor(options.locale, options.labels)
  return formatPoint(parseTimelineDate(input, options.timeZone), options.locale, labels)
}

/** The label a timeline renders for a range: "2019 – 2022", "Mar – Jun 2024", "2023 – present". */
export function formatTimelineRange(
  start: TimelineDateInput,
  end: TimelineRangeEnd,
  options: FormatOptions,
): string {
  const labels = labelsFor(options.locale, options.labels)
  const text = rangeText(
    parseTimelineDate(start, options.timeZone),
    end === "present" ? "present" : parseTimelineDate(end, options.timeZone),
    options.locale,
    labels,
  )
  return typeof text === "string" ? text : `${text.start}${text.separator}${text.end}`
}

/** "last year", "in 3 weeks", "yesterday" — in the unit of the date's own precision. */
export function formatTimelineRelative(
  point: TimelinePoint,
  now: TimelinePoint,
  locale: string,
): string {
  const rtf = getRelativeTimeFormat(locale, { numeric: "auto" })
  const ref = atPrecision(now, point.precision)
  switch (point.precision) {
    case "year":
      return rtf.format(point.year - ref.year, "year")
    case "quarter":
      return rtf.format(
        point.year * 4 + Math.ceil(point.month / 3) - (ref.year * 4 + Math.ceil(ref.month / 3)),
        "quarter",
      )
    case "month":
      return rtf.format(point.year * 12 + point.month - (ref.year * 12 + ref.month), "month")
    case "week":
      return rtf.format(Math.round((point.start - ref.start) / (7 * DAY)), "week")
    case "day":
      return rtf.format(Math.round((point.start - ref.start) / DAY), "day")
    case "datetime": {
      const minutes = Math.round((point.start - ref.start) / 60_000)
      if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute")
      if (Math.abs(minutes) < 24 * 60) return rtf.format(Math.round(minutes / 60), "hour")
      const days = Math.round(
        (atPrecision(point, "day").start - atPrecision(ref, "day").start) / DAY,
      )
      return rtf.format(days, "day")
    }
  }
}

// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------

export type TimelineOrientation = "vertical" | "horizontal"
/**
 * Where the content sits relative to the axis. `end` (the default) is after it
 * — right of a vertical axis, below a horizontal one; `start` is before it;
 * `alternating` flips per item; `opposite` puts the date on one side and the
 * content on the other.
 */
export type TimelinePlacement = "start" | "end" | "alternating" | "opposite"
export type TimelineDensity = "compact" | "comfortable"
/** `dashed` is for what is planned, uncertain or not yet reached. */
export type TimelineConnector = "solid" | "dashed" | "none"
export type TimelineTone = "neutral" | "brand" | "success" | "warning" | "danger" | "muted"

interface TimelineContextValue {
  orientation: TimelineOrientation
  placement: TimelinePlacement
  density: TimelineDensity
  connector: TimelineConnector
  locale: string
  timeZone: string
  labels: TimelineLabels
  relative: boolean
  /** The reference for relative labels: the caller's `now`, or the clock after mount. */
  now: TimelinePoint | null
  /** The caller's `now` only — what decides which spans are future. */
  boundary: TimelinePoint | null
  order: "asc" | "desc" | undefined
}

const TimelineContext = createContext<TimelineContextValue | null>(null)

function useTimeline(part: string): TimelineContextValue {
  const context = use(TimelineContext)
  if (!context) throw new Error(`${part} must be used within a Timeline`)
  return context
}

/** Where one child sits in the list, worked out by the list rather than the child. */
interface Slot {
  index: number
  isLast: boolean
  /** The span from this item to the next reaches past `now`. */
  isFuture: boolean
  /** More items are folded away below this one. */
  trailing: boolean
}

const SlotContext = createContext<Slot | null>(null)

const ItemContext = createContext<{ tone: TimelineTone; isCurrent: boolean }>({
  tone: "neutral",
  isCurrent: false,
})

// ---------------------------------------------------------------------------
// Children as entries
// ---------------------------------------------------------------------------

interface DatedProps {
  date?: TimelineDateInput
  start?: TimelineDateInput
  end?: TimelineRangeEnd
}

interface Entry {
  element: ReactElement<DatedProps>
  start: TimelinePoint | null
  end: TimelinePoint | "present" | null
}

function toEntries(children: ReactNode, context: TimelineContextValue): Entry[] {
  const entries = Children.toArray(children)
    .filter((child): child is ReactElement<DatedProps> => isValidElement(child))
    .map((element): Entry => {
      const first =
        element.props.start ??
        element.props.date ??
        (element.type === TimelineNow && context.boundary ? context.boundary : undefined)
      const start =
        first === undefined
          ? null
          : typeof first === "object" && "dateTime" in first
            ? first
            : parseTimelineDate(first, context.timeZone)
      const last = element.props.end
      const end: Entry["end"] =
        last === undefined
          ? null
          : last === "present"
            ? "present"
            : parseTimelineDate(last, context.timeZone)
      return { element, start, end }
    })
  if (!context.order) return entries
  // Undated children have nowhere to go in a sorted list, so they go last.
  const direction = context.order === "desc" ? -1 : 1
  return [...entries].sort((a, b) => {
    if (!a.start || !b.start) return a.start ? -1 : b.start ? 1 : 0
    return (a.start.start - b.start.start) * direction
  })
}

/** The latest moment an entry touches, for deciding whether a span is still ahead. */
function latest(entry: Entry | undefined, now: TimelinePoint | null): number | null {
  if (!entry?.start) return null
  if (entry.end === "present") return now?.start ?? entry.start.start
  return entry.end ? entry.end.start : entry.start.start
}

function slotted(entries: Entry[], context: TimelineContextValue, offset = 0, trailing = false) {
  return entries.map((entry, index) => {
    const here = latest(entry, context.boundary)
    const next = latest(entries[index + 1], context.boundary)
    const isLast = index === entries.length - 1
    const isFuture =
      !isLast &&
      context.boundary !== null &&
      here !== null &&
      next !== null &&
      Math.max(here, next) > context.boundary.start
    return (
      <SlotContext
        key={entry.element.key}
        value={{ index: offset + index, isLast, isFuture, trailing: isLast && trailing }}
      >
        {entry.element}
      </SlotContext>
    )
  })
}

// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------

/** Column templates for the vertical list; items take them over through `subgrid`. */
const VERTICAL_COLUMNS: Record<TimelinePlacement, string> = {
  end: "grid-cols-[auto_minmax(0,1fr)]",
  start: "grid-cols-[minmax(0,1fr)_auto]",
  opposite: "grid-cols-[auto_minmax(0,1fr)] @xl:grid-cols-[auto_auto_minmax(0,1fr)]",
  alternating:
    "grid-cols-[auto_minmax(0,1fr)] @xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]",
}

interface ItemLayout {
  /** The date has a cell of its own rather than sitting at the top of the content. */
  split: boolean
  /** `end` right-aligns always; `end-wide` only once the two-sided layout applies. */
  align: "start" | "end" | "end-wide"
  item: string
  axis: string
  time: string
  body: string
}

function itemLayout(orientation: TimelineOrientation, placement: TimelinePlacement, index: number) {
  const flipped = placement === "alternating" && index % 2 === 1
  const split = placement === "alternating" || placement === "opposite"

  if (orientation === "horizontal") {
    const above = "row-start-1 self-end pb-3"
    const below = "row-start-3 pt-3"
    return {
      split,
      align: "start",
      item: "row-span-3 grid snap-start grid-rows-subgrid",
      axis: "row-start-2 flex items-center",
      time: cn("pe-6", flipped ? below : above),
      body: cn("pe-6", placement === "start" || flipped ? above : below),
    } satisfies ItemLayout
  }

  const base = {
    item: "col-span-full grid grid-cols-subgrid grid-rows-[auto_1fr]",
    axis: "row-start-1 row-end-3 flex flex-col items-center",
  }
  if (placement === "end") {
    return {
      ...base,
      split,
      align: "start",
      axis: cn(base.axis, "col-start-1"),
      time: "",
      body: "col-start-2 row-start-1 row-end-3",
    } satisfies ItemLayout
  }
  if (placement === "start") {
    return {
      ...base,
      split,
      align: "end",
      axis: cn(base.axis, "col-start-2"),
      time: "",
      body: "col-start-1 row-start-1 row-end-3 text-end",
    } satisfies ItemLayout
  }
  // Two-sided: below `@xl` this is the `end` layout with the date in a row of
  // its own above the content; from `@xl` on the date crosses the axis.
  return {
    ...base,
    split,
    align: flipped ? "end-wide" : "start",
    axis: cn(base.axis, "col-start-1 @xl:col-start-2"),
    time: cn(
      "col-start-2 row-start-1",
      flipped ? "@xl:col-start-3" : "@xl:col-start-1 @xl:row-end-3 @xl:text-end",
    ),
    body: cn(
      "col-start-2 row-start-2 @xl:row-start-1 @xl:row-end-3",
      flipped ? "@xl:col-start-1 @xl:text-end" : "@xl:col-start-3",
    ),
  } satisfies ItemLayout
}

/**
 * A marker taller than the first line of text (a 24–40px disc or avatar) is
 * centred on its row, so the text beside it moves down by half the difference
 * and the first line stays level with the marker's centre.
 */
const DISC_OFFSET = [
  "group-has-[[data-disc=sm]]/timeline-item:pt-0.5",
  "group-has-[[data-disc=md]]/timeline-item:pt-1.5",
  "group-has-[[data-disc=lg]]/timeline-item:pt-2.5",
]

/** Flex rows inside the content follow the text to the end side when it is there. */
const FLEX_ALIGN =
  "group-data-[align=end]/timeline-item:justify-end @xl:group-data-[align=end-wide]/timeline-item:justify-end"

/**
 * Marks are ink. `brand` is the action fill — the item the reader is on —
 * `neutral` is ink, `muted` the subtle grey; the three state tokens are for an
 * item whose state is the point (delivered, delayed, failed).
 */
const DOT_TONES: Record<TimelineTone, string> = {
  neutral: "bg-quebi-fg",
  brand: "bg-quebi-action",
  success: "bg-quebi-success",
  warning: "bg-quebi-warn",
  danger: "bg-quebi-danger",
  muted: "bg-quebi-fg-subtle",
}

const RING_TONES: Record<TimelineTone, string> = {
  neutral: "border-quebi-rule",
  brand: "border-quebi-action",
  success: "border-quebi-success",
  warning: "border-quebi-warn",
  danger: "border-quebi-danger",
  muted: "border-quebi-fg-subtle",
}

/**
 * Icon and number markers are discs: the raised ground for `neutral`, the
 * action fill for `brand`, a hairline ring for `muted`, and a state token's own
 * tint for the three states.
 */
const TINT_TONES: Record<TimelineTone, string> = {
  neutral: "border-quebi-hairline bg-quebi-raised text-quebi-fg",
  brand: "border-quebi-action bg-quebi-action text-quebi-on-action",
  success: "border-quebi-success/20 bg-quebi-success/10 text-quebi-success",
  warning: "border-quebi-warn/20 bg-quebi-warn/10 text-quebi-warn",
  danger: "border-quebi-danger/20 bg-quebi-danger/10 text-quebi-danger",
  muted: "border-quebi-hairline bg-transparent text-quebi-fg-subtle",
}

// ---------------------------------------------------------------------------
// Timeline
// ---------------------------------------------------------------------------

/** One item as data, for `items`. Builds the same `TimelineItem` children would. */
export interface TimelineItemData extends DatedProps {
  id: string
  title: ReactNode
  description?: ReactNode
  /** A line of small print under the title: an author, a version, a place. */
  meta?: ReactNode
  /** Badges, or anything else that belongs in a row beside the meta. */
  badges?: ReactNode
  /** Makes the title a link. */
  href?: string
  tone?: TimelineTone
  isCurrent?: boolean
  connector?: TimelineConnector
  /** Marker shorthands; `marker` wins over `icon`, which wins over `avatar` and `number`. */
  marker?: ReactNode
  icon?: ReactNode
  avatar?: { src?: string; initials?: string; alt?: string }
  number?: number | string
  /** Folded behind a "Details" disclosure. */
  details?: ReactNode
  /** Anything else, after the description. */
  content?: ReactNode
}

export interface TimelineProps extends Omit<React.ComponentProps<"ol">, "children"> {
  children?: ReactNode
  /** Items as data; used instead of `children`. */
  items?: TimelineItemData[]
  orientation?: TimelineOrientation
  placement?: TimelinePlacement
  density?: TimelineDensity
  /** `sequential` spaces items evenly; `proportional` places them on a time axis. */
  spacing?: "sequential" | "proportional"
  /** Sort by date — `desc` is newest first. Without it, children keep the order written. */
  order?: "asc" | "desc"
  /** Sticky headers per year, month or day. Vertical, sequential timelines only. */
  groupBy?: "year" | "month" | "day"
  /** Show the first N items and fold the rest behind a "Show N more" toggle. */
  collapseAfter?: number
  /** The default connector. A span that reaches past `now` is `dashed` regardless. */
  connector?: TimelineConnector
  /** The present moment. Never read from the clock during render — see the component docs. */
  now?: TimelineDateInput
  /** Render point dates as "3 days ago", with the absolute date in the tooltip. */
  relative?: boolean
  /** BCP 47 locale. Defaults to the one react-aria resolves. */
  locale?: string
  /** Time zone that `Date`s and timestamps are read in. Strings do not need one. */
  timeZone?: string
  labels?: Partial<TimelineLabels>
  /** What to render when there are no items. */
  emptyState?: ReactNode
}

export function Timeline({
  children,
  items,
  orientation = "vertical",
  placement = "end",
  density = "comfortable",
  spacing = "sequential",
  order,
  groupBy,
  collapseAfter,
  connector = "solid",
  now,
  relative = false,
  locale: localeProp,
  timeZone = DEFAULT_TIME_ZONE,
  labels: labelOverrides,
  emptyState,
  className,
  ...props
}: TimelineProps) {
  const { locale: ariaLocale } = useLocale()
  const locale = localeProp ?? ariaLocale
  const [expanded, setExpanded] = useState(false)

  // "3 days ago" needs a clock, and a clock read during render is a different
  // answer in the prerender and in the browser. Picked up after mount instead.
  const [mountedNow, setMountedNow] = useState<number | null>(null)
  useEffect(() => {
    if (relative && now === undefined) setMountedNow(Date.now())
  }, [relative, now])

  const nowPoint =
    now !== undefined
      ? parseTimelineDate(now, timeZone)
      : mountedNow !== null
        ? parseTimelineDate(mountedNow, timeZone)
        : null

  const context: TimelineContextValue = {
    orientation: spacing === "proportional" ? "horizontal" : orientation,
    placement,
    density,
    connector,
    locale,
    timeZone,
    labels: labelsFor(locale, labelOverrides),
    relative,
    now: nowPoint,
    // Only a `now` the caller gave decides what is future: a clock picked up
    // after mount would redraw the connectors after hydration.
    boundary: now !== undefined ? nowPoint : null,
    order,
  }

  const entries = toEntries(items ? items.map(itemFromData) : children, context)

  if (entries.length === 0) {
    return (
      <div
        data-slot="timeline-empty"
        className={cn(
          "rounded-(--q-radius-surface) border border-dashed border-quebi-hairline px-4 py-6 text-center text-quebi-body-s text-quebi-fg-muted",
          className,
        )}
      >
        {emptyState ?? context.labels.empty}
      </div>
    )
  }

  if (spacing === "proportional") {
    return (
      <TimelineContext value={context}>
        <ProportionalTimeline entries={entries} className={className} {...props} />
      </TimelineContext>
    )
  }

  const isCollapsed = collapseAfter !== undefined && !expanded && entries.length > collapseAfter
  const visible = isCollapsed ? entries.slice(0, collapseAfter) : entries
  const hidden = entries.length - visible.length

  const slots = slotted(visible, context, 0, isCollapsed)
  // Slots are worked out over the whole list, so the line and its future
  // dashing run on across group headings rather than restarting under each.
  const listItems =
    groupBy && context.orientation === "vertical"
      ? groupRuns(visible, groupBy).map((run) => (
          <GroupShell
            key={run.key}
            label={run.label ? formatPoint(run.label, locale, context.labels) : null}
          >
            {slots.slice(run.offset, run.offset + run.entries.length)}
          </GroupShell>
        ))
      : slots

  const list = (
    <ol
      data-slot="timeline-list"
      data-orientation={context.orientation}
      data-placement={placement}
      {...props}
      className={cn(
        "grid",
        context.orientation === "vertical"
          ? [VERTICAL_COLUMNS[placement], density === "compact" ? "gap-x-3" : "gap-x-4"]
          : [
              "w-max grid-flow-col grid-rows-[auto_auto_auto]",
              density === "compact" ? "auto-cols-[11rem]" : "auto-cols-[15rem]",
            ],
      )}
    >
      {listItems}
    </ol>
  )

  return (
    <TimelineContext value={context}>
      <div
        data-slot="timeline"
        data-density={density}
        className={cn("@container flex min-w-0 flex-col", className)}
      >
        {context.orientation === "horizontal" ? (
          <ScrollArea
            orientation="horizontal"
            scrollFade
            // A focusable region, so the arrow keys scroll it for a keyboard user.
            tabIndex={0}
            role="region"
            aria-label={props["aria-label"] ?? "Timeline"}
            className="snap-x snap-proximity pb-3 focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset"
          >
            {list}
          </ScrollArea>
        ) : (
          list
        )}
        {collapseAfter !== undefined && entries.length > collapseAfter ? (
          <ShowMore className="mt-5" isSelected={expanded} onChange={setExpanded}>
            {expanded
              ? context.labels.showLess
              : context.labels.showMore(getNumberFormat(locale).format(hidden))}
          </ShowMore>
        ) : null}
      </div>
    </TimelineContext>
  )
}

function groupRuns(entries: Entry[], groupBy: "year" | "month" | "day") {
  const runs: { key: string; label: TimelinePoint | null; offset: number; entries: Entry[] }[] = []
  entries.forEach((entry, index) => {
    const label = entry.start ? atPrecision(entry.start, groupBy) : null
    const key = label?.dateTime ?? runs.at(-1)?.key ?? "undated"
    const run = runs.at(-1)
    if (run && run.key === key) run.entries.push(entry)
    else runs.push({ key, label, offset: index, entries: [entry] })
  })
  return runs
}

function itemFromData(data: TimelineItemData): ReactElement<DatedProps> {
  const marker =
    data.marker ??
    (data.icon ? (
      <TimelineMarker icon={data.icon} />
    ) : data.avatar ? (
      <TimelineMarker {...data.avatar} />
    ) : data.number !== undefined ? (
      <TimelineMarker number={data.number} />
    ) : undefined)
  return (
    <TimelineItem
      key={data.id}
      date={data.date}
      start={data.start}
      end={data.end}
      tone={data.tone}
      isCurrent={data.isCurrent}
      connector={data.connector}
      marker={marker}
    >
      <TimelineTitle href={data.href}>{data.title}</TimelineTitle>
      {data.meta || data.badges ? (
        <TimelineMeta>
          {data.meta}
          {data.badges}
        </TimelineMeta>
      ) : null}
      {data.description ? <TimelineDescription>{data.description}</TimelineDescription> : null}
      {data.content}
      {data.details ? <TimelineDetails>{data.details}</TimelineDetails> : null}
    </TimelineItem>
  )
}

// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------

export interface TimelineItemProps extends Omit<React.ComponentProps<"li">, "children">, DatedProps {
  /** Replaces the rendered date label. The date props still place and sort the item. */
  time?: ReactNode
  tone?: TimelineTone
  /** Replaces the default dot — usually a `TimelineMarker` with an icon, avatar or number. */
  marker?: ReactNode
  /** The connector from this item to the next. */
  connector?: TimelineConnector
  /** The item the timeline is at: `aria-current`, and a pulsing marker. */
  isCurrent?: boolean
  children?: ReactNode
}

export function TimelineItem({
  date,
  start,
  end,
  time,
  tone = "neutral",
  marker,
  connector,
  isCurrent = false,
  className,
  children,
  ...props
}: TimelineItemProps) {
  const context = useTimeline("TimelineItem")
  const slot = use(SlotContext) ?? { index: 0, isLast: true, isFuture: false, trailing: false }
  const layout = itemLayout(context.orientation, context.placement, slot.index)
  const vertical = context.orientation === "vertical"
  const line = connector ?? (slot.isFuture ? "dashed" : context.connector)
  const hasDate = (start ?? date) !== undefined
  const timeNode = time ?? (hasDate ? <TimelineTime date={date} start={start} end={end} /> : null)
  const compact = context.density === "compact"

  return (
    <ItemContext value={{ tone, isCurrent }}>
      <li
        {...props}
        data-slot="timeline-item"
        data-align={layout.align}
        data-current={isCurrent || undefined}
        aria-current={isCurrent || undefined}
        className={cn("group/timeline-item", layout.item, className)}
      >
        <div data-slot="timeline-axis" aria-hidden="true" className={layout.axis}>
          <span
            className={cn("flex shrink-0 items-center justify-center", vertical ? "min-h-5" : "min-w-5")}
          >
            {marker ?? <TimelineMarker />}
          </span>
          {!slot.isLast || slot.trailing ? (
            <span
              data-slot="timeline-connector"
              data-connector={line}
              className={cn(
                "flex-1 border-quebi-hairline",
                vertical ? "my-1 min-h-3 w-0 border-s" : "mx-1 h-0 min-w-3 border-t",
                line === "dashed" && "border-dashed",
                line === "none" && "border-transparent",
                slot.trailing &&
                  (vertical
                    ? "[mask-image:linear-gradient(to_bottom,black,transparent)]"
                    : "[mask-image:linear-gradient(to_right,black,transparent)]"),
              )}
            />
          ) : null}
        </div>
        {layout.split && timeNode ? (
          <div
            data-slot="timeline-time-cell"
            className={cn(
              "font-mono text-xs/5 text-quebi-fg-subtle tabular-nums",
              vertical && DISC_OFFSET,
              layout.time,
            )}
          >
            {timeNode}
          </div>
        ) : null}
        <div
          data-slot="timeline-body"
          className={cn(
            "flex min-w-0 flex-col",
            compact ? "gap-0.5" : "gap-1",
            layout.body,
            vertical && DISC_OFFSET,
            vertical && !slot.isLast && (compact ? "pb-4" : "pb-7"),
            vertical && slot.isLast && slot.trailing && "pb-4",
          )}
        >
          {!layout.split && timeNode ? (
            <div
              data-slot="timeline-time-cell"
              className={cn(
                "font-mono text-xs/5 text-quebi-fg-subtle tabular-nums",
              )}
            >
              {timeNode}
            </div>
          ) : null}
          {children}
        </div>
      </li>
    </ItemContext>
  )
}

export interface TimelineMarkerProps extends Omit<React.ComponentProps<"span">, "children"> {
  /** A plain marker's shape: a filled dot or a hollow ring. */
  variant?: "dot" | "ring"
  size?: "sm" | "md" | "lg"
  /** Defaults to the item's tone. */
  tone?: TimelineTone
  icon?: ReactNode
  /** Avatar image; `initials` is the fallback. */
  src?: string
  initials?: string
  alt?: string
  number?: number | string
  /** Defaults to the item's `isCurrent`. Dropped under reduced motion. */
  pulse?: boolean
  /** A custom node, drawn as given. */
  children?: ReactNode
}

const DOT_SIZES = { sm: "size-2", md: "size-3", lg: "size-4" }
const DISC_SIZES = { sm: "size-6 text-xs [&_svg]:size-3.5", md: "size-8 text-sm [&_svg]:size-4", lg: "size-10 text-base [&_svg]:size-5" }
const AVATAR_SIZES = { sm: "sm", md: "md", lg: "lg" } as const

export function TimelineMarker({
  variant = "dot",
  size: sizeProp,
  tone: toneProp,
  icon,
  src,
  initials,
  alt,
  number,
  pulse,
  children,
  className,
  ...props
}: TimelineMarkerProps) {
  const item = use(ItemContext)
  const context = use(TimelineContext)
  const tone = toneProp ?? item.tone
  const size = sizeProp ?? (context?.density === "compact" ? "sm" : "md")
  const isPulsing = pulse ?? item.isCurrent

  const kind = children
    ? "custom"
    : icon
      ? "icon"
      : src || initials
        ? "avatar"
        : number !== undefined
          ? "number"
          : variant

  const body =
    kind === "custom" ? (
      children
    ) : kind === "avatar" ? (
      <Avatar src={src} initials={initials} alt={alt} size={AVATAR_SIZES[size]} />
    ) : kind === "icon" || kind === "number" ? (
      <span
        className={cn(
          "flex items-center justify-center rounded-full border font-mono tabular-nums",
          DISC_SIZES[size],
          TINT_TONES[tone],
        )}
      >
        {kind === "icon" ? icon : number}
      </span>
    ) : (
      <span
        className={cn(
          "block rounded-full forced-colors:outline forced-colors:outline-1",
          DOT_SIZES[size],
          kind === "ring" ? ["border bg-quebi-bg", RING_TONES[tone]] : DOT_TONES[tone],
        )}
      />
    )

  return (
    <span
      {...props}
      data-slot="timeline-marker"
      data-kind={kind}
      data-disc={kind === "icon" || kind === "number" || kind === "avatar" ? size : undefined}
      data-tone={tone}
      className={cn("relative inline-flex shrink-0 items-center justify-center", className)}
    >
      {isPulsing ? (
        <span
          data-slot="timeline-marker-pulse"
          className={cn(
            "absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:hidden",
            DOT_TONES[tone],
          )}
        />
      ) : null}
      <span className="relative inline-flex">{body}</span>
    </span>
  )
}

export interface TimelineTimeProps extends DatedProps {
  className?: string
}

/** The date label of an item, as `<time>` elements. `TimelineItem` renders it for you. */
export function TimelineTime({ date, start, end, className }: TimelineTimeProps) {
  const context = useTimeline("TimelineTime")
  const first = start ?? date
  if (first === undefined) return null
  const from = parseTimelineDate(first, context.timeZone)

  if (end === undefined) {
    const absolute = formatPoint(from, context.locale, context.labels)
    const showRelative = context.relative && context.now
    return (
      <time
        dateTime={from.dateTime}
        title={showRelative ? absolute : undefined}
        className={className}
      >
        {showRelative && context.now
          ? formatTimelineRelative(from, context.now, context.locale)
          : absolute}
      </time>
    )
  }

  const to = end === "present" ? "present" : parseTimelineDate(end, context.timeZone)
  const text = rangeText(from, to, context.locale, context.labels)
  if (typeof text === "string") {
    return (
      <time dateTime={from.dateTime} className={className}>
        {text}
      </time>
    )
  }
  return (
    <span data-slot="timeline-range" className={className}>
      <time dateTime={from.dateTime}>{text.start}</time>
      {text.separator}
      {to === "present" ? text.end : <time dateTime={to.dateTime}>{text.end}</time>}
    </span>
  )
}

export interface TimelineTitleProps extends React.ComponentProps<"div"> {
  /** Makes the title a library `Link`. */
  href?: string
}

export function TimelineTitle({ href, className, children, ...props }: TimelineTitleProps) {
  return (
    <div
      {...props}
      data-slot="timeline-title"
      className={cn("font-display text-base/5 font-light tracking-wide text-quebi-fg", className)}
    >
      {href ? (
        <Link href={href} className="no-underline hover:underline">
          {children}
        </Link>
      ) : (
        children
      )}
    </div>
  )
}

export function TimelineDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      {...props}
      data-slot="timeline-description"
      className={cn("text-quebi-body-s text-quebi-fg-muted", className)}
    />
  )
}

/** Small print in a row: an author, a version, badges. */
export function TimelineMeta({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      {...props}
      data-slot="timeline-meta"
      className={cn(
        "flex flex-wrap items-center gap-x-2 gap-y-1 text-quebi-caption text-quebi-fg-subtle",
        FLEX_ALIGN,
        className,
      )}
    />
  )
}

/** Detail folded behind a plain `Disclosure`, for an item worth expanding. */
export function TimelineDetails({
  label,
  children,
  className,
}: {
  label?: ReactNode
  children: ReactNode
  className?: string
}) {
  const context = useTimeline("TimelineDetails")
  return (
    <Disclosure
      variant="plain"
      className={cn(
        "w-auto self-start",
        "group-data-[align=end]/timeline-item:self-end @xl:group-data-[align=end-wide]/timeline-item:self-end",
        className,
      )}
    >
      <DisclosureTrigger className="w-auto px-0">{label ?? context.labels.details}</DisclosureTrigger>
      <DisclosurePanel>
        <div className="pb-1">{children}</div>
      </DisclosurePanel>
    </Disclosure>
  )
}

export interface TimelineNowProps extends Omit<TimelineItemProps, "marker" | "tone" | "start" | "end"> {
  /** Defaults to the timeline's `labels.now`. */
  label?: ReactNode
}

/**
 * The present, between the items either side of it. With `order` set it is
 * sorted into place by the timeline's `now` (or its own `date`).
 */
export function TimelineNow({ label, date, time, children, ...props }: TimelineNowProps) {
  const context = useTimeline("TimelineNow")
  const vertical = context.orientation === "vertical"
  return (
    <TimelineItem
      {...props}
      data-now=""
      date={date}
      time={time}
      marker={
        <span
          className={cn(
            "bg-quebi-action",
            vertical ? "h-0.5 w-5" : "h-5 w-0.5",
          )}
        />
      }
    >
      <span className="quebi-eyebrow text-quebi-fg">{label ?? context.labels.now}</span>
      {children}
    </TimelineItem>
  )
}

export interface TimelineGroupProps extends Omit<React.ComponentProps<"li">, "children"> {
  /** The sticky heading: "2024", "March 2024". */
  label: ReactNode
  /** Places the group when the timeline is sorted. */
  date?: TimelineDateInput
  headingLevel?: 2 | 3 | 4 | 5 | 6
  children?: ReactNode
}

/** A run of items under a sticky heading. `groupBy` builds these for you. */
export function TimelineGroup({ label, date: _date, children, ...props }: TimelineGroupProps) {
  const context = useTimeline("TimelineGroup")
  return (
    <GroupShell label={label} {...props}>
      {slotted(toEntries(children, context), context)}
    </GroupShell>
  )
}

function GroupShell({
  label,
  headingLevel = 3,
  className,
  children,
  ...props
}: Omit<TimelineGroupProps, "date">) {
  const context = useTimeline("TimelineGroup")
  const Heading = `h${headingLevel}` as const
  if (context.orientation === "horizontal") {
    // A horizontal list has no room for a heading row; the name stays for a reader.
    return (
      <li {...props} data-slot="timeline-group" className={cn("contents", className)}>
        <Heading className="sr-only">{label}</Heading>
        <ol className="contents">{children}</ol>
      </li>
    )
  }
  return (
    <li
      {...props}
      data-slot="timeline-group"
      className={cn("col-span-full grid grid-cols-subgrid", className)}
    >
      {label ? (
        <Heading
          data-slot="timeline-group-heading"
          className="quebi-eyebrow sticky top-0 z-10 col-span-full bg-quebi-bg py-2 text-quebi-fg-muted"
        >
          {label}
        </Heading>
      ) : null}
      <ol className="col-span-full grid grid-cols-subgrid">{children}</ol>
    </li>
  )
}

// ---------------------------------------------------------------------------
// Proportional spacing
// ---------------------------------------------------------------------------

type TickUnit = "year" | "month" | "week" | "day"

const UNIT_MS: Record<TickUnit, number> = {
  year: 365.25 * DAY,
  month: 30.44 * DAY,
  week: 7 * DAY,
  day: DAY,
}
/** Pixels per unit: enough for a tick label, and the scale of everything else. */
const UNIT_PX: Record<TickUnit, number> = { year: 96, month: 64, week: 56, day: 36 }
/** A gap longer than this many units, with no range across it, is compressed. */
const GAP_UNITS = 5
const BREAK_PX = 40
const LABEL_PX = { compact: 128, comfortable: 176 }
const MIN_TICK_SPACING = 40

function unitFor(span: number): TickUnit {
  if (span > 4 * UNIT_MS.year) return "year"
  if (span > 5 * UNIT_MS.month) return "month"
  if (span > 4 * UNIT_MS.week) return "week"
  return "day"
}

function floorTo(ms: number, unit: TickUnit): number {
  const c = componentsOf(ms)
  if (unit === "year") return Date.UTC(c.year, 0, 1)
  if (unit === "month") return Date.UTC(c.year, c.month - 1, 1)
  if (unit === "week") return atPrecision(pointFrom("day", c), "week").start
  return Date.UTC(c.year, c.month - 1, c.day)
}

function stepFrom(ms: number, unit: TickUnit): number {
  const c = componentsOf(ms)
  if (unit === "year") return Date.UTC(c.year + 1, 0, 1)
  if (unit === "month") return Date.UTC(c.year, c.month, 1)
  return ms + (unit === "week" ? 7 : 1) * DAY
}

interface Segment {
  from: number
  to: number
  x: number
  width: number
  compressed: boolean
}

/**
 * Time to pixels, piecewise: linear everywhere except across a long empty
 * stretch, which gets `BREAK_PX` whatever its length.
 */
export function buildTimeScale(
  intervals: { start: number; end: number }[],
  domain: { start: number; end: number },
  unit: TickUnit,
) {
  const pxPerMs = UNIT_PX[unit] / UNIT_MS[unit]
  const boundaries = [
    ...new Set([domain.start, domain.end, ...intervals.flatMap((i) => [i.start, i.end])]),
  ]
    .filter((b) => b >= domain.start && b <= domain.end)
    .sort((a, b) => a - b)
  const segments: Segment[] = []
  let x = 0
  for (let i = 0; i < boundaries.length - 1; i++) {
    const from = boundaries[i] as number
    const to = boundaries[i + 1] as number
    const covered = intervals.some((range) => range.end - range.start > 0 && range.start <= from && range.end >= to)
    const compressed = !covered && to - from > GAP_UNITS * UNIT_MS[unit]
    const width = compressed ? BREAK_PX : (to - from) * pxPerMs
    segments.push({ from, to, x, width, compressed })
    x += width
  }
  const at = (ms: number) => {
    const segment =
      segments.find((s) => ms >= s.from && ms <= s.to) ??
      (ms < domain.start ? segments[0] : segments.at(-1))
    if (!segment) return 0
    if (segment.compressed) return segment.x + (ms <= segment.from ? 0 : segment.width)
    return segment.x + ((ms - segment.from) / (segment.to - segment.from || 1)) * segment.width
  }
  return { at, segments, width: x }
}

/** Greedy lanes: each item goes in the first lane whose last item ends before it starts. */
export function assignLanes(extents: { x: number; right: number }[]): number[] {
  const laneEnds: number[] = []
  return extents.map(({ x, right }) => {
    const lane = laneEnds.findIndex((laneEnd) => laneEnd <= x)
    const index = lane === -1 ? laneEnds.length : lane
    laneEnds[index] = right
    return index
  })
}

function ProportionalTimeline({
  entries: allEntries,
  className,
  ...props
}: { entries: Entry[] } & Omit<TimelineProps, "children" | "items">) {
  const context = useTimeline("Timeline")
  // The axis draws its own "now", so a `TimelineNow` has nothing to add here.
  const entries = allEntries
    .filter((entry): entry is Entry & { start: TimelinePoint } => entry.start !== null)
    .filter((entry) => entry.element.type !== TimelineNow)
    .sort((a, b) => a.start.start - b.start.start)

  const closed = entries.map((entry) =>
    entry.end && entry.end !== "present" ? entry.end.end : entry.start.start,
  )
  // An open range runs to `now`, or to the last thing the timeline knows of.
  const present = context.boundary?.start ?? Math.max(...closed)
  const intervals = entries.map((entry, index) => ({
    start: entry.start.start,
    end: entry.end === "present" ? Math.max(present, entry.start.end) : (closed[index] as number),
  }))
  const min = Math.min(...intervals.map((i) => i.start))
  const max = Math.max(...intervals.map((i) => i.end), context.boundary?.start ?? min)
  const unit = unitFor(max - min)
  const domain = { start: floorTo(min, unit), end: stepFrom(floorTo(max, unit), unit) }
  const scale = buildTimeScale(intervals, domain, unit)
  const labelPx = LABEL_PX[context.density]

  const placed = entries.map((entry, index) => {
    const interval = intervals[index] as { start: number; end: number }
    const x = scale.at(interval.start)
    const barWidth = entry.end ? Math.max(8, scale.at(interval.end) - x) : 0
    return { entry, x, barWidth, right: x + Math.max(barWidth, labelPx) + 12 }
  })
  const lanes = assignLanes(placed)

  const ticks: { x: number; label: string; key: number }[] = []
  for (let t = domain.start; t <= domain.end; t = stepFrom(t, unit)) {
    if (scale.segments.some((s) => s.compressed && t > s.from && t < s.to)) continue
    const x = scale.at(t)
    const previous = ticks.at(-1)
    if (previous && x - previous.x < MIN_TICK_SPACING) continue
    const c = componentsOf(t)
    const label =
      unit === "year" || (unit === "month" && c.month === 1)
        ? dateFormat(context.locale, { year: "numeric" }).format(t)
        : unit === "month"
          ? dateFormat(context.locale, { month: "short", year: ticks.length ? undefined : "numeric" }).format(t)
          : dateFormat(context.locale, { day: "numeric", month: "short" }).format(t)
    ticks.push({ x, label, key: t })
  }
  const width = scale.width + labelPx
  const nowX =
    context.boundary &&
    context.boundary.start >= domain.start &&
    context.boundary.start <= domain.end
      ? scale.at(context.boundary.start)
      : null

  return (
    <div
      data-slot="timeline"
      data-spacing="proportional"
      className={cn("flex min-w-0 flex-col", className)}
    >
      <ScrollArea
        orientation="horizontal"
        scrollFade
        tabIndex={0}
        role="region"
        aria-label={props["aria-label"] ?? "Timeline"}
        className="pb-3 focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset"
      >
        {/* The bottom padding is the "Now" label's row, clear of the tick labels. */}
        <div className={cn("relative", nowX !== null && "pb-6")} style={{ width }}>
          <div aria-hidden="true" className="pointer-events-none absolute inset-0">
            {ticks.map((tick) => (
              <span
                key={tick.key}
                className="absolute top-6 bottom-0 border-s border-quebi-hairline"
                style={{ insetInlineStart: tick.x }}
              />
            ))}
            {scale.segments
              .filter((s) => s.compressed)
              .map((s) => (
                <span
                  key={s.from}
                  data-slot="timeline-break"
                  className="absolute top-0 bottom-0 border-x border-dashed border-quebi-hairline"
                  style={{ insetInlineStart: s.x + 6, width: s.width - 12 }}
                />
              ))}
            {nowX !== null ? (
              <span
                data-slot="timeline-now-line"
                className="absolute top-0 bottom-0 border-s-2 border-quebi-rule"
                style={{ insetInlineStart: nowX }}
              >
                <span className="quebi-eyebrow absolute bottom-0 ms-1.5 text-quebi-fg">
                  {context.labels.now}
                </span>
              </span>
            ) : null}
          </div>
          <div
            aria-hidden="true"
            data-slot="timeline-ticks"
            className="relative h-6 border-b border-quebi-hairline"
          >
            {ticks.map((tick) => (
              <span
                key={tick.key}
                className="absolute bottom-1 ps-1 font-mono text-xs whitespace-nowrap text-quebi-fg-subtle tabular-nums"
                style={{ insetInlineStart: tick.x }}
              >
                {tick.label}
              </span>
            ))}
          </div>
          <ol
            data-slot="timeline-list"
            data-orientation="horizontal"
            {...props}
            className="relative grid gap-y-3 pt-3"
          >
            {placed.map(({ entry, x, barWidth }, index) => (
              <ProportionalItem
                key={entry.element.key}
                entry={entry}
                lane={lanes[index] ?? 0}
                x={x}
                barWidth={barWidth}
                labelPx={labelPx}
              />
            ))}
          </ol>
        </div>
      </ScrollArea>
    </div>
  )
}

function ProportionalItem({
  entry,
  lane,
  x,
  barWidth,
  labelPx,
}: {
  entry: Entry
  lane: number
  x: number
  barWidth: number
  labelPx: number
}) {
  const props = entry.element.props as TimelineItemProps
  const tone = props.tone ?? "neutral"
  const isRange = props.end !== undefined
  return (
    <li
      data-slot="timeline-item"
      aria-current={props.isCurrent || undefined}
      className="col-start-1 flex flex-col gap-1.5"
      style={{
        gridRow: lane + 1,
        marginInlineStart: isRange ? x : x - 6,
        width: Math.max(barWidth, labelPx),
      }}
    >
      <span aria-hidden="true" className="flex h-3 items-center">
        {isRange ? (
          <span
            data-slot="timeline-bar"
            className={cn(
              "h-2",
              DOT_TONES[tone],
              props.end === "present" && "mask-r-from-60%",
            )}
            style={{ width: barWidth }}
          />
        ) : (
          <span className={cn("size-3 rounded-full", DOT_TONES[tone])} />
        )}
      </span>
      <span className="flex min-w-0 flex-col gap-0.5 pe-3">
        <span className="font-mono text-xs text-quebi-fg-subtle tabular-nums">
          {props.time ?? <TimelineTime date={props.date} start={props.start} end={props.end} />}
        </span>
        {props.children}
      </span>
    </li>
  )
}