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.
Files changed (55) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/plugin/.claude-plugin/plugin.json +13 -0
  4. package/plugin/.mcp.json +12 -0
  5. package/plugin/CLAUDE.md +10 -0
  6. package/plugin/commands/recall.md +55 -0
  7. package/plugin/commands/remember.md +34 -0
  8. package/plugin/commands/resume.md +45 -0
  9. package/plugin/commands/stash.md +34 -0
  10. package/plugin/commands/status.md +33 -0
  11. package/plugin/dist/analysis/worker.d.ts +1 -0
  12. package/plugin/dist/analysis/worker.js +233 -0
  13. package/plugin/dist/analysis/worker.js.map +1 -0
  14. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  15. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  16. package/plugin/dist/hooks/handler.d.ts +286 -0
  17. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  18. package/plugin/dist/hooks/handler.js +2413 -0
  19. package/plugin/dist/hooks/handler.js.map +1 -0
  20. package/plugin/dist/index.d.ts +447 -0
  21. package/plugin/dist/index.d.ts.map +1 -0
  22. package/plugin/dist/index.js +7334 -0
  23. package/plugin/dist/index.js.map +1 -0
  24. package/plugin/dist/observations-CorAAc1A.d.mts +192 -0
  25. package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
  26. package/plugin/dist/tool-registry-e710BvXq.mjs +3574 -0
  27. package/plugin/dist/tool-registry-e710BvXq.mjs.map +1 -0
  28. package/plugin/hooks/hooks.json +78 -0
  29. package/plugin/laminark.db +0 -0
  30. package/plugin/package.json +17 -0
  31. package/plugin/scripts/README.md +65 -0
  32. package/plugin/scripts/bump-version.sh +42 -0
  33. package/plugin/scripts/dev-sync.sh +58 -0
  34. package/plugin/scripts/ensure-deps.sh +15 -0
  35. package/plugin/scripts/install.sh +139 -0
  36. package/plugin/scripts/local-install.sh +138 -0
  37. package/plugin/scripts/uninstall.sh +133 -0
  38. package/plugin/scripts/update.sh +39 -0
  39. package/plugin/scripts/verify-install.sh +87 -0
  40. package/plugin/skills/status/SKILL.md +6 -0
  41. package/plugin/ui/activity.js +197 -0
  42. package/plugin/ui/app.js +1612 -0
  43. package/plugin/ui/graph.js +2560 -0
  44. package/plugin/ui/help/activity-feed.png +0 -0
  45. package/plugin/ui/help/analysis-panel.png +0 -0
  46. package/plugin/ui/help/graph-toolbar.png +0 -0
  47. package/plugin/ui/help/graph-view.png +0 -0
  48. package/plugin/ui/help/settings.png +0 -0
  49. package/plugin/ui/help/timeline.png +0 -0
  50. package/plugin/ui/help.js +932 -0
  51. package/plugin/ui/index.html +756 -0
  52. package/plugin/ui/settings.js +1414 -0
  53. package/plugin/ui/styles.css +3856 -0
  54. package/plugin/ui/timeline.js +652 -0
  55. 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
+ };