dodil/erp-ui
src/components/timeline/Timeline.tsx
/**
* Timeline — what was said and done, newest first, grouped by day. The record of a relationship: calls,
* emails, meetings, notes, and tasks with their state.
*/
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
import type { ReactNode } from "react";
import { formatDate } from "../../lib/format";
import { StatusBadge } from "../status-badge/StatusBadge";
export interface TimelineItem {
id: string;
kind: string;
title: string;
body?: string;
/** ISO time it happened (or is due). */
at: string;
actor?: string;
status?: string | null;
}
export function Timeline({ items, empty }: { items: TimelineItem[]; empty?: ReactNode }) {
if (!items.length) return <Text color="gray">{empty ?? "Nothing logged yet."}</Text>;
const sorted = [...items].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
const days: [string, TimelineItem[]][] = [];
for (const it of sorted) {
const day = it.at.slice(0, 10);
const last = days[days.length - 1];
if (last && last[0] === day) last[1].push(it); else days.push([day, [it]]);
}
return (
<Flex direction="column" gap="4" asChild>
<ol aria-label="Timeline" style={{ listStyle: "none", padding: 0, margin: 0 }}>
{days.map(([day, list]) => (
<li key={day}>
<Text as="div" size="1" color="gray" weight="medium" mb="2">{formatDate(day)}</Text>
<Flex direction="column" gap="3" asChild>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{list.map((it) => (
<li key={it.id}>
<Flex gap="3" align="start">
<Badge variant="soft" color="gray" style={{ minWidth: 64, justifyContent: "center" }}>
{it.kind.toLowerCase()}
</Badge>
<Box style={{ flex: 1 }}>
<Flex gap="2" align="center">
<Text weight="medium">{it.title}</Text>
{it.status && <StatusBadge status={it.status} />}
</Flex>
{it.body && <Text as="p" size="2" style={{ whiteSpace: "pre-wrap" }}>{it.body}</Text>}
{it.actor && <Text as="p" size="1" color="gray">{it.actor}</Text>}
</Box>
</Flex>
</li>
))}
</ul>
</Flex>
</li>
))}
</ol>
</Flex>
);
}