@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,278 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useMemo } from "react";
4
+ import {
5
+ FaRobot,
6
+ FaFire,
7
+ FaSnowflake,
8
+ FaBolt,
9
+ FaArrowTrendUp,
10
+ FaArrowTrendDown,
11
+ FaRotate,
12
+ } from "react-icons/fa6";
13
+ import { motion, AnimatePresence } from "framer-motion";
14
+ import { aiAgent, randomLang } from "../lib/aiAgent";
15
+ import type { DashboardData } from "../types";
16
+
17
+ type RoastItem = {
18
+ type: string;
19
+ target: string;
20
+ text: string;
21
+ icon: React.ComponentType<{ className?: string }>;
22
+ };
23
+
24
+ const REFRESH_INTERVAL = 20000;
25
+
26
+ export default function AIRoasting({ data }: { data: DashboardData | null }) {
27
+ const [refreshKey, setRefreshKey] = useState(0);
28
+
29
+ const roasts = useMemo(() => {
30
+ if (!data?.overall?.length) return [];
31
+
32
+ const overall = data.overall.sort((a, b) => a.rank - b.rank);
33
+ const topTeam = overall[0];
34
+ const secondTeam = overall[1];
35
+ const bottomTeam = overall[overall.length - 1];
36
+ const secondFromBottom = overall[overall.length - 2];
37
+ const midTeam = overall[Math.floor(overall.length / 2)];
38
+ const mostTransfers = [...overall].sort(
39
+ (a, b) => (b.transfersLeft ?? 0) - (a.transfersLeft ?? 0),
40
+ )[0];
41
+ const hottest = [...overall].sort(
42
+ (a, b) => (b.lastMatchPoints ?? 0) - (a.lastMatchPoints ?? 0),
43
+ )[0];
44
+ const coldest = [...overall].sort(
45
+ (a, b) => (a.lastMatchPoints ?? 0) - (b.lastMatchPoints ?? 0),
46
+ )[0];
47
+
48
+ const gapEntries = overall
49
+ .map((t, i) => ({
50
+ a: t,
51
+ b: overall[i + 1],
52
+ gap: t.points - (overall[i + 1]?.points ?? 0),
53
+ }))
54
+ .filter((e) => e.b && e.gap > 0)
55
+ .sort((a, b) => b.gap - a.gap);
56
+ const widestGap = gapEntries[0];
57
+
58
+ const lang = randomLang();
59
+ const items: RoastItem[] = [];
60
+
61
+ if (topTeam) {
62
+ const avgPoints = topTeam.points / Math.max(1, data.daily?.length ?? 1);
63
+ items.push({
64
+ type: "praise",
65
+ target: topTeam.name,
66
+ text: aiAgent.leader(topTeam.name, avgPoints, lang),
67
+ icon: FaFire,
68
+ });
69
+ }
70
+
71
+ if (bottomTeam) {
72
+ items.push({
73
+ type: "roast",
74
+ target: bottomTeam.name,
75
+ text: aiAgent.roast(bottomTeam.name, bottomTeam.points, lang),
76
+ icon: FaSnowflake,
77
+ });
78
+ }
79
+
80
+ if (midTeam) {
81
+ const volatility = (midTeam.points % 20) + 5;
82
+ items.push({
83
+ type: "mid",
84
+ target: midTeam.name,
85
+ text: aiAgent.consistency(midTeam.name, volatility, lang),
86
+ icon: FaRobot,
87
+ });
88
+ }
89
+
90
+ if (secondTeam && topTeam) {
91
+ const gap = topTeam.points - secondTeam.points;
92
+ items.push({
93
+ type: "chase",
94
+ target: `${secondTeam.name} vs ${topTeam.name}`,
95
+ text: aiAgent.gap(topTeam.name, secondTeam.name, gap, lang),
96
+ icon: FaArrowTrendUp,
97
+ });
98
+ }
99
+
100
+ if (mostTransfers && (mostTransfers.transfersLeft ?? 0) > 0) {
101
+ items.push({
102
+ type: "strategy",
103
+ target: mostTransfers.name,
104
+ text: aiAgent.transfers(
105
+ mostTransfers.name,
106
+ mostTransfers.transfersLeft ?? 0,
107
+ lang,
108
+ ),
109
+ icon: FaRobot,
110
+ });
111
+ }
112
+
113
+ if (hottest && (hottest.lastMatchPoints ?? 0) > 0) {
114
+ items.push({
115
+ type: "surge",
116
+ target: hottest.name,
117
+ text: aiAgent.praise(hottest.name, hottest.lastMatchPoints ?? 0, lang),
118
+ icon: FaBolt,
119
+ });
120
+ }
121
+
122
+ if (coldest && coldest !== hottest && coldest.lastMatchPoints != null) {
123
+ items.push({
124
+ type: "collapse",
125
+ target: coldest.name,
126
+ text: aiAgent.collapse(coldest.name, coldest.lastMatchPoints, lang),
127
+ icon: FaArrowTrendDown,
128
+ });
129
+ }
130
+
131
+ if (secondFromBottom && bottomTeam && secondFromBottom !== bottomTeam) {
132
+ const gap = Math.max(1, secondFromBottom.points - bottomTeam.points);
133
+ items.push({
134
+ type: "danger",
135
+ target: secondFromBottom.name,
136
+ text:
137
+ gap > 50
138
+ ? `${secondFromBottom.name} is just ${gap} pts above last. The basement door is open.`
139
+ : `${secondFromBottom.name} is only ${gap} pts clear of last place. Panic button time.`,
140
+ icon: FaSnowflake,
141
+ });
142
+ }
143
+
144
+ if (widestGap && widestGap.gap > 30) {
145
+ items.push({
146
+ type: "gap",
147
+ target: `${widestGap.a.name} >> ${widestGap.b.name}`,
148
+ text: `${widestGap.a.name} leads ${widestGap.b.name} by ${widestGap.gap} pts — the biggest chasm in the league. That's not a gap, that's a Grand Canyon.`,
149
+ icon: FaArrowTrendUp,
150
+ });
151
+ }
152
+
153
+ return items;
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
155
+ }, [data, refreshKey]);
156
+
157
+ useEffect(() => {
158
+ const refreshTimer = setInterval(
159
+ () => setRefreshKey((k) => k + 1),
160
+ REFRESH_INTERVAL,
161
+ );
162
+ return () => clearInterval(refreshTimer);
163
+ }, []);
164
+
165
+ if (!roasts.length) {
166
+ return (
167
+ <div className="ledger-note py-6 text-center">
168
+ AI is still warming up the roasts...
169
+ </div>
170
+ );
171
+ }
172
+
173
+ const pastel: Record<string, { bg: string; border: string; text: string }> = {
174
+ praise: { bg: "#c8f7d5", border: "#a8e6cf", text: "#2d6a4f" },
175
+ roast: { bg: "#ffd6d9", border: "#ffb3ba", text: "#9b2226" },
176
+ mid: { bg: "#d6edff", border: "#bae1ff", text: "#1a5276" },
177
+ chase: { bg: "#fff3cd", border: "#ffe69c", text: "#856404" },
178
+ strategy: { bg: "#e2d9f3", border: "#d0bdf4", text: "#4a1a6b" },
179
+ surge: { bg: "#c8f7d5", border: "#a8e6cf", text: "#2d6a4f" },
180
+ collapse: { bg: "#ffd6d9", border: "#ffb3ba", text: "#9b2226" },
181
+ danger: { bg: "#fce4ec", border: "#f8bbd0", text: "#880e4f" },
182
+ gap: { bg: "#fff3cd", border: "#ffe69c", text: "#856404" },
183
+ };
184
+
185
+ return (
186
+ <div>
187
+ <div className="flex items-center gap-2 mb-3">
188
+ <span className="scribble-kicker">{roasts.length} hot takes</span>
189
+ <span className="flex items-center gap-1 text-[13px] font-bold text-(--ink-faint)">
190
+ <FaRotate className="animate-spin" />
191
+ <span>live</span>
192
+ </span>
193
+ </div>
194
+ <div className="grid gap-2 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
195
+ <AnimatePresence mode="popLayout">
196
+ {roasts.map((roast) => {
197
+ const p = pastel[roast.type] ?? pastel.roast;
198
+ return (
199
+ <motion.div
200
+ key={`${roast.type}-${roast.target}-${refreshKey}`}
201
+ layout
202
+ initial={{ opacity: 0, scale: 0.92 }}
203
+ animate={{ opacity: 1, scale: 1 }}
204
+ exit={{ opacity: 0, scale: 0.92 }}
205
+ transition={{ duration: 0.3 }}
206
+ className="hover-jitter rounded-xl border p-3 relative overflow-hidden"
207
+ style={{
208
+ borderColor: p.border,
209
+ background: p.bg,
210
+ boxShadow: `0 0 18px color-mix(in srgb, ${p.border} 30%, transparent)`,
211
+ }}
212
+ >
213
+ <motion.div
214
+ className="absolute inset-0 rounded-xl pointer-events-none"
215
+ style={{
216
+ background: `radial-gradient(circle at 50% 0%, color-mix(in srgb, ${p.border} 30%, transparent) 0%, transparent 70%)`,
217
+ }}
218
+ animate={{ opacity: [0.3, 0.7, 0.3] }}
219
+ transition={{
220
+ repeat: Infinity,
221
+ duration: 2.5,
222
+ ease: "easeInOut",
223
+ }}
224
+ />
225
+
226
+ <motion.div
227
+ className="absolute -inset-0.5 rounded-xl pointer-events-none"
228
+ style={{
229
+ border: `2px solid ${p.border}`,
230
+ opacity: 0.4,
231
+ }}
232
+ animate={{
233
+ opacity: [0.2, 0.6, 0.2],
234
+ filter: ["blur(3px)", "blur(7px)", "blur(3px)"],
235
+ }}
236
+ transition={{
237
+ repeat: Infinity,
238
+ duration: 2,
239
+ ease: "easeInOut",
240
+ }}
241
+ />
242
+
243
+ <div className="relative z-10">
244
+ <div className="flex items-center gap-1.5 mb-1.5">
245
+ <span
246
+ className="rounded-full px-2 py-0.5 text-[14px] font-bold uppercase tracking-wide"
247
+ style={{
248
+ background: `color-mix(in srgb, ${p.border} 50%, transparent)`,
249
+ color: p.text,
250
+ }}
251
+ >
252
+ <roast.icon className="inline text-[14px] mr-0.5" />
253
+ {roast.type}
254
+ </span>
255
+ </div>
256
+ <p
257
+ className="text-[14px] font-bold leading-tight mb-1"
258
+ style={{ color: p.text }}
259
+ >
260
+ {roast.target}
261
+ </p>
262
+ <p
263
+ className="text-[13px] leading-relaxed"
264
+ style={{
265
+ color: `color-mix(in srgb, ${p.text} 70%, transparent)`,
266
+ }}
267
+ >
268
+ {roast.text}
269
+ </p>
270
+ </div>
271
+ </motion.div>
272
+ );
273
+ })}
274
+ </AnimatePresence>
275
+ </div>
276
+ </div>
277
+ );
278
+ }
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { motion } from "framer-motion";
5
+ import type { DashboardData } from "../types";
6
+ import { getPlayedMatchRows } from "../lib/dashboardData";
7
+ import { formatCompactNumber } from "../lib/utils";
8
+ import { getChartColor } from "../lib/utils/getChartColor";
9
+
10
+ function buildWavePath(
11
+ scores: number[],
12
+ maxVal: number,
13
+ color: string,
14
+ svgW: number,
15
+ svgH: number,
16
+ ): { wavePath: string; fillPath: string } {
17
+ if (!scores.length || maxVal === 0) {
18
+ const base = `M0,${svgH / 2} L${svgW},${svgH / 2}`;
19
+ return {
20
+ wavePath: base,
21
+ fillPath: `${base} L${svgW},${svgH} L0,${svgH} Z`,
22
+ };
23
+ }
24
+
25
+ const points = scores.map((s, i) => {
26
+ const x = (i / Math.max(scores.length - 1, 1)) * svgW;
27
+ const y = svgH - 10 - (s / maxVal) * (svgH - 30);
28
+ return { x, y };
29
+ });
30
+
31
+ let d = `M0,${svgH - 10}`;
32
+ for (let i = 0; i < points.length; i++) {
33
+ if (i === 0) {
34
+ d += ` L${points[i].x},${points[i].y}`;
35
+ } else {
36
+ const prev = points[i - 1];
37
+ const cx = (prev.x + points[i].x) / 2;
38
+ d += ` Q${prev.x},${prev.y} ${cx},${(prev.y + points[i].y) / 2}`;
39
+ }
40
+ }
41
+ d += ` L${svgW},${svgH - 10}`;
42
+
43
+ const fill = `${d} L${svgW},${svgH} L0,${svgH} Z`;
44
+
45
+ return { wavePath: d, fillPath: fill };
46
+ }
47
+
48
+ export default function ColorWave({ data }: { data: DashboardData | null }) {
49
+ const waves = useMemo(() => {
50
+ if (!data?.overall?.length) return [];
51
+ const daily = getPlayedMatchRows(data.daily);
52
+ const allScores = data.overall.map((team) =>
53
+ daily.map((r) => Number(r[team.name] ?? 0)).filter((v) => v > 0),
54
+ );
55
+ const globalMax = Math.max(...allScores.flat(), 1);
56
+
57
+ return data.overall
58
+ .sort((a, b) => a.rank - b.rank)
59
+ .slice(0, 8)
60
+ .map((team, i) => {
61
+ const scores = allScores[i];
62
+ const color = getChartColor(i, 8);
63
+ const { wavePath, fillPath } = buildWavePath(
64
+ scores,
65
+ globalMax,
66
+ color,
67
+ 180,
68
+ 64,
69
+ );
70
+ return {
71
+ name: team.name,
72
+ rank: team.rank,
73
+ points: team.points,
74
+ color,
75
+ scores,
76
+ wavePath,
77
+ fillPath,
78
+ };
79
+ });
80
+ }, [data]);
81
+
82
+ if (!waves.length) {
83
+ return (
84
+ <div className="ledger-note py-6 text-center">No wave data yet...</div>
85
+ );
86
+ }
87
+
88
+ const svgW = 180;
89
+ const svgH = 64;
90
+
91
+ return (
92
+ <div
93
+ className="rounded-2xl border-2 p-4 wobbly"
94
+ style={{
95
+ borderColor: "color-mix(in srgb, var(--accent-cyan) 34%, var(--line))",
96
+ background:
97
+ "linear-gradient(180deg, color-mix(in srgb, var(--paper) 88%, var(--accent-cyan) 12%), color-mix(in srgb, var(--paper) 92%, var(--paper-2) 8%))",
98
+ }}
99
+ >
100
+ <div className="mb-3 flex items-center gap-2 border-b border-dashed border-(--border-wobbly)/40 pb-2">
101
+ <span className="text-lg" style={{ color: "var(--accent-cyan)" }}>
102
+
103
+ </span>
104
+ <span className="font-bold text-lg text-(--ink)">
105
+ Points Color Wave
106
+ </span>
107
+ <span className="text-xs text-(--ink-faint) ml-auto">
108
+ season flow
109
+ </span>
110
+ </div>
111
+
112
+ <div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
113
+ {waves.map((w, i) => (
114
+ <motion.div
115
+ key={w.name}
116
+ initial={{ opacity: 0, x: -12 }}
117
+ animate={{ opacity: 1, x: 0 }}
118
+ transition={{ delay: i * 0.05 }}
119
+ className="flex items-center gap-2.5 rounded-xl border-2 p-2.5"
120
+ style={{
121
+ borderColor: `color-mix(in srgb, ${w.color} 28%, var(--line))`,
122
+ background: `color-mix(in srgb, ${w.color} 6%, var(--paper) 94%)`,
123
+ }}
124
+ >
125
+ <div className="flex flex-col items-center min-w-10">
126
+ <span
127
+ className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-black"
128
+ style={{
129
+ background: `color-mix(in srgb, ${w.color} 30%, var(--paper) 70%)`,
130
+ color: "var(--ink)",
131
+ border: `1px solid color-mix(in srgb, ${w.color} 50%, var(--line))`,
132
+ }}
133
+ >
134
+ #{w.rank}
135
+ </span>
136
+ </div>
137
+
138
+ <div className="flex-1 min-w-0">
139
+ <div className="flex items-center justify-between gap-1 mb-1">
140
+ <span className="truncate text-[15px] font-bold text-(--ink)">
141
+ {w.name}
142
+ </span>
143
+ <span className="text-[15px] font-black text-(--ink-soft)">
144
+ {formatCompactNumber(w.points)}
145
+ </span>
146
+ </div>
147
+
148
+ <svg viewBox={`0 0 ${svgW} ${svgH}`} className="w-full h-auto">
149
+ <motion.path
150
+ d={w.fillPath}
151
+ fill={`color-mix(in srgb, ${w.color} 18%, transparent)`}
152
+ initial={{ opacity: 0 }}
153
+ animate={{ opacity: 1 }}
154
+ transition={{ delay: i * 0.05 + 0.2 }}
155
+ />
156
+ <motion.path
157
+ d={w.wavePath}
158
+ fill="none"
159
+ stroke={w.color}
160
+ strokeWidth={2}
161
+ strokeLinecap="round"
162
+ strokeLinejoin="round"
163
+ initial={{ pathLength: 0 }}
164
+ animate={{ pathLength: 1 }}
165
+ transition={{ delay: i * 0.05 + 0.1, duration: 0.8 }}
166
+ />
167
+ {w.scores.map((s, si) => {
168
+ const x = (si / Math.max(w.scores.length - 1, 1)) * svgW;
169
+ const maxVal = Math.max(...w.scores, 1);
170
+ const y = svgH - 10 - (s / maxVal) * (svgH - 30);
171
+ return (
172
+ <motion.circle
173
+ key={si}
174
+ cx={x}
175
+ cy={y}
176
+ r={2.5}
177
+ fill={w.color}
178
+ stroke="var(--paper)"
179
+ strokeWidth={1}
180
+ initial={{ scale: 0 }}
181
+ animate={{ scale: 1 }}
182
+ transition={{ delay: i * 0.05 + 0.3 + si * 0.02 }}
183
+ />
184
+ );
185
+ })}
186
+ </svg>
187
+ </div>
188
+ </motion.div>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,207 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { FaCrown, FaShield, FaBolt, FaStar, FaArrowRight, FaArrowLeft } from "react-icons/fa6";
5
+ import { motion } from "framer-motion";
6
+ import type { DashboardData } from "../types";
7
+ import { formatCompactNumber } from "../lib/utils";
8
+ import { getChartColor } from "../lib/utils/getChartColor";
9
+
10
+ export default function CrownBattle({
11
+ data,
12
+ }: {
13
+ data: DashboardData | null;
14
+ }) {
15
+ const top = useMemo(() => {
16
+ if (!data?.overall?.length) return null;
17
+ const sorted = [...data.overall].sort((a, b) => a.rank - b.rank);
18
+ return {
19
+ leader: sorted[0],
20
+ runnerUp: sorted[1],
21
+ third: sorted[2],
22
+ gap: sorted[0].points - (sorted[1]?.points ?? 0),
23
+ gap2: (sorted[1]?.points ?? 0) - (sorted[2]?.points ?? 0),
24
+ leaderColor: getChartColor(0, 8),
25
+ runnerColor: getChartColor(1, 8),
26
+ thirdColor: getChartColor(2, 8),
27
+ };
28
+ }, [data]);
29
+
30
+ if (!top) {
31
+ return <div className="ledger-note py-6 text-center">No battle data yet...</div>;
32
+ }
33
+
34
+ return (
35
+ <div
36
+ className="rounded-2xl border-2 p-4 wobbly overflow-hidden"
37
+ style={{
38
+ borderColor: "color-mix(in srgb, var(--accent-amber) 34%, var(--line))",
39
+ background:
40
+ "radial-gradient(ellipse at 50% 0%, color-mix(in srgb, var(--accent-amber) 10%, var(--paper) 90%), var(--paper) 80%)",
41
+ }}
42
+ >
43
+ <div className="mb-3 flex items-center gap-2 border-b border-dashed border-(--border-wobbly)/40 pb-2">
44
+ <FaStar className="text-lg" style={{ color: "var(--accent-amber)" }} />
45
+ <span className="font-bold font-(--font-note) text-lg text-(--ink)">
46
+ Crown Battle
47
+ </span>
48
+ <span className="text-xs text-(--ink-faint) ml-auto">
49
+ battle for the throne
50
+ </span>
51
+ </div>
52
+
53
+ <div className="relative flex items-end justify-center gap-3 min-h-[200px]">
54
+ {/* Crown position indicator */}
55
+ <motion.div
56
+ className="absolute top-0 left-1/2 -translate-x-1/2 z-10"
57
+ animate={{ y: [0, -6, 0] }}
58
+ transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
59
+ >
60
+ <FaCrown
61
+ className="text-[45px]"
62
+ style={{
63
+ color: "var(--marker-yellow)",
64
+ filter: "drop-shadow(0 4px 12px rgba(234,179,8,0.5))",
65
+ }}
66
+ />
67
+ </motion.div>
68
+
69
+ {/* Runner Up */}
70
+ <motion.div
71
+ initial={{ opacity: 0, y: 30 }}
72
+ animate={{ opacity: 1, y: 0 }}
73
+ transition={{ delay: 0.2, type: "spring", stiffness: 100 }}
74
+ className="flex-1 rounded-xl border-2 p-3 text-center mt-12"
75
+ style={{
76
+ borderColor: `color-mix(in srgb, ${top.runnerColor} 34%, var(--line))`,
77
+ background: `color-mix(in srgb, ${top.runnerColor} 8%, var(--paper) 92%)`,
78
+ }}
79
+ >
80
+ <motion.div
81
+ animate={{ y: [0, -3, 0] }}
82
+ transition={{ repeat: Infinity, duration: 3, ease: "easeInOut", delay: 0.5 }}
83
+ >
84
+ <FaShield className="mx-auto mb-2 text-xl" style={{ color: top.runnerColor }} />
85
+ </motion.div>
86
+ <span className="text-xs font-bold uppercase tracking-wider text-(--ink-faint)">
87
+ #2 · Challenger
88
+ </span>
89
+ <p className="text-[15px] font-bold text-(--ink) mt-1">
90
+ {top.runnerUp.name.length > 12
91
+ ? top.runnerUp.name.slice(0, 11) + "…"
92
+ : top.runnerUp.name}
93
+ </p>
94
+ <p className="text-base font-black mt-1" style={{ color: top.runnerColor }}>
95
+ {formatCompactNumber(top.runnerUp.points)}
96
+ </p>
97
+ <div
98
+ className="mt-1.5 inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[10px] font-bold"
99
+ style={{
100
+ background: "color-mix(in srgb, var(--marker-pink) 18%, var(--panel) 82%)",
101
+ color: "var(--ink)",
102
+ }}
103
+ >
104
+ <FaArrowRight className="text-[9px]" />
105
+ <span>Δ {formatCompactNumber(top.gap)}</span>
106
+ </div>
107
+ </motion.div>
108
+
109
+ {/* Leader */}
110
+ <motion.div
111
+ initial={{ opacity: 0, y: 20, scale: 0.9 }}
112
+ animate={{ opacity: 1, y: 0, scale: 1 }}
113
+ transition={{ delay: 0.1, type: "spring", stiffness: 120 }}
114
+ className="flex-1 rounded-xl border-2 p-3 text-center mt-8 relative z-10"
115
+ style={{
116
+ borderColor: `color-mix(in srgb, ${top.leaderColor} 50%, var(--line))`,
117
+ background: `radial-gradient(ellipse at 50% 30%, color-mix(in srgb, var(--marker-yellow) 16%, var(--paper) 84%), color-mix(in srgb, ${top.leaderColor} 8%, var(--paper) 92%))`,
118
+ boxShadow: `0 4px 24px color-mix(in srgb, var(--marker-yellow) 20%, transparent)`,
119
+ }}
120
+ >
121
+ {/* Sparkle effects */}
122
+ {[0, 1, 2].map((i) => (
123
+ <motion.div
124
+ key={i}
125
+ className="absolute pointer-events-none"
126
+ style={{
127
+ top: `${10 + i * 30}%`,
128
+ left: i % 2 === 0 ? "8%" : "auto",
129
+ right: i % 2 === 1 ? "8%" : "auto",
130
+ }}
131
+ animate={{
132
+ scale: [0, 1.2, 0],
133
+ opacity: [0, 0.8, 0],
134
+ }}
135
+ transition={{
136
+ repeat: Infinity,
137
+ duration: 1.5 + i * 0.3,
138
+ delay: i * 0.4,
139
+ ease: "easeInOut",
140
+ }}
141
+ >
142
+ <FaStar className="text-[6px]" style={{ color: "var(--marker-yellow)" }} />
143
+ </motion.div>
144
+ ))}
145
+
146
+ <motion.div
147
+ animate={{ rotate: [-5, 5, -5], scale: [1, 1.05, 1] }}
148
+ transition={{ repeat: Infinity, duration: 2.5, ease: "easeInOut", delay: 0.2 }}
149
+ >
150
+ <FaBolt className="mx-auto mb-2 text-xl" style={{ color: "var(--marker-yellow)" }} />
151
+ </motion.div>
152
+ <span className="text-xs font-bold uppercase tracking-wider text-(--marker-yellow)">
153
+ #1 · Champion
154
+ </span>
155
+ <p className="text-[15px] font-bold text-(--ink) mt-1">
156
+ {top.leader.name.length > 12
157
+ ? top.leader.name.slice(0, 11) + "…"
158
+ : top.leader.name}
159
+ </p>
160
+ <p className="text-lg font-black mt-1" style={{ color: "var(--marker-yellow)" }}>
161
+ {formatCompactNumber(top.leader.points)}
162
+ </p>
163
+ </motion.div>
164
+
165
+ {/* Third Place */}
166
+ <motion.div
167
+ initial={{ opacity: 0, y: 30 }}
168
+ animate={{ opacity: 1, y: 0 }}
169
+ transition={{ delay: 0.3, type: "spring", stiffness: 100 }}
170
+ className="flex-1 rounded-xl border-2 p-3 text-center mt-16"
171
+ style={{
172
+ borderColor: `color-mix(in srgb, ${top.thirdColor} 34%, var(--line))`,
173
+ background: `color-mix(in srgb, ${top.thirdColor} 8%, var(--paper) 92%)`,
174
+ }}
175
+ >
176
+ <motion.div
177
+ animate={{ y: [0, 3, 0] }}
178
+ transition={{ repeat: Infinity, duration: 3.5, ease: "easeInOut", delay: 1 }}
179
+ >
180
+ <FaShield className="mx-auto mb-2 text-xl" style={{ color: top.thirdColor }} />
181
+ </motion.div>
182
+ <span className="text-xs font-bold uppercase tracking-wider text-(--ink-faint)">
183
+ #3 · Contender
184
+ </span>
185
+ <p className="text-[15px] font-bold text-(--ink) mt-1">
186
+ {top.third.name.length > 12
187
+ ? top.third.name.slice(0, 11) + "…"
188
+ : top.third.name}
189
+ </p>
190
+ <p className="text-base font-black mt-1" style={{ color: top.thirdColor }}>
191
+ {formatCompactNumber(top.third.points)}
192
+ </p>
193
+ <div
194
+ className="mt-1.5 inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[10px] font-bold"
195
+ style={{
196
+ background: "color-mix(in srgb, var(--marker-blue) 18%, var(--panel) 82%)",
197
+ color: "var(--ink)",
198
+ }}
199
+ >
200
+ <FaArrowLeft className="text-[9px]" />
201
+ <span>Δ {formatCompactNumber(top.gap2)}</span>
202
+ </div>
203
+ </motion.div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }