Timeline
MoleculeChronological activity feed with sticky day headings, drawn with the same DOM as kui-react. Grouping uses the day in `timeZone`, not the raw ISO date: an event at 23:50 UTC otherwise lands under the wrong heading for anyone east or west of the server. A server-rendered page formats in the server's zone unless `timeZone` is given, so pass the viewer's zone when you know it. `title`, `body` and `meta` are escaped text (kui-react takes a node); `titlePartial` / `bodyPartial` / `metaPartial` include a partial instead. `icon` is a Font Awesome class. An item with an invalid date is skipped.
- Tuesday, August 25, 2026
-
Reply received Replied“Interesting — can you send details?”
-
Email sentRe: shipping integration
- Monday, August 24, 2026
-
Call completed4m 12s · reached the right person
-
Enriched18 employees · logistics software
<%- include('modules/ui/Timeline', {
timeZone: viewerZone,
items: activities.map((a) => ({
id: a.activityId,
at: a.occurredAt,
title: a.type,
body: a.summary,
icon: 'fa-solid fa-envelope',
tone: 'info',
})),
}) %>
No activity on this company yet.
-
Bounced
-
Queued
<%- include('modules/ui/Timeline', { items: [], emptyMessage: 'No activity yet.' }) %>
<%- include('modules/ui/Timeline', { items: items, groupByDay: false }) %>
- Mittwoch, 26. August 2026
-
Nächster Tag in Auckland Auto
- Dienstag, 25. August 2026
-
Spät in UTCGleicher Tag wie der Eintrag darunter
-
Kurz davor
<%- include('modules/ui/Timeline', { timeZone: 'Pacific/Auckland', locale: 'de-DE', items: items }) %>
<%
// Timeline — a chronological activity feed with sticky day headings. Same DOM and classes as kui-react
// modules/ui/Timeline.tsx.
//
// Locals:
// items — [{ id, at, title, body?, icon?, tone?, meta? }], newest first after sorting
// id — stable identity (kept for parity; the markup does not carry it)
// at — Date, ISO string or epoch ms (anything `new Date()` accepts). An item whose date is invalid
// is skipped (kui-react throws a RangeError there)
// title — short label, escaped text
// body — detail text, escaped
// meta — text at the right of the header row, escaped
// icon — Font Awesome class of the marker, e.g. 'fa-solid fa-envelope'
// tone — 'default' (default) | 'success' | 'warning' | 'error' | 'info'
// titlePartial / bodyPartial / metaPartial — absolute path of a partial included instead of the text
// (React takes a node there, EJS takes a partial); its locals are titleLocals /
// bodyLocals / metaLocals
// timeZone — IANA zone for the day grouping and the time (default: the server's own)
// locale — BCP 47 tag for the date and time formatting
// groupByDay — day headings between the items (default true)
// emptyMessage — shown when there are no items (default 'Nothing here yet.')
// className — extra classes on the root
//
// Grouping is by the day in `timeZone`, not by the raw ISO date: 23:50 UTC is another day for someone east
// of Greenwich. Unlike kui-react, which formats in the viewer's own zone, a server-rendered page formats in the
// server's zone unless `timeZone` is given: pass the viewer's zone when you know it.
var _items = Array.isArray(locals.items) ? locals.items : [];
var _timeZone = locals.timeZone || undefined;
var _locale = locals.locale || undefined;
var _groupByDay = locals.groupByDay !== false;
var _emptyMessage = locals.emptyMessage != null ? locals.emptyMessage : 'Nothing here yet.';
var _className = locals.className || '';
var TONE_CLASS = {
'default': 'bg-surface-sunken text-text-secondary',
success: 'bg-success-subtle text-success-fg',
warning: 'bg-warning-subtle text-warning-fg',
error: 'bg-error-subtle text-error-fg',
info: 'bg-info-subtle text-info-fg'
};
var _rendered = [];
if (_items.length > 0) {
var dayFormat = new Intl.DateTimeFormat(_locale, { timeZone: _timeZone, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
// hourCycle rather than hour12: false, which renders midnight as hour "24" on some ICU builds.
var timeFormat = new Intl.DateTimeFormat(_locale, { timeZone: _timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
var sorted = _items
.map(function (item) { return { item: item || {}, at: (item && item.at instanceof Date) ? item.at : new Date(item ? item.at : NaN) }; })
.filter(function (e) { return !isNaN(e.at.getTime()); })
.sort(function (a, b) { return b.at.getTime() - a.at.getTime(); });
_rendered = sorted.map(function (entry, index) {
var day = dayFormat.format(entry.at);
var previousDay = index === 0 ? null : dayFormat.format(sorted[index - 1].at);
return { item: entry.item, at: entry.at, day: day, time: timeFormat.format(entry.at), showDay: _groupByDay && day !== previousDay };
});
}
%>
<% if (_rendered.length === 0) { %>
<p class="py-8 text-center text-sm text-text-secondary<%= _className ? ' ' + _className : '' %>"><%= _emptyMessage %></p>
<% } else { %>
<ol class="flex flex-col<%= _className ? ' ' + _className : '' %>">
<% _rendered.forEach(function (r) { var item = r.item; %>
<% if (r.showDay) { %><li class="sticky top-0 z-10 bg-surface-base/95 py-2 text-xs font-medium uppercase tracking-wide text-text-secondary backdrop-blur"><%= r.day %></li><% } %>
<li class="relative flex gap-3 pb-5 pl-1">
<span aria-hidden="true" class="absolute left-[1.0625rem] top-8 bottom-0 w-px bg-border last:hidden"></span>
<span aria-hidden="true" class="relative z-[1] flex h-[2.125rem] w-[2.125rem] shrink-0 items-center justify-center rounded-full <%= TONE_CLASS[item.tone] || TONE_CLASS['default'] %>"><% if (item.icon) { %><i class="<%= item.icon %>" aria-hidden="true"></i><% } %></span>
<div class="min-w-0 flex-1 pt-1.5">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-sm text-text-primary"><% if (item.titlePartial) { %><%- include(item.titlePartial, item.titleLocals || {}) %><% } else { %><%= item.title != null ? item.title : '' %><% } %></span>
<time datetime="<%= r.at.toISOString() %>" class="text-xs text-text-secondary tabular-nums"><%= r.time %></time>
<% if (item.metaPartial) { %><span class="ml-auto"><%- include(item.metaPartial, item.metaLocals || {}) %></span><% } else if (item.meta) { %><span class="ml-auto"><%= item.meta %></span><% } %>
</div>
<% if (item.bodyPartial) { %><div class="mt-1 text-sm text-text-secondary"><%- include(item.bodyPartial, item.bodyLocals || {}) %></div><% } else if (item.body) { %><div class="mt-1 text-sm text-text-secondary"><%= item.body %></div><% } %>
</div>
</li>
<% }); %>
</ol>
<% } %>