Live Feed

Rolling activity stream for surfacing incidents, deploys, and operational signals in real time.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/live-feed.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client";

import * as React from "react";

import { cn } from "../../lib/utils";
import { Badge } from "../badge";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../card";
import {
  SeverityBadge,
  type SeverityBadgeLevel,
} from "../severity-badge/severity-badge";

export type LiveFeedEvent = {
  id: string;
  message?: string;
  severity: SeverityBadgeLevel;
  source?: string;
  timestamp: Date | number | string;
  title: string;
};

export type LiveFeedProps = React.ComponentPropsWithoutRef<"div"> & {
  description?: string;
  emptyLabel?: string;
  events: LiveFeedEvent[];
  maxItems?: number;
  now?: Date | number | string;
  tickMs?: number;
  title?: string;
};

const SECOND_MS = 1000;
const MINUTE_MS = 60 * SECOND_MS;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;

function normalizeDate(input: Date | number | string): Date {
  if (input instanceof Date) {
    return new Date(input.getTime());
  }

  return new Date(input);
}

function useLiveDate(now: LiveFeedProps["now"], tickMs: number) {
  const fixedNow = React.useMemo(
    () => (now ? normalizeDate(now) : undefined),
    [now],
  );
  const [liveNow, setLiveNow] = React.useState<Date>(fixedNow ?? new Date());

  React.useEffect(() => {
    if (fixedNow) {
      setLiveNow(fixedNow);
      return;
    }

    const interval = window.setInterval(() => {
      setLiveNow(new Date());
    }, tickMs);

    return () => {
      window.clearInterval(interval);
    };
  }, [fixedNow, tickMs]);

  return liveNow;
}

function formatRelative(eventDate: Date, now: Date): string {
  const deltaMs = now.getTime() - eventDate.getTime();

  if (deltaMs < 5 * SECOND_MS) {
    return "just now";
  }

  if (deltaMs < MINUTE_MS) {
    return `${Math.floor(deltaMs / SECOND_MS)}s ago`;
  }

  if (deltaMs < HOUR_MS) {
    return `${Math.floor(deltaMs / MINUTE_MS)}m ago`;
  }

  if (deltaMs < DAY_MS) {
    return `${Math.floor(deltaMs / HOUR_MS)}h ago`;
  }

  if (deltaMs < 7 * DAY_MS) {
    return `${Math.floor(deltaMs / DAY_MS)}d ago`;
  }

  return SHORT_DATE_FORMATTER.format(eventDate);
}

const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
  day: "numeric",
  month: "short",
});

const ABSOLUTE_FORMATTER = new Intl.DateTimeFormat("en-US", {
  hour: "numeric",
  minute: "2-digit",
  month: "short",
  second: "2-digit",
});

function formatAbsolute(eventDate: Date): string {
  return ABSOLUTE_FORMATTER.format(eventDate);
}

function sortEventsDesc(events: LiveFeedEvent[]): LiveFeedEvent[] {
  return [...events].sort(
    (a, b) =>
      normalizeDate(b.timestamp).getTime() -
      normalizeDate(a.timestamp).getTime(),
  );
}

function LiveFeedRow({
  event,
  isLatest,
  now,
}: {
  event: LiveFeedEvent;
  isLatest: boolean;
  now: Date;
}) {
  const eventDate = normalizeDate(event.timestamp);

  return (
    <li className="flex gap-3 border-b border-border/60 py-3 last:border-b-0">
      <div className="pt-1">
        <SeverityBadge
          level={event.severity}
          pulse={isLatest ? event.severity === "critical" : undefined}
          tone="soft"
        />
      </div>
      <div className="min-w-0 flex-1">
        <div className="flex items-baseline justify-between gap-2">
          <p className="truncate text-sm font-medium">{event.title}</p>
          <time
            className="whitespace-nowrap text-xs text-muted-foreground"
            dateTime={eventDate.toISOString()}
            title={formatAbsolute(eventDate)}
          >
            {formatRelative(eventDate, now)}
          </time>
        </div>
        {event.message ? (
          <p className="mt-0.5 text-sm text-muted-foreground">
            {event.message}
          </p>
        ) : null}
        {event.source ? (
          <p className="mt-1 text-xs uppercase tracking-[0.14em] text-muted-foreground">
            {event.source}
          </p>
        ) : null}
      </div>
    </li>
  );
}

export const LiveFeed = React.forwardRef<HTMLDivElement, LiveFeedProps>(
  (
    {
      className,
      description,
      emptyLabel = "No events yet",
      events,
      maxItems = 50,
      now,
      tickMs = 30_000,
      title = "Live feed",
      ...props
    },
    ref,
  ) => {
    const liveNow = useLiveDate(now, tickMs);
    const visibleEvents = React.useMemo(
      () => sortEventsDesc(events).slice(0, maxItems),
      [events, maxItems],
    );

    return (
      <Card className={cn("shadow-sm", className)} ref={ref} {...props}>
        <CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0 pb-3">
          <div className="space-y-1">
            <CardTitle className="text-base">{title}</CardTitle>
            {description ? (
              <CardDescription>{description}</CardDescription>
            ) : null}
          </div>
          <Badge variant="outline">
            <span
              aria-hidden="true"
              className="relative mr-1.5 inline-flex size-2"
            >
              <span className="absolute inline-flex size-2 animate-ping rounded-full bg-emerald-500 opacity-70" />
              <span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
            </span>{" "}
            Live
          </Badge>
        </CardHeader>
        <CardContent className="pt-0">
          {visibleEvents.length === 0 ? (
            <div className="py-8 text-center text-sm text-muted-foreground">
              {emptyLabel}
            </div>
          ) : (
            <ul className="max-h-[360px] divide-y divide-border/60 overflow-y-auto">
              {visibleEvents.map((event, index) => (
                <LiveFeedRow
                  event={event}
                  isLatest={index === 0}
                  key={event.id}
                  now={liveNow}
                />
              ))}
            </ul>
          )}
        </CardContent>
      </Card>
    );
  },
);

LiveFeed.displayName = "LiveFeed";
typescript

Dependencies

  • @vllnt/ui@^0.2.1