@qiaolei81/copilot-session-viewer 0.3.1 → 0.3.3

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Application Insights Telemetry Module
3
+ *
4
+ * This module initializes and configures Application Insights for telemetry tracking.
5
+ * Must be required BEFORE any other modules (especially Express) in server.js.
6
+ *
7
+ * Features:
8
+ * - Auto-collection of requests, dependencies, exceptions, and performance counters
9
+ * - Custom event and metric tracking
10
+ * - Automatic disabling in test environments
11
+ * - Support for manual disabling via DISABLE_TELEMETRY env var
12
+ */
13
+
14
+ const appInsights = require('applicationinsights');
15
+
16
+ // Determine if telemetry should be disabled
17
+ const isTestEnvironment = process.env.NODE_ENV === 'test';
18
+ const isDisabled = process.env.DISABLE_TELEMETRY === 'true' || isTestEnvironment;
19
+
20
+ // Default connection string (can be overridden via env var)
21
+ const DEFAULT_CONNECTION_STRING = 'InstrumentationKey=39f4fbf1-d82f-42c3-b4ef-ea92a1fd82cb;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/;LiveEndpoint=https://eastus.livediagnostics.monitor.azure.com/;ApplicationId=7d4bb432-f2f5-4526-a5e6-31901e5a2db2';
22
+
23
+ let client = null;
24
+
25
+ if (!isDisabled) {
26
+ try {
27
+ // Get connection string from environment or use default
28
+ const connectionString = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || DEFAULT_CONNECTION_STRING;
29
+
30
+ // Setup and start Application Insights
31
+ appInsights.setup(connectionString)
32
+ .setAutoDependencyCorrelation(true)
33
+ .setAutoCollectRequests(true)
34
+ .setAutoCollectPerformance(true, true)
35
+ .setAutoCollectExceptions(true)
36
+ .setAutoCollectDependencies(true)
37
+ .setAutoCollectConsole(false) // Disable console tracking to avoid noise
38
+ .setUseDiskRetryCaching(true)
39
+ .setSendLiveMetrics(false) // Disable live metrics for local dev tool
40
+ .setDistributedTracingMode(appInsights.DistributedTracingModes.AI_AND_W3C)
41
+ .start();
42
+
43
+ client = appInsights.defaultClient;
44
+
45
+ // Set context properties
46
+ client.context.tags[client.context.keys.cloudRole] = 'copilot-session-viewer';
47
+ client.context.tags[client.context.keys.cloudRoleInstance] = require('os').hostname();
48
+
49
+ console.log('✅ Application Insights telemetry initialized');
50
+ } catch (error) {
51
+ console.error('❌ Failed to initialize Application Insights:', error.message);
52
+ // Continue without telemetry rather than crashing
53
+ client = createNoOpClient();
54
+ }
55
+ } else {
56
+ // Return no-op client for test environment or when disabled
57
+ client = createNoOpClient();
58
+
59
+ if (isTestEnvironment) {
60
+ console.log('📊 Telemetry disabled (test environment)');
61
+ } else {
62
+ console.log('📊 Telemetry disabled (DISABLE_TELEMETRY=true)');
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Creates a no-op client that safely ignores all telemetry calls
68
+ * Used when telemetry is disabled or in test environments
69
+ */
70
+ function createNoOpClient() {
71
+ return {
72
+ trackEvent: () => {},
73
+ trackMetric: () => {},
74
+ trackException: () => {},
75
+ trackTrace: () => {},
76
+ trackDependency: () => {},
77
+ trackRequest: () => {},
78
+ flush: (callback) => {
79
+ if (callback) callback();
80
+ }
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Track a custom event
86
+ * @param {string} name - Event name
87
+ * @param {Object} properties - Event properties
88
+ */
89
+ function trackEvent(name, properties = {}) {
90
+ if (client && client.trackEvent) {
91
+ client.trackEvent({
92
+ name,
93
+ properties
94
+ });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Track a custom metric
100
+ * @param {string} name - Metric name
101
+ * @param {number} value - Metric value
102
+ * @param {Object} properties - Additional properties
103
+ */
104
+ function trackMetric(name, value, properties = {}) {
105
+ if (client && client.trackMetric) {
106
+ client.trackMetric({
107
+ name,
108
+ value,
109
+ properties
110
+ });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Track an exception
116
+ * @param {Error} error - Error object
117
+ * @param {Object} properties - Additional properties
118
+ */
119
+ function trackException(error, properties = {}) {
120
+ if (client && client.trackException) {
121
+ client.trackException({
122
+ exception: error,
123
+ properties
124
+ });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Flush telemetry data (useful for short-lived processes)
130
+ * @returns {Promise<void>}
131
+ */
132
+ function flush() {
133
+ return new Promise((resolve) => {
134
+ if (client && client.flush) {
135
+ client.flush({
136
+ callback: () => resolve()
137
+ });
138
+ } else {
139
+ resolve();
140
+ }
141
+ });
142
+ }
143
+
144
+ // Export the client and helper functions
145
+ module.exports = {
146
+ client,
147
+ trackEvent,
148
+ trackMetric,
149
+ trackException,
150
+ flush,
151
+ isEnabled: !isDisabled
152
+ };
@@ -146,7 +146,8 @@ async function getSessionMetadataOptimized(filePath, maxMessageLength = 200) {
146
146
  copilotVersion: copilotVersion || null,
147
147
  selectedModel: selectedModel || null,
148
148
  hasSessionEnd,
149
- lastEventTime: lastTimestamp
149
+ lastEventTime: lastTimestamp,
150
+ firstEventTime: firstTimestamp
150
151
  };
151
152
  } catch (err) {
152
153
  console.error(`Error reading session metadata from ${filePath}:`, err.message);
package/views/index.ejs CHANGED
@@ -485,6 +485,8 @@
485
485
  to { transform: rotate(360deg); }
486
486
  }
487
487
  </style>
488
+
489
+ <%- include('telemetry-snippet') %>
488
490
  </head>
489
491
  <body>
490
492
  <div class="container">
@@ -537,503 +539,16 @@
537
539
  </div>
538
540
  <% } %>
539
541
  </div>
540
-
541
- <!-- JSON data embedded safely -->
542
- <script type="application/json" id="sessions-data"><%- JSON.stringify(sessions || []) %></script>
543
- <script type="application/json" id="meta-data"><%- JSON.stringify({ totalSessions: totalSessions || 0, hasMore: hasMore || false }) %></script>
544
542
 
545
543
  <script>
546
- // Parse JSON data safely from script tags
547
- const initialSessions = JSON.parse(document.getElementById('sessions-data').textContent);
548
- const metaData = JSON.parse(document.getElementById('meta-data').textContent);
549
- const totalSessionsFromServer = metaData.totalSessions;
550
- const hasMoreFromServer = metaData.hasMore;
551
-
552
- // Infinite scroll state — per-source
553
- let allSessions = [...initialSessions];
554
- // Per-source pagination state
555
- const sourceState = {};
556
- // Initialize from initial server load (copilot is default active pill)
557
- sourceState['copilot'] = { offset: initialSessions.length, hasMore: hasMoreFromServer };
558
-
559
- let isLoading = false;
560
-
561
- // Filter state
562
- let currentSourceFilter = 'copilot';
563
-
564
- function currentState() {
565
- if (!sourceState[currentSourceFilter]) {
566
- sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
567
- }
568
- return sourceState[currentSourceFilter];
569
- }
570
-
571
- // Load more sessions for current source
572
- async function loadMoreSessions() {
573
- const state = currentState();
574
- if (isLoading || !state.hasMore) return;
575
-
576
- isLoading = true;
577
- const loadingIndicator = document.getElementById('loading-indicator');
578
- loadingIndicator.style.display = 'block';
579
-
580
- try {
581
- const response = await fetch(`/api/sessions/load-more?offset=${currentState().offset}&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
582
- if (!response.ok) throw new Error('Failed to load more sessions');
583
-
584
- const data = await response.json();
585
- const existingIds = new Set(allSessions.map(s => s.id));
586
- const newSessions = [];
587
- for (const s of data.sessions) {
588
- if (!existingIds.has(s.id)) {
589
- allSessions.push(s);
590
- newSessions.push(s);
591
- }
592
- }
593
- currentState().offset += data.sessions.length;
594
- currentState().hasMore = data.hasMore;
595
-
596
- // Load tags for new sessions
597
- await attachTagsToSessions(newSessions);
598
-
599
- renderAllSessions();
600
- } catch (err) {
601
- console.error('Error loading more sessions:', err);
602
- } finally {
603
- isLoading = false;
604
- loadingIndicator.style.display = 'none';
605
- }
606
- }
607
-
608
- // (load more button removed — pure infinite scroll)
609
-
610
- // Get filtered sessions based on current filter
611
- function getFilteredSessions() {
612
- return allSessions.filter(session => session.source === currentSourceFilter);
613
- }
614
-
615
- // Render all sessions (grouped by date)
616
- function renderAllSessions() {
617
- const container = document.getElementById('sessions-container');
618
- container.innerHTML = ''; // Clear existing
619
-
620
- const filteredSessions = getFilteredSessions();
621
-
622
- if (filteredSessions.length === 0) {
623
- container.innerHTML = '<div style="text-align: center; color: #6e7681; padding: 40px; font-size: 14px;">No sessions found for this filter.</div>';
624
- return;
625
- }
626
-
627
- const grouped = groupSessionsByDate(filteredSessions);
628
- const sortedDates = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); // Descending
629
-
630
- sortedDates.forEach(dateKey => {
631
- const dateHeader = document.createElement('div');
632
- dateHeader.className = 'date-group-header';
633
- dateHeader.textContent = formatDateHeader(grouped[dateKey][0].createdAt);
634
- container.appendChild(dateHeader);
635
-
636
- const grid = document.createElement('div');
637
- grid.className = 'recent-list';
638
- grouped[dateKey].forEach(session => {
639
- grid.innerHTML += renderSessionCard(session);
640
- });
641
- container.appendChild(grid);
642
- });
643
- }
644
-
645
- // Check if user has scrolled near bottom
646
- function checkScrollPosition() {
647
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
648
- const windowHeight = window.innerHeight;
649
- const docHeight = document.documentElement.scrollHeight;
650
-
651
- // Load more when user is within 500px of bottom
652
- if (scrollTop + windowHeight >= docHeight - 500 && currentState().hasMore && !isLoading) {
653
- loadMoreSessions();
654
- }
655
- }
656
-
657
- // Throttle scroll events for performance
658
- let scrollTimeout;
659
- function throttledScroll() {
660
- if (scrollTimeout) return;
661
- scrollTimeout = setTimeout(() => {
662
- checkScrollPosition();
663
- scrollTimeout = null;
664
- }, 100);
665
- }
666
-
667
- function viewSession(e) {
668
- e.preventDefault();
669
- const sessionId = document.getElementById('sessionInput').value.trim();
670
- if (sessionId) {
671
- window.location.href = `/session/${sessionId}`;
672
- }
673
- }
674
-
675
- // Bind form submit event
676
- document.getElementById('sessionForm').addEventListener('submit', viewSession);
677
-
678
- // File import handling
679
- const fileInput = document.getElementById('fileInput');
680
- const importLink = document.getElementById('importLink');
681
- const importStatus = document.getElementById('importStatus');
682
-
683
- // Click import link to select file
684
- importLink.addEventListener('click', (e) => {
685
- e.preventDefault();
686
- fileInput.click();
687
- });
688
-
689
- // Auto-upload when file is selected
690
- fileInput.addEventListener('change', async (e) => {
691
- const file = e.target.files[0];
692
- if (!file) return;
693
-
694
- if (!file.name.endsWith('.zip')) {
695
- showStatus('error', '❌ Please select a .zip file');
696
- return;
697
- }
698
-
699
- importLink.style.pointerEvents = 'none';
700
- importLink.style.opacity = '0.5';
701
- importLink.textContent = 'Importing...';
702
- showStatus('loading', 'Uploading and extracting session...');
703
-
704
- try {
705
- const formData = new FormData();
706
- formData.append('sessionZip', file);
707
-
708
- const response = await fetch('/session/import', {
709
- method: 'POST',
710
- body: formData
711
- });
712
-
713
- const result = await response.json();
714
-
715
- if (response.ok) {
716
- showStatus('success', `✅ Session ${result.sessionId} imported successfully!`);
717
-
718
- // Reload page after 1.5 seconds to show new session
719
- setTimeout(() => {
720
- window.location.reload();
721
- }, 1500);
722
- } else {
723
- showStatus('error', `❌ Import failed: ${result.error}`);
724
- importLink.style.pointerEvents = 'auto';
725
- importLink.style.opacity = '1';
726
- importLink.textContent = 'Import session from zip';
727
- }
728
- } catch (err) {
729
- showStatus('error', `❌ Import failed: ${err.message}`);
730
- importLink.style.pointerEvents = 'auto';
731
- importLink.style.opacity = '1';
732
- importLink.textContent = 'Import session from zip';
733
- } finally {
734
- // Reset file input
735
- fileInput.value = '';
736
- }
737
- });
738
-
739
- function showStatus(type, message) {
740
- importStatus.className = `import-status ${type}`;
741
- importStatus.textContent = message;
742
- }
743
-
744
- // Format duration from milliseconds to human-readable format
745
- function formatDuration(ms) {
746
- if (!ms || ms < 0) return '—';
747
-
748
- const seconds = Math.floor(ms / 1000);
749
- const minutes = Math.floor(seconds / 60);
750
- const hours = Math.floor(minutes / 60);
751
-
752
- if (hours > 0) {
753
- const remainingMinutes = minutes % 60;
754
- return `${hours}h ${remainingMinutes}m`;
755
- } else if (minutes > 0) {
756
- const remainingSeconds = seconds % 60;
757
- return `${minutes}m ${remainingSeconds}s`;
758
- } else {
759
- return `${seconds}s`;
760
- }
761
- }
762
-
763
- // Format date to YYYY/MM/DD
764
- function formatDateHeader(dateStr) {
765
- const date = new Date(dateStr);
766
- const year = date.getFullYear();
767
- const month = String(date.getMonth() + 1).padStart(2, '0');
768
- const day = String(date.getDate()).padStart(2, '0');
769
- return `${year}/${month}/${day}`;
770
- }
771
-
772
- // Get date key from timestamp (YYYY-MM-DD for grouping)
773
- function getDateKey(timestamp) {
774
- if (!timestamp) return 'Unknown';
775
- const date = new Date(timestamp);
776
- const year = date.getFullYear();
777
- const month = String(date.getMonth() + 1).padStart(2, '0');
778
- const day = String(date.getDate()).padStart(2, '0');
779
- return `${year}-${month}-${day}`;
780
- }
781
-
782
- // Group sessions by date
783
- function groupSessionsByDate(sessions) {
784
- const groups = {};
785
- sessions.forEach(session => {
786
- const dateKey = getDateKey(session.createdAt);
787
- if (!groups[dateKey]) {
788
- groups[dateKey] = [];
789
- }
790
- groups[dateKey].push(session);
791
- });
792
- return groups;
793
- }
794
-
795
- // Render session card HTML
796
- function renderSessionCard(session) {
797
- // Add status badges
798
- let badges = '';
799
-
800
- // Add source badge (use backend-provided metadata - Violation #3 & #5 fix)
801
- const sourceClass = session.sourceBadgeClass || 'source-copilot';
802
- const sourceLabel = session.sourceName || 'Copilot';
803
- badges += `<span class="status-badge ${sourceClass}" title="${sourceLabel}">${sourceLabel}</span>`;
804
-
805
- if (session.sessionStatus === 'wip') {
806
- badges += '<span class="status-badge wip" title="Session in progress">🔄 WIP</span>';
807
- }
808
- if (session.isImported) {
809
- badges += '<span class="status-badge imported" title="Imported session">📥</span>';
810
- }
811
- if (session.hasInsight) {
812
- badges += '<span class="status-badge insight" title="Has Agent Review">💡</span>';
813
- }
814
- // Add model and version badges
815
- if (session.selectedModel) {
816
- const modelShort = session.selectedModel.replace('claude-', '').replace('gpt-', '').replace('gemini-', '');
817
- let modelClass = 'model-other';
818
- if (session.selectedModel.includes('claude')) {
819
- modelClass = 'model-claude';
820
- } else if (session.selectedModel.includes('gpt')) {
821
- modelClass = 'model-gpt';
822
- } else if (session.selectedModel.includes('gemini')) {
823
- modelClass = 'model-gemini';
824
- }
825
- badges += `<span class="status-badge model ${modelClass}" title="Model: ${escapeHtml(session.selectedModel)}">${escapeHtml(modelShort)}</span>`;
826
- }
827
- if (session.copilotVersion) {
828
- badges += `<span class="status-badge version" title="CLI version">${escapeHtml(session.copilotVersion)}</span>`;
829
- }
830
-
831
- let summaryHtml = '';
832
- if (session.summary && session.summary !== 'No summary' && session.summary !== 'Legacy session') {
833
- summaryHtml = `<div class="session-summary">${escapeHtml(session.summary)}</div>`;
834
- } else {
835
- summaryHtml = '<div class="session-summary" style="color: #6e7681; font-style: italic;">No summary available</div>';
836
- }
837
-
838
- let workspaceHtml = '';
839
- if (session.workspace && session.workspace.cwd) {
840
- workspaceHtml = `
841
- <div class="session-info-item workspace" title="${escapeHtml(session.workspace.cwd)}">
842
- <svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
843
- <span class="session-info-value">${escapeHtml(session.workspace.cwd.replace(/^\/Users\/[^/]+/, '~'))}</span>
844
- </div>
845
- `;
846
- }
847
-
848
- const createdAtStr = session.createdAt
849
- ? new Date(session.createdAt).toLocaleString('en-US', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
850
- : 'unknown';
851
-
852
- let durationHtml = '';
853
- if (session.duration) {
854
- durationHtml = `
855
- <div class="session-info-item">
856
- <svg viewBox="0 0 16 16" fill="currentColor"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM7.25 12.5v-5A.75.75 0 0 1 8 6.75h2.5a.75.75 0 0 1 0 1.5H8.75v4.25a.75.75 0 0 1-1.5 0Z"></path></svg>
857
- <span class="session-info-value">${formatDuration(session.duration)}</span>
858
- </div>
859
- `;
860
- }
861
-
862
- const wipClass = session.sessionStatus === 'wip' ? ' recent-item-wip' : '';
863
-
864
- // Render tags
865
- let tagsHtml = '';
866
- if (session.tags && session.tags.length > 0) {
867
- const tagsItems = session.tags.map(tag => {
868
- const color = getTagColor(tag);
869
- return `<span class="session-tag" style="background-color: ${color}" title="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`;
870
- }).join('');
871
- tagsHtml = `<div class="session-tags">${tagsItems}</div>`;
872
- }
873
-
874
- return `
875
- <a href="/session/${session.id}" class="recent-item${wipClass}">
876
- <div class="session-id">
877
- <span class="session-id-text" title="${escapeHtml(session.id)}">${escapeHtml(session.id)}</span>
878
- </div>
879
- <div class="session-badges-tags">
880
- <div class="session-badges">${badges}</div>
881
- ${tagsHtml}
882
- </div>
883
- ${summaryHtml}
884
- <div class="session-divider"></div>
885
- <div class="session-info">
886
- ${workspaceHtml}
887
- <div class="session-info-item">
888
- <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"></path></svg>
889
- <span class="session-info-value">${createdAtStr}</span>
890
- </div>
891
- ${durationHtml}
892
- <div class="session-info-item">
893
- <svg viewBox="0 0 16 16" fill="currentColor"><path d="M7.72.72a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-.22-.22v1.69a.75.75 0 0 1-1.5 0V3.06l-.22.22a.75.75 0 0 1-1.06-1.06ZM2 7a.75.75 0 0 0 0 1.5h3.69l-.22.22a.75.75 0 1 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06l-1.5-1.5a.75.75 0 0 0-1.06 1.06l.22.22Zm8.53-.28a.75.75 0 0 0 0 1.06l1.5 1.5a.75.75 0 1 0 1.06-1.06l-.22-.22H16a.75.75 0 0 0 0-1.5h-3.13l.22-.22a.75.75 0 0 0-1.06-1.06ZM7.72 12.22a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 1 1-1.06 1.06l-.22-.22v1.69a.75.75 0 0 1-1.5 0v-1.69l-.22.22a.75.75 0 0 1-1.06-1.06Z"></path></svg>
894
- <span class="session-info-value">${session.eventCount || 0} events</span>
895
- </div>
896
- </div>
897
- </a>
898
- `;
899
- }
900
-
901
- function escapeHtml(text) {
902
- const div = document.createElement('div');
903
- div.textContent = text;
904
- return div.innerHTML;
905
- }
906
-
907
- // Tag colors (same as session-vue.ejs)
908
- const tagColors = [
909
- '#3b82f6', // blue
910
- '#10b981', // green
911
- '#f59e0b', // amber
912
- '#ef4444', // red
913
- '#8b5cf6', // purple
914
- '#ec4899', // pink
915
- '#06b6d4', // cyan
916
- '#f97316' // orange
917
- ];
918
-
919
- function getTagColor(tag) {
920
- let hash = 0;
921
- for (let i = 0; i < tag.length; i++) {
922
- hash = tag.charCodeAt(i) + ((hash << 5) - hash);
923
- }
924
- return tagColors[Math.abs(hash) % tagColors.length];
925
- }
926
-
927
- // Load tags for sessions
928
- async function loadSessionTags(sessionIds) {
929
- try {
930
- const tagPromises = sessionIds.map(id =>
931
- fetch(`/api/sessions/${id}/tags`)
932
- .then(r => r.ok ? r.json() : { tags: [] })
933
- .then(data => ({ id, tags: data.tags || [] }))
934
- .catch(() => ({ id, tags: [] }))
935
- );
936
- const results = await Promise.all(tagPromises);
937
- const tagsMap = {};
938
- results.forEach(({ id, tags }) => {
939
- tagsMap[id] = tags;
940
- });
941
- return tagsMap;
942
- } catch (err) {
943
- console.error('Error loading session tags:', err);
944
- return {};
945
- }
946
- }
947
-
948
- // Attach tags to sessions
949
- async function attachTagsToSessions(sessions) {
950
- const sessionIds = sessions.map(s => s.id);
951
- const tagsMap = await loadSessionTags(sessionIds);
952
- sessions.forEach(session => {
953
- session.tags = tagsMap[session.id] || [];
954
- });
955
- }
956
-
957
- // Source directory hints (from server, platform-aware)
958
- const sourceHints = <%- sourceHints || '{}' %>;
959
-
960
- function updateSourceHint(source) {
961
- const hint = document.getElementById('sourceHint');
962
- if (hint && sourceHints[source]) {
963
- hint.innerHTML = 'Sessions from <span class="hint-code">' + sourceHints[source] + '</span>';
964
- } else if (hint) {
965
- hint.textContent = '';
966
- }
967
- }
968
-
969
- // Filter pill click handler
970
- function setupFilterPills() {
971
- const filterPills = document.querySelectorAll('.filter-pill');
972
- // Show hint for initial active pill
973
- updateSourceHint(currentSourceFilter);
974
- filterPills.forEach(pill => {
975
- pill.addEventListener('click', async () => {
976
- filterPills.forEach(p => p.classList.remove('active'));
977
- pill.classList.add('active');
978
- currentSourceFilter = pill.getAttribute('data-source');
979
- updateSourceHint(currentSourceFilter);
980
-
981
- // Init per-source state if first visit
982
- if (!sourceState[currentSourceFilter]) {
983
- sourceState[currentSourceFilter] = { offset: 0, hasMore: true };
984
- }
985
-
986
- // Always fetch first batch when switching to a new source (backend-filtered)
987
- if (sourceState[currentSourceFilter].offset === 0 && !isLoading) {
988
- isLoading = true;
989
- // Show loading state immediately (clear old results)
990
- const container = document.getElementById('sessions-container');
991
- container.innerHTML = '<div style="text-align: center; color: #6e7681; padding: 40px; font-size: 14px;">⏳ Loading...</div>';
992
- document.getElementById('loading-indicator').style.display = 'none';
993
- try {
994
- const resp = await fetch(`/api/sessions/load-more?offset=0&limit=20&source=${encodeURIComponent(currentSourceFilter)}`);
995
- if (resp.ok) {
996
- const data = await resp.json();
997
- const existingIds = new Set(allSessions.map(s => s.id));
998
- const newSessions = [];
999
- for (const s of (data.sessions || [])) {
1000
- if (!existingIds.has(s.id)) {
1001
- allSessions.push(s);
1002
- newSessions.push(s);
1003
- }
1004
- }
1005
- sourceState[currentSourceFilter].offset = (data.sessions || []).length;
1006
- sourceState[currentSourceFilter].hasMore = data.hasMore;
1007
-
1008
- // Load tags for new sessions
1009
- await attachTagsToSessions(newSessions);
1010
- }
1011
- } catch (e) {
1012
- console.error('Failed to load sessions for source:', currentSourceFilter, e);
1013
- } finally {
1014
- isLoading = false;
1015
- }
1016
- }
1017
-
1018
- renderAllSessions();
1019
- });
1020
- });
1021
- }
1022
-
1023
- // Render grouped sessions
1024
- document.addEventListener('DOMContentLoaded', async function() {
1025
- // Load tags for initial sessions
1026
- await attachTagsToSessions(allSessions);
1027
-
1028
- renderAllSessions();
1029
-
1030
- // Infinite scroll
1031
- window.addEventListener('scroll', throttledScroll);
1032
-
1033
- // Setup filter pills
1034
- setupFilterPills();
1035
- });
544
+ window.__PAGE_DATA = {
545
+ sessions: <%- JSON.stringify(sessions || []) %>,
546
+ totalSessions: <%= totalSessions || 0 %>,
547
+ hasMore: <%= hasMore || false %>,
548
+ sourceHints: <%- sourceHints || '{}' %>
549
+ };
1036
550
  </script>
551
+ <script src="/public/js/homepage.min.js"></script>
1037
552
 
1038
553
  <style>
1039
554
  .date-group-header {