@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,687 @@
1
+ import type {
2
+ DailyChartRow,
3
+ DashboardData,
4
+ OverallChartItem,
5
+ RawApiUser,
6
+ RawUsersSyncResult,
7
+ ScrapedDashboardPayload,
8
+ ScrapedLeaderboardItem,
9
+ ScrapedSquadPlayer,
10
+ TransformOptions,
11
+ } from "./types.js";
12
+
13
+ const DEFAULT_TOTAL_TRANSFERS = 160;
14
+ const FLOAT_TOLERANCE = 0.01;
15
+
16
+ const RAW_TEAM_ALIASES: Record<string, string> = {
17
+ "Pankaj Konde": "PKs11",
18
+ "Rishikesh Shinde": "Watapi",
19
+ "Rushabh Shah": "RushS01",
20
+ "Vijay Swami": "VATVAGHOOL XI",
21
+ "Aditya Raut": "SquadSeven9",
22
+ "Nilesh Birajdar": "Nilesh Birajdar",
23
+ "Ravi Kiran Guthula": "RKs Stallions",
24
+ "Rahul Sharma": "RSAwesome 11",
25
+ "Raviteja Jakkani": "Bat Bowl XI",
26
+ };
27
+
28
+ const resolveTeamName = (
29
+ name: string,
30
+ aliases: Record<string, string> | undefined,
31
+ ) => aliases?.[name] ?? RAW_TEAM_ALIASES[name] ?? name;
32
+
33
+ const normalizeTeamName = (name: string) =>
34
+ name.toLowerCase().replace(/[^a-z0-9]/g, "");
35
+
36
+ const calculateTotalPoints = (user: RawApiUser) =>
37
+ user.matches.reduce((sum, match) => sum + match.points, 0);
38
+
39
+ const roundPoint = (value: number) => Math.round(value * 10) / 10;
40
+
41
+ const compareLeadersByPoints = (
42
+ a: Pick<ScrapedLeaderboardItem, "name" | "points" | "rank">,
43
+ b: Pick<ScrapedLeaderboardItem, "name" | "points" | "rank">,
44
+ ) => {
45
+ const pointDifference = b.points - a.points;
46
+ if (pointDifference !== 0) {
47
+ return pointDifference;
48
+ }
49
+
50
+ const rankDifference =
51
+ (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER);
52
+ if (rankDifference !== 0) {
53
+ return rankDifference;
54
+ }
55
+
56
+ return a.name.localeCompare(b.name);
57
+ };
58
+
59
+ const getLatestMatchIdFromDaily = (daily: DailyChartRow[]) =>
60
+ daily.reduce((latest, row) => {
61
+ const match = row.day.match(/^Match\s+(\d+)$/i);
62
+ const matchId = match ? Number(match[1]) : Number.NaN;
63
+
64
+ return Number.isFinite(matchId) ? Math.max(latest, matchId) : latest;
65
+ }, 0);
66
+
67
+ const findRawUserIndex = (
68
+ users: RawApiUser[],
69
+ scrapedName: string,
70
+ aliases: Record<string, string> | undefined,
71
+ ) => {
72
+ const target = normalizeTeamName(resolveTeamName(scrapedName, aliases));
73
+ return users.findIndex((user) => normalizeTeamName(user.temname) === target);
74
+ };
75
+
76
+ const normalizeSquadPlayer = (value: unknown): ScrapedSquadPlayer | null => {
77
+ if (typeof value === "string") {
78
+ const name = value.trim();
79
+ return name ? { name } : null;
80
+ }
81
+
82
+ if (!value || typeof value !== "object") {
83
+ return null;
84
+ }
85
+
86
+ const item = value as Record<string, unknown>;
87
+ const rawName = [item.name, item.playerName, item.fullName].find(
88
+ (entry): entry is string =>
89
+ typeof entry === "string" && entry.trim().length > 0,
90
+ );
91
+
92
+ if (!rawName) {
93
+ return null;
94
+ }
95
+
96
+ const rawNumber = [item.number, item.id, item.playerId].find(
97
+ (entry): entry is string | number =>
98
+ (typeof entry === "string" && entry.trim().length > 0) ||
99
+ typeof entry === "number",
100
+ );
101
+ const rawPoints = [item.points, item.playerPoints, item.pts].find(
102
+ (entry) => entry !== undefined && entry !== null,
103
+ );
104
+
105
+ return {
106
+ name: rawName.trim(),
107
+ number: rawNumber === undefined ? undefined : String(rawNumber).trim(),
108
+ points: toFiniteNumber(rawPoints),
109
+ };
110
+ };
111
+
112
+ export const toFiniteNumber = (value: unknown) => {
113
+ const numeric =
114
+ typeof value === "string"
115
+ ? Number(value.replace(/,/g, ""))
116
+ : typeof value === "number"
117
+ ? value
118
+ : Number.NaN;
119
+
120
+ return Number.isFinite(numeric) ? numeric : undefined;
121
+ };
122
+
123
+ export const addLeaderboardMetrics = (
124
+ leaders: ScrapedLeaderboardItem[],
125
+ options: TransformOptions = {},
126
+ ): OverallChartItem[] => {
127
+ const totalTransfers = options.totalTransfers ?? DEFAULT_TOTAL_TRANSFERS;
128
+ const sorted = [...leaders]
129
+ .filter((leader) => Number.isFinite(leader.points))
130
+ .sort(compareLeadersByPoints);
131
+ const previousRankMap = new Map(
132
+ sorted
133
+ .map((leader) => ({
134
+ name: leader.name,
135
+ previousPoints:
136
+ leader.netPoints ?? leader.points - (leader.lastMatchPoints ?? 0),
137
+ }))
138
+ .sort((a, b) => b.previousPoints - a.previousPoints)
139
+ .map((leader, index) => [leader.name, index + 1]),
140
+ );
141
+ const maxLastMatchPoints = Math.max(
142
+ ...sorted.map((leader) => leader.lastMatchPoints ?? 0),
143
+ );
144
+ const leaderboardRange = sorted.length
145
+ ? sorted[0].points - sorted[sorted.length - 1].points
146
+ : 0;
147
+
148
+ return sorted.map((leader, index) => {
149
+ const rank = index + 1;
150
+ const previousRank = leader.netRank ?? previousRankMap.get(leader.name);
151
+ const nextLeader = sorted[index + 1];
152
+ const gapToNext = nextLeader ? leader.points - nextLeader.points : 0;
153
+ const usedTransfers =
154
+ typeof leader.transfersLeft === "number"
155
+ ? totalTransfers - leader.transfersLeft
156
+ : 0;
157
+
158
+ return {
159
+ name: leader.name,
160
+ points: leader.points,
161
+ rank,
162
+ previousRank,
163
+ previousPoints:
164
+ leader.netPoints ?? leader.points - (leader.lastMatchPoints ?? 0),
165
+ lastMatchPoints: leader.lastMatchPoints,
166
+ gapToNext,
167
+ gapPercent: leaderboardRange ? (gapToNext / leaderboardRange) * 100 : 0,
168
+ movement:
169
+ previousRank === undefined
170
+ ? "new"
171
+ : rank < previousRank
172
+ ? "up"
173
+ : rank > previousRank
174
+ ? "down"
175
+ : "same",
176
+ transfersLeft: leader.transfersLeft,
177
+ transfersUsed: leader.transfersUsed,
178
+ totalTransfers: leader.totalTransfers,
179
+ boostersUsed: leader.boostersUsed,
180
+ efficiency:
181
+ usedTransfers > 0
182
+ ? Number((leader.points / usedTransfers).toFixed(2))
183
+ : undefined,
184
+ isLastMatchLeader:
185
+ (leader.lastMatchPoints ?? 0) > 0 &&
186
+ leader.lastMatchPoints === maxLastMatchPoints,
187
+ captain: leader.captain,
188
+ viceCaptain: leader.viceCaptain,
189
+ players: leader.players,
190
+ };
191
+ });
192
+ };
193
+
194
+ export const buildManualDashboard = (
195
+ users: RawApiUser[],
196
+ options: TransformOptions = {},
197
+ ): DashboardData => {
198
+ const latestMatchId = users.reduce((latest, user) => {
199
+ const userLatest = user.matches.reduce(
200
+ (max, match) => Math.max(max, match.matchId),
201
+ 0,
202
+ );
203
+
204
+ return Math.max(latest, userLatest);
205
+ }, 0);
206
+ const sortedByPoints = [...users].sort((a, b) => b.points - a.points);
207
+ const leaders = sortedByPoints.map((user, index) => ({
208
+ rank: index + 1,
209
+ name: user.temname,
210
+ points: user.points,
211
+ lastMatchPoints:
212
+ user.matches.find((match) => match.matchId === latestMatchId)?.points ??
213
+ 0,
214
+ }));
215
+
216
+ const daily: DailyChartRow[] = Array.from(
217
+ { length: latestMatchId },
218
+ (_, i) => {
219
+ const matchId = i + 1;
220
+ const matchScores = users
221
+ .map((user) => ({
222
+ team: user.temname,
223
+ points:
224
+ user.matches.find((match) => match.matchId === matchId)?.points ??
225
+ 0,
226
+ }))
227
+ .sort((a, b) => b.points - a.points);
228
+ const row: DailyChartRow = { day: `Match ${matchId}` };
229
+
230
+ matchScores.forEach(({ team, points }) => {
231
+ row[team] = points;
232
+ });
233
+
234
+ return row;
235
+ },
236
+ );
237
+
238
+ return {
239
+ overall: addLeaderboardMetrics(leaders, options),
240
+ daily,
241
+ completedMatches: latestMatchId || undefined,
242
+ source: "database",
243
+ };
244
+ };
245
+
246
+ export const normalizePayload = (
247
+ payload: unknown,
248
+ options: TransformOptions = {},
249
+ ): ScrapedDashboardPayload | null => {
250
+ if (!payload || typeof payload !== "object") {
251
+ return null;
252
+ }
253
+
254
+ const payloadObject = payload as {
255
+ leaders?: unknown;
256
+ completedMatches?: unknown;
257
+ updatedAt?: unknown;
258
+ dailyTransferUpdatedAt?: unknown;
259
+ match?: unknown;
260
+ time?: unknown;
261
+ Data?: {
262
+ Value?: unknown;
263
+ FeedTime?: {
264
+ UTCTime?: unknown;
265
+ ISTTime?: unknown;
266
+ };
267
+ };
268
+ Meta?: {
269
+ Timestamp?: {
270
+ UTCTime?: unknown;
271
+ ISTTime?: unknown;
272
+ };
273
+ };
274
+ };
275
+
276
+ const leaders = Array.isArray(payload)
277
+ ? payload
278
+ : Array.isArray(payloadObject.leaders)
279
+ ? payloadObject.leaders
280
+ : Array.isArray(payloadObject.Data?.Value)
281
+ ? payloadObject.Data.Value
282
+ : undefined;
283
+
284
+ if (!Array.isArray(leaders)) {
285
+ return null;
286
+ }
287
+
288
+ const normalized = leaders
289
+ .map((leader): ScrapedLeaderboardItem | null => {
290
+ if (!leader || typeof leader !== "object") {
291
+ return null;
292
+ }
293
+
294
+ const item = leader as Record<string, unknown>;
295
+ const rank = toFiniteNumber(item.rank);
296
+ const points = toFiniteNumber(item.points);
297
+ const nameCandidates = [item.name, item.temname, item.usrname].filter(
298
+ (value): value is string => typeof value === "string",
299
+ );
300
+ const name =
301
+ nameCandidates
302
+ .map((value) => value.trim())
303
+ .find(Boolean)
304
+ ?.trim() ?? "";
305
+ const lastMatchPoints = toFiniteNumber(item.lastMatchPoints ?? item.last);
306
+ const netPoints = toFiniteNumber(item.netPoints);
307
+ const netRank = toFiniteNumber(item.netRank);
308
+ const transfersLeft = toFiniteNumber(item.transfersLeft);
309
+ const transfersUsed = toFiniteNumber(item.transfersUsed);
310
+ const totalTransfers = toFiniteNumber(item.totalTransfers);
311
+ const captain = normalizeSquadPlayer(item.captain ?? item.c);
312
+ const viceCaptain = normalizeSquadPlayer(item.viceCaptain ?? item.v);
313
+ const captainPoints = toFiniteNumber(item.cPoints);
314
+ const viceCaptainPoints = toFiniteNumber(item.vPoints);
315
+ const boostersValue = [item.boostersUsed, item.boosters].find(
316
+ (entry) => entry !== undefined && entry !== null,
317
+ );
318
+ const players = Array.isArray(item.players)
319
+ ? item.players
320
+ .map((player) => normalizeSquadPlayer(player))
321
+ .filter((player): player is NonNullable<typeof player> =>
322
+ Boolean(player),
323
+ )
324
+ : undefined;
325
+
326
+ if (!name || points === undefined || rank === undefined) {
327
+ return null;
328
+ }
329
+
330
+ return {
331
+ rank: rank ?? Number.MAX_SAFE_INTEGER,
332
+ name: resolveTeamName(name, options.teamAliases),
333
+ points,
334
+ lastMatchPoints,
335
+ netPoints,
336
+ netRank,
337
+ transfersLeft,
338
+ transfersUsed,
339
+ totalTransfers,
340
+ boostersUsed:
341
+ typeof boostersValue === "string"
342
+ ? boostersValue.trim()
343
+ : typeof boostersValue === "number" &&
344
+ Number.isFinite(boostersValue)
345
+ ? String(boostersValue)
346
+ : undefined,
347
+ captain:
348
+ captain && captainPoints !== undefined
349
+ ? { ...captain, points: captainPoints }
350
+ : captain,
351
+ viceCaptain:
352
+ viceCaptain && viceCaptainPoints !== undefined
353
+ ? { ...viceCaptain, points: viceCaptainPoints }
354
+ : viceCaptain,
355
+ players,
356
+ };
357
+ })
358
+ .filter((leader): leader is ScrapedLeaderboardItem => Boolean(leader))
359
+ .sort(compareLeadersByPoints)
360
+ .map((leader, index) => ({
361
+ ...leader,
362
+ rank: index + 1,
363
+ }));
364
+
365
+ const updatedAtValue = [
366
+ payloadObject.updatedAt,
367
+ payloadObject.dailyTransferUpdatedAt,
368
+ payloadObject.time,
369
+ payloadObject.Data?.FeedTime?.UTCTime,
370
+ payloadObject.Data?.FeedTime?.ISTTime,
371
+ payloadObject.Meta?.Timestamp?.UTCTime,
372
+ payloadObject.Meta?.Timestamp?.ISTTime,
373
+ ].find((value) => value !== undefined && value !== null);
374
+ const completedMatches = [
375
+ payloadObject.completedMatches,
376
+ payloadObject.match,
377
+ String(payloadObject.Data?.FeedTime?.UTCTime ?? "").match(
378
+ /Match\s*(\d{1,2})/i,
379
+ )?.[1],
380
+ ]
381
+ .map((value) => toFiniteNumber(value))
382
+ .find((value) => value !== undefined);
383
+
384
+ const updatedAt =
385
+ typeof updatedAtValue === "string" &&
386
+ !Number.isNaN(Date.parse(updatedAtValue))
387
+ ? new Date(updatedAtValue).toISOString()
388
+ : updatedAtValue instanceof Date &&
389
+ !Number.isNaN(updatedAtValue.getTime())
390
+ ? updatedAtValue.toISOString()
391
+ : new Date().toISOString();
392
+
393
+ return normalized.length
394
+ ? {
395
+ updatedAt,
396
+ dailyTransferUpdatedAt:
397
+ typeof payloadObject.dailyTransferUpdatedAt === "string" &&
398
+ !Number.isNaN(Date.parse(payloadObject.dailyTransferUpdatedAt))
399
+ ? new Date(payloadObject.dailyTransferUpdatedAt).toISOString()
400
+ : undefined,
401
+ completedMatches:
402
+ completedMatches !== undefined && completedMatches > 0
403
+ ? Math.floor(completedMatches)
404
+ : undefined,
405
+ leaders: normalized,
406
+ }
407
+ : null;
408
+ };
409
+
410
+ export const normalizeRawApiUsers = (payload: unknown): RawApiUser[] | null => {
411
+ if (!Array.isArray(payload)) {
412
+ return null;
413
+ }
414
+
415
+ const normalized = payload
416
+ .map((user): RawApiUser | null => {
417
+ if (!user || typeof user !== "object") {
418
+ return null;
419
+ }
420
+
421
+ const item = user as Record<string, unknown>;
422
+ const rno = toFiniteNumber(item.rno);
423
+ const temname =
424
+ typeof item.temname === "string" ? item.temname.trim() : "";
425
+ const matches = Array.isArray(item.matches)
426
+ ? item.matches
427
+ .map((match) => {
428
+ if (!match || typeof match !== "object") {
429
+ return null;
430
+ }
431
+
432
+ const matchItem = match as Record<string, unknown>;
433
+ const matchId = toFiniteNumber(matchItem.matchId);
434
+ const points = toFiniteNumber(matchItem.points);
435
+
436
+ if (matchId === undefined || points === undefined) {
437
+ return null;
438
+ }
439
+
440
+ return { matchId, points };
441
+ })
442
+ .filter(
443
+ (
444
+ match,
445
+ ): match is {
446
+ matchId: number;
447
+ points: number;
448
+ } => Boolean(match),
449
+ )
450
+ .sort((a, b) => a.matchId - b.matchId)
451
+ : [];
452
+
453
+ if (rno === undefined || !temname || !matches.length) {
454
+ return null;
455
+ }
456
+
457
+ return {
458
+ rno,
459
+ temname,
460
+ matches,
461
+ points: matches.reduce((sum, match) => sum + match.points, 0),
462
+ };
463
+ })
464
+ .filter((user): user is RawApiUser => Boolean(user))
465
+ .sort((a, b) => a.rno - b.rno);
466
+
467
+ return normalized.length ? normalized : null;
468
+ };
469
+
470
+ export const buildDashboardFromSnapshot = (
471
+ snapshot: ScrapedDashboardPayload,
472
+ manualDashboard: DashboardData,
473
+ options: TransformOptions = {},
474
+ ): DashboardData => {
475
+ const liveDaily: DailyChartRow | null = snapshot.leaders.some(
476
+ (leader) => typeof leader.lastMatchPoints === "number",
477
+ )
478
+ ? snapshot.leaders.reduce<DailyChartRow>(
479
+ (row, leader) => {
480
+ row[leader.name] = leader.lastMatchPoints ?? 0;
481
+ return row;
482
+ },
483
+ { day: "Live Update" },
484
+ )
485
+ : null;
486
+ const manualCompletedMatches =
487
+ manualDashboard.completedMatches ??
488
+ getLatestMatchIdFromDaily(manualDashboard.daily);
489
+ const completedMatches =
490
+ typeof snapshot.completedMatches === "number" &&
491
+ snapshot.completedMatches > 0
492
+ ? snapshot.completedMatches
493
+ : liveDaily
494
+ ? (manualCompletedMatches ?? 0) + 1
495
+ : manualCompletedMatches;
496
+
497
+ return {
498
+ overall: addLeaderboardMetrics(snapshot.leaders, options),
499
+ daily: liveDaily
500
+ ? [...manualDashboard.daily, liveDaily]
501
+ : manualDashboard.daily,
502
+ updatedAt: snapshot.updatedAt,
503
+ dailyTransferUpdatedAt: snapshot.dailyTransferUpdatedAt,
504
+ completedMatches,
505
+ source: "database",
506
+ snapshot,
507
+ };
508
+ };
509
+
510
+ export const syncRawUsersWithSnapshot = (
511
+ users: RawApiUser[],
512
+ snapshot: ScrapedDashboardPayload,
513
+ options: TransformOptions = {},
514
+ ): RawUsersSyncResult => {
515
+ const clonedUsers = users.map((user) => ({
516
+ ...user,
517
+ matches: user.matches.map((match) => ({ ...match })),
518
+ }));
519
+ const maxMatchId = clonedUsers.reduce(
520
+ (max, user) => Math.max(max, ...user.matches.map((match) => match.matchId)),
521
+ 0,
522
+ );
523
+ const completedMatches =
524
+ typeof snapshot.completedMatches === "number" &&
525
+ Number.isFinite(snapshot.completedMatches) &&
526
+ snapshot.completedMatches > 0
527
+ ? Math.floor(snapshot.completedMatches)
528
+ : undefined;
529
+ const unmatchedNames: string[] = [];
530
+ const updates = snapshot.leaders.flatMap((leader) => {
531
+ const userIndex = findRawUserIndex(
532
+ clonedUsers,
533
+ leader.name,
534
+ options.teamAliases,
535
+ );
536
+
537
+ if (userIndex < 0) {
538
+ unmatchedNames.push(leader.name);
539
+ return [];
540
+ }
541
+
542
+ const user = clonedUsers[userIndex];
543
+ const currentTotal = calculateTotalPoints(user);
544
+ const delta = roundPoint(leader.points - currentTotal);
545
+ const latestMatch = user.matches.find(
546
+ (match) => match.matchId === maxMatchId,
547
+ );
548
+
549
+ return [
550
+ {
551
+ userIndex,
552
+ delta,
553
+ latestMatchPoints: leader.lastMatchPoints,
554
+ currentLatestPoints: latestMatch?.points ?? 0,
555
+ },
556
+ ];
557
+ });
558
+
559
+ if (!updates.length) {
560
+ return {
561
+ status: "skipped",
562
+ users: clonedUsers,
563
+ unmatchedNames,
564
+ };
565
+ }
566
+
567
+ const changedUpdates = updates.filter(
568
+ (update) => Math.abs(update.delta) > FLOAT_TOLERANCE,
569
+ );
570
+
571
+ if (!changedUpdates.length) {
572
+ return {
573
+ status: "unchanged",
574
+ users: clonedUsers,
575
+ matchId: maxMatchId,
576
+ completedMatches,
577
+ unmatchedNames,
578
+ };
579
+ }
580
+
581
+ const hasFutureReportedMatch =
582
+ typeof completedMatches === "number" && completedMatches > maxMatchId;
583
+ const shouldForceUpdateLatest =
584
+ typeof completedMatches === "number" && completedMatches === maxMatchId;
585
+ const updatesFitLatestMatch = changedUpdates.every((update) => {
586
+ if (typeof update.latestMatchPoints !== "number") {
587
+ return false;
588
+ }
589
+
590
+ return (
591
+ Math.abs(
592
+ update.currentLatestPoints + update.delta - update.latestMatchPoints,
593
+ ) <= FLOAT_TOLERANCE
594
+ );
595
+ });
596
+ const mode =
597
+ (updatesFitLatestMatch || shouldForceUpdateLatest) &&
598
+ !hasFutureReportedMatch
599
+ ? "update-latest"
600
+ : "append";
601
+ const matchId =
602
+ mode === "append"
603
+ ? Math.max(maxMatchId + 1, completedMatches ?? maxMatchId + 1)
604
+ : maxMatchId;
605
+
606
+ changedUpdates.forEach((update) => {
607
+ const user = clonedUsers[update.userIndex];
608
+
609
+ if (mode === "update-latest") {
610
+ const latestMatch = user.matches.find(
611
+ (match) => match.matchId === matchId,
612
+ );
613
+
614
+ if (latestMatch) {
615
+ latestMatch.points = roundPoint(latestMatch.points + update.delta);
616
+ } else {
617
+ user.matches.push({ matchId, points: update.delta });
618
+ }
619
+ } else if (matchId > maxMatchId + 1) {
620
+ const gapStart = maxMatchId + 1;
621
+ const latestPoints =
622
+ typeof update.latestMatchPoints === "number"
623
+ ? roundPoint(update.latestMatchPoints)
624
+ : undefined;
625
+ const catchupPoints =
626
+ latestPoints !== undefined
627
+ ? roundPoint(update.delta - latestPoints)
628
+ : roundPoint(update.delta);
629
+
630
+ user.matches.push({ matchId: gapStart, points: catchupPoints });
631
+
632
+ for (let id = gapStart + 1; id < matchId; id += 1) {
633
+ user.matches.push({ matchId: id, points: 0 });
634
+ }
635
+
636
+ if (latestPoints !== undefined) {
637
+ user.matches.push({ matchId, points: latestPoints });
638
+ }
639
+ } else {
640
+ user.matches.push({ matchId, points: update.delta });
641
+ }
642
+
643
+ user.matches.sort((a, b) => a.matchId - b.matchId);
644
+ user.points = calculateTotalPoints(user);
645
+ });
646
+
647
+ return {
648
+ status: "updated",
649
+ users: clonedUsers,
650
+ matchId,
651
+ mode,
652
+ completedMatches,
653
+ unmatchedNames,
654
+ };
655
+ };
656
+
657
+ export const serializeRawApiUsersModule = (users: RawApiUser[]) => {
658
+ const usersForSource = users.map((user) => ({
659
+ rno: user.rno,
660
+ temname: user.temname,
661
+ points: 0,
662
+ matches: [...user.matches].sort((a, b) => a.matchId - b.matchId),
663
+ }));
664
+
665
+ return `export type RawApiUser = {
666
+ rno: number;
667
+ temname: string;
668
+ points: number;
669
+ matches: Array<{
670
+ matchId: number;
671
+ points: number;
672
+ }>;
673
+ };
674
+
675
+ const calculateTotalPoints = (
676
+ matches: Array<{ matchId: number; points: number }>,
677
+ ): number => {
678
+ return matches.reduce((sum, match) => sum + match.points, 0);
679
+ };
680
+
681
+ export const rawApiUsers: RawApiUser[] = ${JSON.stringify(usersForSource, null, 2)}
682
+ .map((user) => ({
683
+ ...user,
684
+ points: calculateTotalPoints(user.matches),
685
+ }));
686
+ `;
687
+ };