@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,238 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import {
5
+ FaMicroscope,
6
+ FaCircle,
7
+ FaArrowTrendUp,
8
+ FaArrowTrendDown,
9
+ } from "react-icons/fa6";
10
+ import { motion } from "framer-motion";
11
+ import type { DashboardData } from "../types";
12
+ import { getPlayedMatchRows } from "../lib/dashboardData";
13
+ import { getChartColor } from "../lib/utils/getChartColor";
14
+
15
+ function calcStdDev(values: number[]): number {
16
+ const n = values.length;
17
+ if (n < 2) return 0;
18
+ const mean = values.reduce((a, b) => a + b, 0) / n;
19
+ const sqDiffs = values.map((v) => (v - mean) ** 2);
20
+ return Math.sqrt(sqDiffs.reduce((a, b) => a + b, 0) / (n - 1));
21
+ }
22
+
23
+ export default function TeamDNAScanner({
24
+ data,
25
+ }: {
26
+ data: DashboardData | null;
27
+ }) {
28
+ const teams = useMemo(() => {
29
+ if (!data?.overall?.length) return [];
30
+ const daily = getPlayedMatchRows(data.daily);
31
+
32
+ return data.overall
33
+ .sort((a, b) => a.rank - b.rank)
34
+ .slice(0, 8)
35
+ .map((team, i) => {
36
+ const scores = daily.map((r) => Number(r[team.name] ?? 0));
37
+ const avg = scores.length
38
+ ? scores.reduce((a, b) => a + b, 0) / scores.length
39
+ : 0;
40
+ const high = scores.length ? Math.max(...scores) : 0;
41
+ const low = scores.length ? Math.min(...scores) : 0;
42
+ const stdDev = calcStdDev(scores);
43
+ const volatility = avg > 0 ? Math.min(100, (stdDev / avg) * 100) : 0;
44
+ const consistency = Math.max(0, Math.min(100, 100 - volatility));
45
+
46
+ return {
47
+ name: team.name,
48
+ rank: team.rank,
49
+ points: team.points,
50
+ matches: scores.length,
51
+ avg: Math.round(avg),
52
+ high: Math.round(high),
53
+ low: Math.round(low),
54
+ volatility: Math.round(volatility),
55
+ consistency: Math.round(consistency),
56
+ lastMatch: team.lastMatchPoints ?? scores.at(-1) ?? 0,
57
+ color: getChartColor(i, 8),
58
+ };
59
+ });
60
+ }, [data]);
61
+
62
+ if (!teams.length) {
63
+ return (
64
+ <div className="ledger-note py-6 text-center">No DNA data yet...</div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div
70
+ className="rounded-2xl border-2 p-4 wobbly overflow-hidden"
71
+ style={{
72
+ borderColor: "color-mix(in srgb, var(--marker-blue) 34%, var(--line))",
73
+ background:
74
+ "linear-gradient(160deg, color-mix(in srgb, var(--paper) 90%, var(--paper-3) 10%), color-mix(in srgb, var(--paper) 92%, var(--paper-2) 8%))",
75
+ }}
76
+ >
77
+ <div className="mb-3 flex items-center gap-2 border-b border-dashed border-(--border-wobbly)/40 pb-2">
78
+ <FaMicroscope
79
+ className="text-lg"
80
+ style={{ color: "var(--marker-blue)" }}
81
+ />
82
+ <span className="font-bold text-lg text-(--ink)">Team DNA Scanner</span>
83
+ <span className="text-xs text-(--ink-faint) ml-auto">
84
+ genetic analysis
85
+ </span>
86
+ </div>
87
+
88
+ <div className="grid gap-2.5 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
89
+ {teams.map((team, i) => (
90
+ <motion.div
91
+ key={team.name}
92
+ initial={{ opacity: 0, y: 12 }}
93
+ animate={{ opacity: 1, y: 0 }}
94
+ transition={{ delay: i * 0.07 }}
95
+ className="relative rounded-xl border-2 p-3 overflow-hidden"
96
+ style={{
97
+ borderColor: `color-mix(in srgb, ${team.color} 34%, var(--line))`,
98
+ background: `color-mix(in srgb, ${team.color} 5%, var(--paper) 95%)`,
99
+ }}
100
+ >
101
+ <motion.div
102
+ className="absolute inset-x-0 pointer-events-none"
103
+ style={{
104
+ height: 2,
105
+ background: `linear-gradient(90deg, transparent 0%, ${team.color} 50%, transparent 100%)`,
106
+ opacity: 0.6,
107
+ zIndex: 1,
108
+ }}
109
+ animate={{ top: ["0%", "100%", "0%"] }}
110
+ transition={{
111
+ repeat: Infinity,
112
+ duration: 3 + (i % 3),
113
+ ease: "linear",
114
+ delay: i * 0.3,
115
+ }}
116
+ />
117
+
118
+ <div className="relative z-10">
119
+ <div className="mb-2 flex items-center gap-2">
120
+ <span
121
+ className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-black"
122
+ style={{
123
+ background: `color-mix(in srgb, ${team.color} 30%, var(--paper) 70%)`,
124
+ color: "var(--ink)",
125
+ border: `1px solid color-mix(in srgb, ${team.color} 50%, var(--line))`,
126
+ }}
127
+ >
128
+ #{team.rank}
129
+ </span>
130
+ <span className="flex-1 truncate text-base font-bold text-(--ink)">
131
+ {team.name}
132
+ </span>
133
+ <span className="text-[15px] font-black text-(--ink)">
134
+ {team.points.toLocaleString()}
135
+ </span>
136
+ </div>
137
+
138
+ <div className="grid grid-cols-4 gap-1 mb-2">
139
+ {[
140
+ { label: "Matches", value: team.matches },
141
+ { label: "Avg", value: team.avg.toLocaleString() },
142
+ { label: "High", value: team.high.toLocaleString() },
143
+ { label: "Low", value: team.low.toLocaleString() },
144
+ ].map((stat) => (
145
+ <div
146
+ key={stat.label}
147
+ className="rounded-lg px-2 py-1.5 text-center"
148
+ style={{
149
+ background:
150
+ "color-mix(in srgb, var(--panel) 40%, transparent)",
151
+ }}
152
+ >
153
+ <p className="text-[10px] font-bold uppercase tracking-wider text-(--ink-faint)">
154
+ {stat.label}
155
+ </p>
156
+ <p className="text-[15px] font-black text-(--ink)">
157
+ {stat.value}
158
+ </p>
159
+ </div>
160
+ ))}
161
+ </div>
162
+
163
+ <div className="mb-2">
164
+ <div className="flex items-center justify-between text-xs text-(--ink-faint) mb-1">
165
+ <span>Consistency</span>
166
+ <span className="font-bold">{team.consistency}%</span>
167
+ </div>
168
+ <div
169
+ className="h-1.5 w-full rounded-full overflow-hidden"
170
+ style={{
171
+ background:
172
+ "color-mix(in srgb, var(--panel) 60%, transparent)",
173
+ }}
174
+ >
175
+ <motion.div
176
+ initial={{ width: 0 }}
177
+ animate={{ width: `${team.consistency}%` }}
178
+ transition={{ duration: 0.8, delay: i * 0.07 }}
179
+ className="h-full rounded-full"
180
+ style={{
181
+ background:
182
+ team.consistency >= 60
183
+ ? "var(--marker-green)"
184
+ : team.consistency >= 40
185
+ ? "var(--marker-yellow)"
186
+ : "var(--marker-pink)",
187
+ }}
188
+ />
189
+ </div>
190
+ </div>
191
+
192
+ <div className="flex items-center gap-3">
193
+ <div
194
+ className="flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-bold"
195
+ style={{
196
+ background:
197
+ "color-mix(in srgb, var(--panel) 50%, transparent)",
198
+ }}
199
+ >
200
+ <FaCircle
201
+ className="text-[6px]"
202
+ style={{
203
+ color:
204
+ team.volatility >= 50
205
+ ? "var(--marker-pink)"
206
+ : team.volatility >= 30
207
+ ? "var(--marker-yellow)"
208
+ : "var(--marker-green)",
209
+ }}
210
+ />
211
+ <span className="text-(--ink-faint)">
212
+ {team.volatility}% volatility
213
+ </span>
214
+ </div>
215
+ <div
216
+ className="flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-bold"
217
+ style={{
218
+ background:
219
+ "color-mix(in srgb, var(--panel) 50%, transparent)",
220
+ }}
221
+ >
222
+ {team.lastMatch >= team.avg ? (
223
+ <FaArrowTrendUp className="text-[10px] text-(--marker-green)" />
224
+ ) : (
225
+ <FaArrowTrendDown className="text-[10px] text-(--marker-pink)" />
226
+ )}
227
+ <span className="text-(--ink-faint)">
228
+ last {team.lastMatch.toLocaleString()}
229
+ </span>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </motion.div>
234
+ ))}
235
+ </div>
236
+ </div>
237
+ );
238
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ type ThemeMode = "dark" | "light";
6
+
7
+ const STORAGE_KEY = "ipl:theme";
8
+
9
+ const getStoredTheme = (): ThemeMode | null => {
10
+ if (typeof window === "undefined") {
11
+ return null;
12
+ }
13
+
14
+ try {
15
+ const stored = window.localStorage.getItem(STORAGE_KEY);
16
+ return stored === "dark" || stored === "light" ? stored : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ };
21
+
22
+ const getPreferredTheme = (): ThemeMode => {
23
+ if (typeof window === "undefined") return "light";
24
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
25
+ ? "dark"
26
+ : "light";
27
+ };
28
+
29
+ const getCurrentTheme = (): ThemeMode => {
30
+ if (typeof document === "undefined") return "light";
31
+ return document.documentElement.dataset.theme === "light" ? "light" : "dark";
32
+ };
33
+
34
+ const setTheme = (theme: ThemeMode) => {
35
+ document.documentElement.dataset.theme = theme;
36
+ try {
37
+ window.localStorage.setItem(STORAGE_KEY, theme);
38
+ } catch {
39
+ // ignore
40
+ }
41
+ };
42
+
43
+ export default function ThemeToggle() {
44
+ const [theme, setThemeState] = useState<ThemeMode>(
45
+ () => getStoredTheme() ?? getCurrentTheme() ?? getPreferredTheme(),
46
+ );
47
+
48
+ useEffect(() => {
49
+ setTheme(theme);
50
+ }, [theme]);
51
+
52
+ const toggle = () => {
53
+ const next: ThemeMode = theme === "dark" ? "light" : "dark";
54
+ setThemeState(next);
55
+ };
56
+
57
+ return (
58
+ <button
59
+ type="button"
60
+ className="theme-toggle"
61
+ onClick={toggle}
62
+ aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
63
+ title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
64
+ >
65
+ <span className="theme-toggle-doodle" aria-hidden="true">
66
+ {theme === "dark" ? "☼" : "☾"}
67
+ </span>
68
+ <span className="theme-toggle-copy">
69
+ <span>{theme === "dark" ? "Flip to light" : "Flip to dark"}</span>
70
+ <small>{theme === "dark" ? "journal paper" : "chalk notebook"}</small>
71
+ </span>
72
+ </button>
73
+ );
74
+ }
@@ -0,0 +1,138 @@
1
+ import { motion } from "framer-motion";
2
+ import { FaUserShield } from "react-icons/fa6";
3
+ import type { OverallChartItem } from "../../types";
4
+ import { getTeamColor, formatLedgerNumber } from "../../utils/dashboard";
5
+
6
+ interface CaptainBoardProps {
7
+ teams: OverallChartItem[];
8
+ }
9
+
10
+ export function CaptainBoard({ teams }: CaptainBoardProps) {
11
+ if (!teams.length) {
12
+ return (
13
+ <p className="ledger-note py-6">
14
+ Captain and vice-captain picks will appear when squad snapshots are
15
+ available.
16
+ </p>
17
+ );
18
+ }
19
+
20
+ return (
21
+ <div className="captain-grid">
22
+ {teams.map((team, index) => {
23
+ const color = getTeamColor(index, teams.length);
24
+ return (
25
+ <motion.div
26
+ key={team.name}
27
+ initial={{ opacity: 0, y: 16 }}
28
+ animate={{ opacity: 1, y: 0 }}
29
+ transition={{ delay: index * 0.06 }}
30
+ className="relative"
31
+ >
32
+ <article
33
+ className={`captain-scrap captain-scrap-${index % 3} relative overflow-hidden`}
34
+ style={{
35
+ borderColor: `color-mix(in srgb, ${color} 44%, var(--line))`,
36
+ background: `color-mix(in srgb, ${color} 8%, var(--panel) 92%)`,
37
+ boxShadow: `0 0 18px color-mix(in srgb, ${color} 20%, transparent)`,
38
+ }}
39
+ >
40
+ <motion.div
41
+ className="absolute inset-0 rounded-xl pointer-events-none"
42
+ style={{
43
+ background: `radial-gradient(circle at 50% 0%, color-mix(in srgb, ${color} 20%, transparent) 0%, transparent 70%)`,
44
+ }}
45
+ animate={{ opacity: [0.3, 0.7, 0.3] }}
46
+ transition={{
47
+ repeat: Infinity,
48
+ duration: 2.5 + (index % 3),
49
+ ease: "easeInOut",
50
+ delay: index * 0.2,
51
+ }}
52
+ />
53
+
54
+ <motion.div
55
+ className="absolute -inset-0.5 rounded-xl pointer-events-none"
56
+ style={{
57
+ border: `2px solid ${color}`,
58
+ opacity: 0.5,
59
+ }}
60
+ animate={{
61
+ opacity: [0.2, 0.6, 0.2],
62
+ filter: ["blur(3px)", "blur(7px)", "blur(3px)"],
63
+ }}
64
+ transition={{
65
+ repeat: Infinity,
66
+ duration: 2,
67
+ ease: "easeInOut",
68
+ delay: index * 0.15,
69
+ }}
70
+ />
71
+
72
+ <div className="relative z-10">
73
+ <div className="captain-top">
74
+ <div>
75
+ <p
76
+ className="captain-team compact"
77
+ style={{ color: "var(--ink)" }}
78
+ >
79
+ #{team.rank} {team.name}
80
+ </p>
81
+ </div>
82
+ <div className="captain-icon">
83
+ <FaUserShield className="text-base" style={{ color }} />
84
+ </div>
85
+ </div>
86
+
87
+ <div className="captain-players compact-grid">
88
+ <div className="captain-role">
89
+ <span
90
+ className="captain-badge captain-badge-pink"
91
+ style={{
92
+ boxShadow: `0 0 8px color-mix(in srgb, #ec4899 30%, transparent)`,
93
+ }}
94
+ >
95
+ C
96
+ </span>
97
+ <div>
98
+ <p className="captain-player">
99
+ {team.captain?.name ?? "TBD"}
100
+ </p>
101
+ {typeof team.captain?.points === "number" && (
102
+ <p className="captain-player-sub">
103
+ {formatLedgerNumber(team.captain.points)}
104
+ <span className="captain-player-sub-pts"> pts</span>
105
+ </p>
106
+ )}
107
+ </div>
108
+ </div>
109
+ <div className="captain-role">
110
+ <span
111
+ className="captain-badge captain-badge-blue"
112
+ style={{
113
+ boxShadow: `0 0 8px color-mix(in srgb, #3b82f6 30%, transparent)`,
114
+ }}
115
+ >
116
+ VC
117
+ </span>
118
+ <div>
119
+ <p className="captain-player">
120
+ {team.viceCaptain?.name ?? "TBD"}
121
+ </p>
122
+ {typeof team.viceCaptain?.points === "number" && (
123
+ <p className="captain-player-sub">
124
+ {formatLedgerNumber(team.viceCaptain.points)}
125
+ <span className="captain-player-sub-pts"> pts</span>
126
+ </p>
127
+ )}
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </article>
133
+ </motion.div>
134
+ );
135
+ })}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ Bar,
3
+ BarChart,
4
+ CartesianGrid,
5
+ Cell,
6
+ LabelList,
7
+ ResponsiveContainer,
8
+ Tooltip,
9
+ XAxis,
10
+ YAxis,
11
+ } from "recharts";
12
+ import TeamPills from "../ui/TeamPills";
13
+ import type { ChartBoardProps } from "../../types/dashboard";
14
+ import { createChartData } from "../../utils/dashboard";
15
+
16
+ export function ChartBoard({ items, valueLabel, emptyLabel }: ChartBoardProps) {
17
+ if (!items.length) {
18
+ return <p className="ledger-note py-6">{emptyLabel ?? "No data yet."}</p>;
19
+ }
20
+
21
+ const chartData = createChartData(items);
22
+ const leader = chartData.reduce((best, item) =>
23
+ item.value > best.value ? item : best,
24
+ );
25
+
26
+ return (
27
+ <div className="space-y-3">
28
+ <TeamPills items={chartData} max={3} />
29
+
30
+ <div
31
+ className="border px-2 py-3 sm:px-3"
32
+ style={{
33
+ height: 380,
34
+ border: "1.5px solid",
35
+ borderRadius: `24px ${32 + (chartData.length % 2) * 8}px ${28 + (leader.value % 3) * 6}px 24px`,
36
+ borderColor: `color-mix(in srgb, ${leader.fill} 36%, var(--line))`,
37
+ background: `linear-gradient(175deg, color-mix(in srgb, var(--paper) 88%, var(--panel-strong) 12%), color-mix(in srgb, ${leader.fill} 18%, var(--paper-strong) 82%))`,
38
+ boxShadow: `0 20px 36px color-mix(in srgb, ${leader.fill} 10%, var(--shadow)), inset 0 0 0 1px color-mix(in srgb, var(--chalk) 6%, transparent)`,
39
+ transform: "rotate(-0.3deg)",
40
+ }}
41
+ >
42
+ <ResponsiveContainer width="100%" height="100%">
43
+ <BarChart
44
+ data={chartData}
45
+ margin={{ top: 14, right: 10, bottom: 56, left: 0 }}
46
+ barCategoryGap="20%"
47
+ >
48
+ <CartesianGrid
49
+ vertical={false}
50
+ stroke="color-mix(in srgb, var(--ink) 12%, transparent)"
51
+ strokeDasharray="4 6"
52
+ />
53
+ <XAxis
54
+ dataKey="label"
55
+ tickLine={false}
56
+ axisLine={false}
57
+ interval={0}
58
+ tick={({ x, y, payload }) => {
59
+ const name = String(payload.value);
60
+ const truncated =
61
+ name.length > 14 ? name.slice(0, 13) + "…" : name;
62
+ const mid = Math.ceil(truncated.length / 2);
63
+ const line1 = truncated.slice(0, mid);
64
+ const line2 = truncated.slice(mid);
65
+ return (
66
+ <text
67
+ x={x}
68
+ y={Number(y) + 6}
69
+ fill="var(--ink)"
70
+ fontSize={11}
71
+ fontWeight={800}
72
+ textAnchor="middle"
73
+ >
74
+ <tspan x={x} dy={0}>
75
+ {line1}
76
+ </tspan>
77
+ {line2 && (
78
+ <tspan x={x} dy={12}>
79
+ {line2}
80
+ </tspan>
81
+ )}
82
+ </text>
83
+ );
84
+ }}
85
+ />
86
+ <YAxis
87
+ tickFormatter={(value) => formatCompactNumber(Number(value))}
88
+ tickLine={false}
89
+ axisLine={false}
90
+ width={48}
91
+ tick={{ fill: "var(--ink-soft)", fontSize: 11, fontWeight: 700 }}
92
+ />
93
+ <Tooltip
94
+ cursor={{
95
+ fill: "color-mix(in srgb, var(--marker-yellow) 20%, transparent)",
96
+ }}
97
+ contentStyle={{
98
+ background:
99
+ "color-mix(in srgb, var(--paper) 96%, var(--panel-strong) 4%)",
100
+ border:
101
+ "1px solid color-mix(in srgb, var(--ink) 16%, transparent)",
102
+ borderRadius: "14px",
103
+ color: "var(--ink)",
104
+ }}
105
+ formatter={(value, _name, entry) => {
106
+ const note =
107
+ entry && "payload" in entry && entry.payload
108
+ ? String(
109
+ (entry.payload as { sublabel?: string }).sublabel ?? "",
110
+ )
111
+ : "";
112
+
113
+ return [
114
+ `${formatCompactNumber(Number(value ?? 0))} ${valueLabel}`,
115
+ note,
116
+ ];
117
+ }}
118
+ labelFormatter={(label) => `${label}`}
119
+ />
120
+ <Bar
121
+ dataKey="value"
122
+ radius={[16, 16, 0, 0]}
123
+ animationDuration={700}
124
+ strokeWidth={2}
125
+ >
126
+ {chartData.map((item) => (
127
+ <Cell
128
+ key={item.label}
129
+ fill={item.fill}
130
+ stroke={`color-mix(in srgb, ${item.fill} 78%, var(--ink) 22%)`}
131
+ opacity={0.96}
132
+ />
133
+ ))}
134
+ <LabelList
135
+ dataKey="value"
136
+ position="center"
137
+ angle={-90}
138
+ formatter={(value) => String(value ?? "")}
139
+ style={{ fill: "var(--ink)", fontSize: 11, fontWeight: 800 }}
140
+ />
141
+ {items.some((item) => typeof item.difference === "number") && (
142
+ <LabelList
143
+ dataKey="difference"
144
+ position="top"
145
+ formatter={(value) =>
146
+ typeof value === "number"
147
+ ? `+${value}`
148
+ : String(value ?? "")
149
+ }
150
+ style={{ fill: "var(--ink)", fontSize: 10, fontWeight: 700 }}
151
+ />
152
+ )}
153
+ </Bar>
154
+ </BarChart>
155
+ </ResponsiveContainer>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ // Import the formatting function
162
+ import { formatCompactNumber } from "../../utils/dashboard";
@@ -0,0 +1,23 @@
1
+ import { FaFire } from "react-icons/fa6";
2
+ import type { LatestBadgeProps } from "../../types/dashboard";
3
+ import { formatLedgerNumber } from "../../utils/dashboard";
4
+
5
+ export function LatestBadge({ pts, isTop }: LatestBadgeProps) {
6
+ const color =
7
+ pts < 200
8
+ ? "var(--marker-pink)"
9
+ : pts <= 400
10
+ ? "var(--marker-orange)"
11
+ : "var(--marker-green)";
12
+ return (
13
+ <span
14
+ className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-semibold"
15
+ style={{
16
+ background: `color-mix(in srgb, ${color} 22%, var(--panel-strong))`,
17
+ }}
18
+ >
19
+ {isTop ? <FaFire className="text-[0.75rem]" style={{ color }} /> : null}
20
+ <span>{formatLedgerNumber(pts)}</span>
21
+ </span>
22
+ );
23
+ }