diff --git a/apps/speakers/routes.py b/apps/speakers/routes.py index e38745143..d11d2e6f4 100644 --- a/apps/speakers/routes.py +++ b/apps/speakers/routes.py @@ -591,7 +591,17 @@ def api_segments(day: str) -> Any: if not DATE_RE.fullmatch(day): return error_response("Invalid day format", 400) + try: + limit = max(0, int(request.args.get("limit", 20))) + offset = max(0, int(request.args.get("offset", 0))) + except (ValueError, TypeError): + return error_response("Invalid limit/offset parameter", 400) + segments = _scan_segment_embeddings(day) + segments.sort(key=lambda s: s["key"]) + total = len(segments) + segments = segments[offset : offset + limit] + principal = get_journal_principal() principal_id = principal["id"] if principal else None for seg in segments: @@ -620,7 +630,7 @@ def api_segments(day: str) -> Any: seg["attribution_null"] = 0 seg["attribution_non_owner_total"] = 0 - return jsonify({"segments": segments}) + return jsonify({"segments": segments, "total": total}) @speakers_bp.route("/api/speakers///") diff --git a/apps/speakers/tests/test_routes.py b/apps/speakers/tests/test_routes.py index 95b393c31..07a980acb 100644 --- a/apps/speakers/tests/test_routes.py +++ b/apps/speakers/tests/test_routes.py @@ -1047,3 +1047,42 @@ def test_remove_voiceprint_no_file(speakers_env): removed = _remove_voiceprint("alice_test", "20240101", "143022_300", "mic_audio", 1) assert removed is False + + +def test_api_segments_pagination(speakers_env): + """Segments endpoint supports limit/offset pagination.""" + from flask import Flask + + from apps.speakers.routes import speakers_bp + + env = speakers_env() + for i in range(25): + h = 8 + (i // 6) + m = (i % 6) * 10 + key = f"{h:02d}{m:02d}00_300" + env.create_segment("20240101", key, ["mic_audio"], num_sentences=2) + + app = Flask(__name__) + app.register_blueprint(speakers_bp) + + with app.test_client() as client: + resp = client.get("/app/speakers/api/segments/20240101") + assert resp.status_code == 200 + data = resp.get_json() + assert data["total"] == 25 + assert len(data["segments"]) == 20 + + resp = client.get("/app/speakers/api/segments/20240101?limit=20&offset=20") + assert resp.status_code == 200 + data = resp.get_json() + assert data["total"] == 25 + assert len(data["segments"]) == 5 + + resp = client.get("/app/speakers/api/segments/20240101?limit=10&offset=5") + assert resp.status_code == 200 + data = resp.get_json() + assert data["total"] == 25 + assert len(data["segments"]) == 10 + + keys = [s["key"] for s in data["segments"]] + assert keys == sorted(keys) diff --git a/apps/speakers/workspace.html b/apps/speakers/workspace.html index db6b1ba32..d6bb2e27e 100644 --- a/apps/speakers/workspace.html +++ b/apps/speakers/workspace.html @@ -724,6 +724,72 @@ outline: 2px solid #3b82f6; outline-offset: 2px; } + +/* Progressive disclosure - collapsed detail */ +.spk-detail-collapsed .spk-speakers, +.spk-detail-collapsed .spk-sentences { + display: none; +} + +.spk-detail-summary { + padding: 8px 16px; + font-size: 13px; + color: #6b7280; + display: flex; + align-items: center; + gap: 12px; +} + +.spk-detail-summary span { + display: flex; + align-items: center; + gap: 4px; +} + +.spk-expand-btn { + background: none; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + color: #374151; + cursor: pointer; + margin: 8px 16px; + transition: background 0.15s; +} + +.spk-expand-btn:hover { + background: #f9fafb; +} + +/* Pagination - load more */ +.spk-load-more { + padding: 8px 16px; + border-top: 1px solid #e5e7eb; + text-align: center; +} + +.spk-load-more-btn { + background: none; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 6px 16px; + font-size: 13px; + color: #374151; + cursor: pointer; + width: 100%; + transition: background 0.15s; +} + +.spk-load-more-btn:hover { + background: #f3f4f6; +} + +.spk-segments-count { + font-weight: 400; + color: #9ca3af; + font-size: 12px; +}
@@ -737,6 +803,9 @@
  • Loading...
+
@@ -806,6 +875,7 @@ const discoveryBanner = document.getElementById('spkDiscoveryBanner'); let segments = []; + let segmentTotal = 0; let selectedSegment = null; let selectedSource = null; let audioPlayer = null; @@ -816,6 +886,7 @@ const statusTimers = {}; let currentFilter = 'all'; let playingSentenceId = null; + let detailExpanded = false; let ownerDetectionInFlight = false; checkOwnerStatus(); @@ -823,7 +894,9 @@ window.addEventListener('hashchange', () => { const hash = window.location.hash.slice(1); - if (hash) selectSegmentByKey(hash); + if (hash && !selectSegmentByKey(hash) && segments.length < segmentTotal) { + loadUntilFound(hash); + } }); segmentList.addEventListener('keydown', e => { @@ -1078,13 +1151,19 @@ } function loadSegments() { - fetch(`/app/speakers/api/segments/${day}`) + const fetchLimit = Math.max(segments.length, 20); + fetch(`/app/speakers/api/segments/${day}?limit=${fetchLimit}&offset=0`) .then(r => r.json()) .then(data => { segments = data.segments || []; + segmentTotal = data.total || 0; renderSegmentList(); + updateLoadMore(); if (window.location.hash) { - selectSegmentByKey(window.location.hash.slice(1)); + const hashKey = window.location.hash.slice(1); + if (!selectSegmentByKey(hashKey) && segments.length < segmentTotal) { + loadUntilFound(hashKey); + } } }) .catch(() => { @@ -1092,9 +1171,60 @@ }); } + function loadMoreSegments() { + const btn = document.getElementById('spkLoadMoreBtn'); + if (btn) btn.disabled = true; + fetch(`/app/speakers/api/segments/${day}?limit=20&offset=${segments.length}`) + .then(r => r.json()) + .then(data => { + const newSegments = data.segments || []; + segments = segments.concat(newSegments); + segmentTotal = data.total || 0; + renderSegmentList(); + updateLoadMore(); + }) + .catch(() => { + if (btn) btn.disabled = false; + }); + } + + function loadUntilFound(key) { + fetch(`/app/speakers/api/segments/${day}?limit=20&offset=${segments.length}`) + .then(r => r.json()) + .then(data => { + const newSegments = data.segments || []; + if (newSegments.length === 0) return; + segments = segments.concat(newSegments); + segmentTotal = data.total || 0; + renderSegmentList(); + updateLoadMore(); + if (!selectSegmentByKey(key) && segments.length < segmentTotal) { + loadUntilFound(key); + } + }); + } + + function updateLoadMore() { + const container = document.getElementById('spkLoadMore'); + const btn = document.getElementById('spkLoadMoreBtn'); + if (!container || !btn) return; + + if (segmentTotal > 20 && segments.length < segmentTotal) { + container.style.display = ''; + btn.textContent = `Load more segments (${segments.length} of ${segmentTotal})`; + btn.onclick = loadMoreSegments; + } else { + container.style.display = 'none'; + } + } + function renderSegmentList() { if (segments.length === 0) { segmentList.innerHTML = '
  • ' + emptyStateHTML(emptyIcons.segment, 'no speakers discovered today', 'solstone hasn\'t found any speaker segments for this day yet') + '
  • '; + const header = document.querySelector('.spk-segments-header'); + if (header) { + header.textContent = 'Segments'; + } return; } @@ -1150,17 +1280,29 @@ || segmentList.querySelector('.spk-segment'); if (target) target.focus(); } + + const header = document.querySelector('.spk-segments-header'); + if (header) { + if (segmentTotal > 20) { + header.innerHTML = `Segments ${segments.length} of ${segmentTotal}`; + } else { + header.textContent = 'Segments'; + } + } } function selectSegmentByKey(key) { const seg = segments.find(s => s.key === key); if (seg) selectSegment(seg); + return !!seg; } function selectSegment(seg) { + const switchingSegment = !selectedSegment || selectedSegment.key !== seg.key; selectedSegment = seg; selectedSource = seg.sources[0] || null; currentFilter = 'all'; + if (switchingSegment) detailExpanded = false; renderSegmentList(); renderDetail(seg); } @@ -1171,10 +1313,20 @@ return; } + const summaryParts = []; + if (seg.speaker_count > 0) { + summaryParts.push(`${seg.speaker_count} speaker${seg.speaker_count !== 1 ? 's' : ''}`); + } + if (seg.attribution_total > 0) { + const reviewed = seg.attribution_total - seg.attribution_needs_review; + summaryParts.push(`${reviewed}/${seg.attribution_total} attributed`); + } + let html = `

    ${seg.start} - ${seg.end}

    + ${summaryParts.length ? `
    ${summaryParts.join('')}
    ` : ''} `; html += '
    Loading speakers...
    '; @@ -1192,9 +1344,29 @@ } html += '
    '; + + if (!detailExpanded) { + html += ''; + } + html += '
    Loading...
    '; detailPanel.innerHTML = html; + if (!detailExpanded) { + detailPanel.classList.add('spk-detail-collapsed'); + } else { + detailPanel.classList.remove('spk-detail-collapsed'); + } + + const expandBtn = document.getElementById('spkExpandBtn'); + if (expandBtn) { + expandBtn.addEventListener('click', () => { + detailExpanded = true; + detailPanel.classList.remove('spk-detail-collapsed'); + expandBtn.remove(); + }); + } + detailPanel.querySelectorAll('.spk-source-tab').forEach(tab => { tab.addEventListener('click', () => { selectedSource = tab.dataset.source; diff --git a/tests/baselines/api/speakers/segments.json b/tests/baselines/api/speakers/segments.json index a5a8ed0f2..51b07886a 100644 --- a/tests/baselines/api/speakers/segments.json +++ b/tests/baselines/api/speakers/segments.json @@ -1,3 +1,4 @@ { - "segments": [] + "segments": [], + "total": 0 }