Skip to content

Commit bccebc9

Browse files
Expand to all 20 UK series, Taskmaster theme, rich dashboard
- Scraper: fetch all 20 UK series from taskmaster.info (was 9) - Data: 100 contestants across 20 seasons with full task breakdowns - Theme: Taskmaster-inspired styling (maroon, cream, typewriter fonts) - Dashboard: new tiles for task makeup, season stats, all-time records - WatchMode: all 20 seasons available for play-along scoring - Fix: ChatMessage type import, blank page CSS issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cd97e45 commit bccebc9

17 files changed

Lines changed: 7764 additions & 996 deletions

client/src/App.css

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1 @@
1-
#root {
2-
max-width: 1280px;
3-
margin: 0 auto;
4-
padding: 2rem;
5-
text-align: center;
6-
}
7-
8-
.logo {
9-
height: 6em;
10-
padding: 1.5em;
11-
will-change: filter;
12-
transition: filter 300ms;
13-
}
14-
.logo:hover {
15-
filter: drop-shadow(0 0 2em #646cffaa);
16-
}
17-
.logo.react:hover {
18-
filter: drop-shadow(0 0 2em #61dafbaa);
19-
}
20-
21-
@keyframes logo-spin {
22-
from {
23-
transform: rotate(0deg);
24-
}
25-
to {
26-
transform: rotate(360deg);
27-
}
28-
}
29-
30-
@media (prefers-reduced-motion: no-preference) {
31-
a:nth-of-type(2) .logo {
32-
animation: logo-spin infinite 20s linear;
33-
}
34-
}
35-
36-
.card {
37-
padding: 2em;
38-
}
39-
40-
.read-the-docs {
41-
color: #888;
42-
}
1+
/* App styles are in styles/theme.css */

client/src/components/ChatPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useRef, useEffect } from "react";
2-
import { sendChatMessage, ChatMessage } from "../hooks/useData";
2+
import { sendChatMessage } from "../hooks/useData";
3+
import type { ChatMessage } from "../hooks/useData";
34

45
const SUGGESTIONS = [
56
"Do winners tend to score better on prize tasks?",

client/src/components/ContestantDetail.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export default function ContestantDetail({ contestant: c, onClose }: Props) {
3636
<div className="detail-panel">
3737
<button className="detail-close" onClick={onClose}></button>
3838

39-
<h2 style={{ color: "var(--tm-gold)", marginBottom: "0.5rem" }}>
39+
<h2 style={{ color: "var(--tm-red)", marginBottom: "0.5rem", fontFamily: "'Special Elite', 'Courier New', monospace" }}>
4040
{c.name}
4141
{c.seasonWins > 0 && <span className="winner-badge">🏆</span>}
4242
</h2>
@@ -57,7 +57,7 @@ export default function ContestantDetail({ contestant: c, onClose }: Props) {
5757
<BreakdownTable title="⚖️ By Judgement" data={c.taskBreakdown.judgement} />
5858

5959
<div style={{ marginTop: "1rem" }}>
60-
<h3 style={{ color: "var(--tm-gold-light)", marginBottom: "0.5rem" }}>Extra Stats</h3>
60+
<h3 style={{ color: "var(--tm-brown)", marginBottom: "0.5rem" }}>Extra Stats</h3>
6161
<p style={{ color: "var(--tm-text-muted)", fontSize: "0.9rem" }}>
6262
Tasks: {c.tasksAttempted} attempted, {c.tasksWon} won ({c.taskWinPct}%)<br />
6363
Bonus: +{c.bonusPoints} · Deductions: -{c.pointsDeducted} · DQs: {c.dqs}
@@ -74,7 +74,7 @@ function BreakdownTable({ title, data }: { title: string; data: Record<string, {
7474

7575
return (
7676
<div style={{ marginBottom: "1rem" }}>
77-
<h3 style={{ color: "var(--tm-gold-light)", marginBottom: "0.5rem" }}>{title}</h3>
77+
<h3 style={{ color: "var(--tm-brown)", marginBottom: "0.5rem" }}>{title}</h3>
7878
<table className="comparison-table" style={{ fontSize: "0.85rem" }}>
7979
<thead>
8080
<tr><th>Type</th><th>Att.</th><th>Won</th><th>Win%</th><th>PpT</th></tr>

client/src/components/Dashboard.tsx

Lines changed: 244 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { useState, useEffect } from "react";
2-
import { fetchContestants, fetchAnalysis, fetchSeasons } from "../hooks/useData";
2+
import { fetchContestants, fetchAnalysis } from "../hooks/useData";
3+
4+
interface TaskStat { attempted: number; won: number; winPct: number; ppt: number }
5+
interface Contestant {
6+
id: number; name: string; seasonIds: number[]; seasonWins: number;
7+
episodes: number; episodeWins: number; episodeWinPct: number;
8+
totalPoints: number; pointsPerTask: number; pointsPerEpisode: number;
9+
tasksAttempted: number; tasksWon: number; taskWinPct: number;
10+
basePoints: number; bonusPoints: number; pointsDeducted: number; dqs: number;
11+
taskBreakdown: {
12+
format: Record<string, TaskStat>;
13+
setting: Record<string, TaskStat>;
14+
activity: Record<string, TaskStat>;
15+
judgement: Record<string, TaskStat>;
16+
};
17+
}
318

419
interface Analysis {
520
totalContestants: number;
@@ -9,28 +24,136 @@ interface Analysis {
924
seasonWinners: { season: number; name: string; id: number }[];
1025
}
1126

27+
function computeShowStats(contestants: Contestant[]) {
28+
// Task makeup by setting (prize/filmed/homework/live)
29+
const settingTotals: Record<string, number> = {};
30+
const formatTotals: Record<string, number> = {};
31+
const activityTotals: Record<string, number> = {};
32+
const judgementTotals: Record<string, number> = {};
33+
let totalTasks = 0;
34+
let totalBonus = 0, totalDeductions = 0, totalDQs = 0;
35+
let totalEpisodes = 0, totalPoints = 0;
36+
37+
// Per-season aggregates
38+
const seasonData: Record<number, { bonus: number; deductions: number; dqs: number; points: number; episodes: number; contestants: number }> = {};
39+
40+
// Records
41+
let highestPpT = { name: "", val: 0 };
42+
let lowestPpT = { name: "", val: Infinity };
43+
let mostDQs = { name: "", val: 0 };
44+
let mostEpWins = { name: "", val: 0 };
45+
let mostTasksWon = { name: "", val: 0 };
46+
let highestTotal = { name: "", val: 0 };
47+
let lowestTotal = { name: "", val: Infinity };
48+
49+
for (const c of contestants) {
50+
// Aggregates
51+
totalBonus += c.bonusPoints;
52+
totalDeductions += c.pointsDeducted;
53+
totalDQs += c.dqs;
54+
totalPoints += c.totalPoints;
55+
56+
// Records
57+
if (c.pointsPerTask > highestPpT.val) highestPpT = { name: c.name, val: c.pointsPerTask };
58+
if (c.pointsPerTask < lowestPpT.val) lowestPpT = { name: c.name, val: c.pointsPerTask };
59+
if (c.dqs > mostDQs.val) mostDQs = { name: c.name, val: c.dqs };
60+
if (c.episodeWins > mostEpWins.val) mostEpWins = { name: c.name, val: c.episodeWins };
61+
if (c.tasksWon > mostTasksWon.val) mostTasksWon = { name: c.name, val: c.tasksWon };
62+
if (c.totalPoints > highestTotal.val) highestTotal = { name: c.name, val: c.totalPoints };
63+
if (c.totalPoints < lowestTotal.val) lowestTotal = { name: c.name, val: c.totalPoints };
64+
65+
for (const sId of c.seasonIds) {
66+
if (!seasonData[sId]) seasonData[sId] = { bonus: 0, deductions: 0, dqs: 0, points: 0, episodes: 0, contestants: 0 };
67+
seasonData[sId].bonus += c.bonusPoints;
68+
seasonData[sId].deductions += c.pointsDeducted;
69+
seasonData[sId].dqs += c.dqs;
70+
seasonData[sId].points += c.totalPoints;
71+
seasonData[sId].contestants += 1;
72+
seasonData[sId].episodes = Math.max(seasonData[sId].episodes, c.episodes);
73+
}
74+
75+
// Task breakdowns — divide by 5 (contestants per season) to get unique task counts
76+
for (const [k, v] of Object.entries(c.taskBreakdown.setting)) {
77+
settingTotals[k] = (settingTotals[k] || 0) + v.attempted;
78+
}
79+
for (const [k, v] of Object.entries(c.taskBreakdown.format)) {
80+
formatTotals[k] = (formatTotals[k] || 0) + v.attempted;
81+
}
82+
for (const [k, v] of Object.entries(c.taskBreakdown.activity)) {
83+
activityTotals[k] = (activityTotals[k] || 0) + v.attempted;
84+
}
85+
for (const [k, v] of Object.entries(c.taskBreakdown.judgement)) {
86+
judgementTotals[k] = (judgementTotals[k] || 0) + v.attempted;
87+
}
88+
totalTasks += c.tasksAttempted;
89+
}
90+
91+
// Each task is attempted by 5 contestants, so unique tasks = total / 5
92+
const uniqueTasks = Math.round(totalTasks / 5);
93+
for (const seasons of Object.values(seasonData)) {
94+
totalEpisodes += seasons.episodes;
95+
}
96+
97+
// Per-season bonus/dqs for the table
98+
const seasonStats = Object.entries(seasonData)
99+
.map(([s, d]) => ({ season: parseInt(s), ...d, avgPpE: +(d.points / (d.episodes * 5)).toFixed(1) }))
100+
.sort((a, b) => a.season - b.season);
101+
102+
return {
103+
settingTotals, formatTotals, activityTotals, judgementTotals,
104+
uniqueTasks, totalTasks, totalBonus, totalDeductions, totalDQs,
105+
totalEpisodes, totalPoints,
106+
seasonStats,
107+
records: { highestPpT, lowestPpT, mostDQs, mostEpWins, mostTasksWon, highestTotal, lowestTotal },
108+
};
109+
}
110+
12111
export default function Dashboard() {
13112
const [analysis, setAnalysis] = useState<Analysis | null>(null);
113+
const [contestants, setContestants] = useState<Contestant[]>([]);
14114
const [loading, setLoading] = useState(true);
15115

16116
useEffect(() => {
17-
fetchAnalysis().then((data) => { setAnalysis(data); setLoading(false); });
117+
Promise.all([fetchAnalysis(), fetchContestants()]).then(([a, c]) => {
118+
setAnalysis(a);
119+
setContestants(c);
120+
setLoading(false);
121+
});
18122
}, []);
19123

20124
if (loading || !analysis) return <div className="loading"><div className="spinner" /> Loading insights...</div>;
21125

22126
const w = analysis.winners;
23127
const nw = analysis.nonWinners;
128+
const stats = computeShowStats(contestants);
129+
130+
const divBy5 = (totals: Record<string, number>) => {
131+
const out: Record<string, number> = {};
132+
for (const [k, v] of Object.entries(totals)) out[k] = Math.round(v / 5);
133+
return out;
134+
};
135+
const settingTasks = divBy5(stats.settingTotals);
136+
const formatTasks = divBy5(stats.formatTotals);
137+
const activityTasks = divBy5(stats.activityTotals);
138+
const settingTotal = Object.values(settingTasks).reduce((a, b) => a + b, 0);
139+
const formatTotal = Object.values(formatTasks).reduce((a, b) => a + b, 0);
140+
const activityTotal = Object.values(activityTasks).reduce((a, b) => a + b, 0);
141+
142+
const settingLabels: Record<string, string> = { prize: "🎁 Prize", filmed: "🎬 Filmed", homework: "📝 Homework", live: "🎤 Live" };
143+
const formatLabels: Record<string, string> = { solo: "👤 Solo", team: "👥 Team", split: "✂️ Split", tiebreak: "⚡ Tiebreak" };
144+
const activityLabels: Record<string, string> = { creative: "🎨 Creative", mental: "🧠 Mental", physical: "💪 Physical", social: "🗣️ Social" };
24145

25146
return (
26147
<div>
27148
<div className="card">
28149
<h2>🏆 Taskmaster UK — By The Numbers</h2>
29150
<div className="stat-grid">
30151
<div className="stat-item"><div className="stat-value">{analysis.totalContestants}</div><div className="stat-label">Contestants</div></div>
31-
<div className="stat-item"><div className="stat-value">{analysis.totalSeasons}</div><div className="stat-label">Seasons</div></div>
32-
<div className="stat-item"><div className="stat-value">{w.count}</div><div className="stat-label">Champions</div></div>
33-
<div className="stat-item"><div className="stat-value">{w.avgPointsPerTask}</div><div className="stat-label">Avg Winner PpT</div></div>
152+
<div className="stat-item"><div className="stat-value">{analysis.totalSeasons}</div><div className="stat-label">Series</div></div>
153+
<div className="stat-item"><div className="stat-value">{stats.totalEpisodes}</div><div className="stat-label">Episodes</div></div>
154+
<div className="stat-item"><div className="stat-value">{stats.uniqueTasks}</div><div className="stat-label">Tasks</div></div>
155+
<div className="stat-item"><div className="stat-value">{stats.totalDQs}</div><div className="stat-label">Disqualifications</div></div>
156+
<div className="stat-item"><div className="stat-value">{stats.totalBonus}</div><div className="stat-label">Bonus Points Awarded</div></div>
34157
</div>
35158
</div>
36159

@@ -74,32 +197,139 @@ export default function Dashboard() {
74197
</div>
75198

76199
<div className="card">
77-
<h2>🏆 Season Champions</h2>
200+
<h2>📋 Task Makeup — By Setting</h2>
201+
<table className="comparison-table">
202+
<thead><tr><th>Setting</th><th>Tasks</th><th>% of Total</th></tr></thead>
203+
<tbody>
204+
{Object.entries(settingTasks)
205+
.sort(([,a],[,b]) => b - a)
206+
.map(([k, v]) => (
207+
<tr key={k}>
208+
<td>{settingLabels[k] || k}</td>
209+
<td><strong>{v}</strong></td>
210+
<td>{((v / settingTotal) * 100).toFixed(1)}%</td>
211+
</tr>
212+
))}
213+
</tbody>
214+
</table>
215+
<BarChart data={settingTasks} labels={settingLabels} total={settingTotal} />
216+
</div>
217+
218+
<div className="card">
219+
<h2>👥 Task Makeup — By Format</h2>
220+
<table className="comparison-table">
221+
<thead><tr><th>Format</th><th>Tasks</th><th>% of Total</th></tr></thead>
222+
<tbody>
223+
{Object.entries(formatTasks)
224+
.sort(([,a],[,b]) => b - a)
225+
.map(([k, v]) => (
226+
<tr key={k}>
227+
<td>{formatLabels[k] || k}</td>
228+
<td><strong>{v}</strong></td>
229+
<td>{((v / formatTotal) * 100).toFixed(1)}%</td>
230+
</tr>
231+
))}
232+
</tbody>
233+
</table>
234+
<BarChart data={formatTasks} labels={formatLabels} total={formatTotal} />
235+
</div>
236+
237+
<div className="card">
238+
<h2>🎯 Task Makeup — By Activity</h2>
78239
<table className="comparison-table">
79-
<thead><tr><th>Season</th><th>Champion</th></tr></thead>
240+
<thead><tr><th>Activity</th><th>Tasks</th><th>% of Total</th></tr></thead>
80241
<tbody>
81-
{analysis.seasonWinners.map((sw) => (
82-
<tr key={sw.season}>
83-
<td>Series {sw.season}</td>
84-
<td><strong>{sw.name}</strong></td>
242+
{Object.entries(activityTasks)
243+
.sort(([,a],[,b]) => b - a)
244+
.map(([k, v]) => (
245+
<tr key={k}>
246+
<td>{activityLabels[k] || k}</td>
247+
<td><strong>{v}</strong></td>
248+
<td>{((v / activityTotal) * 100).toFixed(1)}%</td>
249+
</tr>
250+
))}
251+
</tbody>
252+
</table>
253+
<BarChart data={activityTasks} labels={activityLabels} total={activityTotal} />
254+
</div>
255+
256+
<div className="card card-full">
257+
<h2>📊 Season-by-Season Stats</h2>
258+
<table className="comparison-table" style={{ tableLayout: "auto" }}>
259+
<thead><tr><th>Series</th><th>Eps</th><th>Bonus</th><th>Deductions</th><th>DQs</th><th>Avg PpE</th></tr></thead>
260+
<tbody>
261+
{stats.seasonStats.map((s) => (
262+
<tr key={s.season}>
263+
<td>S{s.season}</td>
264+
<td>{s.episodes}</td>
265+
<td>{s.bonus}</td>
266+
<td>{s.deductions}</td>
267+
<td>{s.dqs}</td>
268+
<td><strong>{s.avgPpE}</strong></td>
85269
</tr>
86270
))}
87271
</tbody>
88272
</table>
89273
</div>
274+
275+
<div className="card">
276+
<h2>🎖️ All-Time Records</h2>
277+
<table className="comparison-table">
278+
<thead><tr><th>Record</th><th>Contestant</th><th>Value</th></tr></thead>
279+
<tbody>
280+
<tr><td>🔥 Highest PpT</td><td><strong>{stats.records.highestPpT.name}</strong></td><td>{stats.records.highestPpT.val}</td></tr>
281+
<tr><td>📉 Lowest PpT</td><td><strong>{stats.records.lowestPpT.name}</strong></td><td>{stats.records.lowestPpT.val}</td></tr>
282+
<tr><td>⭐ Most Points</td><td><strong>{stats.records.highestTotal.name}</strong></td><td>{stats.records.highestTotal.val}</td></tr>
283+
<tr><td>😬 Fewest Points</td><td><strong>{stats.records.lowestTotal.name}</strong></td><td>{stats.records.lowestTotal.val}</td></tr>
284+
<tr><td>🏅 Most Tasks Won</td><td><strong>{stats.records.mostTasksWon.name}</strong></td><td>{stats.records.mostTasksWon.val}</td></tr>
285+
<tr><td>📺 Most Episode Wins</td><td><strong>{stats.records.mostEpWins.name}</strong></td><td>{stats.records.mostEpWins.val}</td></tr>
286+
<tr><td>🚫 Most DQs</td><td><strong>{stats.records.mostDQs.name}</strong></td><td>{stats.records.mostDQs.val}</td></tr>
287+
</tbody>
288+
</table>
289+
</div>
90290
</div>
91291
</div>
92292
);
93293
}
94294

295+
function BarChart({ data, labels, total }: { data: Record<string, number>; labels: Record<string, string>; total: number }) {
296+
const sorted = Object.entries(data).sort(([,a],[,b]) => b - a);
297+
const max = sorted[0]?.[1] || 1;
298+
const colors = ["#790000", "#a1663c", "#CC0000", "#44250a", "#8a7a60"];
299+
return (
300+
<div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "0.35rem" }}>
301+
{sorted.map(([k, v], i) => (
302+
<div key={k} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
303+
<span style={{ width: "110px", fontSize: "0.75rem", textAlign: "right", flexShrink: 0 }}>
304+
{(labels[k] || k).replace(/^[^\s]+\s/, "")}
305+
</span>
306+
<div style={{ flex: 1, background: "var(--tm-cream-dark)", borderRadius: "3px", height: "18px", overflow: "hidden" }}>
307+
<div style={{
308+
width: `${(v / max) * 100}%`,
309+
height: "100%",
310+
background: colors[i % colors.length],
311+
borderRadius: "3px",
312+
transition: "width 0.5s",
313+
}} />
314+
</div>
315+
<span style={{ fontSize: "0.75rem", width: "40px", flexShrink: 0 }}>
316+
{((v / total) * 100).toFixed(0)}%
317+
</span>
318+
</div>
319+
))}
320+
</div>
321+
);
322+
}
323+
95324
function CompRow({ label, w, nw, suffix = "" }: { label: string; w: number; nw: number; suffix?: string }) {
96325
const edge = w - nw;
97-
const edgeStr = edge > 0 ? `+${edge.toFixed(2)}${suffix}` : `${edge.toFixed(2)}${suffix}`;
326+
const fmt = (n: number) => Number.isInteger(n) ? n.toString() : n.toFixed(2);
327+
const edgeStr = edge > 0 ? `+${fmt(edge)}${suffix}` : `${fmt(edge)}${suffix}`;
98328
return (
99329
<tr>
100330
<td>{label}</td>
101-
<td><strong>{w}{suffix}</strong></td>
102-
<td>{nw}{suffix}</td>
331+
<td><strong>{fmt(w)}{suffix}</strong></td>
332+
<td>{fmt(nw)}{suffix}</td>
103333
<td className={edge > 0 ? "highlight-better" : "highlight-worse"}>{edgeStr}</td>
104334
</tr>
105335
);

0 commit comments

Comments
 (0)