Files
c-relay-pg/docs/subscription_table_collapse_plan.md
T
2025-10-31 10:39:06 -04:00

13 KiB

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

[▶] 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:

// 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)

<tr class="subscription-group-header" data-wsi-pointer="0x12345678" data-expanded="false">
    <td colspan="4" style="cursor: pointer; user-select: none;">
        <span class="expand-icon">▶</span>
        <strong>WSI Pointer:</strong> 0x12345678
        <span class="group-summary">
            | Subscriptions: 3 | Oldest: 15m 30s
        </span>
    </td>
</tr>

Detail Rows (Initially Hidden)

<tr class="subscription-detail-row" data-wsi-group="0x12345678" style="display: none;">
    <td style="padding-left: 30px;"><!-- Empty for WSI Pointer --></td>
    <td>sub-001</td>
    <td>5m 30s</td>
    <td>kinds:[1]</td>
</tr>

JavaScript Implementation

Modified populateSubscriptionDetailsTable Function

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 = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
        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 = `
            <td colspan="4" style="padding: 8px;">
                <span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
                <strong style="font-family: 'Courier New', monospace; font-size: 12px;">WSI: ${wsiPointer}</strong>
                <span style="color: #666; margin-left: 15px;">
                    Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
                </span>
            </td>
        `;

        // 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 = `
                <td style="padding-left: 30px; font-family: 'Courier New', monospace; font-size: 11px; color: #999;">└─</td>
                <td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
                <td>${durationStr}</td>
                <td>${filtersDisplay}</td>
            `;
            
            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)

/* 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