laminark 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/package.json +65 -0
- package/plugin/.claude-plugin/plugin.json +13 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/CLAUDE.md +10 -0
- package/plugin/commands/recall.md +55 -0
- package/plugin/commands/remember.md +34 -0
- package/plugin/commands/resume.md +45 -0
- package/plugin/commands/stash.md +34 -0
- package/plugin/commands/status.md +33 -0
- package/plugin/dist/analysis/worker.d.ts +1 -0
- package/plugin/dist/analysis/worker.js +233 -0
- package/plugin/dist/analysis/worker.js.map +1 -0
- package/plugin/dist/config-t8LZeB-u.mjs +90 -0
- package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
- package/plugin/dist/hooks/handler.d.ts +286 -0
- package/plugin/dist/hooks/handler.d.ts.map +1 -0
- package/plugin/dist/hooks/handler.js +2413 -0
- package/plugin/dist/hooks/handler.js.map +1 -0
- package/plugin/dist/index.d.ts +447 -0
- package/plugin/dist/index.d.ts.map +1 -0
- package/plugin/dist/index.js +7334 -0
- package/plugin/dist/index.js.map +1 -0
- package/plugin/dist/observations-CorAAc1A.d.mts +192 -0
- package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
- package/plugin/dist/tool-registry-e710BvXq.mjs +3574 -0
- package/plugin/dist/tool-registry-e710BvXq.mjs.map +1 -0
- package/plugin/hooks/hooks.json +78 -0
- package/plugin/laminark.db +0 -0
- package/plugin/package.json +17 -0
- package/plugin/scripts/README.md +65 -0
- package/plugin/scripts/bump-version.sh +42 -0
- package/plugin/scripts/dev-sync.sh +58 -0
- package/plugin/scripts/ensure-deps.sh +15 -0
- package/plugin/scripts/install.sh +139 -0
- package/plugin/scripts/local-install.sh +138 -0
- package/plugin/scripts/uninstall.sh +133 -0
- package/plugin/scripts/update.sh +39 -0
- package/plugin/scripts/verify-install.sh +87 -0
- package/plugin/skills/status/SKILL.md +6 -0
- package/plugin/ui/activity.js +197 -0
- package/plugin/ui/app.js +1612 -0
- package/plugin/ui/graph.js +2560 -0
- package/plugin/ui/help/activity-feed.png +0 -0
- package/plugin/ui/help/analysis-panel.png +0 -0
- package/plugin/ui/help/graph-toolbar.png +0 -0
- package/plugin/ui/help/graph-view.png +0 -0
- package/plugin/ui/help/settings.png +0 -0
- package/plugin/ui/help/timeline.png +0 -0
- package/plugin/ui/help.js +932 -0
- package/plugin/ui/index.html +756 -0
- package/plugin/ui/settings.js +1414 -0
- package/plugin/ui/styles.css +3856 -0
- package/plugin/ui/timeline.js +652 -0
- package/plugin/ui/tools.js +826 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laminark Timeline View
|
|
3
|
+
*
|
|
4
|
+
* Renders a chronological vertical timeline of sessions with observations
|
|
5
|
+
* inside them. Topic shift points appear as dividers between observation
|
|
6
|
+
* groups. Supports expand/collapse, infinite scroll, and SSE live updates.
|
|
7
|
+
*
|
|
8
|
+
* @module timeline
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Module state
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
let timelineContainer = null;
|
|
16
|
+
let currentOffset = 0;
|
|
17
|
+
let isLoadingMore = false;
|
|
18
|
+
let hasMoreData = true;
|
|
19
|
+
const PAGE_SIZE = 50;
|
|
20
|
+
const DEFAULT_EXPANDED_COUNT = 3;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Initialization
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initialize the timeline view on a given container element.
|
|
28
|
+
* Sets up the intersection observer for lazy rendering and scroll listeners.
|
|
29
|
+
* @param {string} containerId - ID of the container element
|
|
30
|
+
*/
|
|
31
|
+
function initTimeline(containerId) {
|
|
32
|
+
timelineContainer = document.getElementById(containerId);
|
|
33
|
+
if (!timelineContainer) {
|
|
34
|
+
console.warn('[laminark:timeline] Container not found:', containerId);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Ensure the container has the timeline structure
|
|
39
|
+
if (!timelineContainer.querySelector('.timeline-container')) {
|
|
40
|
+
var wrapper = document.createElement('div');
|
|
41
|
+
wrapper.className = 'timeline-container';
|
|
42
|
+
|
|
43
|
+
var spine = document.createElement('div');
|
|
44
|
+
spine.className = 'timeline-spine';
|
|
45
|
+
wrapper.appendChild(spine);
|
|
46
|
+
|
|
47
|
+
var sessions = document.createElement('div');
|
|
48
|
+
sessions.className = 'timeline-sessions';
|
|
49
|
+
wrapper.appendChild(sessions);
|
|
50
|
+
|
|
51
|
+
var sentinel = document.createElement('div');
|
|
52
|
+
sentinel.className = 'timeline-sentinel';
|
|
53
|
+
wrapper.appendChild(sentinel);
|
|
54
|
+
|
|
55
|
+
timelineContainer.innerHTML = '';
|
|
56
|
+
timelineContainer.appendChild(wrapper);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Set up infinite scroll via IntersectionObserver on the sentinel
|
|
60
|
+
var sentinel = timelineContainer.querySelector('.timeline-sentinel');
|
|
61
|
+
if (sentinel && typeof IntersectionObserver !== 'undefined') {
|
|
62
|
+
var observer = new IntersectionObserver(function (entries) {
|
|
63
|
+
entries.forEach(function (entry) {
|
|
64
|
+
if (entry.isIntersecting && !isLoadingMore && hasMoreData) {
|
|
65
|
+
loadOlderSessions();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}, { root: timelineContainer, rootMargin: '200px' });
|
|
69
|
+
observer.observe(sentinel);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Wire up SSE event listeners
|
|
73
|
+
wireSSEListeners();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Data loading
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load timeline data from the API and render it.
|
|
82
|
+
* @param {Object} [range] - Optional time range filters
|
|
83
|
+
* @param {string} [range.from] - ISO8601 start
|
|
84
|
+
* @param {string} [range.to] - ISO8601 end
|
|
85
|
+
*/
|
|
86
|
+
async function loadTimelineData(range) {
|
|
87
|
+
currentOffset = 0;
|
|
88
|
+
hasMoreData = true;
|
|
89
|
+
|
|
90
|
+
var data = await fetchTimelineFromAPI(range, 0);
|
|
91
|
+
|
|
92
|
+
var sessionsContainer = timelineContainer
|
|
93
|
+
? timelineContainer.querySelector('.timeline-sessions')
|
|
94
|
+
: null;
|
|
95
|
+
if (!sessionsContainer) return;
|
|
96
|
+
|
|
97
|
+
// Clear any existing content
|
|
98
|
+
sessionsContainer.innerHTML = '';
|
|
99
|
+
|
|
100
|
+
if (!data.sessions.length && !data.observations.length) {
|
|
101
|
+
showTimelineEmptyState(sessionsContainer);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
renderTimelineData(data, sessionsContainer);
|
|
106
|
+
currentOffset = data.sessions.length;
|
|
107
|
+
|
|
108
|
+
// If we got fewer sessions than the page size, no more to load
|
|
109
|
+
if (data.sessions.length < PAGE_SIZE) {
|
|
110
|
+
hasMoreData = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fetch timeline data from the REST API.
|
|
116
|
+
* @param {Object} [range] - Optional time range
|
|
117
|
+
* @param {number} offset - Pagination offset
|
|
118
|
+
* @returns {Promise<{sessions: Array, observations: Array, topicShifts: Array}>}
|
|
119
|
+
*/
|
|
120
|
+
async function fetchTimelineFromAPI(range, offset) {
|
|
121
|
+
var params = new URLSearchParams();
|
|
122
|
+
if (range && range.from) params.set('from', range.from);
|
|
123
|
+
if (range && range.to) params.set('to', range.to);
|
|
124
|
+
params.set('limit', String(PAGE_SIZE * 10)); // observations limit
|
|
125
|
+
if (offset > 0) params.set('offset', String(offset));
|
|
126
|
+
if (window.laminarkState && window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
|
|
127
|
+
|
|
128
|
+
var url = '/api/timeline?' + params.toString();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
var res = await fetch(url);
|
|
132
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
133
|
+
return await res.json();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('[laminark:timeline] Failed to fetch data:', err);
|
|
136
|
+
return { sessions: [], observations: [], topicShifts: [] };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Load older sessions for infinite scroll.
|
|
142
|
+
*/
|
|
143
|
+
async function loadOlderSessions() {
|
|
144
|
+
if (isLoadingMore || !hasMoreData) return;
|
|
145
|
+
isLoadingMore = true;
|
|
146
|
+
|
|
147
|
+
var data = await fetchTimelineFromAPI(null, currentOffset);
|
|
148
|
+
|
|
149
|
+
if (data.sessions.length === 0) {
|
|
150
|
+
hasMoreData = false;
|
|
151
|
+
isLoadingMore = false;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var sessionsContainer = timelineContainer
|
|
156
|
+
? timelineContainer.querySelector('.timeline-sessions')
|
|
157
|
+
: null;
|
|
158
|
+
if (!sessionsContainer) {
|
|
159
|
+
isLoadingMore = false;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
renderTimelineData(data, sessionsContainer, true);
|
|
164
|
+
currentOffset += data.sessions.length;
|
|
165
|
+
|
|
166
|
+
if (data.sessions.length < PAGE_SIZE) {
|
|
167
|
+
hasMoreData = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
isLoadingMore = false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Rendering
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render timeline data into the sessions container.
|
|
179
|
+
* @param {Object} data - API response with sessions, observations, topicShifts
|
|
180
|
+
* @param {HTMLElement} container - The .timeline-sessions container
|
|
181
|
+
* @param {boolean} [append] - If true, append to existing content (infinite scroll)
|
|
182
|
+
*/
|
|
183
|
+
function renderTimelineData(data, container, append) {
|
|
184
|
+
// Group observations by session
|
|
185
|
+
var obsBySession = new Map();
|
|
186
|
+
data.observations.forEach(function (obs) {
|
|
187
|
+
var sid = obs.sessionId || '__ungrouped__';
|
|
188
|
+
if (!obsBySession.has(sid)) obsBySession.set(sid, []);
|
|
189
|
+
obsBySession.get(sid).push(obs);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Group topic shifts by session timestamp range
|
|
193
|
+
var shiftsBySession = new Map();
|
|
194
|
+
data.sessions.forEach(function (session) {
|
|
195
|
+
var sessionShifts = data.topicShifts.filter(function (ts) {
|
|
196
|
+
return ts.timestamp >= session.startedAt &&
|
|
197
|
+
(!session.endedAt || ts.timestamp <= session.endedAt);
|
|
198
|
+
});
|
|
199
|
+
if (sessionShifts.length > 0) {
|
|
200
|
+
shiftsBySession.set(session.id, sessionShifts);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Determine which sessions should start expanded
|
|
205
|
+
var existingCards = container.querySelectorAll('.session-card');
|
|
206
|
+
var existingCount = existingCards.length;
|
|
207
|
+
|
|
208
|
+
var fragment = document.createDocumentFragment();
|
|
209
|
+
|
|
210
|
+
// Render sessions in order (already reverse chronological from API)
|
|
211
|
+
data.sessions.forEach(function (session, index) {
|
|
212
|
+
var sessionObs = obsBySession.get(session.id) || [];
|
|
213
|
+
var sessionShifts = shiftsBySession.get(session.id) || [];
|
|
214
|
+
|
|
215
|
+
// Sort observations chronologically within session
|
|
216
|
+
sessionObs.sort(function (a, b) {
|
|
217
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Expand the first 3 sessions on initial load
|
|
221
|
+
var shouldExpand = !append && (index + existingCount) < DEFAULT_EXPANDED_COUNT;
|
|
222
|
+
|
|
223
|
+
var card = createSessionCard(session, sessionObs, sessionShifts, shouldExpand);
|
|
224
|
+
fragment.appendChild(card);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Handle ungrouped observations
|
|
228
|
+
var ungrouped = obsBySession.get('__ungrouped__');
|
|
229
|
+
if (ungrouped && ungrouped.length > 0 && !append) {
|
|
230
|
+
var ungroupedSession = {
|
|
231
|
+
id: '__ungrouped__',
|
|
232
|
+
startedAt: null,
|
|
233
|
+
endedAt: null,
|
|
234
|
+
summary: 'Ungrouped observations',
|
|
235
|
+
observationCount: ungrouped.length,
|
|
236
|
+
};
|
|
237
|
+
var ungroupedCard = createSessionCard(ungroupedSession, ungrouped, [], false);
|
|
238
|
+
fragment.appendChild(ungroupedCard);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
container.appendChild(fragment);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create a session card element with observations and topic shifts.
|
|
246
|
+
* @param {Object} session - Session data
|
|
247
|
+
* @param {Array} observations - Observations in this session
|
|
248
|
+
* @param {Array} shifts - Topic shifts in this session
|
|
249
|
+
* @param {boolean} expanded - Whether the card starts expanded
|
|
250
|
+
* @returns {HTMLElement}
|
|
251
|
+
*/
|
|
252
|
+
function createSessionCard(session, observations, shifts, expanded) {
|
|
253
|
+
var card = document.createElement('div');
|
|
254
|
+
card.className = 'session-card' + (expanded ? '' : ' collapsed');
|
|
255
|
+
card.setAttribute('data-session-id', session.id);
|
|
256
|
+
|
|
257
|
+
// Header
|
|
258
|
+
var header = document.createElement('div');
|
|
259
|
+
header.className = 'session-header';
|
|
260
|
+
|
|
261
|
+
var headerLeft = document.createElement('div');
|
|
262
|
+
headerLeft.className = 'session-header-left';
|
|
263
|
+
|
|
264
|
+
var toggleIcon = document.createElement('span');
|
|
265
|
+
toggleIcon.className = 'toggle-icon';
|
|
266
|
+
toggleIcon.textContent = '\u25BC'; // down arrow
|
|
267
|
+
headerLeft.appendChild(toggleIcon);
|
|
268
|
+
|
|
269
|
+
var title = document.createElement('h3');
|
|
270
|
+
title.textContent = session.startedAt
|
|
271
|
+
? formatSessionDate(session.startedAt)
|
|
272
|
+
: (session.summary || 'Session');
|
|
273
|
+
headerLeft.appendChild(title);
|
|
274
|
+
|
|
275
|
+
header.appendChild(headerLeft);
|
|
276
|
+
|
|
277
|
+
var headerRight = document.createElement('div');
|
|
278
|
+
headerRight.className = 'session-meta';
|
|
279
|
+
|
|
280
|
+
// Duration
|
|
281
|
+
if (session.startedAt && session.endedAt) {
|
|
282
|
+
var durationSpan = document.createElement('span');
|
|
283
|
+
durationSpan.className = 'session-duration';
|
|
284
|
+
durationSpan.textContent = formatDuration(session.startedAt, session.endedAt);
|
|
285
|
+
headerRight.appendChild(durationSpan);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Observation count badge
|
|
289
|
+
var countBadge = document.createElement('span');
|
|
290
|
+
countBadge.className = 'session-badge';
|
|
291
|
+
var obsCount = session.observationCount != null ? session.observationCount : observations.length;
|
|
292
|
+
countBadge.textContent = obsCount + ' obs';
|
|
293
|
+
headerRight.appendChild(countBadge);
|
|
294
|
+
|
|
295
|
+
// Active badge if session has no end time
|
|
296
|
+
if (session.startedAt && !session.endedAt && session.id !== '__ungrouped__') {
|
|
297
|
+
var activeBadge = document.createElement('span');
|
|
298
|
+
activeBadge.className = 'session-badge active';
|
|
299
|
+
activeBadge.textContent = 'Active';
|
|
300
|
+
headerRight.appendChild(activeBadge);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
header.appendChild(headerRight);
|
|
304
|
+
card.appendChild(header);
|
|
305
|
+
|
|
306
|
+
// Summary (if available)
|
|
307
|
+
if (session.summary && session.id !== '__ungrouped__') {
|
|
308
|
+
var summaryDiv = document.createElement('div');
|
|
309
|
+
summaryDiv.className = 'session-summary';
|
|
310
|
+
summaryDiv.textContent = session.summary;
|
|
311
|
+
card.appendChild(summaryDiv);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Observation list
|
|
315
|
+
var obsList = document.createElement('div');
|
|
316
|
+
obsList.className = 'observation-list';
|
|
317
|
+
|
|
318
|
+
// Interleave observations and topic shifts chronologically
|
|
319
|
+
var items = [];
|
|
320
|
+
observations.forEach(function (obs) {
|
|
321
|
+
items.push({ time: obs.createdAt, kind: 'obs', data: obs });
|
|
322
|
+
});
|
|
323
|
+
shifts.forEach(function (shift) {
|
|
324
|
+
items.push({ time: shift.timestamp, kind: 'shift', data: shift });
|
|
325
|
+
});
|
|
326
|
+
items.sort(function (a, b) { return a.time.localeCompare(b.time); });
|
|
327
|
+
|
|
328
|
+
items.forEach(function (item) {
|
|
329
|
+
if (item.kind === 'obs') {
|
|
330
|
+
obsList.appendChild(createObservationEntry(item.data));
|
|
331
|
+
} else {
|
|
332
|
+
obsList.appendChild(createTopicShiftMarker(item.data));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
card.appendChild(obsList);
|
|
337
|
+
|
|
338
|
+
// Click header to toggle expand/collapse
|
|
339
|
+
header.addEventListener('click', function () {
|
|
340
|
+
card.classList.toggle('collapsed');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return card;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Create an observation entry element.
|
|
348
|
+
* @param {Object} obs - Observation data
|
|
349
|
+
* @returns {HTMLElement}
|
|
350
|
+
*/
|
|
351
|
+
function createObservationEntry(obs) {
|
|
352
|
+
var entry = document.createElement('div');
|
|
353
|
+
entry.className = 'observation-entry';
|
|
354
|
+
|
|
355
|
+
var timeSpan = document.createElement('span');
|
|
356
|
+
timeSpan.className = 'obs-time';
|
|
357
|
+
timeSpan.textContent = formatTimeShort(obs.createdAt);
|
|
358
|
+
entry.appendChild(timeSpan);
|
|
359
|
+
|
|
360
|
+
var typeDot = document.createElement('span');
|
|
361
|
+
typeDot.className = 'obs-type-dot';
|
|
362
|
+
typeDot.setAttribute('data-type', obs.type || 'default');
|
|
363
|
+
entry.appendChild(typeDot);
|
|
364
|
+
|
|
365
|
+
var textSpan = document.createElement('span');
|
|
366
|
+
textSpan.className = 'obs-text';
|
|
367
|
+
// Truncate to 120 chars with ellipsis
|
|
368
|
+
var text = obs.text || '';
|
|
369
|
+
textSpan.textContent = text.length > 120 ? text.substring(0, 120) + '...' : text;
|
|
370
|
+
entry.appendChild(textSpan);
|
|
371
|
+
|
|
372
|
+
return entry;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Create a topic shift marker element.
|
|
377
|
+
* @param {Object} shift - Topic shift data
|
|
378
|
+
* @returns {HTMLElement}
|
|
379
|
+
*/
|
|
380
|
+
function createTopicShiftMarker(shift) {
|
|
381
|
+
var marker = document.createElement('div');
|
|
382
|
+
marker.className = 'topic-shift-marker';
|
|
383
|
+
|
|
384
|
+
var label = document.createElement('span');
|
|
385
|
+
label.className = 'shift-label';
|
|
386
|
+
label.textContent = 'Topic shifted';
|
|
387
|
+
marker.appendChild(label);
|
|
388
|
+
|
|
389
|
+
// Confidence indicator dot
|
|
390
|
+
if (shift.confidence != null) {
|
|
391
|
+
var dot = document.createElement('span');
|
|
392
|
+
dot.className = 'confidence-dot';
|
|
393
|
+
// green for high (>= 0.7), yellow for medium
|
|
394
|
+
dot.style.backgroundColor = shift.confidence >= 0.7 ? '#3fb950' : '#d29922';
|
|
395
|
+
dot.title = 'Confidence: ' + (shift.confidence * 100).toFixed(0) + '%';
|
|
396
|
+
marker.appendChild(dot);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return marker;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Show the empty state message.
|
|
404
|
+
* @param {HTMLElement} container
|
|
405
|
+
*/
|
|
406
|
+
function showTimelineEmptyState(container) {
|
|
407
|
+
var msg = document.createElement('p');
|
|
408
|
+
msg.className = 'empty-state';
|
|
409
|
+
msg.textContent = 'No sessions recorded yet. Timeline will populate as you use Claude.';
|
|
410
|
+
container.appendChild(msg);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Scroll helpers
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Scroll to a specific session card by session ID.
|
|
419
|
+
* @param {string} sessionId
|
|
420
|
+
*/
|
|
421
|
+
function scrollToSession(sessionId) {
|
|
422
|
+
if (!timelineContainer) return;
|
|
423
|
+
var card = timelineContainer.querySelector('[data-session-id="' + sessionId + '"]');
|
|
424
|
+
if (card) {
|
|
425
|
+
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
426
|
+
// Expand it if collapsed
|
|
427
|
+
card.classList.remove('collapsed');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Scroll to the most recent (topmost) session.
|
|
433
|
+
*/
|
|
434
|
+
function scrollToToday() {
|
|
435
|
+
if (!timelineContainer) return;
|
|
436
|
+
var firstCard = timelineContainer.querySelector('.session-card');
|
|
437
|
+
if (firstCard) {
|
|
438
|
+
firstCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
439
|
+
firstCard.classList.remove('collapsed');
|
|
440
|
+
} else {
|
|
441
|
+
timelineContainer.scrollTop = 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// SSE live updates
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
function wireSSEListeners() {
|
|
450
|
+
// New observation: prepend to the correct session card
|
|
451
|
+
document.addEventListener('laminark:new_observation', function (e) {
|
|
452
|
+
var obs = e.detail;
|
|
453
|
+
if (!timelineContainer) return;
|
|
454
|
+
|
|
455
|
+
var sessionId = obs.sessionId;
|
|
456
|
+
if (!sessionId) return;
|
|
457
|
+
|
|
458
|
+
var card = timelineContainer.querySelector('[data-session-id="' + sessionId + '"]');
|
|
459
|
+
|
|
460
|
+
if (card) {
|
|
461
|
+
// Add observation to existing session card
|
|
462
|
+
var obsList = card.querySelector('.observation-list');
|
|
463
|
+
if (obsList) {
|
|
464
|
+
obsList.appendChild(createObservationEntry(obs));
|
|
465
|
+
}
|
|
466
|
+
// Update badge count
|
|
467
|
+
var badge = card.querySelector('.session-badge:not(.active)');
|
|
468
|
+
if (badge) {
|
|
469
|
+
var current = parseInt(badge.textContent, 10) || 0;
|
|
470
|
+
badge.textContent = (current + 1) + ' obs';
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
// Create new session card at the top
|
|
474
|
+
var sessionsContainer = timelineContainer.querySelector('.timeline-sessions');
|
|
475
|
+
if (sessionsContainer) {
|
|
476
|
+
var newSession = {
|
|
477
|
+
id: sessionId,
|
|
478
|
+
startedAt: obs.createdAt,
|
|
479
|
+
endedAt: null,
|
|
480
|
+
summary: null,
|
|
481
|
+
observationCount: 1,
|
|
482
|
+
};
|
|
483
|
+
var newCard = createSessionCard(newSession, [obs], [], true);
|
|
484
|
+
sessionsContainer.insertBefore(newCard, sessionsContainer.firstChild);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Session start: create new empty session card at top
|
|
490
|
+
document.addEventListener('laminark:session_start', function (e) {
|
|
491
|
+
var session = e.detail;
|
|
492
|
+
if (!timelineContainer) return;
|
|
493
|
+
|
|
494
|
+
var sessionsContainer = timelineContainer.querySelector('.timeline-sessions');
|
|
495
|
+
if (!sessionsContainer) return;
|
|
496
|
+
|
|
497
|
+
// Check if card already exists
|
|
498
|
+
var existing = timelineContainer.querySelector('[data-session-id="' + session.id + '"]');
|
|
499
|
+
if (existing) return;
|
|
500
|
+
|
|
501
|
+
var newSession = {
|
|
502
|
+
id: session.id,
|
|
503
|
+
startedAt: session.startedAt || new Date().toISOString(),
|
|
504
|
+
endedAt: null,
|
|
505
|
+
summary: null,
|
|
506
|
+
observationCount: 0,
|
|
507
|
+
};
|
|
508
|
+
var card = createSessionCard(newSession, [], [], true);
|
|
509
|
+
sessionsContainer.insertBefore(card, sessionsContainer.firstChild);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Session end: update session card header
|
|
513
|
+
document.addEventListener('laminark:session_end', function (e) {
|
|
514
|
+
var session = e.detail;
|
|
515
|
+
if (!timelineContainer) return;
|
|
516
|
+
|
|
517
|
+
var card = timelineContainer.querySelector('[data-session-id="' + session.id + '"]');
|
|
518
|
+
if (!card) return;
|
|
519
|
+
|
|
520
|
+
// Update duration in meta
|
|
521
|
+
var metaDiv = card.querySelector('.session-meta');
|
|
522
|
+
if (metaDiv && session.startedAt && session.endedAt) {
|
|
523
|
+
var durationSpan = metaDiv.querySelector('.session-duration');
|
|
524
|
+
if (!durationSpan) {
|
|
525
|
+
durationSpan = document.createElement('span');
|
|
526
|
+
durationSpan.className = 'session-duration';
|
|
527
|
+
metaDiv.insertBefore(durationSpan, metaDiv.firstChild);
|
|
528
|
+
}
|
|
529
|
+
durationSpan.textContent = formatDuration(session.startedAt, session.endedAt);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Remove "Active" badge
|
|
533
|
+
var activeBadge = card.querySelector('.session-badge.active');
|
|
534
|
+
if (activeBadge) {
|
|
535
|
+
activeBadge.remove();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Topic shift: insert marker in active session
|
|
540
|
+
document.addEventListener('laminark:topic_shift', function (e) {
|
|
541
|
+
var shift = e.detail;
|
|
542
|
+
if (!timelineContainer) return;
|
|
543
|
+
|
|
544
|
+
// Find the active session card (one without end time -- has active badge)
|
|
545
|
+
var activeCard = timelineContainer.querySelector('.session-badge.active');
|
|
546
|
+
var card = activeCard ? activeCard.closest('.session-card') : null;
|
|
547
|
+
|
|
548
|
+
// Fallback: use session ID if provided
|
|
549
|
+
if (!card && shift.sessionId) {
|
|
550
|
+
card = timelineContainer.querySelector('[data-session-id="' + shift.sessionId + '"]');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!card) return;
|
|
554
|
+
|
|
555
|
+
var obsList = card.querySelector('.observation-list');
|
|
556
|
+
if (obsList) {
|
|
557
|
+
obsList.appendChild(createTopicShiftMarker(shift));
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Formatting helpers
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Format a date for session headers: "Mon Jan 15, 2:30 PM"
|
|
568
|
+
* @param {string} isoString
|
|
569
|
+
* @returns {string}
|
|
570
|
+
*/
|
|
571
|
+
function formatSessionDate(isoString) {
|
|
572
|
+
try {
|
|
573
|
+
var d = new Date(isoString);
|
|
574
|
+
return d.toLocaleDateString('en-US', {
|
|
575
|
+
weekday: 'short',
|
|
576
|
+
month: 'short',
|
|
577
|
+
day: 'numeric',
|
|
578
|
+
}) + ', ' + d.toLocaleTimeString('en-US', {
|
|
579
|
+
hour: 'numeric',
|
|
580
|
+
minute: '2-digit',
|
|
581
|
+
hour12: true,
|
|
582
|
+
});
|
|
583
|
+
} catch (e) {
|
|
584
|
+
return isoString;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Format time for observation entries: "2:31 PM"
|
|
590
|
+
* @param {string} isoString
|
|
591
|
+
* @returns {string}
|
|
592
|
+
*/
|
|
593
|
+
function formatTimeShort(isoString) {
|
|
594
|
+
try {
|
|
595
|
+
var d = new Date(isoString);
|
|
596
|
+
return d.toLocaleTimeString('en-US', {
|
|
597
|
+
hour: 'numeric',
|
|
598
|
+
minute: '2-digit',
|
|
599
|
+
hour12: true,
|
|
600
|
+
});
|
|
601
|
+
} catch (e) {
|
|
602
|
+
return isoString || '';
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Format duration between two ISO timestamps: "45 min", "2h 15min", etc.
|
|
608
|
+
* @param {string} startIso
|
|
609
|
+
* @param {string} endIso
|
|
610
|
+
* @returns {string}
|
|
611
|
+
*/
|
|
612
|
+
function formatDuration(startIso, endIso) {
|
|
613
|
+
try {
|
|
614
|
+
var start = new Date(startIso).getTime();
|
|
615
|
+
var end = new Date(endIso).getTime();
|
|
616
|
+
var diffMs = end - start;
|
|
617
|
+
if (diffMs < 0) return '';
|
|
618
|
+
|
|
619
|
+
var minutes = Math.round(diffMs / 60000);
|
|
620
|
+
if (minutes < 60) return minutes + ' min';
|
|
621
|
+
|
|
622
|
+
var hours = Math.floor(minutes / 60);
|
|
623
|
+
var mins = minutes % 60;
|
|
624
|
+
return hours + 'h' + (mins > 0 ? ' ' + mins + 'min' : '');
|
|
625
|
+
} catch (e) {
|
|
626
|
+
return '';
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Jump to Today button handler
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
635
|
+
var jumpBtn = document.querySelector('.jump-today-btn');
|
|
636
|
+
if (jumpBtn) {
|
|
637
|
+
jumpBtn.addEventListener('click', function () {
|
|
638
|
+
scrollToToday();
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Export for use by app.js
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
window.laminarkTimeline = {
|
|
648
|
+
initTimeline: initTimeline,
|
|
649
|
+
loadTimelineData: loadTimelineData,
|
|
650
|
+
scrollToSession: scrollToSession,
|
|
651
|
+
scrollToToday: scrollToToday,
|
|
652
|
+
};
|