Linha do Tempo de Atividades
Histórico de atividades (quem fez o quê, em quê e quando) com avatar de iniciais, ícone e cor por tipo e o “de → para” do que mudou. Agrupa por dia em seções que abrem e fecham, com a contagem e o intervalo de horas de cada dia.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<LinhaDoTempoDeAtividades>
Histórico de atividades: quem fez o quê, em quê e quando, com o "de → para" do que mudou. Agrupa por dia em seções que abrem e fecham (com a contagem e o intervalo de horas de cada dia) e marca cada item com ícone e cor por tipo. Os itens aparecem na ordem recebida (normalmente do mais recente para o mais antigo).
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | ItemDaLinhaDoTempo[] | — | |
| agruparPorDia | boolean | true | |
| gruposAbertos | number | 1 | Quantos dias começam abertos, a partir do primeiro. |
| formatarDia | (data: Date) => string | formatarDiaPadrao | Título de cada dia. Padrão: "qua., 21 de out. de 2026". |
| formatarHora | (data: Date) => string | formatarHoraPadrao | Hora de cada item. Padrão: "14:30". |
| formatarContagem | (quantidade: number) => string | contarPadrao | Contagem no título do dia. Padrão: "3 atividades". |
| textoVazio | string | "Nenhuma atividade registrada." |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, useState, type ReactNode } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
/** Cor do marcador da atividade. */
export type TomDaAtividade = "neutro" | "info" | "sucesso" | "alerta" | "erro";
export type AutorDaAtividade = { nome: string; imagem?: string };
export type ItemDaLinhaDoTempo = {
id: string;
/** Data e hora. Texto em ISO com hora ("2026-10-21T14:30:00"); só a data é lida como UTC pelo navegador. */
quando: Date | string;
autor?: AutorDaAtividade;
/** O que aconteceu, depois do nome do autor ("moveu", "comentou em"). */
acao: ReactNode;
/** Em que aconteceu, em destaque depois da ação ("Revisar contrato"). */
alvo?: ReactNode;
/** Nome do que mudou, antes do "de → para" ("Etapa", "Prazo"). */
campo?: ReactNode;
/** Valor anterior (riscado). */
de?: ReactNode;
/** Valor novo. */
para?: ReactNode;
/** Ícone no marcador; sem ele, um ponto na cor do tom. */
icone?: ReactNode;
tom?: TomDaAtividade;
};
const COR_DO_TOM: Record<TomDaAtividade, string> = {
neutro: "text-muted",
info: "text-accent",
sucesso: "text-success",
alerta: "text-warning",
erro: "text-critical",
};
const formatarDiaPadrao = (d: Date) =>
d.toLocaleDateString("pt-BR", { weekday: "short", day: "numeric", month: "short", year: "numeric" });
const formatarHoraPadrao = (d: Date) => d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
const contarPadrao = (n: number) => (n === 1 ? "1 atividade" : `${n} atividades`);
const paraData = (v: Date | string) => (v instanceof Date ? v : new Date(v));
const chaveDoDia = (d: Date) =>
Number.isNaN(d.getTime()) ? "invalida" : `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
function iniciais(nome: string) {
const partes = nome.trim().split(/\s+/).filter(Boolean);
if (partes.length === 0) return "?";
if (partes.length === 1) return partes[0].slice(0, 2).toUpperCase();
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
/** Cor estável por nome, entre as cores de série do tema. */
function corDoNome(nome: string) {
let soma = 0;
for (const letra of nome) soma = (soma * 31 + letra.charCodeAt(0)) >>> 0;
return `var(--series-${(soma % 8) + 1})`;
}
function Avatar({ autor }: { autor: AutorDaAtividade }) {
if (autor.imagem) {
return (
// eslint-disable-next-line @next/next/no-img-element -- a biblioteca não depende do next/image
<img src={autor.imagem} alt="" className="inline-block size-6 shrink-0 rounded-full object-cover align-middle" />
);
}
const cor = corDoNome(autor.nome);
return (
<span
aria-hidden="true"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs leading-none font-semibold text-ink align-middle"
style={{ background: `color-mix(in oklab, ${cor} 28%, var(--page))` }}
>
{iniciais(autor.nome)}
</span>
);
}
function Linha({ item, hora }: { item: ItemDaLinhaDoTempo; hora: string }) {
const data = paraData(item.quando);
const temMudanca = item.de !== undefined || item.para !== undefined;
return (
<li className="relative flex gap-3 py-2.5">
<span
aria-hidden="true"
className={`relative z-10 flex size-7 shrink-0 items-center justify-center rounded-full border border-rule bg-page [&_svg]:size-3.5 ${COR_DO_TOM[item.tom ?? "neutro"]}`}
>
{item.icone ?? <span className="size-2 rounded-full bg-current" />}
</span>
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 text-sm leading-relaxed text-ink-2">
{item.autor && (
<span className="mr-1 inline-flex items-center gap-1.5 align-top">
<Avatar autor={item.autor} />
<span className="font-semibold text-ink">{item.autor.nome}</span>
</span>
)}
{item.acao}
{item.alvo && (
<>
{" "}
<span className="font-medium text-ink">{item.alvo}</span>
</>
)}
</p>
<time dateTime={Number.isNaN(data.getTime()) ? undefined : data.toISOString()} className="shrink-0 pt-0.5 text-xs whitespace-nowrap text-muted tabular-nums">
{hora}
</time>
</div>
{temMudanca && (
<p className="mt-1 text-xs text-ink-2">
{item.campo && <span className="text-muted">{item.campo}: </span>}
{item.de !== undefined && <del className="text-muted decoration-muted/60">{item.de}</del>}
{item.de !== undefined && item.para !== undefined && (
<span aria-hidden="true" className="mx-1.5 text-muted">
→
</span>
)}
{item.para !== undefined && <ins className="font-medium text-ink no-underline">{item.para}</ins>}
</p>
)}
</div>
</li>
);
}
/** Lista com a linha vertical que liga os marcadores. */
function Lista({ children }: { children: ReactNode }) {
return (
<ol className="relative before:absolute before:top-4 before:bottom-4 before:left-3.5 before:w-px before:-translate-x-1/2 before:bg-rule">
{children}
</ol>
);
}
/**
* Histórico de atividades: quem fez o quê, em quê e quando, com o "de → para"
* do que mudou. Agrupa por dia em seções que abrem e fecham (com a contagem e o
* intervalo de horas de cada dia) e marca cada item com ícone e cor por tipo.
*
* Os itens aparecem na ordem recebida (normalmente do mais recente para o mais antigo).
*/
export function LinhaDoTempoDeAtividades({
itens,
agruparPorDia = true,
gruposAbertos = 1,
formatarDia = formatarDiaPadrao,
formatarHora = formatarHoraPadrao,
formatarContagem = contarPadrao,
textoVazio = "Nenhuma atividade registrada.",
}: {
itens: ItemDaLinhaDoTempo[];
agruparPorDia?: boolean;
/** Quantos dias começam abertos, a partir do primeiro. */
gruposAbertos?: number;
/** Título de cada dia. Padrão: "qua., 21 de out. de 2026". */
formatarDia?: (data: Date) => string;
/** Hora de cada item. Padrão: "14:30". */
formatarHora?: (data: Date) => string;
/** Contagem no título do dia. Padrão: "3 atividades". */
formatarContagem?: (quantidade: number) => string;
textoVazio?: string;
}) {
const semMovimento = useSemMovimento();
const idBase = useId();
// Só guarda o que a pessoa mudou; o resto segue `gruposAbertos`.
const [alternados, setAlternados] = useState<Record<string, boolean>>({});
if (itens.length === 0) {
return <p className="rounded-xl border border-dashed border-rule px-4 py-8 text-center text-sm text-muted">{textoVazio}</p>;
}
if (!agruparPorDia) {
return (
<Lista>
{itens.map((item) => {
const d = paraData(item.quando);
return <Linha key={item.id} item={item} hora={`${formatarDia(d)}, ${formatarHora(d)}`} />;
})}
</Lista>
);
}
const grupos: { chave: string; data: Date; itens: ItemDaLinhaDoTempo[] }[] = [];
const porChave = new Map<string, (typeof grupos)[number]>();
for (const item of itens) {
const data = paraData(item.quando);
const chave = chaveDoDia(data);
const grupo = porChave.get(chave);
if (grupo) grupo.itens.push(item);
else {
const novo = { chave, data, itens: [item] };
porChave.set(chave, novo);
grupos.push(novo);
}
}
return (
<div className="flex flex-col divide-y divide-rule rounded-xl border border-rule bg-page">
{grupos.map((grupo, i) => {
const aberto = alternados[grupo.chave] ?? i < gruposAbertos;
const idPainel = `${idBase}-${grupo.chave}`;
const horas = grupo.itens.map((it) => paraData(it.quando).getTime()).filter((t) => !Number.isNaN(t));
const intervalo =
horas.length > 1
? `${formatarHora(new Date(Math.min(...horas)))}–${formatarHora(new Date(Math.max(...horas)))}`
: null;
return (
<section key={grupo.chave}>
<h3 className="m-0">
<button
type="button"
aria-expanded={aberto}
aria-controls={idPainel}
onClick={() => setAlternados((a) => ({ ...a, [grupo.chave]: !aberto }))}
className="flex min-h-12 w-full cursor-pointer items-center gap-3 px-4 py-2 text-left outline-accent hover:bg-surface focus-visible:outline-2 focus-visible:-outline-offset-2"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={`size-4 shrink-0 text-muted ${semMovimento ? "" : "transition-transform"} ${aberto ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 6 6 6-6 6" />
</svg>
<span className="text-sm font-semibold text-ink first-letter:uppercase">
{chaveDoDia(grupo.data) === "invalida" ? "—" : formatarDia(grupo.data)}
</span>
{intervalo && <span className="hidden text-xs text-muted tabular-nums sm:inline">{intervalo}</span>}
<span className="ml-auto shrink-0 text-xs text-muted">{formatarContagem(grupo.itens.length)}</span>
</button>
</h3>
{/* grid-rows 0fr → 1fr anima a altura sem medir; `inert` tira o conteúdo fechado do Tab. */}
<div
id={idPainel}
inert={!aberto}
className={`grid ${semMovimento ? "" : "transition-[grid-template-rows] duration-300 ease-out"} ${aberto ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`}
>
<div className="overflow-hidden">
<div className="px-4 pb-2">
<Lista>
{grupo.itens.map((item) => (
<Linha key={item.id} item={item} hora={formatarHora(paraData(item.quando))} />
))}
</Lista>
</div>
</div>
</div>
</section>
);
})}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { LinhaDoTempoDeAtividades, type ItemDaLinhaDoTempo } from "./LinhaDoTempoDeAtividades";
const icone = (d: string) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
const ICONES = {
criar: icone("M12 5v14M5 12h14"),
mover: icone("M7 7h12l-3-3M17 17H5l3 3"),
editar: icone("M4 20h4L19 9l-4-4L4 16v4z"),
comentar: icone("M21 12a8 8 0 0 1-11.6 7.1L4 21l1.9-5.4A8 8 0 1 1 21 12z"),
concluir: icone("m5 12.5 4.5 4.5L19 7.5"),
excluir: icone("M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"),
entrar: icone("M15 3h4v18h-4M10 17l5-5-5-5M15 12H3"),
};
const ANA = { nome: "Ana Souza" };
const BRUNO = { nome: "Bruno Lima" };
const CARLA = { nome: "Carla Mendes" };
const ITENS: ItemDaLinhaDoTempo[] = [
{ id: "1", quando: "2026-10-21T17:42:00", autor: ANA, acao: "concluiu", alvo: "Revisar contrato de fornecedor", icone: ICONES.concluir, tom: "sucesso" },
{
id: "2",
quando: "2026-10-21T16:05:00",
autor: BRUNO,
acao: "moveu",
alvo: "Campanha de Black Friday",
campo: "Etapa",
de: "A fazer",
para: "Em andamento",
icone: ICONES.mover,
tom: "info",
},
{
id: "3",
quando: "2026-10-21T11:20:00",
autor: CARLA,
acao: "alterou o prazo de",
alvo: "Relatório trimestral",
campo: "Prazo",
de: "24/10",
para: "31/10",
icone: ICONES.editar,
tom: "alerta",
},
{ id: "4", quando: "2026-10-21T09:02:00", autor: ANA, acao: "entrou na plataforma", icone: ICONES.entrar },
{ id: "5", quando: "2026-10-20T18:30:00", autor: BRUNO, acao: "comentou em", alvo: "Campanha de Black Friday", icone: ICONES.comentar, tom: "info" },
{ id: "6", quando: "2026-10-20T15:10:00", autor: CARLA, acao: "excluiu", alvo: "Rascunho antigo", icone: ICONES.excluir, tom: "erro" },
{ id: "7", quando: "2026-10-20T10:45:00", autor: ANA, acao: "criou", alvo: "Relatório trimestral", icone: ICONES.criar, tom: "sucesso" },
{ id: "8", quando: "2026-10-17T14:00:00", autor: BRUNO, acao: "criou", alvo: "Campanha de Black Friday", icone: ICONES.criar, tom: "sucesso" },
{ id: "9", quando: "2026-10-17T13:12:00", acao: "Backup automático concluído", tom: "neutro" },
];
export default defineStories({
args: { agruparPorDia: true, gruposAbertos: 1, comIcones: true, vazio: false },
controls: {
agruparPorDia: { type: "boolean" },
gruposAbertos: { type: "number", min: 0, max: 3 },
comIcones: { type: "boolean", label: "ícones (sem = ponto colorido)" },
vazio: { type: "boolean", label: "sem itens" },
},
render: (a) => (
<div className="w-full max-w-2xl">
<LinhaDoTempoDeAtividades
key={`${a.gruposAbertos}`}
itens={a.vazio ? [] : a.comIcones ? ITENS : ITENS.map((i) => ({ ...i, icone: undefined }))}
agruparPorDia={a.agruparPorDia}
gruposAbertos={a.gruposAbertos}
/>
</div>
),
stories: [
{ name: "Agrupada por dia" },
{ name: "Todos os dias abertos", args: { gruposAbertos: 3 } },
{ name: "Sem agrupar", args: { agruparPorDia: false } },
{ name: "Pontos coloridos", args: { comIcones: false, gruposAbertos: 2 } },
{ name: "Vazia", args: { vazio: true } },
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Componentes da biblioteca (diretos e indiretos)
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}