# Subject timeline

> One record's comments, files and versions merged newest first, from `recordServer(...).timeline`, skipping the sections the caller can't read.

Source: https://bettersupabase.com/docs/registry/ui-recipes/subject-timeline

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [comments](https://bettersupabase.com/blocks/comments), [attachments](https://bettersupabase.com/blocks/attachments), [versions](https://bettersupabase.com/blocks/versions) blocks must be installed (`npx better-supabase add comments attachments versions`). Its page on bettersupabase.com is [/recipes/subject-timeline](https://bettersupabase.com/recipes/subject-timeline).

Web, with shadcn components:

```bash
npx shadcn add badge button card empty
```

```tsx title="apps/examples/nextjs/src/features/customers/components/customer-timeline.tsx"
/**
 * The customer's history from `customerRecord.timeline`: comments, files and
 * versions in one list, newest first.
 */
export async function CustomerTimeline({
  timeline,
  authors,
}: {
  timeline: Loaded<readonly TimelineRow[]>;
  authors: Readonly<Record<string, string>>;
}) {
  const [t, format] = await Promise.all([
    getExtracted("customers"),
    getFormatter(),
  ]);

  const kindLabel = (kind: string): string => {
    switch (kind) {
      case "comment": {
        return t("Comment");
      }

      case "attachment": {
        return t("File");
      }

      case "version": {
        return t("Version");
      }

      default: {
        return kind;
      }
    }
  };

  return (
    <Card size="sm" data-recipe="record-timeline">
      <CardHeader>
        <CardTitle>{t("Timeline")}</CardTitle>
        <CardDescription>
          {t("Comments, files and versions, newest first.")}
        </CardDescription>
      </CardHeader>
      <CardContent>
        {timeline.kind === "error" ? (
          <RecordLoadError title={t("The timeline didn't load")} />
        ) : timeline.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing happened yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Comments, files and versions show up here.")}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <a
                href="#comments"
                className={buttonVariants({ variant: "outline", size: "sm" })}
              >
                {t("Write a comment")}
              </a>
            </EmptyContent>
          </Empty>
        ) : (
          <ol className="flex flex-col gap-3">
            {timeline.data.map((entry) => (
              <li key={entry.id} className="flex items-center gap-2 text-sm">
                <Badge variant="outline">{kindLabel(entry.kind)}</Badge>
                <span className="truncate">
                  {(entry.actor === null ? undefined : authors[entry.actor]) ??
                    t("Someone")}
                </span>
                <time
                  dateTime={new Date(entry.at).toISOString()}
                  className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums"
                >
                  {format.relativeTime(entry.at)}
                </time>
              </li>
            ))}
          </ol>
        )}
      </CardContent>
    </Card>
  );
}
```