@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,590 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import type {
5
+ DashboardData,
6
+ RawApiUser,
7
+ ScrapedDashboardPayload,
8
+ ScrapedLeaderboardItem,
9
+ } from "../app/types.ts";
10
+ import {
11
+ addLeaderboardMetrics,
12
+ buildDashboardFromSnapshot,
13
+ buildManualDashboard,
14
+ normalizePayload,
15
+ normalizeRawApiUsers,
16
+ serializeRawApiUsersModule,
17
+ syncRawUsersWithSnapshot,
18
+ toFiniteNumber,
19
+ } from "../app/api/ipl/transform.ts";
20
+
21
+ describe("IPL data transformation", () => {
22
+ it("parses finite numbers from numeric and comma-formatted values", () => {
23
+ assert.equal(toFiniteNumber(1200), 1200);
24
+ assert.equal(toFiniteNumber("7,125.5"), 7125.5);
25
+ assert.equal(toFiniteNumber("0"), 0);
26
+ assert.equal(toFiniteNumber("abc"), undefined);
27
+ assert.equal(toFiniteNumber(null), undefined);
28
+ });
29
+
30
+ it("normalizes scraped payloads by trimming names, coercing numbers, and sorting ranks", () => {
31
+ const payload = normalizePayload({
32
+ updatedAt: "2026-04-18T10:55:57.567Z",
33
+ completedMatches: "39",
34
+ leaders: [
35
+ {
36
+ rank: "2",
37
+ name: " Watapi ",
38
+ points: "6,281",
39
+ lastMatchPoints: "62",
40
+ transfersLeft: "106",
41
+ boostersUsed: " 0 ",
42
+ },
43
+ {
44
+ rank: "1",
45
+ name: "Deccan Dominators",
46
+ points: "7,125",
47
+ lastMatchPoints: "52.5",
48
+ transfersLeft: "101",
49
+ boostersUsed: "1",
50
+ },
51
+ {
52
+ rank: "bad",
53
+ name: "Ignored",
54
+ points: 99,
55
+ },
56
+ ],
57
+ });
58
+
59
+ assert.ok(payload);
60
+ assert.equal(payload.leaders.length, 2);
61
+ assert.equal(payload.leaders[0].name, "Deccan Dominators");
62
+ assert.equal(payload.leaders[0].points, 7125);
63
+ assert.equal(payload.leaders[0].lastMatchPoints, 52.5);
64
+ assert.equal(payload.leaders[0].transfersLeft, 101);
65
+ assert.equal(payload.leaders[1].name, "Watapi");
66
+ assert.equal(payload.leaders[1].boostersUsed, "0");
67
+ assert.equal(payload.updatedAt, "2026-04-18T10:55:57.567Z");
68
+ assert.equal(payload.completedMatches, 39);
69
+ });
70
+
71
+ it("normalizes a raw leaderboard array with net point context", () => {
72
+ const payload = normalizePayload([
73
+ {
74
+ rank: 1,
75
+ name: "Deccan Dominators",
76
+ points: 11627,
77
+ lastMatchPoints: 461,
78
+ netPoints: 11166,
79
+ netRank: 1,
80
+ transfersLeft: "87",
81
+ boostersUsed: "2",
82
+ },
83
+ ]);
84
+
85
+ assert.ok(payload);
86
+ assert.equal(payload.leaders[0].points, 11627);
87
+ assert.equal(payload.leaders[0].netPoints, 11166);
88
+ assert.equal(payload.leaders[0].netRank, 1);
89
+ assert.equal(payload.leaders[0].transfersLeft, 87);
90
+ });
91
+
92
+ it("normalizes nested fantasy payloads from Data.Value", () => {
93
+ const payload = normalizePayload({
94
+ Data: {
95
+ Value: [
96
+ { rank: 1, temname: "Deccan Dominators", points: 12037 },
97
+ { rank: 2, usrname: "SquadSeven9", points: 9815.5 },
98
+ ],
99
+ FeedTime: {
100
+ UTCTime: "2026-04-27T04:04:37.000Z",
101
+ },
102
+ },
103
+ completedMatches: 39,
104
+ });
105
+
106
+ assert.ok(payload);
107
+ assert.equal(payload.leaders.length, 2);
108
+ assert.equal(payload.leaders[0].name, "Deccan Dominators");
109
+ assert.equal(payload.leaders[1].name, "SquadSeven9");
110
+ assert.equal(payload.completedMatches, 39);
111
+ });
112
+
113
+ it("normalizes bookmarklet-style payload fields", () => {
114
+ const payload = normalizePayload({
115
+ time: "2026-04-28T09:15:00.000Z",
116
+ match: 41,
117
+ leaders: [
118
+ {
119
+ rank: 1,
120
+ name: "VATVAGHOOL XI",
121
+ points: 7777.5,
122
+ last: 120.5,
123
+ transfersLeft: 5,
124
+ transfersUsed: 2,
125
+ totalTransfers: 7,
126
+ boosters: 0,
127
+ c: "Shubman Gill",
128
+ v: "Jasprit Bumrah",
129
+ cPoints: 88,
130
+ vPoints: 54,
131
+ players: ["Shubman Gill", { name: "Jasprit Bumrah", points: 54 }],
132
+ },
133
+ ],
134
+ });
135
+
136
+ assert.ok(payload);
137
+ assert.equal(payload.updatedAt, "2026-04-28T09:15:00.000Z");
138
+ assert.equal(payload.completedMatches, 41);
139
+ assert.equal(payload.leaders[0].name, "VATVAGHOOL XI");
140
+ assert.equal(payload.leaders[0].lastMatchPoints, 120.5);
141
+ assert.equal(payload.leaders[0].transfersLeft, 5);
142
+ assert.equal(payload.leaders[0].transfersUsed, 2);
143
+ assert.equal(payload.leaders[0].totalTransfers, 7);
144
+ assert.equal(payload.leaders[0].boostersUsed, "0");
145
+ assert.equal(payload.leaders[0].captain?.name, "Shubman Gill");
146
+ assert.equal(payload.leaders[0].captain?.points, 88);
147
+ assert.equal(payload.leaders[0].viceCaptain?.name, "Jasprit Bumrah");
148
+ assert.equal(payload.leaders[0].viceCaptain?.points, 54);
149
+ assert.deepEqual(
150
+ payload.leaders[0].players?.map((player) => ({
151
+ name: player.name,
152
+ points: player.points,
153
+ })),
154
+ [
155
+ { name: "Shubman Gill", points: undefined },
156
+ { name: "Jasprit Bumrah", points: 54 },
157
+ ],
158
+ );
159
+ });
160
+
161
+ it("rejects invalid scraped payloads", () => {
162
+ assert.equal(normalizePayload(null), null);
163
+ assert.equal(normalizePayload({}), null);
164
+ assert.equal(normalizePayload({ leaders: "nope" }), null);
165
+ assert.equal(
166
+ normalizePayload({
167
+ leaders: [{ rank: "?", name: "", points: "x" }],
168
+ }),
169
+ null,
170
+ );
171
+ });
172
+
173
+ it("normalizes raw users loaded from storage by sorting matches and recalculating totals", () => {
174
+ const users = normalizeRawApiUsers([
175
+ {
176
+ rno: "2",
177
+ temname: " Team B ",
178
+ points: 999,
179
+ matches: [
180
+ { matchId: "2", points: "15" },
181
+ { matchId: "1", points: "25" },
182
+ ],
183
+ },
184
+ {
185
+ rno: 1,
186
+ temname: "Team A",
187
+ matches: [{ matchId: 1, points: 10 }],
188
+ },
189
+ {
190
+ rno: 3,
191
+ temname: "Broken",
192
+ matches: [],
193
+ },
194
+ ]);
195
+
196
+ assert.ok(users);
197
+ assert.equal(users.length, 2);
198
+ assert.equal(users[0].temname, "Team A");
199
+ assert.equal(users[1].temname, "Team B");
200
+ assert.deepEqual(users[1].matches, [
201
+ { matchId: 1, points: 25 },
202
+ { matchId: 2, points: 15 },
203
+ ]);
204
+ assert.equal(users[1].points, 40);
205
+ });
206
+
207
+ it("adds rank movement, point gaps, efficiency, and latest-match winner flags", () => {
208
+ const leaders: ScrapedLeaderboardItem[] = [
209
+ {
210
+ rank: 3,
211
+ name: "Gamma",
212
+ points: 80,
213
+ lastMatchPoints: 0,
214
+ transfersLeft: 130,
215
+ },
216
+ {
217
+ rank: 1,
218
+ name: "Alpha",
219
+ points: 100,
220
+ lastMatchPoints: 10,
221
+ transfersLeft: 150,
222
+ },
223
+ {
224
+ rank: 2,
225
+ name: "Beta",
226
+ points: 95,
227
+ lastMatchPoints: 50,
228
+ transfersLeft: 155,
229
+ },
230
+ ];
231
+
232
+ const rows = addLeaderboardMetrics(leaders);
233
+
234
+ assert.deepEqual(
235
+ rows.map((row) => row.name),
236
+ ["Alpha", "Beta", "Gamma"],
237
+ );
238
+ assert.equal(rows[0].previousPoints, 90);
239
+ assert.equal(rows[0].gapToNext, 5);
240
+ assert.equal(rows[0].gapPercent, 25);
241
+ assert.equal(rows[0].movement, "same");
242
+ assert.equal(rows[0].efficiency, 10);
243
+ assert.equal(rows[1].movement, "up");
244
+ assert.equal(rows[1].isLastMatchLeader, true);
245
+ assert.equal(rows[1].efficiency, 19);
246
+ assert.equal(rows[2].movement, "down");
247
+ assert.equal(rows[2].gapToNext, 0);
248
+ assert.equal(rows[2].isLastMatchLeader, false);
249
+ });
250
+
251
+ it("uses scraped net points and net rank when provided", () => {
252
+ const rows = addLeaderboardMetrics([
253
+ {
254
+ rank: 2,
255
+ name: "Beta",
256
+ points: 120,
257
+ lastMatchPoints: 10,
258
+ netPoints: 110,
259
+ netRank: 1,
260
+ },
261
+ {
262
+ rank: 1,
263
+ name: "Alpha",
264
+ points: 125,
265
+ lastMatchPoints: 50,
266
+ netPoints: 75,
267
+ netRank: 2,
268
+ },
269
+ ]);
270
+
271
+ assert.equal(rows[0].name, "Alpha");
272
+ assert.equal(rows[0].previousPoints, 75);
273
+ assert.equal(rows[0].previousRank, 2);
274
+ assert.equal(rows[0].movement, "up");
275
+ assert.equal(rows[1].previousPoints, 110);
276
+ assert.equal(rows[1].previousRank, 1);
277
+ assert.equal(rows[1].movement, "down");
278
+ });
279
+
280
+ it("preserves captain and vice-captain data in overall rows", () => {
281
+ const rows = addLeaderboardMetrics([
282
+ {
283
+ rank: 1,
284
+ name: "Alpha",
285
+ points: 125,
286
+ lastMatchPoints: 50,
287
+ transfersLeft: 3,
288
+ transfersUsed: 8,
289
+ totalTransfers: 11,
290
+ boostersUsed: "1",
291
+ captain: { name: "Captain A", points: 91 },
292
+ viceCaptain: { name: "Vice A", points: 42 },
293
+ players: [{ name: "Captain A", points: 91 }, { name: "Vice A", points: 42 }],
294
+ },
295
+ ]);
296
+
297
+ assert.equal(rows[0].captain?.name, "Captain A");
298
+ assert.equal(rows[0].captain?.points, 91);
299
+ assert.equal(rows[0].viceCaptain?.name, "Vice A");
300
+ assert.equal(rows[0].viceCaptain?.points, 42);
301
+ assert.equal(rows[0].transfersLeft, 3);
302
+ assert.equal(rows[0].transfersUsed, 8);
303
+ assert.equal(rows[0].totalTransfers, 11);
304
+ assert.equal(rows[0].boostersUsed, "1");
305
+ });
306
+
307
+ it("builds manual dashboard data from raw match rows", () => {
308
+ const users: RawApiUser[] = [
309
+ {
310
+ rno: 1,
311
+ temname: "Team A",
312
+ points: 30,
313
+ matches: [
314
+ { matchId: 1, points: 10 },
315
+ { matchId: 2, points: 20 },
316
+ ],
317
+ },
318
+ {
319
+ rno: 2,
320
+ temname: "Team B",
321
+ points: 40,
322
+ matches: [
323
+ { matchId: 1, points: 25 },
324
+ { matchId: 2, points: 15 },
325
+ ],
326
+ },
327
+ ];
328
+
329
+ const dashboard = buildManualDashboard(users);
330
+
331
+ assert.equal(dashboard.source, "database");
332
+ assert.deepEqual(
333
+ dashboard.overall.map((row) => row.name),
334
+ ["Team B", "Team A"],
335
+ );
336
+ assert.equal(dashboard.overall[0].lastMatchPoints, 15);
337
+ assert.equal(dashboard.daily.length, 2);
338
+ assert.equal(dashboard.daily[0].day, "Match 1");
339
+ assert.equal(dashboard.daily[0]["Team B"], 25);
340
+ assert.equal(dashboard.daily[1]["Team A"], 20);
341
+ assert.equal(dashboard.completedMatches, 2);
342
+ });
343
+
344
+ it("merges live snapshots with manual daily rows when latest match points exist", () => {
345
+ const manualDashboard: DashboardData = {
346
+ source: "database",
347
+ overall: [],
348
+ daily: [{ day: "Match 1", Alpha: 10, Beta: 20 }],
349
+ };
350
+ const snapshot: ScrapedDashboardPayload = {
351
+ updatedAt: "2026-04-18T10:55:57.567Z",
352
+ leaders: [
353
+ {
354
+ rank: 1,
355
+ name: "Alpha",
356
+ points: 100,
357
+ lastMatchPoints: 55,
358
+ },
359
+ {
360
+ rank: 2,
361
+ name: "Beta",
362
+ points: 90,
363
+ lastMatchPoints: 44,
364
+ },
365
+ ],
366
+ };
367
+
368
+ const dashboard = buildDashboardFromSnapshot(snapshot, manualDashboard);
369
+
370
+ assert.equal(dashboard.source, "database");
371
+ assert.equal(dashboard.updatedAt, snapshot.updatedAt);
372
+ assert.equal(dashboard.completedMatches, 2);
373
+ assert.equal(dashboard.daily.length, 2);
374
+ assert.equal(dashboard.daily[1].day, "Live Update");
375
+ assert.equal(dashboard.daily[1]["Alpha"], 55);
376
+ assert.equal(dashboard.overall[0].name, "Alpha");
377
+ assert.equal(dashboard.overall[0].isLastMatchLeader, true);
378
+ assert.equal(dashboard.snapshot, snapshot);
379
+ });
380
+
381
+ it("does not append a live daily row when snapshot has no latest match points", () => {
382
+ const manualDashboard: DashboardData = {
383
+ source: "database",
384
+ overall: [],
385
+ daily: [{ day: "Match 1", Alpha: 10 }],
386
+ };
387
+ const snapshot: ScrapedDashboardPayload = {
388
+ updatedAt: "2026-04-18T10:55:57.567Z",
389
+ leaders: [
390
+ {
391
+ rank: 1,
392
+ name: "Alpha",
393
+ points: 100,
394
+ },
395
+ ],
396
+ };
397
+
398
+ const dashboard = buildDashboardFromSnapshot(snapshot, manualDashboard);
399
+
400
+ assert.equal(dashboard.daily.length, 1);
401
+ assert.equal(dashboard.daily[0].day, "Match 1");
402
+ assert.equal(dashboard.completedMatches, 1);
403
+ assert.equal(dashboard.snapshot, snapshot);
404
+ });
405
+
406
+ it("syncs raw users by appending the next match from scraped total deltas", () => {
407
+ const users: RawApiUser[] = [
408
+ {
409
+ rno: 1,
410
+ temname: "Alpha",
411
+ points: 100,
412
+ matches: [{ matchId: 1, points: 100 }],
413
+ },
414
+ {
415
+ rno: 2,
416
+ temname: "Beta",
417
+ points: 90,
418
+ matches: [{ matchId: 1, points: 90 }],
419
+ },
420
+ ];
421
+ const snapshot: ScrapedDashboardPayload = {
422
+ leaders: [
423
+ { rank: 1, name: "Alpha", points: 130, lastMatchPoints: 30 },
424
+ { rank: 2, name: "Beta", points: 105, lastMatchPoints: 15 },
425
+ ],
426
+ };
427
+
428
+ const result = syncRawUsersWithSnapshot(users, snapshot);
429
+
430
+ assert.equal(result.status, "updated");
431
+ assert.equal(result.mode, "append");
432
+ assert.equal(result.matchId, 2);
433
+ assert.equal(result.users[0].points, 130);
434
+ assert.deepEqual(result.users[0].matches.at(-1), {
435
+ matchId: 2,
436
+ points: 30,
437
+ });
438
+ assert.equal(users[0].matches.length, 1);
439
+ });
440
+
441
+ it("backfills missing match ids when completedMatches is ahead", () => {
442
+ const users: RawApiUser[] = [
443
+ {
444
+ rno: 1,
445
+ temname: "Alpha",
446
+ points: 100,
447
+ matches: [{ matchId: 1, points: 100 }],
448
+ },
449
+ ];
450
+ const snapshot: ScrapedDashboardPayload = {
451
+ completedMatches: 4,
452
+ leaders: [{ rank: 1, name: "Alpha", points: 150, lastMatchPoints: 12 }],
453
+ };
454
+
455
+ const result = syncRawUsersWithSnapshot(users, snapshot);
456
+
457
+ assert.equal(result.status, "updated");
458
+ assert.equal(result.mode, "append");
459
+ assert.equal(result.matchId, 4);
460
+ assert.equal(result.completedMatches, 4);
461
+ assert.deepEqual(result.users[0].matches, [
462
+ { matchId: 1, points: 100 },
463
+ { matchId: 2, points: 38 },
464
+ { matchId: 3, points: 0 },
465
+ { matchId: 4, points: 12 },
466
+ ]);
467
+ assert.equal(result.users[0].points, 150);
468
+ });
469
+
470
+ it("keeps raw users unchanged when scraped totals are already synced", () => {
471
+ const users: RawApiUser[] = [
472
+ {
473
+ rno: 1,
474
+ temname: "Alpha",
475
+ points: 130,
476
+ matches: [
477
+ { matchId: 1, points: 100 },
478
+ { matchId: 2, points: 30 },
479
+ ],
480
+ },
481
+ ];
482
+ const snapshot: ScrapedDashboardPayload = {
483
+ leaders: [{ rank: 1, name: "Alpha", points: 130, lastMatchPoints: 30 }],
484
+ };
485
+
486
+ const result = syncRawUsersWithSnapshot(users, snapshot);
487
+
488
+ assert.equal(result.status, "unchanged");
489
+ assert.equal(result.matchId, 2);
490
+ assert.equal(result.users[0].matches.length, 2);
491
+ });
492
+
493
+ it("updates the latest raw match when a later scrape belongs to the same match", () => {
494
+ const users: RawApiUser[] = [
495
+ {
496
+ rno: 1,
497
+ temname: "Alpha",
498
+ points: 130,
499
+ matches: [
500
+ { matchId: 1, points: 100 },
501
+ { matchId: 2, points: 30 },
502
+ ],
503
+ },
504
+ ];
505
+ const snapshot: ScrapedDashboardPayload = {
506
+ leaders: [{ rank: 1, name: "Alpha", points: 145, lastMatchPoints: 45 }],
507
+ };
508
+
509
+ const result = syncRawUsersWithSnapshot(users, snapshot);
510
+
511
+ assert.equal(result.status, "updated");
512
+ assert.equal(result.mode, "update-latest");
513
+ assert.equal(result.matchId, 2);
514
+ assert.equal(result.users[0].points, 145);
515
+ assert.deepEqual(result.users[0].matches.at(-1), {
516
+ matchId: 2,
517
+ points: 45,
518
+ });
519
+ });
520
+
521
+ it("updates latest match when completedMatches equals current max", () => {
522
+ const users: RawApiUser[] = [
523
+ {
524
+ rno: 1,
525
+ temname: "Alpha",
526
+ points: 145,
527
+ matches: [
528
+ { matchId: 1, points: 100 },
529
+ { matchId: 2, points: 45 },
530
+ ],
531
+ },
532
+ ];
533
+ const snapshot: ScrapedDashboardPayload = {
534
+ completedMatches: 2,
535
+ leaders: [{ rank: 1, name: "Alpha", points: 150 }],
536
+ };
537
+
538
+ const result = syncRawUsersWithSnapshot(users, snapshot);
539
+
540
+ assert.equal(result.status, "updated");
541
+ assert.equal(result.mode, "update-latest");
542
+ assert.equal(result.matchId, 2);
543
+ assert.deepEqual(result.users[0].matches, [
544
+ { matchId: 1, points: 100 },
545
+ { matchId: 2, points: 50 },
546
+ ]);
547
+ });
548
+
549
+ it("matches scraped player aliases back to raw fantasy team names", () => {
550
+ const users: RawApiUser[] = [
551
+ {
552
+ rno: 4,
553
+ temname: "VATVAGHOOL XI",
554
+ points: 100,
555
+ matches: [{ matchId: 1, points: 100 }],
556
+ },
557
+ ];
558
+ const snapshot: ScrapedDashboardPayload = {
559
+ leaders: [
560
+ { rank: 1, name: "Vijay Swami", points: 125, lastMatchPoints: 25 },
561
+ ],
562
+ };
563
+
564
+ const result = syncRawUsersWithSnapshot(users, snapshot);
565
+
566
+ assert.equal(result.status, "updated");
567
+ assert.deepEqual(result.unmatchedNames, []);
568
+ assert.equal(result.users[0].temname, "VATVAGHOOL XI");
569
+ assert.equal(result.users[0].points, 125);
570
+ });
571
+
572
+ it("serializes synced raw users back into a data.ts module", () => {
573
+ const source = serializeRawApiUsersModule([
574
+ {
575
+ rno: 1,
576
+ temname: "Alpha",
577
+ points: 130,
578
+ matches: [
579
+ { matchId: 1, points: 100 },
580
+ { matchId: 2, points: 30 },
581
+ ],
582
+ },
583
+ ]);
584
+
585
+ assert.match(source, /export const rawApiUsers: RawApiUser\[\]/);
586
+ assert.match(source, /"temname": "Alpha"/);
587
+ assert.match(source, /"matchId": 2/);
588
+ assert.match(source, /calculateTotalPoints/);
589
+ });
590
+ });
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import {
5
+ mergeTransferCollections,
6
+ normalizeTransferItem,
7
+ normalizeTransferSnapshot,
8
+ summarizeTransferSnapshot,
9
+ } from "../app/api/ipl/transfers/transform.ts";
10
+
11
+ describe("IPL transfers route helpers", () => {
12
+ it("normalizes the bookmarklet payload into a stable transfer item", () => {
13
+ const payload = normalizeTransferItem({
14
+ team: " Team A ",
15
+ matchesPlayed: "12",
16
+ boostersUsed: "3",
17
+ transfersLeft: " 94 ",
18
+ updatedAt: "2026-05-11T10:30:00.000Z",
19
+ });
20
+
21
+ assert.deepEqual(payload, {
22
+ team: "Team A",
23
+ matchesPlayed: 12,
24
+ boostersUsed: 3,
25
+ transfersLeft: "94",
26
+ updatedAt: "2026-05-11T10:30:00.000Z",
27
+ });
28
+ });
29
+
30
+ it("merges file and database snapshots by team using the newest update", () => {
31
+ const merged = mergeTransferCollections(
32
+ [
33
+ {
34
+ team: "Alpha",
35
+ matchesPlayed: 10,
36
+ boostersUsed: 1,
37
+ transfersLeft: "90",
38
+ updatedAt: "2026-05-11T09:00:00.000Z",
39
+ },
40
+ ],
41
+ [
42
+ {
43
+ team: "Alpha",
44
+ matchesPlayed: 11,
45
+ boostersUsed: 2,
46
+ transfersLeft: "89",
47
+ updatedAt: "2026-05-11T10:00:00.000Z",
48
+ },
49
+ {
50
+ team: "Beta",
51
+ matchesPlayed: 8,
52
+ boostersUsed: 0,
53
+ transfersLeft: "95",
54
+ updatedAt: "2026-05-11T08:00:00.000Z",
55
+ },
56
+ ],
57
+ );
58
+
59
+ assert.deepEqual(merged, [
60
+ {
61
+ team: "Alpha",
62
+ matchesPlayed: 11,
63
+ boostersUsed: 2,
64
+ transfersLeft: "89",
65
+ updatedAt: "2026-05-11T10:00:00.000Z",
66
+ },
67
+ {
68
+ team: "Beta",
69
+ matchesPlayed: 8,
70
+ boostersUsed: 0,
71
+ transfersLeft: "95",
72
+ updatedAt: "2026-05-11T08:00:00.000Z",
73
+ },
74
+ ]);
75
+ });
76
+
77
+ it("normalizes a persisted transfer snapshot payload", () => {
78
+ const snapshot = normalizeTransferSnapshot({
79
+ updatedAt: "2026-05-11T10:00:00.000Z",
80
+ teams: [
81
+ {
82
+ team: "Gamma",
83
+ matchesPlayed: 14,
84
+ boostersUsed: 2,
85
+ transfersLeft: "88",
86
+ updatedAt: "2026-05-11T10:00:00.000Z",
87
+ },
88
+ ],
89
+ });
90
+
91
+ assert.deepEqual(snapshot, {
92
+ updatedAt: "2026-05-11T10:00:00.000Z",
93
+ teams: [
94
+ {
95
+ team: "Gamma",
96
+ matchesPlayed: 14,
97
+ boostersUsed: 2,
98
+ transfersLeft: "88",
99
+ updatedAt: "2026-05-11T10:00:00.000Z",
100
+ },
101
+ ],
102
+ });
103
+
104
+ assert.deepEqual(
105
+ summarizeTransferSnapshot(snapshot?.teams ?? []),
106
+ snapshot,
107
+ );
108
+ });
109
+ });