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]);
+ });
+});