# Subscription Table Collapsible Groups Implementation Plan ## Objective Add collapsible/expandable functionality to subscription groups, where: 1. Each WSI Pointer group starts collapsed, showing only a summary row 2. Clicking the summary row expands to show all subscription details for that WSI Pointer 3. Clicking again collapses the group back to the summary row ## Current Behavior - All subscriptions are displayed in a flat list - WSI Pointer value shown only on first row of each group - No interaction or collapse functionality ## Desired Behavior - **Collapsed state**: One row per WSI Pointer showing summary information - **Expanded state**: Header row + detail rows for each subscription in that group - **Toggle**: Click on header row to expand/collapse - **Visual indicator**: Arrow or icon showing expand/collapse state ## Design Approach ### Option 1: Summary Row + Detail Rows (Recommended) ``` [▶] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m (detail rows hidden) When clicked: [▼] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m | sub-001 | 5m 30s | kinds:[1] | sub-002 | 3m 15s | kinds:[3] | sub-003 | 6m 15s | kinds:[1,3] ``` ### Option 2: First Row as Header (Alternative) ``` [▶] 0x12345678 | sub-001 | 5m 30s | kinds:[1] (+ 2 more) When clicked: [▼] 0x12345678 | sub-001 | 5m 30s | kinds:[1] | sub-002 | 3m 15s | kinds:[3] | sub-003 | 6m 15s | kinds:[1,3] ``` **Recommendation**: Option 1 provides clearer UX and better summary information. ## Implementation Details ### Files to Modify 1. `api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384) 2. `api/index.css` - Add styles for collapsible rows ### Data Structure Changes Need to group subscriptions by WSI Pointer first: ```javascript // Group subscriptions by wsi_pointer const groupedSubscriptions = {}; subscriptionsData.forEach(sub => { const wsiKey = sub.wsi_pointer || 'N/A'; if (!groupedSubscriptions[wsiKey]) { groupedSubscriptions[wsiKey] = []; } groupedSubscriptions[wsiKey].push(sub); }); ``` ### HTML Structure #### Summary Row (Header) ```html WSI Pointer: 0x12345678 | Subscriptions: 3 | Oldest: 15m 30s ``` #### Detail Rows (Initially Hidden) ```html sub-001 5m 30s kinds:[1] ``` ### JavaScript Implementation #### Modified populateSubscriptionDetailsTable Function ```javascript function populateSubscriptionDetailsTable(subscriptionsData) { const tableBody = document.getElementById('subscription-details-table-body'); if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return; tableBody.innerHTML = ''; if (subscriptionsData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No active subscriptions'; tableBody.appendChild(row); return; } // Sort subscriptions by wsi_pointer to group them together subscriptionsData.sort((a, b) => { const wsiA = a.wsi_pointer || ''; const wsiB = b.wsi_pointer || ''; return wsiA.localeCompare(wsiB); }); // Group subscriptions by wsi_pointer const groupedSubscriptions = {}; subscriptionsData.forEach(sub => { const wsiKey = sub.wsi_pointer || 'N/A'; if (!groupedSubscriptions[wsiKey]) { groupedSubscriptions[wsiKey] = []; } groupedSubscriptions[wsiKey].push(sub); }); // Create rows for each group Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => { // Calculate group summary const subCount = subscriptions.length; const now = Math.floor(Date.now() / 1000); const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at)); const oldestDurationStr = formatDuration(oldestDuration); // Create header row (summary) const headerRow = document.createElement('tr'); headerRow.className = 'subscription-group-header'; headerRow.setAttribute('data-wsi-pointer', wsiPointer); headerRow.setAttribute('data-expanded', 'false'); headerRow.style.cursor = 'pointer'; headerRow.style.userSelect = 'none'; headerRow.style.backgroundColor = 'var(--hover-color, #f5f5f5)'; headerRow.innerHTML = ` WSI: ${wsiPointer} Subscriptions: ${subCount} | Oldest: ${oldestDurationStr} `; // Add click handler to toggle expansion headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer)); tableBody.appendChild(headerRow); // Create detail rows (initially hidden) subscriptions.forEach((subscription, index) => { const detailRow = document.createElement('tr'); detailRow.className = 'subscription-detail-row'; detailRow.setAttribute('data-wsi-group', wsiPointer); detailRow.style.display = 'none'; // Calculate duration const duration = now - subscription.created_at; const durationStr = formatDuration(duration); // Format filters let filtersDisplay = 'None'; if (subscription.filters && subscription.filters.length > 0) { const filterDetails = []; subscription.filters.forEach((filter) => { const parts = []; if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) { parts.push(`kinds:[${filter.kinds.join(',')}]`); } if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) { const authorCount = filter.authors.length; if (authorCount === 1) { const shortPubkey = filter.authors[0].substring(0, 8) + '...'; parts.push(`authors:[${shortPubkey}]`); } else { parts.push(`authors:[${authorCount} pubkeys]`); } } if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) { const idCount = filter.ids.length; parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`); } const timeParts = []; if (filter.since && filter.since > 0) { const sinceDate = new Date(filter.since * 1000).toLocaleString(); timeParts.push(`since:${sinceDate}`); } if (filter.until && filter.until > 0) { const untilDate = new Date(filter.until * 1000).toLocaleString(); timeParts.push(`until:${untilDate}`); } if (timeParts.length > 0) { parts.push(timeParts.join(', ')); } if (filter.limit && filter.limit > 0) { parts.push(`limit:${filter.limit}`); } if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) { parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`); } if (parts.length > 0) { filterDetails.push(parts.join(', ')); } else { filterDetails.push('empty filter'); } }); filtersDisplay = filterDetails.join(' | '); } detailRow.innerHTML = ` └─ ${subscription.id || 'N/A'} ${durationStr} ${filtersDisplay} `; tableBody.appendChild(detailRow); }); }); } // Toggle function for expanding/collapsing groups function toggleSubscriptionGroup(wsiPointer) { const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`); const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`); const expandIcon = headerRow.querySelector('.expand-icon'); const isExpanded = headerRow.getAttribute('data-expanded') === 'true'; if (isExpanded) { // Collapse detailRows.forEach(row => row.style.display = 'none'); expandIcon.textContent = '▶'; expandIcon.style.transform = 'rotate(0deg)'; headerRow.setAttribute('data-expanded', 'false'); } else { // Expand detailRows.forEach(row => row.style.display = 'table-row'); expandIcon.textContent = '▼'; expandIcon.style.transform = 'rotate(90deg)'; headerRow.setAttribute('data-expanded', 'true'); } } ``` ### CSS Additions (api/index.css) ```css /* Subscription group header styles */ .subscription-group-header { background-color: var(--hover-color, #f5f5f5); font-weight: 500; transition: background-color 0.2s; } .subscription-group-header:hover { background-color: var(--primary-color-light, #e8e8e8); } .expand-icon { display: inline-block; width: 20px; transition: transform 0.2s ease; font-size: 12px; } /* Detail row styles */ .subscription-detail-row { background-color: var(--background-color, #ffffff); } .subscription-detail-row:hover { background-color: var(--hover-color-light, #fafafa); } /* Dark mode support */ .dark-mode .subscription-group-header { background-color: var(--hover-color-dark, #2a2a2a); } .dark-mode .subscription-group-header:hover { background-color: var(--primary-color-dark, #333333); } .dark-mode .subscription-detail-row { background-color: var(--background-color-dark, #1a1a1a); } .dark-mode .subscription-detail-row:hover { background-color: var(--hover-color-dark, #252525); } ``` ## Features Included 1. ✅ **Collapsible groups**: Each WSI Pointer group can be collapsed/expanded 2. ✅ **Visual indicator**: Arrow icon (▶/▼) shows current state 3. ✅ **Summary information**: Shows subscription count and oldest duration 4. ✅ **Smooth transitions**: Icon rotation animation 5. ✅ **Hover effects**: Visual feedback on header rows 6. ✅ **Tree structure**: Detail rows indented with └─ character 7. ✅ **Dark mode support**: Proper styling for both themes 8. ✅ **Keyboard accessible**: Can be enhanced with keyboard navigation ## User Experience Flow 1. **Initial load**: All groups collapsed, showing summary rows only 2. **Click header**: Group expands, showing all subscription details 3. **Click again**: Group collapses back to summary 4. **Multiple groups**: Each group can be independently expanded/collapsed 5. **Visual feedback**: Hover effects and smooth animations ## Testing Checklist 1. ✅ Verify groups start collapsed by default 2. ✅ Verify clicking header expands group 3. ✅ Verify clicking again collapses group 4. ✅ Verify multiple groups can be expanded simultaneously 5. ✅ Verify summary information is accurate 6. ✅ Verify detail rows display correctly when expanded 7. ✅ Verify styling works in both light and dark modes 8. ✅ Verify no console errors 9. ✅ Verify performance with many subscriptions ## Optional Enhancements 1. **Expand/Collapse All**: Add buttons to expand or collapse all groups at once 2. **Remember State**: Store expansion state in localStorage 3. **Keyboard Navigation**: Add keyboard shortcuts (Space/Enter to toggle) 4. **Animation**: Add slide-down/up animation for detail rows 5. **Search/Filter**: Add ability to search within subscriptions 6. **Export**: Add ability to export subscription data ## Next Steps 1. Review this plan 2. Switch to Code mode 3. Implement the changes in `api/index.js` 4. Add CSS styles in `api/index.css` 5. Test the collapsible functionality 6. Verify all edge cases work correctly