diff --git a/dashboards/admin-web/src/__tests__/timeline-view.test.ts b/dashboards/admin-web/src/__tests__/timeline-view.test.ts
new file mode 100644
index 00000000..b6086149
--- /dev/null
+++ b/dashboards/admin-web/src/__tests__/timeline-view.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildTimelineGroups,
+ getProductLabel,
+ getTimelineStats,
+ type TimelineItem,
+} from '@/lib/timeline-view';
+
+const baseItems: TimelineItem[] = [
+ {
+ itemId: 'timeline_1',
+ occurredAt: '2026-04-04T10:02:00.000Z',
+ eventName: 'artifact.created',
+ productId: 'notelett',
+ title: 'Note created',
+ summary: 'Latest note',
+ artifactRefs: ['art_note_1'],
+ relatedEventIds: ['evt_2'],
+ actorType: 'user',
+ visibility: 'private',
+ correlationId: 'corr_shared',
+ },
+ {
+ itemId: 'timeline_2',
+ occurredAt: '2026-04-04T10:01:00.000Z',
+ eventName: 'capture.transcript.created',
+ productId: 'lysnrai',
+ title: 'Transcript captured',
+ summary: null,
+ artifactRefs: ['art_transcript_1'],
+ relatedEventIds: [],
+ actorType: 'user',
+ visibility: 'private',
+ correlationId: 'corr_shared',
+ },
+ {
+ itemId: 'timeline_3',
+ occurredAt: '2026-04-04T09:59:00.000Z',
+ eventName: 'memory.entry.created',
+ productId: 'mindlyst',
+ title: 'Memory candidate proposed',
+ summary: null,
+ artifactRefs: [],
+ relatedEventIds: [],
+ actorType: 'agent',
+ visibility: 'private',
+ correlationId: null,
+ },
+];
+
+describe('timeline view helpers', () => {
+ it('groups correlation-linked items together and sorts them newest-first', () => {
+ const groups = buildTimelineGroups(baseItems);
+
+ expect(groups).toHaveLength(2);
+ expect(groups[0]).toMatchObject({
+ correlationId: 'corr_shared',
+ latestOccurredAt: '2026-04-04T10:02:00.000Z',
+ productIds: ['notelett', 'lysnrai'],
+ });
+ expect(groups[0]?.items.map(item => item.itemId)).toEqual(['timeline_1', 'timeline_2']);
+ });
+
+ it('computes timeline stats over grouped and ungrouped items', () => {
+ expect(getTimelineStats(baseItems)).toEqual({
+ uniqueProducts: 3,
+ uniqueCorrelations: 1,
+ withArtifacts: 2,
+ groupedChains: 1,
+ });
+ });
+
+ it('returns readable labels for known products', () => {
+ expect(getProductLabel('lysnrai')).toBe('LysnrAI');
+ expect(getProductLabel('custom-tool')).toBe('custom-tool');
+ });
+});
diff --git a/dashboards/admin-web/src/__tests__/timeline.test.ts b/dashboards/admin-web/src/__tests__/timeline.test.ts
index d367db73..a4bb55a9 100644
--- a/dashboards/admin-web/src/__tests__/timeline.test.ts
+++ b/dashboards/admin-web/src/__tests__/timeline.test.ts
@@ -68,6 +68,29 @@ describe('GET /api/timeline', () => {
);
});
+ it('allows an explicit product override for internal timeline review', async () => {
+ mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
+ const fetchMock = vi.fn().mockResolvedValue({
+ status: 200,
+ json: async () => ({ items: [], count: 0 }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const res = await callTimeline('?productId=flowmonk&limit=10');
+
+ expect(res.status).toBe(200);
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'http://localhost:4003/api/timeline?limit=10',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ 'x-user-id': 'usr_admin',
+ 'x-product-id': 'flowmonk',
+ }),
+ })
+ );
+ });
+
it('returns 502 when platform-service proxying fails', async () => {
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
diff --git a/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx b/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx
index 3e0ed529..4720fec2 100644
--- a/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx
+++ b/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Activity, Filter, Link2, RefreshCw, Search } from 'lucide-react';
+import { Activity, Filter, GitBranch, Link2, RefreshCw, Search } from 'lucide-react';
import { createProxyFetch } from '@/lib/proxy-fetch';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -14,20 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-
-type TimelineItem = {
- itemId: string;
- occurredAt: string;
- eventName: string;
- productId: string;
- title: string;
- summary?: string | null;
- artifactRefs: string[];
- relatedEventIds: string[];
- actorType: 'user' | 'agent' | 'system' | 'device';
- visibility: 'private' | 'org' | 'shared' | 'local-only';
- correlationId?: string | null;
-};
+import {
+ buildTimelineGroups,
+ getProductLabel,
+ getTimelineStats,
+ TIMELINE_PRODUCT_OPTIONS,
+ type TimelineItem,
+} from '@/lib/timeline-view';
const apiFetch = createProxyFetch('/api/timeline');
@@ -60,6 +53,7 @@ export default function TimelinePage() {
const [correlationId, setCorrelationId] = useState('');
const [eventName, setEventName] = useState('all');
const [artifactId, setArtifactId] = useState('');
+ const [productId, setProductId] = useState('current');
const [limit, setLimit] = useState('50');
const loadData = useCallback(async () => {
@@ -69,32 +63,21 @@ export default function TimelinePage() {
if (correlationId.trim()) params.set('correlationId', correlationId.trim());
if (artifactId.trim()) params.set('artifactId', artifactId.trim());
if (eventName !== 'all') params.set('eventName', eventName);
+ if (productId !== 'current') params.set('productId', productId);
params.set('limit', limit);
const query = params.toString();
const data = await apiFetch(query ? `?${query}` : '');
setItems(Array.isArray(data?.items) ? data.items : []);
setLoading(false);
- }, [artifactId, correlationId, eventName, limit, userId]);
+ }, [artifactId, correlationId, eventName, limit, productId, userId]);
useEffect(() => {
void loadData();
}, [loadData]);
- const stats = useMemo(() => {
- const uniqueProducts = new Set(items.map(item => item.productId)).size;
- const uniqueCorrelations = new Set(
- items
- .map(item => item.correlationId)
- .filter((value): value is string => typeof value === 'string' && value.length > 0)
- ).size;
- const withArtifacts = items.filter(item => item.artifactRefs.length > 0).length;
- return {
- uniqueProducts,
- uniqueCorrelations,
- withArtifacts,
- };
- }, [items]);
+ const stats = useMemo(() => getTimelineStats(items), [items]);
+ const groups = useMemo(() => buildTimelineGroups(items), [items]);
return (
@@ -102,7 +85,8 @@ export default function TimelinePage() {
Ecosystem Timeline
- Unified cross-product activity stream from the canonical timeline service
+ Canonical timeline stream for the selected product scope, with correlation-chain
+ grouping
void loadData()}>
@@ -129,12 +113,10 @@ export default function TimelinePage() {
-
- Correlations
-
+ Chains
- {stats.uniqueCorrelations}
+ {stats.groupedChains}
@@ -156,7 +138,7 @@ export default function TimelinePage() {
Filters
-
+
setArtifactId(event.target.value)}
/>
+
+
+
+
+
+ {TIMELINE_PRODUCT_OPTIONS.map(option => (
+
+ {option.label}
+
+ ))}
+
+
@@ -212,67 +206,100 @@ export default function TimelinePage() {
) : (
- {items.map(item => (
-
-
-
+ {groups.map(group => (
+
+
+
-
{item.title}
-
- {item.actorType}
+
+
+ {group.items.length > 1 ? `${group.items.length} events` : 'single event'}
- {item.productId}
- {item.eventName}
+ {group.productIds.map(product => (
+
+ {getProductLabel(product)}
+
+ ))}
+
+
+ {group.correlationId
+ ? `Correlation ${group.correlationId}`
+ : 'Standalone item without correlation ID'}
- {item.summary && (
-
{item.summary}
- )}
- {formatDate(item.occurredAt)}
+ Latest activity {formatDate(group.latestOccurredAt)}
-
-
-
- Correlation
-
-
{item.correlationId || '—'}
-
-
-
- Artifact Refs
-
-
- {item.artifactRefs.length === 0 ? (
-
None
- ) : (
- item.artifactRefs.map(ref => (
-
- {ref}
+
+ {group.items.map(item => (
+
+
+
+
+
{item.title}
+
+ {item.actorType}
+
+ {getProductLabel(item.productId)}
+ {item.eventName}
- ))
- )}
-
-
-
-
- Related Events
-
-
- {item.relatedEventIds.length === 0 ? (
-
None
- ) : (
- item.relatedEventIds.map(ref => (
-
-
- {ref}
+ {item.summary && (
+
{item.summary}
+ )}
+
+
+ {formatDate(item.occurredAt)}
+
+
+
+
+
+
+ Correlation
- ))
- )}
+
{item.correlationId || '—'}
+
+
+
+ Artifact Refs
+
+
+ {item.artifactRefs.length === 0 ? (
+
None
+ ) : (
+ item.artifactRefs.map(ref => (
+
+ {ref}
+
+ ))
+ )}
+
+
+
+
+ Related Events
+
+
+ {item.relatedEventIds.length === 0 ? (
+
None
+ ) : (
+ item.relatedEventIds.map(ref => (
+
+
+ {ref}
+
+ ))
+ )}
+
+
+
-
+ ))}
))}
diff --git a/dashboards/admin-web/src/app/api/timeline/route.ts b/dashboards/admin-web/src/app/api/timeline/route.ts
index cedb4fe0..bdfead2e 100644
--- a/dashboards/admin-web/src/app/api/timeline/route.ts
+++ b/dashboards/admin-web/src/app/api/timeline/route.ts
@@ -13,12 +13,18 @@ export async function GET(req: NextRequest) {
const caller = await getCurrentUserFromRequest(req);
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const qs = new URL(req.url).search;
+ const url = new URL(req.url);
+ const productOverride = url.searchParams.get('productId')?.trim();
+ if (productOverride) {
+ url.searchParams.delete('productId');
+ }
+ const qs = url.search;
const headers: Record
= {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
- 'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
+ 'x-product-id':
+ productOverride || req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const res = await fetch(`${PLATFORM_URL}/api/timeline${qs}`, {
diff --git a/dashboards/admin-web/src/lib/timeline-view.ts b/dashboards/admin-web/src/lib/timeline-view.ts
new file mode 100644
index 00000000..52f679d4
--- /dev/null
+++ b/dashboards/admin-web/src/lib/timeline-view.ts
@@ -0,0 +1,85 @@
+export type TimelineItem = {
+ itemId: string;
+ occurredAt: string;
+ eventName: string;
+ productId: string;
+ title: string;
+ summary?: string | null;
+ artifactRefs: string[];
+ relatedEventIds: string[];
+ actorType: 'user' | 'agent' | 'system' | 'device';
+ visibility: 'private' | 'org' | 'shared' | 'local-only';
+ correlationId?: string | null;
+};
+
+export type TimelineGroup = {
+ groupId: string;
+ correlationId: string | null;
+ items: TimelineItem[];
+ productIds: string[];
+ latestOccurredAt: string;
+};
+
+export const TIMELINE_PRODUCT_OPTIONS = [
+ { value: 'current', label: 'Current Admin Product' },
+ { value: 'lysnrai', label: 'LysnrAI' },
+ { value: 'notelett', label: 'NoteLett' },
+ { value: 'mindlyst', label: 'MindLyst' },
+ { value: 'flowmonk', label: 'FlowMonk' },
+ { value: 'chronomind', label: 'ChronoMind' },
+ { value: 'efforise', label: 'EffoRise' },
+ { value: 'actiontrail', label: 'ActionTrail' },
+ { value: 'cowork', label: 'Cowork' },
+] as const;
+
+function compareDescendingIso(a: string, b: string) {
+ return new Date(b).getTime() - new Date(a).getTime();
+}
+
+export function getTimelineStats(items: TimelineItem[]) {
+ return {
+ uniqueProducts: new Set(items.map(item => item.productId)).size,
+ uniqueCorrelations: new Set(
+ items
+ .map(item => item.correlationId)
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
+ ).size,
+ withArtifacts: items.filter(item => item.artifactRefs.length > 0).length,
+ groupedChains: buildTimelineGroups(items).filter(group => group.items.length > 1).length,
+ };
+}
+
+export function buildTimelineGroups(items: TimelineItem[]): TimelineGroup[] {
+ const groups = new Map();
+
+ for (const item of items) {
+ const groupKey = item.correlationId?.trim()
+ ? `corr:${item.correlationId}`
+ : `item:${item.itemId}`;
+ const bucket = groups.get(groupKey);
+ if (bucket) {
+ bucket.push(item);
+ } else {
+ groups.set(groupKey, [item]);
+ }
+ }
+
+ return [...groups.entries()]
+ .map(([groupId, groupItems]) => {
+ const itemsSorted = [...groupItems].sort((left, right) =>
+ compareDescendingIso(left.occurredAt, right.occurredAt)
+ );
+ return {
+ groupId,
+ correlationId: itemsSorted[0]?.correlationId?.trim() || null,
+ items: itemsSorted,
+ productIds: [...new Set(itemsSorted.map(item => item.productId))],
+ latestOccurredAt: itemsSorted[0]?.occurredAt ?? new Date(0).toISOString(),
+ };
+ })
+ .sort((left, right) => compareDescendingIso(left.latestOccurredAt, right.latestOccurredAt));
+}
+
+export function getProductLabel(productId: string) {
+ return TIMELINE_PRODUCT_OPTIONS.find(option => option.value === productId)?.label ?? productId;
+}
diff --git a/docs/ecosystem/ECOSYSTEM_IMPLEMENTATION_TRACKER.md b/docs/ecosystem/ECOSYSTEM_IMPLEMENTATION_TRACKER.md
index f788c59c..988a4c57 100644
--- a/docs/ecosystem/ECOSYSTEM_IMPLEMENTATION_TRACKER.md
+++ b/docs/ecosystem/ECOSYSTEM_IMPLEMENTATION_TRACKER.md
@@ -211,6 +211,7 @@ These should be resolved before claiming the ecosystem docs are fully implementa
Status note:
- admin-web now exposes `/timeline` over the shared platform timeline API
- the first hosted internal timeline UI supports user, artifact, correlation, and event-name filtering
+ - the timeline hardening pass adds explicit product-scope review, correlation-chain grouping, and focused helper tests
---
diff --git a/docs/ecosystem/PHASE4_PERSONAL_TIMELINE_EXECUTION_PLAN.md b/docs/ecosystem/PHASE4_PERSONAL_TIMELINE_EXECUTION_PLAN.md
index f2b29636..a2c963d0 100644
--- a/docs/ecosystem/PHASE4_PERSONAL_TIMELINE_EXECUTION_PLAN.md
+++ b/docs/ecosystem/PHASE4_PERSONAL_TIMELINE_EXECUTION_PLAN.md
@@ -32,6 +32,8 @@ This phase now establishes the shared data contract, the first persisted service
- `dashboards/admin-web/src/app/api/timeline/route.ts`
- `dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx`
- `dashboards/admin-web/src/__tests__/timeline.test.ts`
+- `dashboards/admin-web/src/__tests__/timeline-view.test.ts`
+- `dashboards/admin-web/src/lib/timeline-view.ts`
---
@@ -58,6 +60,8 @@ This is enough to render:
- `cd learning_ai_common_plat && pnpm --filter @bytelyst/events build`
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec vitest run src/modules/timeline/routes.test.ts src/server.test.ts`
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec tsc --noEmit`
+- `cd learning_ai_common_plat/dashboards/admin-web && npm run test -- src/__tests__/timeline.test.ts src/__tests__/timeline-view.test.ts`
+- `cd learning_ai_common_plat/dashboards/admin-web && npm run typecheck`
Observed baseline:
@@ -73,7 +77,7 @@ Observed baseline:
- platform-service now exposes `GET /api/timeline` for unified timeline query
- non-admin JWT callers are scoped to their own timeline while admin and API-key callers can query broader product timelines
- admin-web now exposes the first hosted internal timeline UI at `/timeline`
-- the hosted UI supports filtering by user, correlation, artifact, event name, and page size
+- the hosted UI now supports explicit product scope selection, correlation-chain grouping, user/artifact/event filters, and focused timeline helper tests
---