@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,160 @@
1
+ # IPL Fantasy Cricket Dashboard
2
+
3
+ Private Next.js dashboard for an IPL fantasy league. Combines seeded match history with live leaderboard snapshots to show standings, match-by-match movement, captain picks, cumulative trends, and league insights in one place.
4
+
5
+ ## Screenshots
6
+
7
+ <!-- TODO: Add screenshots -->
8
+ | Dashboard Overview | Performance Tracker |
9
+ |:---:|:---:|
10
+ | ![](./screenshots/dashboard-overview.png) | ![](./screenshots/performance-tracker.png) |
11
+
12
+ | Captain Board | Ledger Table |
13
+ |:---:|:---:|
14
+ | ![](./screenshots/captain-board.png) | ![](./screenshots/ledger-table.png) |
15
+
16
+ | Match Scrubber | AI Roasting |
17
+ |:---:|:---:|
18
+ | ![](./screenshots/match-scrubber.png) | ![](./screenshots/ai-roasting.png) |
19
+
20
+ ---
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ npm install
26
+ ```
27
+
28
+ Create a `.env` file:
29
+
30
+ ```env
31
+ MONGODB_URI=your_mongodb_connection_string
32
+ IPL_LEAGUE_URL=https://fantasy.iplt20.com/classic/league/view/your_league_id
33
+ IPL_POST_SECRET=your_secret_here
34
+ ```
35
+
36
+ Start the app:
37
+
38
+ ```bash
39
+ npm run dev:simple
40
+ ```
41
+
42
+ Open http://localhost:3000
43
+
44
+ ---
45
+
46
+ ## Components
47
+
48
+ ### Dashboard Sections
49
+
50
+ | Component | File | Description |
51
+ |-----------|------|-------------|
52
+ | **StickyMini** | `components/dashboard/StickyMini.tsx` | Compact stat card with title, value, note, and color-coded icon |
53
+ | **SectionCard** | `components/dashboard/SectionCard.tsx` | Animated motion-section wrapper with title, note, accent color, and staggered entrance |
54
+ | **ChartBoard** | `components/dashboard/ChartBoard.tsx` | Recharts bar chart for team data with pill tags and tooltips |
55
+ | **CaptainBoard** | `components/dashboard/CaptainBoard.tsx` | Displays captain/vice-captain picks per team with shield icons |
56
+ | **LedgerTable** | `components/dashboard/LedgerTable.tsx` | Full standings table with colors, boosters, rank arrows, and formatted points |
57
+ | **LatestBadge** | `components/dashboard/LatestBadge.tsx` | Color-coded pill badge for points (pink <200, orange 200-400, green >400) |
58
+
59
+ ### Widgets
60
+
61
+ | Component | File | Description |
62
+ |-----------|------|-------------|
63
+ | **PerformanceTracker** | `components/PerformanceTracker.tsx` | Recharts line chart tracking each team's point trajectory across match days |
64
+ | **CrownBattle** | `components/CrownBattle.tsx` | Head-to-head showdown between #1 and #2 ranked teams |
65
+ | **AIRoasting** | `components/AIRoasting.tsx` | AI-generated roast commentary cards cycling through hot/cold/lightning/trend types |
66
+ | **FantasyStockTicker** | `components/FantasyStockTicker.tsx` | Team stocks with trend arrows, sparklines, and fluctuating values |
67
+ | **TeamDNAScanner** | `components/TeamDNAScanner.tsx` | Sci-fi DNA scan visualization with moving scan-line and std deviation metrics |
68
+ | **MatchRecapScroll** | `components/MatchRecapScroll.tsx` | Scroll-styled recap panel for the latest match day results |
69
+ | **MatchStoryScrubber** | `components/MatchStoryScrubber.tsx` | Interactive timeline scrubber to step through match history with bar charts |
70
+ | **LiveMatchTicker** | `components/LiveMatchTicker.tsx` | Live-scrolling ticker of upcoming matches with countdowns |
71
+ | **ProgressGlowRings** | `components/ProgressGlowRings.tsx` | Animated SVG glowing ring progress indicators per team |
72
+ | **ColorWave** | `components/ColorWave.tsx` | Animated SVG wave/chart visualizing team scores over time |
73
+ | **FireworksBurst** | `components/FireworksBurst.tsx` | Particle-based fireworks celebration on milestones |
74
+ | **ThemeToggle** | `components/ThemeToggle.tsx` | Dark/light mode toggle persisted to localStorage |
75
+
76
+ ### UI Primitives
77
+
78
+ | Component | File | Description |
79
+ |-----------|------|-------------|
80
+ | **TeamPills** | `components/ui/TeamPills.tsx` | Flex-wrap row of bordered pill/chip team labels |
81
+ | **DashboardChartFrame** | `components/ui/DashboardChartFrame.tsx` | Responsive chart wrapper with hover-jitter effects |
82
+ | **DoodleSpinner** | `components/ui/DoodleSpinner.tsx` | Hand-drawn SVG loading spinner with dotted circles |
83
+
84
+ ---
85
+
86
+ ## API Endpoints
87
+
88
+ | Route | Method | Description |
89
+ |-------|--------|-------------|
90
+ | `/api/ipl` | GET | Full dashboard payload used by the UI |
91
+ | `/api/ipl?format=snapshot` | GET | Raw normalized snapshot only |
92
+ | `/api/ipl` | POST | Ingest a leaderboard snapshot |
93
+ | `/api/ipl/transfers` | GET | Transfer/booster snapshot for all teams |
94
+ | `/api/ipl/transfers` | POST | Ingest a single team transfer record |
95
+ | `/api/ipl/upcoming-matches` | GET | Upcoming match schedule |
96
+ | `/api/ops/status` | GET | Health/monitoring endpoint |
97
+
98
+ ---
99
+
100
+ ## Data Flow
101
+
102
+ ```
103
+ GET /api/ipl resolution order:
104
+ 1. MongoDB raw users (if MONGODB_URI configured)
105
+ 2. Fallback: local seed data (app/api/ipl/data.ts)
106
+ 3. MongoDB live snapshot (if MONGODB_URI configured)
107
+ 4. Fallback: local snapshot file (app/api/ipl/live-snapshot.json)
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Configuration
113
+
114
+ All hardcoded values are centralized in `app/lib/config.ts` and overridable via environment variables:
115
+
116
+ | Variable | Default | Description |
117
+ |----------|---------|-------------|
118
+ | `MONGODB_URI` | - | MongoDB connection string |
119
+ | `IPL_LEAGUE_URL` | fantasy.iplt20.com/... | Fantasy league page URL |
120
+ | `IPL_POST_SECRET` | - | Bearer token for POST endpoints |
121
+ | `IPL_DB_NAME` | `ipl` | MongoDB database name |
122
+ | `IPL_COLLECTION_NAME` | `ipl` | MongoDB collection name |
123
+ | `IPL_TOTAL_TRANSFERS` | `160` | Total transfers per season |
124
+ | `IPL_SYNC_INTERVAL_MS` | `120000` | Dashboard polling interval |
125
+ | `IPL_DASHBOARD_STALE_MINUTES` | `20` (prod) / `180` (dev) | Staleness threshold |
126
+ | `IPL_TRANSFERS_STALE_MINUTES` | `720` (prod) / `1440` (dev) | Transfer staleness threshold |
127
+
128
+ ---
129
+
130
+ ## Automation
131
+
132
+ ```bash
133
+ # Capture login state for Playwright scrapers
134
+ npm run capture:ipl-auth
135
+
136
+ # Scrape live leaderboard snapshot
137
+ npm run sync:ipl
138
+
139
+ # Scrape transfer/booster data
140
+ npm run sync:ipl:transfers
141
+
142
+ # Run both in sequence (for cloud jobs)
143
+ npm run sync:cloud
144
+ ```
145
+
146
+ Also supports a browser bookmarklet — visit `/api/ipl/bookmarklet` while the app is running, copy the returned `javascript:` code, and save it as a bookmark. Click it on the fantasy leaderboard page to sync data.
147
+
148
+ ---
149
+
150
+ ## Stack
151
+
152
+ - **Next.js** 16 App Router
153
+ - **React** 19
154
+ - **TypeScript**
155
+ - **Tailwind CSS** 4
156
+ - **Recharts** — charts
157
+ - **Framer Motion** — animations
158
+ - **MongoDB** — data persistence
159
+ - **Zustand** — client state
160
+ - **Playwright** — scraper automation
@@ -0,0 +1,24 @@
1
+ import type { RawApiUser } from "../../types";
2
+ import { TEAMS } from "../../data/teams.ts";
3
+ import { MATCH_POINTS } from "../../data/match-points.ts";
4
+
5
+ const calcPoints = (matches: Array<{ matchId: number; points: number }>) =>
6
+ matches.reduce((sum, m) => sum + m.points, 0);
7
+
8
+ export const rawApiUsers: RawApiUser[] = TEAMS.filter((t) => t.id <= 8)
9
+ .map((team) => {
10
+ const matches = MATCH_POINTS
11
+ .filter((row) => row.teamId === team.id)
12
+ .map((row) => ({ matchId: row.matchId, points: row.points }))
13
+ .sort((a, b) => a.matchId - b.matchId);
14
+ return {
15
+ rno: team.id,
16
+ temname: team.name,
17
+ points: 0,
18
+ matches,
19
+ };
20
+ })
21
+ .map((user) => ({
22
+ ...user,
23
+ points: calcPoints(user.matches),
24
+ }));
@@ -0,0 +1,505 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import type { RawApiUser, ScrapedDashboardPayload } from "../../types";
4
+ import {
5
+ buildDashboardFromSnapshot,
6
+ buildManualDashboard,
7
+ normalizeRawApiUsers,
8
+ normalizePayload,
9
+ serializeRawApiUsersModule,
10
+ syncRawUsersWithSnapshot,
11
+ resolveTeamId,
12
+ getTransformOptions,
13
+ } from "./transform";
14
+ import { rawApiUsers } from "./data";
15
+ import getMongoDb from "../../lib/useDb";
16
+ import { config } from "../../lib/config.ts";
17
+
18
+ export const dynamic = "force-dynamic";
19
+ export const revalidate = 0;
20
+ export const runtime = "nodejs";
21
+
22
+ const DASHBOARD_COLLECTION = config.mongodb.collectionName;
23
+ const DASHBOARD_DOCUMENT_TYPE = config.docTypes.dashboard;
24
+ const RAW_USERS_DOCUMENT_TYPE = config.docTypes.rawUsers;
25
+ const isProduction = process.env.NODE_ENV === "production";
26
+ const allowLocalSnapshotFallback = !isProduction;
27
+ const allowLocalSeedDataWrites =
28
+ !isProduction && process.env.IPL_WRITE_SEED_DATA_FILE === "1";
29
+ const SNAPSHOT_FILE_PATH = config.paths.snapshotFile;
30
+ const SEED_DATA_FILE_PATH = config.paths.seedDataFile;
31
+
32
+ const corsHeaders = {
33
+ "Access-Control-Allow-Origin": "*",
34
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
35
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
36
+ "Cache-Control": "no-store, no-cache, must-revalidate",
37
+ Pragma: "no-cache",
38
+ Expires: "0",
39
+ };
40
+
41
+ const LOG_TAG = "[ipl-api]";
42
+ const shouldLog =
43
+ process.env.IPL_API_LOG === "1" || process.env.NODE_ENV !== "production";
44
+ const shouldLogPayload = process.env.IPL_API_LOG_PAYLOAD === "1";
45
+
46
+ const log = (message: string, details?: unknown) => {
47
+ if (!shouldLog) return;
48
+ if (details === undefined) {
49
+ console.log(LOG_TAG, message);
50
+ return;
51
+ }
52
+ console.log(LOG_TAG, message, details);
53
+ };
54
+
55
+ const opts = getTransformOptions();
56
+
57
+ const getDb = async () => {
58
+ try {
59
+ return await getMongoDb();
60
+ } catch (error) {
61
+ console.error("MongoDB connection failed:", error);
62
+ return null;
63
+ }
64
+ };
65
+
66
+ const readMongoSnapshot = async () => {
67
+ const db = await getDb();
68
+ if (!db) return null;
69
+ try {
70
+ const document = await db
71
+ .collection(DASHBOARD_COLLECTION)
72
+ .findOne({ type: DASHBOARD_DOCUMENT_TYPE });
73
+ return normalizePayload(document);
74
+ } catch (error) {
75
+ console.error("Failed to read IPL snapshot from MongoDB:", error);
76
+ return null;
77
+ }
78
+ };
79
+
80
+ const readMongoRawUsers = async () => {
81
+ const db = await getDb();
82
+ if (!db) return null;
83
+ try {
84
+ const document = await db
85
+ .collection(DASHBOARD_COLLECTION)
86
+ .findOne({ type: RAW_USERS_DOCUMENT_TYPE });
87
+ return normalizeRawApiUsers(document?.users);
88
+ } catch (error) {
89
+ console.error("Failed to read IPL raw users from MongoDB:", error);
90
+ return null;
91
+ }
92
+ };
93
+
94
+ const readSnapshotFile = async () => {
95
+ if (!allowLocalSnapshotFallback) {
96
+ return null;
97
+ }
98
+
99
+ try {
100
+ const content = await readFile(SNAPSHOT_FILE_PATH, "utf8");
101
+ return normalizePayload(JSON.parse(content));
102
+ } catch {
103
+ return null;
104
+ }
105
+ };
106
+
107
+ const readLocalRawUsers = () => normalizeRawApiUsers(rawApiUsers);
108
+ const createEmptyDashboard = () => buildManualDashboard([]);
109
+
110
+ const withPreviousSnapshotMovement = (
111
+ payload: ScrapedDashboardPayload,
112
+ previous: ScrapedDashboardPayload | null,
113
+ ): ScrapedDashboardPayload => {
114
+ if (!previous?.leaders.length) {
115
+ return payload;
116
+ }
117
+
118
+ const previousByName = new Map(
119
+ previous.leaders.map((leader) => [leader.name, leader] as const),
120
+ );
121
+
122
+ return {
123
+ ...payload,
124
+ leaders: payload.leaders.map((leader) => {
125
+ const previousLeader = previousByName.get(leader.name);
126
+
127
+ if (!previousLeader) {
128
+ return leader;
129
+ }
130
+
131
+ return {
132
+ ...leader,
133
+ netRank: previousLeader.rank,
134
+ netPoints: previousLeader.points,
135
+ };
136
+ }),
137
+ };
138
+ };
139
+
140
+ const snapshotFingerprint = (payload: ScrapedDashboardPayload | null) => {
141
+ if (!payload) return "";
142
+ const normalizedLeaders = [...payload.leaders]
143
+ .map((leader) => ({
144
+ rank: leader.rank,
145
+ name: leader.name,
146
+ points: leader.points,
147
+ lastMatchPoints: leader.lastMatchPoints,
148
+ transfersLeft: leader.transfersLeft,
149
+ transfersUsed: leader.transfersUsed,
150
+ totalTransfers: leader.totalTransfers,
151
+ boostersUsed: leader.boostersUsed,
152
+ captain: leader.captain,
153
+ viceCaptain: leader.viceCaptain,
154
+ players: leader.players,
155
+ }))
156
+ .sort((a, b) => a.rank - b.rank);
157
+ return JSON.stringify({
158
+ completedMatches: payload.completedMatches,
159
+ dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
160
+ leaders: normalizedLeaders,
161
+ });
162
+ };
163
+
164
+ const snapshotsAreEqual = (
165
+ current: ScrapedDashboardPayload | null,
166
+ next: ScrapedDashboardPayload,
167
+ ) => Boolean(current) && snapshotFingerprint(current) === snapshotFingerprint(next);
168
+
169
+ const snapshotSummary = (payload: ScrapedDashboardPayload | null) => {
170
+ if (!payload) return null;
171
+ const topLeaders = [...payload.leaders]
172
+ .sort((a, b) => a.rank - b.rank)
173
+ .slice(0, 5)
174
+ .map((leader) => ({
175
+ rank: leader.rank,
176
+ name: leader.name,
177
+ teamId: resolveTeamId(leader.name),
178
+ points: leader.points,
179
+ lastMatchPoints: leader.lastMatchPoints,
180
+ transfersLeft: leader.transfersLeft,
181
+ transfersUsed: leader.transfersUsed,
182
+ totalTransfers: leader.totalTransfers,
183
+ boostersUsed: leader.boostersUsed,
184
+ }));
185
+ return {
186
+ updatedAt: payload.updatedAt,
187
+ dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
188
+ completedMatches: payload.completedMatches,
189
+ leadersCount: payload.leaders.length,
190
+ topLeaders,
191
+ };
192
+ };
193
+
194
+ const writeMongoSnapshot = async (payload: ScrapedDashboardPayload) => {
195
+ const db = await getDb();
196
+ if (!db) return { configured: false, stored: false };
197
+ try {
198
+ const current = await readMongoSnapshot();
199
+ if (snapshotsAreEqual(current, payload)) {
200
+ return { configured: true, stored: true, status: "unchanged" as const };
201
+ }
202
+ await db.collection(DASHBOARD_COLLECTION).updateOne(
203
+ { type: DASHBOARD_DOCUMENT_TYPE },
204
+ {
205
+ $set: {
206
+ type: DASHBOARD_DOCUMENT_TYPE,
207
+ updatedAt: payload.updatedAt,
208
+ dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
209
+ completedMatches: payload.completedMatches,
210
+ leaders: payload.leaders.map((l) => ({
211
+ ...l,
212
+ teamId: resolveTeamId(l.name),
213
+ })),
214
+ },
215
+ },
216
+ { upsert: true },
217
+ );
218
+ return { configured: true, stored: true, status: "updated" as const };
219
+ } catch (error) {
220
+ console.error("Failed to write IPL snapshot to MongoDB:", error);
221
+ return { configured: true, stored: false, status: "failed" as const };
222
+ }
223
+ };
224
+
225
+ const writeMongoRawUsers = async (users: RawApiUser[]) => {
226
+ const db = await getDb();
227
+ if (!db) return { configured: false, stored: false };
228
+ try {
229
+ await db.collection(DASHBOARD_COLLECTION).updateOne(
230
+ { type: RAW_USERS_DOCUMENT_TYPE },
231
+ {
232
+ $set: {
233
+ type: RAW_USERS_DOCUMENT_TYPE,
234
+ updatedAt: new Date().toISOString(),
235
+ users: users.map((user) => ({
236
+ rno: user.rno,
237
+ temname: user.temname,
238
+ points: user.points,
239
+ teamId: resolveTeamId(user.temname),
240
+ matches: [...user.matches].sort((a, b) => a.matchId - b.matchId),
241
+ })),
242
+ },
243
+ },
244
+ { upsert: true },
245
+ );
246
+ return { configured: true, stored: true };
247
+ } catch (error) {
248
+ console.error("Failed to write IPL raw users to MongoDB:", error);
249
+ return { configured: true, stored: false };
250
+ }
251
+ };
252
+
253
+ const writeSnapshotFile = async (payload: ScrapedDashboardPayload) => {
254
+ if (!allowLocalSnapshotFallback) {
255
+ return false;
256
+ }
257
+
258
+ try {
259
+ await writeFile(
260
+ SNAPSHOT_FILE_PATH,
261
+ `${JSON.stringify(payload, null, 2)}\n`,
262
+ "utf8",
263
+ );
264
+ return true;
265
+ } catch (error) {
266
+ console.error("Failed to write IPL live-snapshot.json:", error);
267
+ return false;
268
+ }
269
+ };
270
+
271
+ const writeSeedDataFile = async (users: RawApiUser[]) => {
272
+ if (!allowLocalSeedDataWrites) {
273
+ return { configured: false, stored: false };
274
+ }
275
+
276
+ try {
277
+ await writeFile(
278
+ SEED_DATA_FILE_PATH,
279
+ `${serializeRawApiUsersModule(users)}\n`,
280
+ "utf8",
281
+ );
282
+ return { configured: true, stored: true };
283
+ } catch (error) {
284
+ console.error("Failed to update IPL seed data file:", error);
285
+ return { configured: true, stored: false };
286
+ }
287
+ };
288
+
289
+ export async function OPTIONS() {
290
+ return new Response(null, { status: 204, headers: corsHeaders });
291
+ }
292
+
293
+ export async function GET(request: Request) {
294
+ const url = new URL(request.url);
295
+ const format = url.searchParams.get("format");
296
+ const mongoConfigured = Boolean(process.env.MONGODB_URI);
297
+
298
+ log("GET /api/ipl", {
299
+ format,
300
+ mongoConfigured,
301
+ allowLocalSnapshotFallback,
302
+ url: url.toString(),
303
+ });
304
+
305
+ const [mongoUsers, fileSnapshot, mongoSnapshot] = await Promise.all([
306
+ readMongoRawUsers(),
307
+ readSnapshotFile(),
308
+ readMongoSnapshot(),
309
+ ]);
310
+ const manualUsers = mongoUsers ?? readLocalRawUsers();
311
+ const snapshot = mongoSnapshot ?? fileSnapshot;
312
+ const manualDashboard = manualUsers
313
+ ? buildManualDashboard(manualUsers, opts)
314
+ : createEmptyDashboard();
315
+ const snapshotSource = mongoSnapshot ? "mongo" : fileSnapshot ? "file" : null;
316
+
317
+ log("GET /api/ipl resolved sources", {
318
+ snapshotSource,
319
+ hasMongoRawUsers: Boolean(mongoUsers),
320
+ hasFallbackRawUsers: !mongoUsers && Boolean(rawApiUsers?.length),
321
+ allowLocalSnapshotFallback,
322
+ snapshot: snapshotSummary(snapshot),
323
+ });
324
+
325
+ if (format === "snapshot") {
326
+ if (snapshot) {
327
+ log("GET /api/ipl?format=snapshot -> 200", { snapshotSource, snapshot: snapshotSummary(snapshot) });
328
+ return NextResponse.json(snapshot, { headers: corsHeaders });
329
+ }
330
+ log("GET /api/ipl?format=snapshot -> 503 (no snapshot)", { snapshotSource });
331
+ return NextResponse.json(
332
+ { error: "No IPL snapshot found yet. POST a live snapshot first." },
333
+ { status: 503, headers: corsHeaders },
334
+ );
335
+ }
336
+
337
+ if (snapshot) {
338
+ log("GET /api/ipl -> 200 (dashboard merged)", { snapshotSource, snapshot: snapshotSummary(snapshot) });
339
+ return NextResponse.json(
340
+ buildDashboardFromSnapshot(snapshot, manualDashboard, opts),
341
+ { headers: corsHeaders },
342
+ );
343
+ }
344
+
345
+ if (manualUsers) {
346
+ log("GET /api/ipl -> 200 (manual only)", { users: manualUsers.length });
347
+ return NextResponse.json(manualDashboard, { headers: corsHeaders });
348
+ }
349
+
350
+ log("GET /api/ipl -> 503 (no data)");
351
+ return NextResponse.json(
352
+ { error: "No IPL dashboard data found yet. POST a live snapshot first." },
353
+ { status: 503, headers: corsHeaders },
354
+ );
355
+ }
356
+
357
+ export async function POST(request: Request) {
358
+ const secret = process.env.IPL_POST_SECRET;
359
+ const auth = request.headers.get("authorization");
360
+ const mongoConfigured = Boolean(process.env.MONGODB_URI);
361
+
362
+ if (secret && auth !== `Bearer ${secret}`) {
363
+ log("POST /api/ipl -> 401 (missing or invalid bearer token)");
364
+ return NextResponse.json(
365
+ { error: "Unauthorized" },
366
+ { status: 401, headers: corsHeaders },
367
+ );
368
+ }
369
+
370
+ const payload = normalizePayload(await request.json().catch(() => null));
371
+
372
+ if (!payload) {
373
+ log("POST /api/ipl -> 400 (invalid payload)");
374
+ return NextResponse.json(
375
+ { error: "Invalid payload. Expected { leaders: [...] } or [...] ." },
376
+ { status: 400, headers: corsHeaders },
377
+ );
378
+ }
379
+
380
+ if (isProduction && !mongoConfigured) {
381
+ log("POST /api/ipl -> 503 (MongoDB required in production)");
382
+ return NextResponse.json(
383
+ {
384
+ error:
385
+ "MongoDB storage is required in production. Configure MONGODB_URI for cloud ingestion.",
386
+ },
387
+ { status: 503, headers: corsHeaders },
388
+ );
389
+ }
390
+
391
+ log("POST /api/ipl received payload", {
392
+ summary: snapshotSummary(payload),
393
+ ...(shouldLogPayload ? { payload } : {}),
394
+ });
395
+
396
+ const [manualUsers, currentFileSnapshot, currentMongoSnapshot] = await Promise.all([
397
+ readMongoRawUsers(),
398
+ readSnapshotFile(),
399
+ readMongoSnapshot(),
400
+ ]);
401
+ const previousSnapshot = currentMongoSnapshot ?? currentFileSnapshot;
402
+ const preservedDailyTransferUpdatedAt =
403
+ payload.dailyTransferUpdatedAt ??
404
+ currentMongoSnapshot?.dailyTransferUpdatedAt ??
405
+ currentFileSnapshot?.dailyTransferUpdatedAt;
406
+ const normalizedPayload = withPreviousSnapshotMovement(
407
+ {
408
+ ...payload,
409
+ dailyTransferUpdatedAt: preservedDailyTransferUpdatedAt,
410
+ },
411
+ previousSnapshot,
412
+ );
413
+ const baselineUsers = manualUsers ?? readLocalRawUsers();
414
+ const fileMatches = snapshotsAreEqual(currentFileSnapshot, normalizedPayload);
415
+ const mongoMatches = snapshotsAreEqual(currentMongoSnapshot, normalizedPayload);
416
+ const snapshotUnchanged = mongoConfigured ? mongoMatches : fileMatches;
417
+
418
+ log("POST /api/ipl compare", {
419
+ mongoConfigured, fileMatches, mongoMatches, snapshotUnchanged,
420
+ currentFileSnapshot: snapshotSummary(currentFileSnapshot),
421
+ currentMongoSnapshot: snapshotSummary(currentMongoSnapshot),
422
+ });
423
+
424
+ if (snapshotUnchanged) {
425
+ log("POST /api/ipl -> 200 (unchanged)", {
426
+ storage: {
427
+ mongodbSnapshot: mongoMatches,
428
+ localSnapshotFile: fileMatches,
429
+ mongodbRawUsers: false,
430
+ mongodbConfigured: Boolean(process.env.MONGODB_URI),
431
+ snapshotStatus: "unchanged",
432
+ },
433
+ });
434
+ return NextResponse.json(
435
+ {
436
+ ok: true,
437
+ updatedAt: normalizedPayload.updatedAt,
438
+ dailyTransferUpdatedAt: normalizedPayload.dailyTransferUpdatedAt,
439
+ count: normalizedPayload.leaders.length,
440
+ storage: {
441
+ mongodbSnapshot: mongoMatches, localSnapshotFile: fileMatches,
442
+ mongodbRawUsers: false, mongodbConfigured: Boolean(process.env.MONGODB_URI),
443
+ snapshotStatus: "unchanged",
444
+ },
445
+ rawSync: { status: "unchanged", unmatchedNames: [] },
446
+ },
447
+ { headers: corsHeaders },
448
+ );
449
+ }
450
+
451
+ const mongoStorage = await writeMongoSnapshot(normalizedPayload);
452
+ const fileSnapshotStored = await writeSnapshotFile(normalizedPayload);
453
+ const rawSync = baselineUsers
454
+ ? syncRawUsersWithSnapshot(baselineUsers, normalizedPayload, opts)
455
+ : {
456
+ status: "skipped" as const,
457
+ users: [],
458
+ unmatchedNames: normalizedPayload.leaders.map((leader) => leader.name),
459
+ };
460
+
461
+ const rawUsersStorage =
462
+ manualUsers && rawSync.status === "updated"
463
+ ? await writeMongoRawUsers(rawSync.users)
464
+ : { configured: Boolean(process.env.MONGODB_URI), stored: false };
465
+ const seedFileStorage =
466
+ rawSync.status === "updated"
467
+ ? await writeSeedDataFile(rawSync.users)
468
+ : { configured: allowLocalSeedDataWrites, stored: false };
469
+
470
+ log("POST /api/ipl write results", {
471
+ mongodbSnapshot: mongoStorage,
472
+ localSnapshotFile: fileSnapshotStored,
473
+ mongodbRawUsers: rawUsersStorage,
474
+ seedDataFile: seedFileStorage,
475
+ rawSync: {
476
+ status: rawSync.status, matchId: rawSync.matchId, mode: rawSync.mode,
477
+ completedMatches: rawSync.completedMatches,
478
+ unmatchedNames: rawSync.unmatchedNames,
479
+ usersWritten: rawSync.status === "updated" ? rawSync.users.length : 0,
480
+ },
481
+ });
482
+
483
+ return NextResponse.json(
484
+ {
485
+ ok: true,
486
+ updatedAt: normalizedPayload.updatedAt,
487
+ dailyTransferUpdatedAt: normalizedPayload.dailyTransferUpdatedAt,
488
+ count: normalizedPayload.leaders.length,
489
+ storage: {
490
+ mongodbSnapshot: mongoStorage.stored,
491
+ localSnapshotFile: fileSnapshotStored,
492
+ mongodbRawUsers: rawUsersStorage.stored,
493
+ mongodbConfigured: mongoStorage.configured,
494
+ snapshotStatus: mongoStorage.status,
495
+ },
496
+ seedDataFile: { stored: seedFileStorage.stored },
497
+ rawSync: {
498
+ status: rawSync.status, matchId: rawSync.matchId, mode: rawSync.mode,
499
+ completedMatches: rawSync.completedMatches,
500
+ unmatchedNames: rawSync.unmatchedNames,
501
+ },
502
+ },
503
+ { headers: corsHeaders },
504
+ );
505
+ }