diff --git a/studio/src/components/TweetsTab.jsx b/studio/src/components/TweetsTab.jsx index 0e4fc4e..3b10cbc 100644 --- a/studio/src/components/TweetsTab.jsx +++ b/studio/src/components/TweetsTab.jsx @@ -2,6 +2,7 @@ import { useState, useMemo } from "react"; import { Heart, Repeat2, MessageSquare, Eye, Wand2, X } from "lucide-react"; import { Chip, SentimentChip } from "./primitives.jsx"; import { TONE_DEFS, TONE_COLOR } from "../lib/lexicons.js"; +import { MAX_VISIBLE, capNotice } from "../lib/display.js"; /* ============================================================ * TWEETS TAB @@ -14,7 +15,7 @@ export function TweetsTab({ tweets, selection, onJumpToCaptions }) { const [sortKey, setSortKey] = useState("engagement"); const [onlySelected, setOnlySelected] = useState(false); - const filtered = useMemo(() => { + const matched = useMemo(() => { const needle = q.trim().toLowerCase(); return tweets .filter( @@ -34,10 +35,12 @@ export function TweetsTab({ tweets, selection, onJumpToCaptions }) { if (sortKey === "date") return (b.date || "").localeCompare(a.date || ""); return 0; - }) - .slice(0, 500); + }); }, [tweets, q, sentFilter, toneFilter, sortKey, onlySelected, selection]); + const filtered = useMemo(() => matched.slice(0, MAX_VISIBLE), [matched]); + const notice = capNotice(matched.length); + const visibleIds = useMemo(() => filtered.map((t) => t.uid), [filtered]); const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selection.has(id)); @@ -102,10 +105,14 @@ export function TweetsTab({ tweets, selection, onJumpToCaptions }) { Selected only - {filtered.length} of {tweets.length} + {matched.length} of {tweets.length} + {notice && ( +
{notice}
+ )} +
{selection.count} selected diff --git a/studio/src/lib/display.js b/studio/src/lib/display.js new file mode 100644 index 0000000..be48272 --- /dev/null +++ b/studio/src/lib/display.js @@ -0,0 +1,13 @@ +/* ============================================================ + * DISPLAY HELPERS + * ============================================================ */ + +// The Tweets tab renders at most this many rows for performance. The cap was +// previously applied silently; capNotice surfaces it so users don't think +// matches are missing. +export const MAX_VISIBLE = 500; + +export function capNotice(matchCount, cap = MAX_VISIBLE) { + if (matchCount <= cap) return null; + return `Showing the top ${cap} of ${matchCount.toLocaleString()} matches`; +} diff --git a/studio/src/lib/display.test.js b/studio/src/lib/display.test.js new file mode 100644 index 0000000..973e404 --- /dev/null +++ b/studio/src/lib/display.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { capNotice, MAX_VISIBLE } from "./display.js"; + +describe("capNotice", () => { + it("returns null when the match count is at or below the cap", () => { + expect(capNotice(0, 500)).toBeNull(); + expect(capNotice(500, 500)).toBeNull(); + }); + + it("returns a notice when matches exceed the cap", () => { + expect(capNotice(501, 500)).toBe("Showing the top 500 of 501 matches"); + }); + + it("formats large match counts with thousands separators", () => { + expect(capNotice(12345, 500)).toBe("Showing the top 500 of 12,345 matches"); + }); + + it("defaults to MAX_VISIBLE when no cap is passed", () => { + expect(capNotice(MAX_VISIBLE)).toBeNull(); + expect(capNotice(MAX_VISIBLE + 1)).toBe( + `Showing the top ${MAX_VISIBLE} of ${(MAX_VISIBLE + 1).toLocaleString()} matches`, + ); + }); +});