@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.
- package/README.md +75 -0
- package/package.json +27 -0
- package/src/generate-template.mjs +73 -0
- package/src/index.mjs +98 -0
- package/src/prompts.mjs +78 -0
- package/src/scaffold.mjs +129 -0
- package/src/scraper.mjs +79 -0
- package/template/.dockerignore +13 -0
- package/template/AGENTS.md +5 -0
- package/template/Dockerfile.sync +14 -0
- package/template/README.md +160 -0
- package/template/app/api/ipl/data.ts +24 -0
- package/template/app/api/ipl/route.ts +505 -0
- package/template/app/api/ipl/transfers/route.ts +261 -0
- package/template/app/api/ipl/transfers/transform.ts +156 -0
- package/template/app/api/ipl/transform.ts +20 -0
- package/template/app/api/ipl/upcoming-matches/route.ts +18 -0
- package/template/app/api/ops/status/route.ts +225 -0
- package/template/app/components/AIRoasting.tsx +278 -0
- package/template/app/components/ColorWave.tsx +193 -0
- package/template/app/components/CrownBattle.tsx +207 -0
- package/template/app/components/DashboardContent.tsx +377 -0
- package/template/app/components/FantasyStockTicker.tsx +192 -0
- package/template/app/components/FireworksBurst.tsx +225 -0
- package/template/app/components/LiveMatchTicker.tsx +117 -0
- package/template/app/components/MatchRecapScroll.tsx +135 -0
- package/template/app/components/MatchStoryScrubber.tsx +274 -0
- package/template/app/components/PerformanceTracker.tsx +132 -0
- package/template/app/components/ProgressGlowRings.tsx +157 -0
- package/template/app/components/TeamDNAScanner.tsx +238 -0
- package/template/app/components/ThemeToggle.tsx +74 -0
- package/template/app/components/dashboard/CaptainBoard.tsx +138 -0
- package/template/app/components/dashboard/ChartBoard.tsx +162 -0
- package/template/app/components/dashboard/LatestBadge.tsx +23 -0
- package/template/app/components/dashboard/LedgerTable.tsx +385 -0
- package/template/app/components/dashboard/SectionCard.tsx +59 -0
- package/template/app/components/dashboard/StickyMini.tsx +20 -0
- package/template/app/components/dashboard/index.ts +6 -0
- package/template/app/components/ui/DashboardChartFrame.tsx +74 -0
- package/template/app/components/ui/DoodleSpinner.tsx +15 -0
- package/template/app/components/ui/TeamPills.tsx +41 -0
- package/template/app/data/match-points.ts +3 -0
- package/template/app/data/teams.ts +32 -0
- package/template/app/globals.css +1267 -0
- package/template/app/hooks/dashboard/index.ts +1 -0
- package/template/app/hooks/dashboard/useDashboardModel.ts +25 -0
- package/template/app/hooks/dashboardCache.ts +53 -0
- package/template/app/hooks/dashboardPolling.ts +53 -0
- package/template/app/hooks/snapshotCache.ts +47 -0
- package/template/app/hooks/useDashboardData.ts +28 -0
- package/template/app/layout.tsx +75 -0
- package/template/app/lib/aiAgent.ts +444 -0
- package/template/app/lib/config.ts +29 -0
- package/template/app/lib/dashboard/index.ts +1 -0
- package/template/app/lib/dashboard/model.ts +257 -0
- package/template/app/lib/dashboardData.ts +50 -0
- package/template/app/lib/dashboardView.ts +22 -0
- package/template/app/lib/detailedData.ts +112 -0
- package/template/app/lib/matchStatus.ts +28 -0
- package/template/app/lib/matches.ts +131 -0
- package/template/app/lib/teamBadges.ts +223 -0
- package/template/app/lib/upcomingMatches.ts +154 -0
- package/template/app/lib/useDb.ts +29 -0
- package/template/app/lib/utils/diff.ts +24 -0
- package/template/app/lib/utils/getChartColor.ts +17 -0
- package/template/app/lib/utils/getStdDeviation.ts +6 -0
- package/template/app/lib/utils/time.ts +40 -0
- package/template/app/lib/utils.ts +70 -0
- package/template/app/page.tsx +15 -0
- package/template/app/store/dashboardStore.ts +85 -0
- package/template/app/types/dashboard.ts +75 -0
- package/template/app/types.ts +130 -0
- package/template/app/utils/dashboard/index.ts +72 -0
- package/template/eslint.config.mjs +18 -0
- package/template/infra/cloud-run/README.md +68 -0
- package/template/infra/cloud-run/sync-job.yaml +32 -0
- package/template/infra/cutover/README.md +84 -0
- package/template/infra/vercel/README.md +57 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +7330 -0
- package/template/package.json +47 -0
- package/template/packages/ipl-dashboard-utils/README.md +316 -0
- package/template/packages/ipl-dashboard-utils/package.json +34 -0
- package/template/packages/ipl-dashboard-utils/src/index.ts +22 -0
- package/template/packages/ipl-dashboard-utils/src/transform.ts +687 -0
- package/template/packages/ipl-dashboard-utils/src/types.ts +88 -0
- package/template/packages/ipl-dashboard-utils/tsconfig.build.json +17 -0
- package/template/postcss.config.mjs +7 -0
- package/template/scripts/capture-ipl-auth.mjs +54 -0
- package/template/scripts/deploy-cloud-run-sync.sh +48 -0
- package/template/scripts/deploy-cloud-scheduler.sh +42 -0
- package/template/scripts/dev-simple.js +31 -0
- package/template/scripts/dev-welcome.mjs +38 -0
- package/template/scripts/monitor-ops-status.sh +50 -0
- package/template/scripts/seed-mongodb.ts +115 -0
- package/template/scripts/sync-cloud.mjs +50 -0
- package/template/scripts/sync-ipl.mjs +238 -0
- package/template/scripts/sync-transfers-daily.mjs +175 -0
- package/template/scripts/verify-production.mjs +108 -0
- package/template/tests/coverage-gaps.test.ts +290 -0
- package/template/tests/dashboard-polling.test.ts +96 -0
- package/template/tests/detailed-data.test.ts +60 -0
- package/template/tests/ipl-transform.test.ts +590 -0
- package/template/tests/transfers-route.test.ts +109 -0
- package/template/tests/upcoming-matches.test.ts +34 -0
- package/template/tests/utils-and-cache.test.ts +267 -0
- package/template/tsconfig.json +35 -0
- package/template/vercel.json +7 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { DailyChartRow, DashboardData } from "../types";
|
|
2
|
+
import { getPlayedMatchRows } from "./dashboardData";
|
|
3
|
+
import { getStdDeviation } from "./utils/getStdDeviation";
|
|
4
|
+
|
|
5
|
+
export type TeamBadge = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const HIGH_STREAK_THRESHOLD = 200;
|
|
12
|
+
|
|
13
|
+
const getHistoricalRows = (daily: DailyChartRow[]) => getPlayedMatchRows(daily);
|
|
14
|
+
|
|
15
|
+
const getTeamNames = (data: DashboardData) =>
|
|
16
|
+
Array.from(
|
|
17
|
+
new Set([
|
|
18
|
+
...data.overall.map((team) => team.name),
|
|
19
|
+
...data.daily.flatMap((row) => Object.keys(row)),
|
|
20
|
+
]),
|
|
21
|
+
).filter((key) => key !== "day");
|
|
22
|
+
|
|
23
|
+
const appendBadge = (
|
|
24
|
+
map: Record<string, TeamBadge[]>,
|
|
25
|
+
team: string | undefined,
|
|
26
|
+
badge: TeamBadge,
|
|
27
|
+
) => {
|
|
28
|
+
if (!team) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const next = map[team] ?? [];
|
|
33
|
+
|
|
34
|
+
if (next.some((item) => item.id === badge.id)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
map[team] = [...next, badge];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const buildScoreMap = (historicalRows: DailyChartRow[], teamNames: string[]) =>
|
|
42
|
+
teamNames.reduce<Record<string, number[]>>((acc, team) => {
|
|
43
|
+
acc[team] = historicalRows
|
|
44
|
+
.map((row) => (row[team] === undefined ? null : Number(row[team])))
|
|
45
|
+
.filter((value): value is number => value !== null && Number.isFinite(value));
|
|
46
|
+
|
|
47
|
+
return acc;
|
|
48
|
+
}, {});
|
|
49
|
+
|
|
50
|
+
const getLongestHotStreak = (scores: number[]) => {
|
|
51
|
+
let best = 0;
|
|
52
|
+
let current = 0;
|
|
53
|
+
|
|
54
|
+
scores.forEach((score) => {
|
|
55
|
+
if (score >= HIGH_STREAK_THRESHOLD) {
|
|
56
|
+
current += 1;
|
|
57
|
+
best = Math.max(best, current);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
current = 0;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return best;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const buildTeamBadgeMap = (data: DashboardData) => {
|
|
68
|
+
const badgeMap: Record<string, TeamBadge[]> = {};
|
|
69
|
+
const historicalRows = getHistoricalRows(data.daily);
|
|
70
|
+
const teamNames = getTeamNames(data);
|
|
71
|
+
const scoreMap = buildScoreMap(historicalRows, teamNames);
|
|
72
|
+
|
|
73
|
+
const seasonLeader = [...data.overall].sort((a, b) => a.rank - b.rank)[0];
|
|
74
|
+
appendBadge(badgeMap, seasonLeader?.name, {
|
|
75
|
+
id: "leader",
|
|
76
|
+
label: "Leader",
|
|
77
|
+
description: "Overall points leader",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const hottestTeam = [...data.overall].sort(
|
|
81
|
+
(a, b) => (b.lastMatchPoints ?? 0) - (a.lastMatchPoints ?? 0),
|
|
82
|
+
)[0];
|
|
83
|
+
if ((hottestTeam?.lastMatchPoints ?? 0) > 0) {
|
|
84
|
+
appendBadge(badgeMap, hottestTeam.name, {
|
|
85
|
+
id: "latest-leader",
|
|
86
|
+
label: "MVP",
|
|
87
|
+
description: "Highest latest-match score",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const transferSaver = [...data.overall]
|
|
92
|
+
.filter((team) => typeof team.transfersLeft === "number")
|
|
93
|
+
.sort((a, b) => (b.transfersLeft ?? 0) - (a.transfersLeft ?? 0))[0];
|
|
94
|
+
if (transferSaver) {
|
|
95
|
+
appendBadge(badgeMap, transferSaver.name, {
|
|
96
|
+
id: "transfers-left",
|
|
97
|
+
label: "Saver",
|
|
98
|
+
description: "Most transfers left in hand",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const biggestRiser = [...data.overall]
|
|
103
|
+
.filter(
|
|
104
|
+
(team) =>
|
|
105
|
+
typeof team.previousRank === "number" &&
|
|
106
|
+
team.rank < (team.previousRank ?? team.rank),
|
|
107
|
+
)
|
|
108
|
+
.sort(
|
|
109
|
+
(a, b) =>
|
|
110
|
+
(b.previousRank ?? b.rank) -
|
|
111
|
+
b.rank -
|
|
112
|
+
((a.previousRank ?? a.rank) - a.rank),
|
|
113
|
+
)[0];
|
|
114
|
+
if (biggestRiser) {
|
|
115
|
+
appendBadge(badgeMap, biggestRiser.name, {
|
|
116
|
+
id: "riser",
|
|
117
|
+
label: "Riser",
|
|
118
|
+
description: "Biggest upward move since the last snapshot",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const widestGap = [...data.overall].sort(
|
|
123
|
+
(a, b) => (b.gapToNext ?? 0) - (a.gapToNext ?? 0),
|
|
124
|
+
)[0];
|
|
125
|
+
if ((widestGap?.gapToNext ?? 0) > 0) {
|
|
126
|
+
appendBadge(badgeMap, widestGap.name, {
|
|
127
|
+
id: "gap",
|
|
128
|
+
label: "Gap",
|
|
129
|
+
description: "Largest cushion to the next rank",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const scoreEntries = teamNames.map((team) => {
|
|
134
|
+
const scores = scoreMap[team] ?? [];
|
|
135
|
+
const average =
|
|
136
|
+
scores.reduce((sum, score) => sum + score, 0) / Math.max(scores.length, 1);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
team,
|
|
140
|
+
scores,
|
|
141
|
+
max: scores.length ? Math.max(...scores) : 0,
|
|
142
|
+
zeroCount: scores.filter((score) => score === 0).length,
|
|
143
|
+
volatility: getStdDeviation(scores),
|
|
144
|
+
average,
|
|
145
|
+
streak: getLongestHotStreak(scores),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const duckTeam = [...scoreEntries].sort((a, b) => b.zeroCount - a.zeroCount)[0];
|
|
150
|
+
if ((duckTeam?.zeroCount ?? 0) > 0) {
|
|
151
|
+
appendBadge(badgeMap, duckTeam.team, {
|
|
152
|
+
id: "ducks",
|
|
153
|
+
label: "Ducks",
|
|
154
|
+
description: "Most zero-point matches",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const peakTeam = [...scoreEntries].sort((a, b) => b.max - a.max)[0];
|
|
159
|
+
if ((peakTeam?.max ?? 0) > 0) {
|
|
160
|
+
appendBadge(badgeMap, peakTeam.team, {
|
|
161
|
+
id: "peak",
|
|
162
|
+
label: "Peak",
|
|
163
|
+
description: "Highest single-match score this season",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const mostConsistent = [...scoreEntries]
|
|
168
|
+
.filter((entry) => entry.scores.length > 1)
|
|
169
|
+
.sort((a, b) => a.volatility - b.volatility)[0];
|
|
170
|
+
if (mostConsistent) {
|
|
171
|
+
appendBadge(badgeMap, mostConsistent.team, {
|
|
172
|
+
id: "consistent",
|
|
173
|
+
label: "Consistent",
|
|
174
|
+
description: "Lowest match-to-match volatility",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hotStreakTeam = [...scoreEntries].sort((a, b) => b.streak - a.streak)[0];
|
|
179
|
+
if ((hotStreakTeam?.streak ?? 0) > 1) {
|
|
180
|
+
appendBadge(badgeMap, hotStreakTeam.team, {
|
|
181
|
+
id: "streak",
|
|
182
|
+
label: "Streak",
|
|
183
|
+
description: `Longest ${HIGH_STREAK_THRESHOLD}+ point run`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return badgeMap;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const buildMatchBadgeMap = (row?: DailyChartRow) => {
|
|
191
|
+
const badgeMap: Record<string, TeamBadge[]> = {};
|
|
192
|
+
|
|
193
|
+
if (!row) {
|
|
194
|
+
return badgeMap;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const teams = Object.keys(row).filter((key) => key !== "day");
|
|
198
|
+
const entries = teams.map((team) => ({
|
|
199
|
+
team,
|
|
200
|
+
points: Number(row[team] ?? 0),
|
|
201
|
+
}));
|
|
202
|
+
const leader = [...entries].sort((a, b) => b.points - a.points)[0];
|
|
203
|
+
|
|
204
|
+
if (leader) {
|
|
205
|
+
appendBadge(badgeMap, leader.team, {
|
|
206
|
+
id: "match-leader",
|
|
207
|
+
label: "Match leader",
|
|
208
|
+
description: "Highest score in this match view",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
entries
|
|
213
|
+
.filter((entry) => entry.points === 0)
|
|
214
|
+
.forEach((entry) => {
|
|
215
|
+
appendBadge(badgeMap, entry.team, {
|
|
216
|
+
id: "match-duck",
|
|
217
|
+
label: "Duck",
|
|
218
|
+
description: "Zero points in this match view",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return badgeMap;
|
|
223
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { matches as fallbackMatches } from "./matches.ts";
|
|
2
|
+
import type { UpcomingMatch as Match } from "./matches.ts";
|
|
3
|
+
|
|
4
|
+
type UpcomingSource = "remote" | "fallback";
|
|
5
|
+
|
|
6
|
+
const TEAM_NAME_TO_CODE: Record<string, string> = {
|
|
7
|
+
"Delhi Capitals": "DC",
|
|
8
|
+
"Kolkata Knight Riders": "KKR",
|
|
9
|
+
"Rajasthan Royals": "RR",
|
|
10
|
+
"Gujarat Titans": "GT",
|
|
11
|
+
"Chennai Super Kings": "CSK",
|
|
12
|
+
"Lucknow Super Giants": "LSG",
|
|
13
|
+
"Royal Challengers Bengaluru": "RCB",
|
|
14
|
+
"Mumbai Indians": "MI",
|
|
15
|
+
"Punjab Kings": "PBKS",
|
|
16
|
+
"Sunrisers Hyderabad": "SRH",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TEAM_NAMES = Object.keys(TEAM_NAME_TO_CODE);
|
|
20
|
+
const INDIA_OFFSET = "+05:30";
|
|
21
|
+
const businessStandardScheduleUrl = "https://www.business-standard.com/cricket/ipl/schedule";
|
|
22
|
+
const MONTH_PATTERN =
|
|
23
|
+
"(January|February|March|April|May|June|July|August|September|October|November|December)";
|
|
24
|
+
|
|
25
|
+
const stripHtml = (html: string) =>
|
|
26
|
+
html
|
|
27
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
28
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
29
|
+
.replace(/<[^>]+>/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
|
|
33
|
+
const teamCodeFromName = (teamName: string) => TEAM_NAME_TO_CODE[teamName];
|
|
34
|
+
|
|
35
|
+
const parseDateToIso = (chunk: string) => {
|
|
36
|
+
const longDate =
|
|
37
|
+
chunk.match(
|
|
38
|
+
new RegExp(
|
|
39
|
+
`(?:\\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[a-z]*\\s+)?(\\d{1,2})(?:st|nd|rd|th)?\\s+${MONTH_PATTERN}(?:,?\\s*(?:\\d{4})?)?`,
|
|
40
|
+
"i",
|
|
41
|
+
),
|
|
42
|
+
) ??
|
|
43
|
+
chunk.match(
|
|
44
|
+
new RegExp(
|
|
45
|
+
`${MONTH_PATTERN}\\s+(\\d{1,2})(?:,?\\s*(?:\\d{4})?)?`,
|
|
46
|
+
"i",
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const timeMatch = chunk.match(/(\d{1,2}:\d{2})\s*(am|pm)/i);
|
|
51
|
+
|
|
52
|
+
if (!longDate || !timeMatch) return null;
|
|
53
|
+
|
|
54
|
+
let day: number;
|
|
55
|
+
let monthName: string;
|
|
56
|
+
|
|
57
|
+
if (Number.isNaN(Number(longDate[1]))) {
|
|
58
|
+
monthName = longDate[1];
|
|
59
|
+
day = Number(longDate[2]);
|
|
60
|
+
} else {
|
|
61
|
+
day = Number(longDate[1]);
|
|
62
|
+
monthName = longDate[2];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const monthIndex = new Date(`2026 ${monthName} 1`).getMonth();
|
|
66
|
+
if (!Number.isFinite(day) || monthIndex < 0) return null;
|
|
67
|
+
|
|
68
|
+
const [time, meridiem] = [timeMatch[1], timeMatch[2].toLowerCase()];
|
|
69
|
+
const [hourRaw, minuteRaw] = time.split(":").map(Number);
|
|
70
|
+
const hour24 =
|
|
71
|
+
meridiem === "pm" && hourRaw !== 12
|
|
72
|
+
? hourRaw + 12
|
|
73
|
+
: meridiem === "am" && hourRaw === 12
|
|
74
|
+
? 0
|
|
75
|
+
: hourRaw;
|
|
76
|
+
const localIso = `${String(2026)}-${String(monthIndex + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour24).padStart(2, "0")}:${String(minuteRaw).padStart(2, "0")}:00${INDIA_OFFSET}`;
|
|
77
|
+
|
|
78
|
+
const parsed = new Date(localIso);
|
|
79
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const parseChunkToMatch = (chunk: string): Match | null => {
|
|
83
|
+
const idMatch = chunk.match(/Match\s+(\d+)/i);
|
|
84
|
+
if (!idMatch) return null;
|
|
85
|
+
|
|
86
|
+
const isoDate = parseDateToIso(chunk);
|
|
87
|
+
if (!isoDate) return null;
|
|
88
|
+
|
|
89
|
+
const foundTeams = TEAM_NAMES.map((name) => ({
|
|
90
|
+
name,
|
|
91
|
+
index: chunk.indexOf(name),
|
|
92
|
+
}))
|
|
93
|
+
.filter((entry) => entry.index >= 0)
|
|
94
|
+
.sort((a, b) => a.index - b.index)
|
|
95
|
+
.map((entry) => entry.name);
|
|
96
|
+
|
|
97
|
+
const team1 = foundTeams[0];
|
|
98
|
+
const team2 = foundTeams[1];
|
|
99
|
+
|
|
100
|
+
if (!team1 || !team2) return null;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: Number(idMatch[1]),
|
|
104
|
+
team1: teamCodeFromName(team1),
|
|
105
|
+
team2: teamCodeFromName(team2),
|
|
106
|
+
date: isoDate,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const parseUpcomingMatches = (html: string): Match[] => {
|
|
111
|
+
const text = stripHtml(html);
|
|
112
|
+
const chunks = text.match(/Match\s+\d+[\s\S]*?(?=Match\s+\d+|$)/gi) ?? [];
|
|
113
|
+
|
|
114
|
+
return chunks
|
|
115
|
+
.map(parseChunkToMatch)
|
|
116
|
+
.filter((match): match is Match => Boolean(match))
|
|
117
|
+
.sort((a, b) => a.id - b.id);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const fetchUpcomingMatches = async (): Promise<{
|
|
121
|
+
matches: Match[];
|
|
122
|
+
source: UpcomingSource;
|
|
123
|
+
}> => {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(businessStandardScheduleUrl, {
|
|
126
|
+
cache: "no-store",
|
|
127
|
+
headers: {
|
|
128
|
+
"user-agent":
|
|
129
|
+
"Mozilla/5.0 (compatible; IPLDashboard/1.0; +https://example.com)",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`Schedule fetch failed (${response.status})`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const html = await response.text();
|
|
138
|
+
const parsed = parseUpcomingMatches(html);
|
|
139
|
+
|
|
140
|
+
if (parsed.length) {
|
|
141
|
+
return {
|
|
142
|
+
matches: parsed,
|
|
143
|
+
source: "remote",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Fall back to the bundled schedule below.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
matches: fallbackMatches,
|
|
152
|
+
source: "fallback",
|
|
153
|
+
};
|
|
154
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MongoClient } from "mongodb";
|
|
2
|
+
import { config } from "./config";
|
|
3
|
+
|
|
4
|
+
const uri = process.env.MONGODB_URI;
|
|
5
|
+
|
|
6
|
+
let client: MongoClient;
|
|
7
|
+
let clientPromise: Promise<MongoClient> | undefined;
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
var _mongoClientPromise: Promise<MongoClient> | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (uri) {
|
|
14
|
+
if (!global._mongoClientPromise) {
|
|
15
|
+
client = new MongoClient(uri);
|
|
16
|
+
global._mongoClientPromise = client.connect();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clientPromise = global._mongoClientPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function getMongoDb() {
|
|
23
|
+
if (!clientPromise) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const client = await clientPromise;
|
|
28
|
+
return client.db(config.mongodb.databaseName);
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// utils/diff.ts
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
|
+
export function getDiff(prev: any, next: any, path = ""): Record<string, any> {
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
const diff: Record<string, any> = {};
|
|
6
|
+
|
|
7
|
+
for (const key in next) {
|
|
8
|
+
const newPath = path ? `${path}.${key}` : key;
|
|
9
|
+
|
|
10
|
+
if (
|
|
11
|
+
typeof next[key] === "object" &&
|
|
12
|
+
next[key] !== null &&
|
|
13
|
+
typeof prev?.[key] === "object"
|
|
14
|
+
) {
|
|
15
|
+
Object.assign(diff, getDiff(prev[key], next[key], newPath));
|
|
16
|
+
} else {
|
|
17
|
+
if (prev?.[key] !== next[key]) {
|
|
18
|
+
diff[newPath] = next[key];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return diff;
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const getChartColor = (index: number, total: number) => {
|
|
2
|
+
const safeTotal = Math.max(1, total);
|
|
3
|
+
const palette = [
|
|
4
|
+
"#22d3ee",
|
|
5
|
+
"#f59e0b",
|
|
6
|
+
"#84cc16",
|
|
7
|
+
"#ec4899",
|
|
8
|
+
"#3b82f6",
|
|
9
|
+
"#f97316",
|
|
10
|
+
"#a855f7",
|
|
11
|
+
"#14b8a6",
|
|
12
|
+
"#ef4444",
|
|
13
|
+
"#eab308",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
return palette[index % Math.min(palette.length, safeTotal)];
|
|
17
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// utils/time.ts
|
|
2
|
+
|
|
3
|
+
export function formatMatchTime(date: string, timeZone = "Asia/Kolkata") {
|
|
4
|
+
return new Date(date).toLocaleString("en-IN", {
|
|
5
|
+
timeZone,
|
|
6
|
+
hour: "2-digit",
|
|
7
|
+
minute: "2-digit",
|
|
8
|
+
hour12: true,
|
|
9
|
+
day: "2-digit",
|
|
10
|
+
month: "short",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getMatchStatus(date: string) {
|
|
15
|
+
const now = new Date().getTime();
|
|
16
|
+
const matchTime = new Date(date).getTime();
|
|
17
|
+
|
|
18
|
+
const diff = matchTime - now;
|
|
19
|
+
|
|
20
|
+
if (diff < -4 * 60 * 60 * 1000) return "ENDED"; // 4h after start
|
|
21
|
+
if (diff <= 0) return "LIVE";
|
|
22
|
+
if (diff <= 30 * 60 * 1000) return "STARTING SOON";
|
|
23
|
+
|
|
24
|
+
return "UPCOMING";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getTimeLeft(date: string) {
|
|
28
|
+
const now = new Date().getTime();
|
|
29
|
+
const matchTime = new Date(date).getTime();
|
|
30
|
+
|
|
31
|
+
const diff = matchTime - now;
|
|
32
|
+
|
|
33
|
+
if (diff <= 0) return "Started";
|
|
34
|
+
|
|
35
|
+
const mins = Math.floor(diff / 60000);
|
|
36
|
+
const hrs = Math.floor(mins / 60);
|
|
37
|
+
|
|
38
|
+
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
|
39
|
+
return `${mins}m`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const trimTeamName = (name: string, maxLength: number = 12): string => {
|
|
2
|
+
if (name.length <= maxLength) {
|
|
3
|
+
return name;
|
|
4
|
+
}
|
|
5
|
+
return name.slice(0, maxLength) + "...";
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const splitTeamName = (
|
|
9
|
+
name: string,
|
|
10
|
+
maxPerLine: number = 8,
|
|
11
|
+
): string[] => {
|
|
12
|
+
// First trim if too long
|
|
13
|
+
const trimmed = name.length > 20 ? trimTeamName(name, 15) : name;
|
|
14
|
+
|
|
15
|
+
// Split into two lines - try to split at space first
|
|
16
|
+
if (trimmed.length <= maxPerLine) {
|
|
17
|
+
return [trimmed, ""];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const spaceIndex = trimmed.lastIndexOf(" ", maxPerLine);
|
|
21
|
+
if (spaceIndex > 0) {
|
|
22
|
+
return [trimmed.slice(0, spaceIndex), trimmed.slice(spaceIndex + 1)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// No space found, split at maxPerLine
|
|
26
|
+
return [trimmed.slice(0, maxPerLine), trimmed.slice(maxPerLine)];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const formatCompactNumber = (
|
|
30
|
+
value?: number,
|
|
31
|
+
options: {
|
|
32
|
+
threshold?: number;
|
|
33
|
+
maximumFractionDigits?: number;
|
|
34
|
+
} = {},
|
|
35
|
+
): string => {
|
|
36
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
37
|
+
return "-";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
threshold = 10000,
|
|
42
|
+
maximumFractionDigits = 1,
|
|
43
|
+
} = options;
|
|
44
|
+
|
|
45
|
+
const abs = Math.abs(value);
|
|
46
|
+
if (abs < threshold) {
|
|
47
|
+
return value.toLocaleString("en-IN", {
|
|
48
|
+
maximumFractionDigits: value % 1 === 0 ? 0 : maximumFractionDigits,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const compact = [
|
|
53
|
+
{ unit: "k", size: 1_000 },
|
|
54
|
+
{ unit: "m", size: 1_000_000 },
|
|
55
|
+
{ unit: "b", size: 1_000_000_000 },
|
|
56
|
+
]
|
|
57
|
+
.filter(({ size }) => abs >= size)
|
|
58
|
+
.at(-1);
|
|
59
|
+
|
|
60
|
+
if (!compact) {
|
|
61
|
+
return value.toLocaleString("en-IN", {
|
|
62
|
+
maximumFractionDigits: value % 1 === 0 ? 0 : maximumFractionDigits,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scaled = value / compact.size;
|
|
67
|
+
const rounded = Number(scaled.toFixed(maximumFractionDigits));
|
|
68
|
+
|
|
69
|
+
return `${rounded}${compact.unit}`;
|
|
70
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import DashboardContent from "./components/DashboardContent";
|
|
4
|
+
import LiveMatchTicker from "./components/LiveMatchTicker";
|
|
5
|
+
|
|
6
|
+
export default function Home() {
|
|
7
|
+
return (
|
|
8
|
+
<main className="dashboard-shell relative min-h-screen overflow-hidden text-ink">
|
|
9
|
+
<div className="scrapbook-topbar">
|
|
10
|
+
<LiveMatchTicker />
|
|
11
|
+
</div>
|
|
12
|
+
<DashboardContent />
|
|
13
|
+
</main>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { create } from "zustand";
|
|
4
|
+
import type { DashboardData } from "../types";
|
|
5
|
+
import { readDashboardCache, writeDashboardCache } from "../hooks/dashboardCache";
|
|
6
|
+
|
|
7
|
+
import { config } from "../lib/config.ts";
|
|
8
|
+
|
|
9
|
+
export const DASHBOARD_SYNC_INTERVAL_MS = config.dashboard.syncIntervalMs;
|
|
10
|
+
|
|
11
|
+
type State = {
|
|
12
|
+
data: DashboardData | null;
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: string | null;
|
|
15
|
+
lastSyncedAt: string | null;
|
|
16
|
+
hydrateFromCache: () => void;
|
|
17
|
+
fetchDashboard: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const useDashboardStore = create<State>((set) => ({
|
|
21
|
+
data: null,
|
|
22
|
+
loading: true,
|
|
23
|
+
error: null,
|
|
24
|
+
lastSyncedAt: null,
|
|
25
|
+
|
|
26
|
+
hydrateFromCache: () => {
|
|
27
|
+
const cached = readDashboardCache();
|
|
28
|
+
|
|
29
|
+
if (cached?.data) {
|
|
30
|
+
set({
|
|
31
|
+
data: cached.data,
|
|
32
|
+
loading: false,
|
|
33
|
+
error: null,
|
|
34
|
+
lastSyncedAt: cached.cachedAt,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
fetchDashboard: async () => {
|
|
40
|
+
const hasData = Boolean(useDashboardStore.getState().data);
|
|
41
|
+
|
|
42
|
+
if (!hasData) {
|
|
43
|
+
set({ loading: true, error: null });
|
|
44
|
+
} else {
|
|
45
|
+
set({ error: null });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const [dashboardResponse, transfersResponse] = await Promise.all([
|
|
50
|
+
fetch(`/api/ipl?t=${Date.now()}`, { cache: "no-store" }),
|
|
51
|
+
fetch(`/api/ipl/transfers?t=${Date.now()}`, { cache: "no-store" }),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
if (!dashboardResponse.ok) {
|
|
55
|
+
throw new Error(`Dashboard fetch failed (${dashboardResponse.status})`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const json = (await dashboardResponse.json()) as DashboardData;
|
|
59
|
+
const transfers = transfersResponse.ok
|
|
60
|
+
? ((await transfersResponse.json()) as DashboardData["transfers"])
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
set({
|
|
64
|
+
data: {
|
|
65
|
+
...json,
|
|
66
|
+
transfers,
|
|
67
|
+
},
|
|
68
|
+
loading: false,
|
|
69
|
+
error: null,
|
|
70
|
+
lastSyncedAt: new Date().toISOString(),
|
|
71
|
+
});
|
|
72
|
+
writeDashboardCache({
|
|
73
|
+
...json,
|
|
74
|
+
transfers,
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : "Fetch failed";
|
|
78
|
+
|
|
79
|
+
set({
|
|
80
|
+
error: message,
|
|
81
|
+
loading: false,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}));
|