@vatvaghool/create-ipl-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +75 -0
  2. package/package.json +27 -0
  3. package/src/generate-template.mjs +73 -0
  4. package/src/index.mjs +98 -0
  5. package/src/prompts.mjs +78 -0
  6. package/src/scaffold.mjs +129 -0
  7. package/src/scraper.mjs +79 -0
  8. package/template/.dockerignore +13 -0
  9. package/template/AGENTS.md +5 -0
  10. package/template/Dockerfile.sync +14 -0
  11. package/template/README.md +160 -0
  12. package/template/app/api/ipl/data.ts +24 -0
  13. package/template/app/api/ipl/route.ts +505 -0
  14. package/template/app/api/ipl/transfers/route.ts +261 -0
  15. package/template/app/api/ipl/transfers/transform.ts +156 -0
  16. package/template/app/api/ipl/transform.ts +20 -0
  17. package/template/app/api/ipl/upcoming-matches/route.ts +18 -0
  18. package/template/app/api/ops/status/route.ts +225 -0
  19. package/template/app/components/AIRoasting.tsx +278 -0
  20. package/template/app/components/ColorWave.tsx +193 -0
  21. package/template/app/components/CrownBattle.tsx +207 -0
  22. package/template/app/components/DashboardContent.tsx +377 -0
  23. package/template/app/components/FantasyStockTicker.tsx +192 -0
  24. package/template/app/components/FireworksBurst.tsx +225 -0
  25. package/template/app/components/LiveMatchTicker.tsx +117 -0
  26. package/template/app/components/MatchRecapScroll.tsx +135 -0
  27. package/template/app/components/MatchStoryScrubber.tsx +274 -0
  28. package/template/app/components/PerformanceTracker.tsx +132 -0
  29. package/template/app/components/ProgressGlowRings.tsx +157 -0
  30. package/template/app/components/TeamDNAScanner.tsx +238 -0
  31. package/template/app/components/ThemeToggle.tsx +74 -0
  32. package/template/app/components/dashboard/CaptainBoard.tsx +138 -0
  33. package/template/app/components/dashboard/ChartBoard.tsx +162 -0
  34. package/template/app/components/dashboard/LatestBadge.tsx +23 -0
  35. package/template/app/components/dashboard/LedgerTable.tsx +385 -0
  36. package/template/app/components/dashboard/SectionCard.tsx +59 -0
  37. package/template/app/components/dashboard/StickyMini.tsx +20 -0
  38. package/template/app/components/dashboard/index.ts +6 -0
  39. package/template/app/components/ui/DashboardChartFrame.tsx +74 -0
  40. package/template/app/components/ui/DoodleSpinner.tsx +15 -0
  41. package/template/app/components/ui/TeamPills.tsx +41 -0
  42. package/template/app/data/match-points.ts +3 -0
  43. package/template/app/data/teams.ts +32 -0
  44. package/template/app/globals.css +1267 -0
  45. package/template/app/hooks/dashboard/index.ts +1 -0
  46. package/template/app/hooks/dashboard/useDashboardModel.ts +25 -0
  47. package/template/app/hooks/dashboardCache.ts +53 -0
  48. package/template/app/hooks/dashboardPolling.ts +53 -0
  49. package/template/app/hooks/snapshotCache.ts +47 -0
  50. package/template/app/hooks/useDashboardData.ts +28 -0
  51. package/template/app/layout.tsx +75 -0
  52. package/template/app/lib/aiAgent.ts +444 -0
  53. package/template/app/lib/config.ts +29 -0
  54. package/template/app/lib/dashboard/index.ts +1 -0
  55. package/template/app/lib/dashboard/model.ts +257 -0
  56. package/template/app/lib/dashboardData.ts +50 -0
  57. package/template/app/lib/dashboardView.ts +22 -0
  58. package/template/app/lib/detailedData.ts +112 -0
  59. package/template/app/lib/matchStatus.ts +28 -0
  60. package/template/app/lib/matches.ts +131 -0
  61. package/template/app/lib/teamBadges.ts +223 -0
  62. package/template/app/lib/upcomingMatches.ts +154 -0
  63. package/template/app/lib/useDb.ts +29 -0
  64. package/template/app/lib/utils/diff.ts +24 -0
  65. package/template/app/lib/utils/getChartColor.ts +17 -0
  66. package/template/app/lib/utils/getStdDeviation.ts +6 -0
  67. package/template/app/lib/utils/time.ts +40 -0
  68. package/template/app/lib/utils.ts +70 -0
  69. package/template/app/page.tsx +15 -0
  70. package/template/app/store/dashboardStore.ts +85 -0
  71. package/template/app/types/dashboard.ts +75 -0
  72. package/template/app/types.ts +130 -0
  73. package/template/app/utils/dashboard/index.ts +72 -0
  74. package/template/eslint.config.mjs +18 -0
  75. package/template/infra/cloud-run/README.md +68 -0
  76. package/template/infra/cloud-run/sync-job.yaml +32 -0
  77. package/template/infra/cutover/README.md +84 -0
  78. package/template/infra/vercel/README.md +57 -0
  79. package/template/next.config.ts +7 -0
  80. package/template/package-lock.json +7330 -0
  81. package/template/package.json +47 -0
  82. package/template/packages/ipl-dashboard-utils/README.md +316 -0
  83. package/template/packages/ipl-dashboard-utils/package.json +34 -0
  84. package/template/packages/ipl-dashboard-utils/src/index.ts +22 -0
  85. package/template/packages/ipl-dashboard-utils/src/transform.ts +687 -0
  86. package/template/packages/ipl-dashboard-utils/src/types.ts +88 -0
  87. package/template/packages/ipl-dashboard-utils/tsconfig.build.json +17 -0
  88. package/template/postcss.config.mjs +7 -0
  89. package/template/scripts/capture-ipl-auth.mjs +54 -0
  90. package/template/scripts/deploy-cloud-run-sync.sh +48 -0
  91. package/template/scripts/deploy-cloud-scheduler.sh +42 -0
  92. package/template/scripts/dev-simple.js +31 -0
  93. package/template/scripts/dev-welcome.mjs +38 -0
  94. package/template/scripts/monitor-ops-status.sh +50 -0
  95. package/template/scripts/seed-mongodb.ts +115 -0
  96. package/template/scripts/sync-cloud.mjs +50 -0
  97. package/template/scripts/sync-ipl.mjs +238 -0
  98. package/template/scripts/sync-transfers-daily.mjs +175 -0
  99. package/template/scripts/verify-production.mjs +108 -0
  100. package/template/tests/coverage-gaps.test.ts +290 -0
  101. package/template/tests/dashboard-polling.test.ts +96 -0
  102. package/template/tests/detailed-data.test.ts +60 -0
  103. package/template/tests/ipl-transform.test.ts +590 -0
  104. package/template/tests/transfers-route.test.ts +109 -0
  105. package/template/tests/upcoming-matches.test.ts +34 -0
  106. package/template/tests/utils-and-cache.test.ts +267 -0
  107. package/template/tsconfig.json +35 -0
  108. package/template/vercel.json +7 -0
