From 92efa20c8344b5bb0814ee1ef0bdfb53c716674d Mon Sep 17 00:00:00 2001 From: Christian-Bermejo Date: Sun, 14 Jun 2026 07:50:04 +0800 Subject: [PATCH] Memoize OverviewTab aggregation via a pure overviewStats helper OverviewTab recomputed its full aggregation (sentiment distribution, tone counts, totals, averages, top-5) inline on every render, unlike the other tabs which useMemo. On large datasets any parent re-render redid the whole O(n) sweep. Extract the aggregation verbatim into a pure overviewStats(tweets) in lib/stats.js and call it via useMemo([tweets]) in the component. The JSX is unchanged. As a bonus the dashboard aggregation is now unit-testable without a DOM. Adds lib/stats.test.js (5 tests). Build green (identical CSS hash); 45 tests pass. --- studio/src/components/OverviewTab.jsx | 37 +++++---------- studio/src/lib/stats.js | 49 ++++++++++++++++++++ studio/src/lib/stats.test.js | 65 +++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 studio/src/lib/stats.js create mode 100644 studio/src/lib/stats.test.js diff --git a/studio/src/components/OverviewTab.jsx b/studio/src/components/OverviewTab.jsx index 9e17b9e..239a215 100644 --- a/studio/src/components/OverviewTab.jsx +++ b/studio/src/components/OverviewTab.jsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { BarChart3, Hash, @@ -8,37 +9,23 @@ import { } from "lucide-react"; import { Stat, Bar, Chip, SentimentChip } from "./primitives.jsx"; import { TONE_COLOR } from "../lib/lexicons.js"; +import { overviewStats } from "../lib/stats.js"; /* ============================================================ * OVERVIEW TAB * ============================================================ */ export function OverviewTab({ tweets }) { - const total = tweets.length; - const byLabel = { positive: 0, neutral: 0, negative: 0 }; - const toneCounts = {}; - let totalLikes = 0, - totalRts = 0, - totalReplies = 0, - totalLen = 0, - totalEmoji = 0, - totalHashtags = 0; - for (const t of tweets) { - byLabel[t.sentiment.label]++; - for (const tag of t.tones) toneCounts[tag] = (toneCounts[tag] || 0) + 1; - totalLikes += t.likes; - totalRts += t.retweets; - totalReplies += t.replies; - totalLen += t.length; - totalEmoji += t.emoji; - totalHashtags += t.hashtags; - } - const toneSorted = Object.entries(toneCounts).sort((a, b) => b[1] - a[1]); - const avgLen = total ? Math.round(totalLen / total) : 0; - const avgEmoji = total ? (totalEmoji / total).toFixed(1) : 0; - const top = [...tweets] - .sort((a, b) => b.engagement - a.engagement) - .slice(0, 5); + const { + total, + byLabel, + toneSorted, + avgLen, + avgEmoji, + top, + totalLikes, + totalRts, + } = useMemo(() => overviewStats(tweets), [tweets]); return (
diff --git a/studio/src/lib/stats.js b/studio/src/lib/stats.js new file mode 100644 index 0000000..984859d --- /dev/null +++ b/studio/src/lib/stats.js @@ -0,0 +1,49 @@ +/* ============================================================ + * OVERVIEW STATS + * ============================================================ */ + +// Aggregate the dashboard metrics for the Overview tab. Pure and +// framework-free so the component can memoize it and it can be unit-tested +// directly. Logic moved verbatim out of OverviewTab. +export function overviewStats(tweets) { + const total = tweets.length; + const byLabel = { positive: 0, neutral: 0, negative: 0 }; + const toneCounts = {}; + let totalLikes = 0, + totalRts = 0, + totalReplies = 0, + totalLen = 0, + totalEmoji = 0, + totalHashtags = 0; + for (const t of tweets) { + byLabel[t.sentiment.label]++; + for (const tag of t.tones) toneCounts[tag] = (toneCounts[tag] || 0) + 1; + totalLikes += t.likes; + totalRts += t.retweets; + totalReplies += t.replies; + totalLen += t.length; + totalEmoji += t.emoji; + totalHashtags += t.hashtags; + } + const toneSorted = Object.entries(toneCounts).sort((a, b) => b[1] - a[1]); + const avgLen = total ? Math.round(totalLen / total) : 0; + const avgEmoji = total ? (totalEmoji / total).toFixed(1) : 0; + const top = [...tweets] + .sort((a, b) => b.engagement - a.engagement) + .slice(0, 5); + return { + total, + byLabel, + toneCounts, + totalLikes, + totalRts, + totalReplies, + totalLen, + totalEmoji, + totalHashtags, + toneSorted, + avgLen, + avgEmoji, + top, + }; +} diff --git a/studio/src/lib/stats.test.js b/studio/src/lib/stats.test.js new file mode 100644 index 0000000..1752e22 --- /dev/null +++ b/studio/src/lib/stats.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { overviewStats } from "./stats.js"; + +function tw(over) { + return { + sentiment: { label: "neutral" }, + tones: [], + likes: 0, + retweets: 0, + replies: 0, + length: 0, + emoji: 0, + hashtags: 0, + engagement: 0, + ...over, + }; +} + +describe("overviewStats", () => { + it("returns zeros and empties for no tweets", () => { + const s = overviewStats([]); + expect(s.total).toBe(0); + expect(s.byLabel).toEqual({ positive: 0, neutral: 0, negative: 0 }); + expect(s.avgLen).toBe(0); + expect(s.toneSorted).toEqual([]); + expect(s.top).toEqual([]); + }); + + it("counts sentiment labels", () => { + const s = overviewStats([ + tw({ sentiment: { label: "positive" } }), + tw({ sentiment: { label: "positive" } }), + tw({ sentiment: { label: "negative" } }), + ]); + expect(s.total).toBe(3); + expect(s.byLabel).toEqual({ positive: 2, neutral: 0, negative: 1 }); + }); + + it("sums engagement metrics and averages length/emoji", () => { + const s = overviewStats([ + tw({ likes: 10, retweets: 2, length: 100, emoji: 2 }), + tw({ likes: 5, retweets: 0, length: 50, emoji: 0 }), + ]); + expect(s.totalLikes).toBe(15); + expect(s.totalRts).toBe(2); + expect(s.avgLen).toBe(75); // (100 + 50) / 2 + expect(s.avgEmoji).toBe("1.0"); // (2 + 0) / 2, toFixed(1) + }); + + it("tallies tones sorted by frequency descending", () => { + const s = overviewStats([ + tw({ tones: ["Excited", "CTA"] }), + tw({ tones: ["Excited"] }), + ]); + expect(s.toneSorted[0]).toEqual(["Excited", 2]); + expect(s.toneSorted).toContainEqual(["CTA", 1]); + }); + + it("ranks top performers by engagement and caps at 5", () => { + const tweets = [1, 5, 3, 2, 9, 4, 7].map((e) => tw({ engagement: e })); + const s = overviewStats(tweets); + expect(s.top).toHaveLength(5); + expect(s.top.map((t) => t.engagement)).toEqual([9, 7, 5, 4, 3]); + }); +});