Files
c-relay/docs/subscription_table_collapse_plan.md
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