# Activity timeline

> Calls, emails and meetings with a contact, newest first.

- Block: [engagements](https://bettersupabase.com/blocks/engagements.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/activity-timeline

## Install the block

```bash
npx better-supabase add engagements
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge card empty separator skeleton
```

From `apps/examples/nextjs/src/features/crm/components/activity-timeline.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/crm/components/activity-timeline.tsx):

```tsx
const ICONS = new Map<string, LucideIcon>([
  ["call", PhoneIcon],
  ["email", MailIcon],
  ["meeting", CalendarIcon],
  ["note", StickyNoteIcon],
  ["task", CircleCheckIcon],
]);

/**
 * Render inside `<Suspense>`: the calls, emails, meetings and notes logged
 * on a contact, newest first, with a form to log the next one.
 */
export async function ActivityTimeline({
  subject,
}: {
  subject: { type: ContactKind; id: string };
}) {
  const [session, t, format] = await Promise.all([
    getSession(),
    getExtracted("crm"),
    getFormatter(),
  ]);

  if (!can(session, "engagements.read")) {
    return null;
  }

  const timeline = await settle(() => loadTimeline(subject.type, subject.id));

  if (timeline.kind === "no-organization") {
    return null;
  }

  const canLog = can(session, "engagements.write");

  const types = new Map([
    ["call", t("Call")],
    ["email", t("Email")],
    ["meeting", t("Meeting")],
    ["note", t("Note")],
    ["task", t("Task")],
  ]);

  return (
    <Card data-recipe="activity-timeline">
      <CardHeader>
        <CardTitle>{t("Activity")}</CardTitle>
        <CardDescription>
          {t("Calls, emails and meetings with this contact, newest first.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-6">
        {timeline.kind === "error" ? (
          <CrmLoadError title={t("The activity didn't load")} />
        ) : timeline.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HistoryIcon />
              </EmptyMedia>
              <EmptyTitle>{t("Nothing logged yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Log the first call, email, meeting or note.")}
              </EmptyDescription>
            </EmptyHeader>
            {canLog ? (
              <EmptyContent>
                <LogActivityForm subject={subject} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <>
            {canLog ? (
              <>
                <LogActivityForm subject={subject} />
                <Separator />
              </>
            ) : null}
            <ol className="flex flex-col gap-4">
              {timeline.data.map((entry) => {
                const Icon = ICONS.get(entry.type) ?? StickyNoteIcon;

                return (
                  <li key={entry.id} className="flex gap-3">
                    <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
                      <Icon className="size-4" aria-hidden />
                    </span>
                    <div className="flex min-w-0 flex-1 flex-col gap-1">
                      <div className="flex flex-wrap items-center gap-2">
                        <span className="text-sm font-medium">
                          {entry.title}
                        </span>
                        <Badge variant="secondary">
                          {types.get(entry.type) ?? entry.type}
                        </Badge>
                        {entry.overdue ? (
                          <Badge variant="warning-light">{t("Overdue")}</Badge>
                        ) : null}
                      </div>
                      {entry.body ? (
                        <p className="text-sm whitespace-pre-line text-muted-foreground">
                          {entry.body}
                        </p>
                      ) : null}
                      <time
                        dateTime={entry.createdAt}
                        className="text-xs text-muted-foreground"
                      >
                        {format.dateTime(new Date(entry.createdAt), {
                          dateStyle: "medium",
                          timeStyle: "short",
                        })}
                      </time>
                    </div>
                  </li>
                );
              })}
            </ol>
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Activity timeline" in a React web app that uses shadcn/ui components. Calls, emails and meetings with a contact, newest first.

It uses the better-supabase `engagements` block (Engagements). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add engagements` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge card empty separator skeleton`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/crm/components/activity-timeline.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/activity-timeline.md
- Block docs: https://bettersupabase.com/docs/blocks/engagements
```
