@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,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
|
+
|  |  |
|
|
11
|
+
|
|
12
|
+
| Captain Board | Ledger Table |
|
|
13
|
+
|:---:|:---:|
|
|
14
|
+
|  |  |
|
|
15
|
+
|
|
16
|
+
| Match Scrubber | AI Roasting |
|
|
17
|
+
|:---:|:---:|
|
|
18
|
+
|  |  |
|
|
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
|
+
}
|