Component
Activity timeline
<DomActivityTimeline>Projects application-owned resource rows into an accessible ordered activity trail without owning loading, ordering, querying, or persistence.
Resource API
Cursor-paginated activity store
A Pinia resource store maps scope, scopeId, type, cursor, and limit onto a GET endpoint. It owns refresh, stale-request protection, filtering, pagination, deduplication, and errors; the timeline receives only rows and loading state.
Release activity
0 server records
No activity yet
Events will appear here as this resource changes.
import { computed, reactive } from 'vue';
import { defineStore } from 'pinia';
import { createActivityTimelineMockApi } from './activityTimelineMockApi.js';
export const useActivityTimelineDemoStore = defineActivityTimelineStore('activity-timeline-demo', {
scope: 'release',
scopeId: 'release_208',
baseUrl: '/api/activity',
api: createActivityTimelineMockApi(),
});
/**
* Creates a configured Pinia activity resource store.
*
* @param {string} storeId Unique Pinia store id.
* @param {Record<string, unknown>} options Resource and API configuration.
* @returns {Function} Pinia use-store function.
*/
export function defineActivityTimelineStore(storeId = 'activity-timeline', options = {}) {
return defineStore(storeId, function createConfiguredStore() {
return createActivityTimelineStore(options);
});
}
/**
* Creates the setup-store contract used by the server-backed example.
*
* @param {Record<string, unknown>} options Resource and API configuration.
* @returns {Record<string, unknown>} Store state, getters, and actions.
*/
function createActivityTimelineStore(options) {
const api = options.api || createActivityTimelineRestApi(options);
const state = reactive({
rows: [],
type: '',
loading: false,
loadingMore: false,
nextCursor: null,
hasNextPage: false,
total: 0,
error: '',
lastRequestUrl: '',
requestToken: 0,
});
const rows = computed(() => state.rows);
const type = computed(() => state.type);
const loading = computed(() => state.loading);
const loadingMore = computed(() => state.loadingMore);
const hasNextPage = computed(() => state.hasNextPage);
const total = computed(() => state.total);
const error = computed(() => state.error);
const lastRequestUrl = computed(() => state.lastRequestUrl);
/**
* Loads the first or next cursor page and ignores stale responses.
*
* @param {{ append?: boolean }} requestOptions Loading mode.
* @returns {Promise<void>} Resolves when the request settles.
*/
async function load(requestOptions = {}) {
const append = Boolean(requestOptions.append);
if (append && (!state.hasNextPage || state.loadingMore)) return;
const token = state.requestToken + 1;
state.requestToken = token;
state.error = '';
state.loading = !append;
state.loadingMore = append;
const request = {
scope: options.scope,
scopeId: options.scopeId,
type: state.type || undefined,
cursor: append ? state.nextCursor : undefined,
limit: 4,
};
state.lastRequestUrl = requestUrl(options.baseUrl || '/api/activity', request);
try {
const response = await api.listActivity(request);
if (token !== state.requestToken) return;
state.rows = append ? mergeRows(state.rows, response.data) : response.data;
state.nextCursor = response.pageInfo?.nextCursor || null;
state.hasNextPage = Boolean(response.pageInfo?.hasNextPage);
state.total = Number(response.meta?.total) || state.rows.length;
} catch (error) {
if (token === state.requestToken) state.error = error instanceof Error ? error.message : 'Activity could not be loaded.';
} finally {
if (token === state.requestToken) {
state.loading = false;
state.loadingMore = false;
}
}
}
/**
* Refreshes the first page while retaining visible rows.
*
* @returns {Promise<void>} Resolves after the refresh.
*/
function refresh() {
return load({ append: false });
}
/**
* Appends the next server cursor page.
*
* @returns {Promise<void>} Resolves after pagination.
*/
function loadMore() {
return load({ append: true });
}
/**
* Updates the type query and refreshes from the first cursor page.
*
* @param {string} value Requested activity type.
* @returns {Promise<void>} Resolves after the filtered refresh.
*/
function setType(value) {
state.type = String(value || '');
return refresh();
}
return {
rows,
type,
loading,
loadingMore,
hasNextPage,
total,
error,
lastRequestUrl,
refresh,
loadMore,
setType,
};
}
/**
* Creates a fetch-based adapter for the documented activity endpoint.
*
* @param {Record<string, unknown>} options Resource and URL configuration.
* @returns {{ listActivity: Function }} Activity API adapter.
*/
function createActivityTimelineRestApi(options) {
return {
/**
* Fetches one activity page from the configured resource endpoint.
*
* @param {Record<string, unknown>} request Activity query.
* @returns {Promise<Record<string, unknown>>} Activity response.
*/
async listActivity(request) {
const response = await fetch(requestUrl(options.baseUrl || '/api/activity', request));
if (!response.ok) throw new Error(`Activity request failed with ${response.status}.`);
return response.json();
},
};
}
/**
* Serializes the resource query into the documented endpoint shape.
*
* @param {string} baseUrl Activity endpoint.
* @param {Record<string, unknown>} request Activity query.
* @returns {string} Request URL.
*/
function requestUrl(baseUrl, request) {
const params = new URLSearchParams();
params.set('scope', String(request.scope));
params.set('scopeId', String(request.scopeId));
if (request.type) params.set('type', String(request.type));
if (request.cursor) params.set('cursor', String(request.cursor));
params.set('limit', String(request.limit || 20));
return `${baseUrl}?${params.toString()}`;
}
/**
* Merges cursor pages by stable row id.
*
* @param {Array<Record<string, unknown>>} current Existing rows.
* @param {Array<Record<string, unknown>>} incoming Next cursor page.
* @returns {Array<Record<string, unknown>>} Deduplicated rows.
*/
function mergeRows(current, incoming) {
const rowsById = new Map();
for (const row of [...current, ...(Array.isArray(incoming) ? incoming : [])]) rowsById.set(row.id, row);
return [...rowsById.values()];
}
const activityRows = [
{ id: 'evt_110', type: 'deployment', action: 'Production rollout completed', detail: 'Release 2026.08.4 reached every workspace.', createdAt: '2026-08-12T10:42:00.000Z', actor: 'Deployment worker', status: 'completed', tone: 'success' },
{ id: 'evt_109', type: 'approval', action: 'Release approved', detail: 'The evidence bundle and checksum were accepted.', createdAt: '2026-08-12T10:16:00.000Z', actor: 'Maya Chen', status: 'approved', tone: 'primary' },
{ id: 'evt_108', type: 'comment', action: 'Review note added', detail: 'Requested a final check of regional redirect rules.', createdAt: '2026-08-12T09:54:00.000Z', actor: 'Owen Reed', status: 'noted', tone: 'neutral' },
{ id: 'evt_107', type: 'automation', action: 'Evidence snapshot retained', detail: 'Health checks, policy results, and asset hashes were stored.', createdAt: '2026-08-12T09:31:00.000Z', actor: 'Release worker', status: 'retained', tone: 'neutral' },
{ id: 'evt_106', type: 'approval', action: 'Accessibility review passed', detail: 'Keyboard and screen-reader checks passed on the release candidate.', createdAt: '2026-08-11T16:48:00.000Z', actor: 'Priya Shah', status: 'approved', tone: 'success' },
{ id: 'evt_105', type: 'deployment', action: 'Preview rollout completed', detail: 'The candidate was deployed to the preview environment.', createdAt: '2026-08-11T15:22:00.000Z', actor: 'Deployment worker', status: 'completed', tone: 'success' },
{ id: 'evt_104', type: 'comment', action: 'Release notes updated', detail: 'Added migration guidance for API consumers.', createdAt: '2026-08-11T14:05:00.000Z', actor: 'Nadia Ali', status: 'edited', tone: 'info' },
{ id: 'evt_103', type: 'automation', action: 'Candidate created', detail: 'Build artifacts and dependency evidence were assembled.', createdAt: '2026-08-11T12:37:00.000Z', actor: 'Release worker', status: 'created', tone: 'neutral' },
];
/**
* Creates an isolated API adapter matching a cursor-paginated activity endpoint.
*
* @returns {{ listActivity: Function }} Activity resource API.
*/
export function createActivityTimelineMockApi() {
/**
* Lists one page of resource activity in server-defined chronological order.
*
* @param {{ scope: string, scopeId: string, type?: string, cursor?: string, limit?: number }} request Activity query.
* @returns {Promise<Record<string, unknown>>} Cursor-paginated resource response.
*/
async function listActivity(request) {
await wait(360);
const filtered = request.type
? activityRows.filter(createTypeMatcher(request.type))
: activityRows;
const offset = decodeCursor(request.cursor);
const limit = Math.min(20, Math.max(1, Number(request.limit) || 4));
const data = filtered.slice(offset, offset + limit).map(cloneRow);
const nextOffset = offset + data.length;
return {
data,
pageInfo: {
strategy: 'cursor',
nextCursor: nextOffset < filtered.length ? encodeCursor(nextOffset) : null,
hasNextPage: nextOffset < filtered.length,
},
meta: {
scope: request.scope,
scopeId: request.scopeId,
type: request.type || null,
total: filtered.length,
requestedAt: new Date().toISOString(),
},
};
}
return { listActivity };
}
/**
* Creates a row predicate for one activity type.
*
* @param {string} type Requested activity type.
* @returns {(row: Record<string, unknown>) => boolean} Row predicate.
*/
function createTypeMatcher(type) {
/**
* Reports whether one row matches the requested type.
*
* @param {Record<string, unknown>} row Activity row.
* @returns {boolean} True when types match.
*/
return function matchesType(row) {
return row.type === type;
};
}
/**
* Clones one API row so the mock database remains server-owned.
*
* @param {Record<string, unknown>} row Activity row.
* @returns {Record<string, unknown>} Cloned activity row.
*/
function cloneRow(row) {
return { ...row };
}
/**
* Encodes an offset as an opaque demo cursor.
*
* @param {number} offset Next row offset.
* @returns {string} Opaque cursor.
*/
function encodeCursor(offset) {
return `cursor_${offset}`;
}
/**
* Decodes an opaque demo cursor.
*
* @param {string} cursor Cursor supplied by the previous response.
* @returns {number} Starting row offset.
*/
function decodeCursor(cursor) {
const offset = Number(String(cursor || '').replace('cursor_', ''));
return Number.isFinite(offset) && offset > 0 ? offset : 0;
}
/**
* Waits briefly so loading and pagination states are visible in the example.
*
* @param {number} duration Delay in milliseconds.
* @returns {Promise<void>} Resolves after the delay.
*/
function wait(duration) {
return new Promise(function scheduleWait(resolve) {
setTimeout(resolve, duration);
});
}
Shared resources
Timeline and grid from the same rows
Switch between timeline and grid projections without translating the application's release-activity rows. Both components use the same stable id and logical field names.
Release activity
One application-owned collection, two projections.
- 12 Aug, 11:42Deployment worker
Production rollout completed
CompletedRelease 2026.08.4 reached every production workspace after the final health check.
- 12 Aug, 11:16Maya Chen
Approval recorded
ApprovedMaya approved the exact release checksum and retained the policy evidence.
- 12 Aug, 10:54Owen Reed
Readiness warning resolved
ResolvedThe missing rollback receipt was attached and the release returned to a ready state.
- 12 Aug, 10:31Priya Shah
Release candidate created
CreatedA candidate was created from commit 93ab7a with the production configuration snapshot.
Both views receive the same rows. The timeline reads logical fields directly and preserves the order supplied by the application.
Field mapping
Audit fields and application actions
Map existing action, detail, principal, outcome, and severity fields by name. The actions slot receives the untouched source row so receipt inspection stays in the application.
- Today, 11:08Maya Chen
Access policy published
PublishedThe reviewed policy revision became active for production workspaces.
- Today, 10:41Owen Reed
Exception requested
Review requiredOwen requested temporary billing-export access with a 24-hour expiry.
- Today, 10:39Policy worker
Evidence snapshot retained
RetainedRole assignments, policy checks, and the request checksum were stored together.
Playground
Presentation and states
Activity timeline playground
Adjust layout, density, field mappings, labels, loading, and empty-state presentation without changing the resource rows.
- 10:42Deploy worker
Deployment complete
CompletedAll health checks passed.
- 10:16Maya Chen
Approval recorded
ApprovedThe release checksum was approved.
- 09:31Priya Shah
Candidate created
CreatedRelease evidence was assembled.
<script setup>
import { reactive } from 'vue';
import { DomActivityTimeline } from '@getdom/studio/vue';
const data = reactive({
"rows": [
{
"id": 3,
"title": "Deployment complete",
"description": "All health checks passed.",
"createdAt": "10:42",
"actor": "Deploy worker",
"status": "completed",
"tone": "success"
},
{
"id": 2,
"title": "Approval recorded",
"description": "The release checksum was approved.",
"createdAt": "10:16",
"actor": "Maya Chen",
"status": "approved",
"tone": "primary"
},
{
"id": 1,
"title": "Candidate created",
"description": "Release evidence was assembled.",
"createdAt": "09:31",
"actor": "Priya Shah",
"status": "created",
"tone": "neutral"
}
],
"rowKey": "id",
"titleKey": "title",
"descriptionKey": "description",
"timestampKey": "createdAt",
"metaKey": "actor",
"statusKey": "status",
"toneKey": "tone",
"formatTimestamp": null,
"layout": "stacked",
"density": "comfortable",
"loading": false,
"loadingRows": 4,
"emptyTitle": "No activity yet",
"emptyDescription": "Events will appear here as this resource changes.",
"ariaLabel": "Activity timeline"
});
</script>
<template>
<DomActivityTimeline
v-bind="data"
/>
</template>Architecture
Controlled resource projection
DomActivityTimeline accepts the same application-owned rows and string-or-function rowKey contract as other DOM Studio data components. Field props are logical names, so an ActiveRecord-style serialized row can be rendered directly.
The component preserves supplied order. The application or resource-query layer owns chronology, cursor pagination, refresh, cancellation, permissions, and persistence. The documented Pinia store is an integration example rather than part of the component contract. Passing loading only changes accessible presentation; it never triggers a request.
The public activityTimelineProps export supports wrapper components and Studio tooling. Generated declarations also expose ResourceFieldResolver, ResourceKeyResolver, ActivityTimelineRecord, and ActivityTimelineSlotPayload.
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
rows | array | Array<unknown> | [] | Application-owned resource rows in the exact order they should appear. |
rowKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => string | number) | 'id' | Logical field name or function used to identify each resource row. |
Fields
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
titleKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => unknown) | 'title' | Logical field name or function used for the primary activity label. |
descriptionKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => unknown) | 'description' | Logical field name or function used for supporting activity content. |
timestampKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => unknown) | 'createdAt' | Logical field name or function used for the activity timestamp. |
metaKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => unknown) | 'actor' | Logical field name or function used for actor, source, or secondary metadata. |
statusKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => unknown) | 'status' | Logical field name or function used for an optional status pill. |
toneKeyts | string | function | string | ((row: Record<string, unknown>, index: number) => unknown) | 'tone' | Logical field name or function resolving to a semantic marker and status tone. |
formatTimestamp | function | Function | — | Optional formatter receiving the raw timestamp, row, and source index. |
Labels
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
emptyTitle | string | string | 'No activity yet' | Heading shown when the supplied resource collection is empty. |
emptyDescription | string | string | 'Events will appear here as this resource changes.' | Supporting copy shown for an empty activity collection. |
ariaLabel | string | string | 'Activity timeline' | Accessible label for the ordered activity list. |
Layout
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
layout | 'stacked' | 'split' | string | 'stacked' | Place timestamp metadata above the content or in a separate desktop column. |
density | 'compact' | 'comfortable' | string | 'comfortable' | Spacing and marker scale for the timeline. |
State
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
loading | boolean | boolean | false | Expose busy state and show timeline-shaped skeleton rows when no rows are available. |
loadingRows | number | number | 4 | Number of placeholder rows shown during an initial load. |
Auto-generated from Activity timeline.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #item | ActivityTimelineSlotPayload | Replace the default title, description, status, and metadata content while retaining timeline structure. |
| #marker | ActivityTimelineSlotPayload | Replace the semantic-tone marker for one activity row. |
| #opposite | ActivityTimelineSlotPayload | Replace timestamp and secondary metadata, including the separate column used by split layout. |
| #actions | ActivityTimelineSlotPayload | Append application-owned controls to the default activity content. |
| #loading | { count } | Replace the initial loading placeholders. |
| #empty | — | Replace the empty collection state. |