@@ -0,0 +1,274 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { FaBackwardStep, FaForwardStep, FaClock } from "react-icons/fa6";
5
+ import { Bar, BarChart, Cell, ResponsiveContainer, XAxis, YAxis } from "recharts";
6
+ import { motion, AnimatePresence } from "framer-motion";
7
+ import type { DashboardData } from "../types";
8
+ import { getPlayedMatchRows, parseMatchId } from "../lib/dashboardData";
9
+ import { formatCompactNumber } from "../lib/utils";
10
+ import { getChartColor } from "../lib/utils/getChartColor";
11
+
12
+ type MatchSnapshot = {
13
+ id: number;
14
+ label: string;
15
+ entries: { team: string; points: number; fill: string }[];
16
+ leader: string;
17
+ leaderPts: number;
18
+ totalPoints: number;
19
+ };
20
+
21
+ export default function MatchStoryScrubber({
22
+ data,
23
+ }: {
24
+ data: DashboardData | null;
25
+ }) {
26
+ const snapshots = useMemo(() => {
27
+ if (!data?.overall?.length) return [];
28
+ const daily = getPlayedMatchRows(data.daily);
29
+ const teamOrder = [...data.overall].sort((a, b) => a.rank - b.rank).map((t) => t.name);
30
+
31
+ const results: MatchSnapshot[] = daily
32
+ .map((row) => {
33
+ const id = parseMatchId(row.day);
34
+ if (typeof id !== "number") return null;
35
+
36
+ const entries = teamOrder
37
+ .map((name, i) => ({
38
+ team: name,
39
+ points: Number(row[name] ?? 0),
40
+ fill: getChartColor(i, teamOrder.length),
41
+ }))
42
+ .filter((e) => e.points > 0);
43
+
44
+ if (!entries.length) return null;
45
+
46
+ const sorted = [...entries].sort((a, b) => b.points - a.points);
47
+ return {
48
+ id,
49
+ label: `Match ${id}`,
50
+ entries,
51
+ leader: sorted[0].team,
52
+ leaderPts: sorted[0].points,
53
+ totalPoints: entries.reduce((s, e) => s + e.points, 0),
54
+ };
55
+ })
56
+ .filter((m): m is MatchSnapshot => m !== null)
57
+ .sort((a, b) => a.id - b.id);
58
+
59
+ return results;
60
+ }, [data]);
61
+
62
+ const totalCumulatives = useMemo(() => {
63
+ if (!snapshots.length) return [];
64
+ const map = new Map<string, number>();
65
+ return snapshots.map((m) => {
66
+ m.entries.forEach((e) => {
67
+ map.set(e.team, (map.get(e.team) ?? 0) + e.points);
68
+ });
69
+ return [...map.entries()]
70
+ .sort((a, b) => b[1] - a[1])
71
+ .map(([team, pts]) => ({ team, pts }));
72
+ });
73
+ }, [snapshots]);
74
+
75
+ const [currentIdx, setCurrentIdx] = useState(snapshots.length - 1);
76
+
77
+ if (!snapshots.length) {
78
+ return <div className="ledger-note py-6 text-center">No match data yet...</div>;
79
+ }
80
+
81
+ const current = snapshots[currentIdx];
82
+ const cumulatives = totalCumulatives[currentIdx];
83
+ const progress = snapshots.length > 1 ? currentIdx / (snapshots.length - 1) : 0;
84
+
85
+ return (
86
+ <div
87
+ className="rounded-2xl border-2 p-4 wobbly"
88
+ style={{
89
+ borderColor: "color-mix(in srgb, var(--marker-blue) 34%, var(--line))",
90
+ background:
91
+ "linear-gradient(180deg, color-mix(in srgb, var(--paper) 90%, var(--paper-3) 10%), color-mix(in srgb, var(--paper) 92%, var(--paper-2) 8%))",
92
+ }}
93
+ >
94
+ <div className="mb-3 flex items-center gap-2 border-b border-dashed border-(--border-wobbly)/40 pb-2">
95
+ <FaClock className="text-lg" style={{ color: "var(--marker-blue)" }} />
96
+ <span className="font-bold font-(--font-note) text-lg text-(--ink)">
97
+ Match Story Scrubber
98
+ </span>
99
+ <span className="text-xs text-(--ink-faint) ml-auto">
100
+ drag to relive the season
101
+ </span>
102
+ </div>
103
+
104
+ <div className="mb-3 flex items-center gap-2">
105
+ <button
106
+ type="button"
107
+ onClick={() => setCurrentIdx(Math.max(0, currentIdx - 1))}
108
+ disabled={currentIdx === 0}
109
+ className="rounded-lg border p-2 text-[15px] disabled:opacity-30 hover-jitter"
110
+ style={{
111
+ borderColor: "color-mix(in srgb, var(--marker-blue) 30%, var(--line))",
112
+ background: "color-mix(in srgb, var(--panel) 60%, transparent)",
113
+ color: "var(--ink)",
114
+ }}
115
+ >
116
+ <FaBackwardStep />
117
+ </button>
118
+
119
+ <div className="flex-1 relative h-6">
120
+ <input
121
+ type="range"
122
+ min={0}
123
+ max={snapshots.length - 1}
124
+ value={currentIdx}
125
+ onChange={(e) => setCurrentIdx(Number(e.target.value))}
126
+ className="w-full h-full cursor-pointer opacity-0 absolute inset-0 z-10"
127
+ />
128
+ <div
129
+ className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full"
130
+ style={{ background: "color-mix(in srgb, var(--panel) 60%, transparent)" }}
131
+ >
132
+ <motion.div
133
+ className="h-full rounded-full"
134
+ style={{
135
+ background: "var(--marker-blue)",
136
+ width: `${progress * 100}%`,
137
+ }}
138
+ layout
139
+ />
140
+ </div>
141
+ <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-0.5 pointer-events-none">
142
+ {snapshots.filter((_, i) => i % Math.max(1, Math.floor(snapshots.length / 6)) === 0).map((m) => (
143
+ <span
144
+ key={m.id}
145
+ className="text-[9px] font-bold text-(--ink-faint) -translate-y-3"
146
+ >
147
+ M{m.id}
148
+ </span>
149
+ ))}
150
+ </div>
151
+ </div>
152
+
153
+ <button
154
+ type="button"
155
+ onClick={() => setCurrentIdx(Math.min(snapshots.length - 1, currentIdx + 1))}
156
+ disabled={currentIdx === snapshots.length - 1}
157
+ className="rounded-lg border p-2 text-[15px] disabled:opacity-30 hover-jitter"
158
+ style={{
159
+ borderColor: "color-mix(in srgb, var(--marker-blue) 30%, var(--line))",
160
+ background: "color-mix(in srgb, var(--panel) 60%, transparent)",
161
+ color: "var(--ink)",
162
+ }}
163
+ >
164
+ <FaForwardStep />
165
+ </button>
166
+ </div>
167
+
168
+ <AnimatePresence mode="wait">
169
+ <motion.div
170
+ key={current.id}
171
+ initial={{ opacity: 0, x: 30 }}
172
+ animate={{ opacity: 1, x: 0 }}
173
+ exit={{ opacity: 0, x: -30 }}
174
+ transition={{ duration: 0.25 }}
175
+ >
176
+ <div
177
+ className="rounded-xl border p-3 mb-3"
178
+ style={{
179
+ borderColor: "color-mix(in srgb, var(--marker-yellow) 24%, var(--line))",
180
+ background: "color-mix(in srgb, var(--marker-yellow) 6%, var(--paper) 94%)",
181
+ }}
182
+ >
183
+ <div className="flex items-center justify-between gap-2 text-base mb-2">
184
+ <span className="font-black text-(--ink)">{current.label}</span>
185
+ <span className="text-(--ink-faint)">
186
+ <span className="font-bold text-(--marker-green)">{current.leader}</span> leads with{" "}
187
+ {formatCompactNumber(current.leaderPts)} pts
188
+ </span>
189
+ </div>
190
+
191
+ <div>
192
+ <ResponsiveContainer width="100%" height={120}>
193
+ <BarChart
194
+ data={current.entries}
195
+ margin={{ top: 4, right: 4, bottom: 14, left: 0 }}
196
+ barCategoryGap="15%"
197
+ >
198
+ <XAxis
199
+ dataKey="team"
200
+ tickLine={false}
201
+ axisLine={false}
202
+ interval={0}
203
+ tick={({ x, y, payload }) => (
204
+ <text
205
+ x={x}
206
+ y={Number(y) + 6}
207
+ fill="var(--ink)"
208
+ fontSize={12}
209
+ fontWeight={700}
210
+ textAnchor="end"
211
+ transform={`rotate(-35, ${x}, ${y})`}
212
+ >
213
+ {String(payload.value).length > 10
214
+ ? String(payload.value).slice(0, 9) + "…"
215
+ : String(payload.value)}
216
+ </text>
217
+ )}
218
+ />
219
+ <YAxis hide />
220
+ <Bar dataKey="points" radius={[4, 4, 0, 0]} animationDuration={300}>
221
+ {current.entries.map((e) => (
222
+ <Cell key={e.team} fill={e.fill} opacity={0.85} />
223
+ ))}
224
+ </Bar>
225
+ </BarChart>
226
+ </ResponsiveContainer>
227
+ </div>
228
+ </div>
229
+
230
+ {cumulatives && (
231
+ <div
232
+ className="rounded-xl border p-2.5"
233
+ style={{
234
+ borderColor: "color-mix(in srgb, var(--marker-blue) 20%, var(--line))",
235
+ background: "color-mix(in srgb, var(--panel) 40%, transparent)",
236
+ }}
237
+ >
238
+ <p className="text-sm font-bold text-(--ink-faint) uppercase tracking-wider mb-1.5">
239
+ Cumulative Standings
240
+ </p>
241
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-1">
242
+ {cumulatives.slice(0, 8).map((c, i) => (
243
+ <div
244
+ key={c.team}
245
+ className="flex items-center gap-1.5 rounded-lg px-1.5 py-1"
246
+ style={{
247
+ background: "color-mix(in srgb, var(--paper) 80%, var(--panel) 20%)",
248
+ }}
249
+ >
250
+ <span
251
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-black"
252
+ style={{
253
+ background: `color-mix(in srgb, ${getChartColor(i, 8)} 30%, var(--paper) 70%)`,
254
+ color: "var(--ink)",
255
+ }}
256
+ >
257
+ {i + 1}
258
+ </span>
259
+ <span className="truncate text-xs font-semibold text-(--ink) flex-1">
260
+ {c.team}
261
+ </span>
262
+ <span className="text-xs font-black text-(--ink-soft)">
263
+ {formatCompactNumber(c.pts)}
264
+ </span>
265
+ </div>
266
+ ))}
267
+ </div>
268
+ </div>
269
+ )}
270
+ </motion.div>
271
+ </AnimatePresence>
272
+ </div>
273
+ );
274
+ }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import {
5
+ CartesianGrid,
6
+ Legend,
7
+ Line,
8
+ LineChart,
9
+ ResponsiveContainer,
10
+ Tooltip,
11
+ XAxis,
12
+ YAxis,
13
+ } from "recharts";
14
+ import type { DailyChartRow } from "../types";
15
+ import { getPlayedMatchRows, parseMatchId } from "../lib/dashboardData";
16
+ import { getChartColor } from "../lib/utils/getChartColor";
17
+ import { useDashboardStore } from "../store/dashboardStore";
18
+ import { DashboardChartFrame } from "./ui/DashboardChartFrame";
19
+
20
+ const formatMatchTick = (value: string) => {
21
+ const parsed = parseMatchId(value);
22
+ return typeof parsed === "number" ? `M${parsed}` : value;
23
+ };
24
+
25
+ export default function PerformanceTracker() {
26
+ const data = useDashboardStore((state) => state.data);
27
+
28
+ const { chartData, teams } = useMemo(() => {
29
+ if (!data?.daily?.length) {
30
+ return { chartData: [] as DailyChartRow[], teams: [] as string[] };
31
+ }
32
+
33
+ const filteredData = getPlayedMatchRows(data.daily);
34
+ const names = Array.from(
35
+ new Set(filteredData.flatMap((row) => Object.keys(row))),
36
+ ).filter((key) => key !== "day");
37
+
38
+ const cumulativeData = filteredData.reduce<DailyChartRow[]>(
39
+ (acc, row, index) => {
40
+ const previous = acc[index - 1] ?? { day: "" };
41
+ const cumulativeRow: DailyChartRow = { day: row.day };
42
+
43
+ names.forEach((team) => {
44
+ const current = Number(row[team] ?? 0);
45
+ const previousTotal = Number(previous[team] ?? 0);
46
+ cumulativeRow[team] = previousTotal + current;
47
+ });
48
+
49
+ acc.push(cumulativeRow);
50
+ return acc;
51
+ },
52
+ [],
53
+ );
54
+
55
+ return { chartData: cumulativeData, teams: names };
56
+ }, [data]);
57
+
58
+ if (!chartData.length || !teams.length) {
59
+ return (
60
+ <p className="ledger-note py-6">No performance data available yet.</p>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <div className="space-y-3">
66
+ <DashboardChartFrame chartHeight={380} isMobile={false}>
67
+ <ResponsiveContainer width="100%" height="100%">
68
+ <LineChart
69
+ data={chartData}
70
+ margin={{ top: 18, right: 26, bottom: 16, left: 4 }}
71
+ >
72
+ <CartesianGrid
73
+ stroke="color-mix(in srgb, var(--ink) 10%, transparent)"
74
+ strokeDasharray="4 6"
75
+ vertical={false}
76
+ />
77
+ <XAxis
78
+ dataKey="day"
79
+ tickFormatter={formatMatchTick}
80
+ tickLine={false}
81
+ axisLine={false}
82
+ tick={{ fill: "var(--ink)", fontSize: 11, fontWeight: 700 }}
83
+ />
84
+ <YAxis
85
+ tickFormatter={(value) => Number(value).toLocaleString()}
86
+ tickLine={false}
87
+ axisLine={false}
88
+ width={52}
89
+ tick={{ fill: "var(--ink-soft)", fontSize: 11, fontWeight: 700 }}
90
+ />
91
+ <Tooltip
92
+ contentStyle={{
93
+ background: "color-mix(in srgb, var(--paper) 96%, white 4%)",
94
+ border:
95
+ "1px solid color-mix(in srgb, var(--ink) 16%, transparent)",
96
+ borderRadius: "14px",
97
+ color: "var(--ink)",
98
+ }}
99
+ formatter={(value) =>
100
+ `${Number(value ?? 0).toLocaleString()} pts`
101
+ }
102
+ labelFormatter={(label) =>
103
+ `Match ${formatMatchTick(String(label)).replace("M", "")}`
104
+ }
105
+ />
106
+ <Legend
107
+ verticalAlign="top"
108
+ height={30}
109
+ wrapperStyle={{
110
+ fontSize: "11px",
111
+ fontWeight: 800,
112
+ color: "var(--ink)",
113
+ }}
114
+ />
115
+ {teams.map((team, index) => (
116
+ <Line
117
+ key={team}
118
+ type="natural"
119
+ dataKey={team}
120
+ stroke={getChartColor(index, teams.length)}
121
+ strokeWidth={3}
122
+ dot={false}
123
+ activeDot={false}
124
+ legendType="circle"
125
+ />
126
+ ))}
127
+ </LineChart>
128
+ </ResponsiveContainer>
129
+ </DashboardChartFrame>
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,157 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { FaCrown, FaStar, FaBolt, FaRocket, FaGem, FaFire, FaShield, FaFlag } from "react-icons/fa6";
5
+ import { motion } from "framer-motion";
6
+ import type { DashboardData } from "../types";
7
+ import { getPlayedMatchRows } from "../lib/dashboardData";
8
+ import { formatCompactNumber } from "../lib/utils";
9
+ import { getChartColor } from "../lib/utils/getChartColor";
10
+
11
+ const ringIcons = [FaCrown, FaStar, FaGem, FaRocket, FaBolt, FaFire, FaShield, FaFlag];
12
+
13
+ const R = 40;
14
+ const CIRCUMFERENCE = 2 * Math.PI * R;
15
+
16
+ export default function ProgressGlowRings({
17
+ data,
18
+ }: {
19
+ data: DashboardData | null;
20
+ }) {
21
+ const teams = useMemo(() => {
22
+ if (!data?.overall?.length) return [];
23
+ const daily = getPlayedMatchRows(data.daily);
24
+ const sorted = [...data.overall].sort((a, b) => a.rank - b.rank);
25
+ const leaderPts = sorted[0].points;
26
+
27
+ return sorted.slice(0, 8).map((team, i) => {
28
+ const scores = daily
29
+ .map((r) => Number(r[team.name] ?? 0))
30
+ .filter((v) => v > 0);
31
+ const avg = scores.length
32
+ ? scores.reduce((a, b) => a + b, 0) / scores.length
33
+ : 0;
34
+ const pct = leaderPts > 0 ? (team.points / leaderPts) * 100 : 0;
35
+ const Icon = ringIcons[i % ringIcons.length];
36
+ return {
37
+ name: team.name,
38
+ rank: team.rank,
39
+ points: team.points,
40
+ pct: Math.min(pct, 100),
41
+ avg: Math.round(avg),
42
+ color: getChartColor(i, 8),
43
+ Icon,
44
+ lastMatch: team.lastMatchPoints ?? scores.at(-1) ?? 0,
45
+ };
46
+ });
47
+ }, [data]);
48
+
49
+ if (!teams.length) {
50
+ return <div className="ledger-note py-6 text-center">No data yet...</div>;
51
+ }
52
+
53
+ return (
54
+ <div
55
+ className="rounded-2xl border-2 p-4 wobbly"
56
+ style={{
57
+ borderColor: "color-mix(in srgb, var(--accent-amber) 34%, var(--line))",
58
+ background:
59
+ "linear-gradient(180deg, color-mix(in srgb, var(--paper) 88%, var(--accent-amber) 12%), color-mix(in srgb, var(--paper) 92%, var(--paper-2) 8%))",
60
+ }}
61
+ >
62
+ <div className="mb-3 flex items-center gap-2 border-b border-dashed border-(--border-wobbly)/40 pb-2">
63
+ <FaGem className="text-lg" style={{ color: "var(--accent-amber)" }} />
64
+ <span className="font-bold font-(--font-note) text-lg text-(--ink)">
65
+ Progress Glow Rings
66
+ </span>
67
+ <span className="text-xs text-(--ink-faint) ml-auto">
68
+ % of leader
69
+ </span>
70
+ </div>
71
+
72
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
73
+ {teams.map((t, i) => (
74
+ <motion.div
75
+ key={t.name}
76
+ initial={{ opacity: 0, scale: 0.7 }}
77
+ animate={{ opacity: 1, scale: 1 }}
78
+ transition={{ delay: i * 0.07, type: "spring", stiffness: 120 }}
79
+ className="flex flex-col items-center rounded-xl border-2 p-3"
80
+ style={{
81
+ borderColor: `color-mix(in srgb, ${t.color} 30%, var(--line))`,
82
+ background: `color-mix(in srgb, ${t.color} 6%, var(--paper) 94%)`,
83
+ }}
84
+ >
85
+ <div className="relative w-24 h-24">
86
+ <svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
87
+ <circle
88
+ cx={50}
89
+ cy={50}
90
+ r={R}
91
+ fill="none"
92
+ stroke={`color-mix(in srgb, ${t.color} 12%, transparent)`}
93
+ strokeWidth={8}
94
+ />
95
+ <motion.circle
96
+ cx={50}
97
+ cy={50}
98
+ r={R}
99
+ fill="none"
100
+ stroke={t.color}
101
+ strokeWidth={8}
102
+ strokeLinecap="round"
103
+ strokeDasharray={CIRCUMFERENCE}
104
+ initial={{ strokeDashoffset: CIRCUMFERENCE }}
105
+ animate={{ strokeDashoffset: CIRCUMFERENCE - (t.pct / 100) * CIRCUMFERENCE }}
106
+ transition={{ duration: 1.2, delay: i * 0.07 + 0.2, ease: "easeOut" }}
107
+ />
108
+ </svg>
109
+
110
+ <motion.div
111
+ className="absolute inset-0 flex items-center justify-center"
112
+ animate={{ scale: [1, 1.06, 1] }}
113
+ transition={{ repeat: Infinity, duration: 3, ease: "easeInOut", delay: i * 0.2 }}
114
+ >
115
+ <div
116
+ className="flex h-10 w-10 items-center justify-center rounded-full"
117
+ style={{
118
+ background: `color-mix(in srgb, ${t.color} 18%, var(--paper) 82%)`,
119
+ boxShadow: `0 0 16px color-mix(in srgb, ${t.color} 25%, transparent)`,
120
+ }}
121
+ >
122
+ <t.Icon className="text-lg" style={{ color: t.color }} />
123
+ </div>
124
+ </motion.div>
125
+ </div>
126
+
127
+ <div className="mt-2 w-full text-center">
128
+ <div className="flex items-center justify-center gap-1">
129
+ <span
130
+ className="rounded px-2 py-1 text-[10px] font-black"
131
+ style={{
132
+ background: `color-mix(in srgb, ${t.color} 22%, var(--panel) 78%)`,
133
+ color: "var(--ink)",
134
+ }}
135
+ >
136
+ #{t.rank}
137
+ </span>
138
+ <span className="truncate text-sm font-bold text-(--ink) max-w-[100px]">
139
+ {t.name}
140
+ </span>
141
+ </div>
142
+ <p className="text-lg font-black mt-0.5" style={{ color: t.color }}>
143
+ {formatCompactNumber(t.points)}
144
+ </p>
145
+ <div className="flex items-center justify-center gap-2 mt-1">
146
+ <span className="text-xs font-bold text-(--ink-faint)">
147
+ {t.pct.toFixed(0)}%
148
+ </span>
149
+ <span className="text-[10px] text-(--ink-faint)">avg {formatCompactNumber(t.avg)}</span>
150
+ </div>
151
+ </div>
152
+ </motion.div>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ );
157
+ }