Mr. Doge
Components

Match Timeline

Chronological feed of match events. Goals, cards, penalties, and half/full-time, most-recent-first.

"use client"import { useEffect, useState } from "react"import type { TimelineEvent } from "@mrdoge/protocol"import { MatchHighlight } from "@/registry/mrdoge-ui/match-highlight/match-highlight"import { matchToMatchHighlightProps } from "@/lib/mrdoge-adapters/match-highlight"import { MatchTimeline, MatchTimelineSkeleton } from "@/registry/mrdoge-ui/match-timeline/match-timeline"import { matchToMatchTimelineProps } from "@/lib/mrdoge-adapters/match-timeline"import { getMrDogeClient } from "@/registry/mrdoge-ui/mrdoge-client/mrdoge-client"import { useLiveMatch } from "@/registry/mrdoge-ui/use-live-match/use-live-match"import { useSharedLiveOrCompletedMatchId } from "@/components/docs/demos/use-shared-demo-matches"import { FINISHED_MATCH_ID } from "@/components/docs/sample-data"// matches.subscribe only pushes stats.upd/status.upd. There's no// timeline.upd, so useLiveMatch's own snapshot never gets new events// once a match is subscribed. Polling just the timeline field (a cheap// partial fetch via `select`) while live is the workaround.function useLiveTimeline(matchId: string | undefined, isLive: boolean) {  const [timeline, setTimeline] = useState<TimelineEvent[] | undefined>(undefined)  useEffect(() => {    if (!matchId || !isLive) return    let cancelled = false    function refresh() {      getMrDogeClient()        .matches.get({ id: matchId!, select: { timeline: true } })        .then((result) => {          if (!cancelled) setTimeline(result.timeline)        })        .catch(() => {})    }    refresh()    const interval = setInterval(refresh, 30000)    return () => {      cancelled = true      clearInterval(interval)    }  }, [matchId, isLive])  return timeline}export function MatchTimelineDemo() {  // Prefers a genuinely live match; falls back to a fixed completed match  // if nothing's live right now (e.g. quiet hours): an upcoming match  // would just trade one empty timeline for another.  const resolvedId = useSharedLiveOrCompletedMatchId()  // A live match can resolve before it has any goals/cards yet (e.g. it  // just kicked off) — a duller demo than the guaranteed-populated  // finished match. Once we see that, switch to it instead.  const [fallbackToFinished, setFallbackToFinished] = useState(false)  const matchId = fallbackToFinished ? FINISHED_MATCH_ID : resolvedId  const match = useLiveMatch({ matchId })  const liveTimeline = useLiveTimeline(matchId, match?.status === "live")  const timeline = match    ? matchToMatchTimelineProps({ ...match, timeline: liveTimeline ?? match.timeline })    : null  // "Real" excludes the synthetic live-score/HT/FT divider entries Match  // Timeline always injects for a live or completed match — those aren't  // an actual reported event.  const hasRealEntries = timeline?.entries.some((entry) => entry.type !== "divider") ?? false  useEffect(() => {    if (!fallbackToFinished && match?.status === "live" && !hasRealEntries) {      setFallbackToFinished(true)    }  }, [fallbackToFinished, match?.status, hasRealEntries])  if (match === null) {    return <p className="text-sm text-fd-muted-foreground">Couldn't load this match right now.</p>  }  return (    <div className="flex w-full max-w-sm flex-col gap-3">      {match === undefined ? <MatchHighlight loading /> : <MatchHighlight {...matchToMatchHighlightProps(match)} />}      {match === undefined ? (        <MatchTimelineSkeleton />      ) : timeline && timeline.entries.length > 0 ? (        <MatchTimeline {...timeline} />      ) : (        <p className="text-sm text-fd-muted-foreground">No events reported for this match yet.</p>      )}    </div>  )}

Installation

pnpm dlx shadcn@latest add https://mrdoge.co/r/match-timeline.json

Usage

import { MatchTimeline } from "@/components/match-timeline"

<MatchTimeline
  entries={[
    { id: "1", side: "match", type: "divider", live: true, description: "2nd Half 1 - 0" },
    { id: "2", side: "match", type: "divider", description: "HT 1 - 0" },
    { id: "3", time: "39'", type: "yellow-card", side: "away", description: "J. Mickels" },
    { id: "4", time: "31'", type: "goal", side: "home", description: "J. Bogere", score: { home: 1, away: 0 } },
  ]}
/>

type is an open string: pass whatever event kinds your data has (e.g. "goal", "yellow-card"); unrecognized ones fall back to a generic marker instead of being rejected. side: "match" entries render as a divider (a horizontal line either side of centered text) instead of a row; pass live to color the current one red instead of muted.

Use with the Mr. Doge SDK

Match Timeline takes a plain entries array, so it works with any data source. See the Match Timeline Adapter for the real function mapping a match's timeline (from matches.get()/matches.subscribe()) onto this prop. It's the one behind the example above. It filters down to goals, cards, penalties, and half/full-time; the rest of a real timeline (fouls, tackles, throw-ins, corners, goal kicks, offsides, shots) stays available on the match object itself if you want a denser view.

Props

Prop

Type

MatchTimelineEntry

Prop

Type

MatchTimelineSkeleton

import { MatchTimelineSkeleton } from "@/components/match-timeline" for a loading state before the real entries have arrived. Takes rowCount (default 4) and className.

On this page