@vatvaghool/create-ipl-dashboard 0.1.18 → 0.1.23
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 +38 -129
- package/package.json +2 -3
- package/src/index.mjs +18 -22
- package/src/prompts.mjs +20 -112
- package/src/scaffold.mjs +23 -35
- package/template/README.md +23 -200
- package/template/app/api/ops/seed/route.ts +76 -0
- package/template/app/api/ops/status/route.ts +0 -2
- package/template/app/lib/config.ts +0 -4
- package/template/app/lib/matchStatus.ts +0 -12
- package/template/app/lib/storage/index.ts +1 -10
- package/template/package.json +1 -6
- package/template/scripts/dev-simple.js +1 -1
- package/template/scripts/dev-welcome.mjs +39 -18
- package/template/scripts/seed-league.mjs +0 -55
- package/screenshots/ai-roasting.png +0 -0
- package/screenshots/captain-board.png +0 -0
- package/screenshots/dashboard-overview.png +0 -0
- package/screenshots/ledger-table.png +0 -0
- package/screenshots/match-scrubber.png +0 -0
- package/screenshots/performance-tracker.png +0 -0
- package/src/scraper.mjs +0 -79
- package/template/app/hooks/dashboardPolling.ts +0 -53
- package/template/app/hooks/snapshotCache.ts +0 -47
- package/template/app/lib/storage/google-sheets-storage.ts +0 -147
- package/template/app/lib/utils/diff.ts +0 -24
- package/template/app/lib/utils/time.ts +0 -40
- package/template/screenshots/ai-roasting.png +0 -0
- package/template/screenshots/captain-board.png +0 -0
- package/template/screenshots/dashboard-overview.png +0 -0
- package/template/screenshots/ledger-table.png +0 -0
- package/template/screenshots/match-scrubber.png +0 -0
- package/template/screenshots/performance-tracker.png +0 -0
- package/template/tests/coverage-gaps.test.ts +0 -290
- package/template/tests/dashboard-polling.test.ts +0 -96
- package/template/tests/utils-and-cache.test.ts +0 -267
package/template/README.md
CHANGED
|
@@ -1,234 +1,57 @@
|
|
|
1
1
|
# IPL Fantasy Cricket Dashboard
|
|
2
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
3
|
## Quick Start
|
|
6
4
|
|
|
7
5
|
```bash
|
|
8
|
-
# Scaffold a new project
|
|
9
6
|
npx @vatvaghool/create-ipl-dashboard my-league
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
npm
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Start
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm run dev:simple
|
|
7
|
+
cd my-league
|
|
8
|
+
# Edit .env — set MONGODB_URI, IPL_LEAGUE_URL and IPL_POST_SECRET
|
|
9
|
+
npm run seed:api # Seed match data into MongoDB
|
|
10
|
+
npm run seed:league # Seed league metadata
|
|
11
|
+
npm run capture:ipl-auth # One-time Playwright login
|
|
12
|
+
npm run sync:ipl:watch # Start live sync
|
|
19
13
|
```
|
|
20
14
|
|
|
21
15
|
Open http://localhost:3000
|
|
22
16
|
|
|
23
|
-
|
|
17
|
+
## Commands
|
|
24
18
|
|
|
25
19
|
| Command | Description |
|
|
26
20
|
|---------|-------------|
|
|
27
|
-
| `npm run dev` |
|
|
28
|
-
| `npm run dev:simple` | Dev server (simple, no splash) |
|
|
21
|
+
| `npm run dev:simple` | Start dev server |
|
|
29
22
|
| `npm run build` | Production build |
|
|
30
|
-
| `npm start` | Production server |
|
|
31
23
|
| `npm run capture:ipl-auth` | Capture Playwright login state (one-time) |
|
|
32
24
|
| `npm run sync:ipl` | Scrape live leaderboard snapshot |
|
|
33
|
-
| `npm run sync:ipl:watch` | Scrape leaderboard in watch mode
|
|
34
|
-
| `npm run sync:
|
|
35
|
-
| `npm run
|
|
36
|
-
| `npm run seed:league` | Seed league metadata
|
|
37
|
-
| `npm run
|
|
38
|
-
| `npm run seed:mongodb:reset` | Reset and re-seed MongoDB data |
|
|
39
|
-
| `npm run verify:production` | Verify production setup |
|
|
40
|
-
| `npm run monitor:ops` | Check ops health status |
|
|
41
|
-
| `npm run test` | Run test suite |
|
|
25
|
+
| `npm run sync:ipl:watch` | Scrape leaderboard in watch mode |
|
|
26
|
+
| `npm run sync:cloud` | Run all scrapers |
|
|
27
|
+
| `npm run seed:api` | Seed match data into MongoDB |
|
|
28
|
+
| `npm run seed:league` | Seed league metadata |
|
|
29
|
+
| `npm run test` | Run tests |
|
|
42
30
|
| `npm run lint` | Run linter |
|
|
43
31
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
## Screenshots
|
|
47
|
-
|
|
48
|
-

|
|
49
|
-
|
|
50
|
-
*Dashboard overview — full page*
|
|
51
|
-
|
|
52
|
-

|
|
53
|
-
|
|
54
|
-
*Performance Tracker — Recharts line chart*
|
|
55
|
-
|
|
56
|
-

|
|
57
|
-
|
|
58
|
-
*Captain Board — captain/vice-captain picks*
|
|
59
|
-
|
|
60
|
-

|
|
61
|
-
|
|
62
|
-
*Ledger Table — standings with rank shifts and efficiency*
|
|
63
|
-
|
|
64
|
-

|
|
65
|
-
|
|
66
|
-
*Match Scrubber — interactive timeline scrubber*
|
|
67
|
-
|
|
68
|
-

|
|
69
|
-
|
|
70
|
-
*AI Roast Corner — generated commentary*
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## Components
|
|
75
|
-
|
|
76
|
-
### Dashboard Sections
|
|
77
|
-
|
|
78
|
-
| Component | File | Description |
|
|
79
|
-
|-----------|------|-------------|
|
|
80
|
-
| **StickyMini** | `components/dashboard/StickyMini.tsx` | Compact stat card with title, value, note, and color-coded icon |
|
|
81
|
-
| **SectionCard** | `components/dashboard/SectionCard.tsx` | Animated motion-section wrapper with title, note, accent color, and staggered entrance |
|
|
82
|
-
| **ChartBoard** | `components/dashboard/ChartBoard.tsx` | Recharts bar chart for team data with pill tags and tooltips |
|
|
83
|
-
| **CaptainBoard** | `components/dashboard/CaptainBoard.tsx` | All playing players with points as TeamPills badges, C/VC highlighted |
|
|
84
|
-
| **LedgerTable** | `components/dashboard/LedgerTable.tsx` | Full standings table with colors, boosters, rank arrows, and formatted points |
|
|
85
|
-
| **LatestBadge** | `components/dashboard/LatestBadge.tsx` | Color-coded pill badge for points (pink <200, orange 200-400, green >400) |
|
|
86
|
-
|
|
87
|
-
### Widgets
|
|
88
|
-
|
|
89
|
-
| Component | File | Description |
|
|
90
|
-
|-----------|------|-------------|
|
|
91
|
-
| **PerformanceTracker** | `components/PerformanceTracker.tsx` | Recharts line chart tracking each team's point trajectory across match days |
|
|
92
|
-
| **CrownBattle** | `components/CrownBattle.tsx` | Head-to-head showdown between #1 and #2 ranked teams |
|
|
93
|
-
| **AIRoasting** | `components/AIRoasting.tsx` | AI-generated roast commentary cards cycling through hot/cold/lightning/trend types |
|
|
94
|
-
| **FantasyStockTicker** | `components/FantasyStockTicker.tsx` | Team stocks with trend arrows, sparklines, and fluctuating values |
|
|
95
|
-
| **TeamDNAScanner** | `components/TeamDNAScanner.tsx` | Sci-fi DNA scan visualization with moving scan-line and std deviation metrics |
|
|
96
|
-
| **MatchRecapScroll** | `components/MatchRecapScroll.tsx` | Scroll-styled recap panel for the latest match day results |
|
|
97
|
-
| **MatchStoryScrubber** | `components/MatchStoryScrubber.tsx` | Interactive timeline scrubber to step through match history with bar charts |
|
|
98
|
-
| **LiveMatchTicker** | `components/LiveMatchTicker.tsx` | Live-scrolling ticker of upcoming matches with countdowns |
|
|
99
|
-
| **ProgressGlowRings** | `components/ProgressGlowRings.tsx` | Animated SVG glowing ring progress indicators per team |
|
|
100
|
-
| **ColorWave** | `components/ColorWave.tsx` | Animated SVG wave/chart visualizing team scores over time |
|
|
101
|
-
| **FireworksBurst** | `components/FireworksBurst.tsx` | Particle-based fireworks celebration on milestones |
|
|
102
|
-
| **ThemeToggle** | `components/ThemeToggle.tsx` | Dark/light mode toggle persisted to localStorage |
|
|
103
|
-
|
|
104
|
-
### UI Primitives
|
|
105
|
-
|
|
106
|
-
| Component | File | Description |
|
|
107
|
-
|-----------|------|-------------|
|
|
108
|
-
| **TeamPills** | `components/ui/TeamPills.tsx` | Flex-wrap row of bordered pill/chip team labels |
|
|
109
|
-
| **DashboardChartFrame** | `components/ui/DashboardChartFrame.tsx` | Responsive chart wrapper with hover-jitter effects |
|
|
110
|
-
| **DoodleSpinner** | `components/ui/DoodleSpinner.tsx` | Hand-drawn SVG loading spinner with dotted circles |
|
|
111
|
-
|
|
112
|
-
---
|
|
113
|
-
|
|
114
|
-
## API Endpoints
|
|
32
|
+
## API
|
|
115
33
|
|
|
116
34
|
| Route | Method | Description |
|
|
117
35
|
|-------|--------|-------------|
|
|
118
|
-
| `/api/ipl` | GET |
|
|
119
|
-
| `/api/ipl
|
|
120
|
-
| `/api/ipl` | POST |
|
|
121
|
-
| `/api/ipl/transfers` | GET | Transfer/booster snapshot for all teams (fetched once on mount, not on poll) |
|
|
122
|
-
| `/api/ipl/transfers` | POST | Ingest a single team transfer record |
|
|
36
|
+
| `/api/ipl` | GET | Dashboard payload |
|
|
37
|
+
| `/api/ipl` | POST | Ingest leaderboard snapshot |
|
|
38
|
+
| `/api/ipl/transfers` | GET/POST | Transfer/booster data |
|
|
123
39
|
| `/api/ipl/upcoming-matches` | GET | Upcoming match schedule |
|
|
124
|
-
| `/api/ops/status` | GET | Health
|
|
125
|
-
|
|
126
|
-
---
|
|
127
|
-
|
|
128
|
-
## Data Flow
|
|
129
|
-
|
|
130
|
-
```
|
|
131
|
-
GET /api/ipl resolution order:
|
|
132
|
-
1. Storage raw users (if configured — MongoDB or Google Sheets)
|
|
133
|
-
2. Fallback: local seed data (app/api/ipl/data.ts)
|
|
134
|
-
3. Storage live snapshot (if configured)
|
|
135
|
-
4. Fallback: local snapshot file (app/api/ipl/live-snapshot.json)
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
---
|
|
139
|
-
|
|
140
|
-
## Configuration
|
|
141
|
-
|
|
142
|
-
All hardcoded values are centralized in `app/lib/config.ts` and overridable via environment variables:
|
|
143
|
-
|
|
144
|
-
| Variable | Default | Description |
|
|
145
|
-
|----------|---------|-------------|
|
|
146
|
-
| `STORAGE_BACKEND` | `mongodb` | Storage backend: `mongodb` or `google_sheets` |
|
|
147
|
-
| `MONGODB_URI` | - | MongoDB connection string (for MongoDB backend) |
|
|
148
|
-
| `GOOGLE_SHEET_ID` | - | Google Sheet ID (for Google Sheets backend) |
|
|
149
|
-
| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | - | Google service account email |
|
|
150
|
-
| `GOOGLE_PRIVATE_KEY` | - | Google service account private key |
|
|
151
|
-
| `LEAGUE_NAME` | - | League name (collection/sheet name) |
|
|
152
|
-
| `IPL_LEAGUE_URL` | fantasy.iplt20.com/... | Fantasy league page URL |
|
|
153
|
-
| `IPL_POST_SECRET` | - | Bearer token for POST endpoints |
|
|
154
|
-
| `IPL_DB_NAME` | `ipl` | MongoDB database name (MongoDB only) |
|
|
155
|
-
| `IPL_COLLECTION_NAME` | `ipl` | MongoDB collection name (MongoDB only) |
|
|
156
|
-
| `IPL_TOTAL_TRANSFERS` | `160` | Total transfers per season |
|
|
157
|
-
| `IPL_SYNC_INTERVAL_MS` | `120000` | Dashboard polling interval |
|
|
158
|
-
| `IPL_DASHBOARD_STALE_MINUTES` | `20` (prod) / `180` (dev) | Staleness threshold |
|
|
159
|
-
| `IPL_TRANSFERS_STALE_MINUTES` | `720` (prod) / `1440` (dev) | Transfer staleness threshold |
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## Automation
|
|
164
|
-
|
|
165
|
-
See the [commands table](#available-commands) above for all options.
|
|
166
|
-
|
|
167
|
-
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.
|
|
168
|
-
|
|
169
|
-
---
|
|
40
|
+
| `/api/ops/status` | GET | Health check |
|
|
41
|
+
| `/api/ops/seed` | POST | Seed initial match data into MongoDB |
|
|
170
42
|
|
|
171
43
|
## Deployment
|
|
172
44
|
|
|
173
|
-
### Deploy to Vercel (recommended)
|
|
174
|
-
|
|
175
45
|
```bash
|
|
176
|
-
# Build the app
|
|
177
46
|
npm run build
|
|
178
|
-
|
|
179
|
-
# Deploy to Vercel
|
|
180
47
|
npx vercel --prod
|
|
181
48
|
```
|
|
182
49
|
|
|
183
|
-
Set
|
|
184
|
-
|
|
185
|
-
| Variable | Required | Description |
|
|
186
|
-
|----------|----------|-------------|
|
|
187
|
-
| `STORAGE_BACKEND` | Yes | `mongodb` or `google_sheets` |
|
|
188
|
-
| `MONGODB_URI` | Yes* | MongoDB connection string (required if `mongodb`) |
|
|
189
|
-
| `GOOGLE_SHEET_ID` | Yes* | Google Sheet ID (required if `google_sheets`) |
|
|
190
|
-
| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Yes* | Service account email (required if `google_sheets`) |
|
|
191
|
-
| `GOOGLE_PRIVATE_KEY` | Yes* | Private key (required if `google_sheets`) |
|
|
192
|
-
| `LEAGUE_NAME` | Yes | Collection or sheet name |
|
|
193
|
-
| `IPL_POST_SECRET` | Yes | Bearer token protecting write endpoints |
|
|
194
|
-
| `IPL_LEAGUE_URL` | Yes | Your fantasy league page URL |
|
|
195
|
-
| `IPL_DASHBOARD_STALE_MINUTES` | No | Staleness threshold for health checks (default: `20`) |
|
|
196
|
-
| `IPL_TRANSFERS_STALE_MINUTES` | No | Transfer staleness threshold (default: `720`) |
|
|
197
|
-
|
|
198
|
-
After deployment:
|
|
199
|
-
|
|
200
|
-
1. Verify the app is healthy — visit `https://your-app.vercel.app/api/ops/status`
|
|
201
|
-
2. Run the Playwright scraper (`npm run sync:cloud`) pointed at your production URL to populate live data
|
|
202
|
-
3. Optionally deploy the scraper as a Cloud Run Job with `Dockerfile.sync` for automated sync every 5-10 minutes
|
|
203
|
-
|
|
204
|
-
### Without Vercel
|
|
205
|
-
|
|
206
|
-
```bash
|
|
207
|
-
# Build
|
|
208
|
-
npm run build
|
|
209
|
-
|
|
210
|
-
# Start production server
|
|
211
|
-
npm start
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
The app runs as a standard Node.js server on `process.env.PORT` (default `3000`).
|
|
215
|
-
|
|
216
|
-
### Production requirements
|
|
217
|
-
|
|
218
|
-
- **Storage** (MongoDB or Google Sheets) is required in production for POST endpoints (returns `503` without it)
|
|
219
|
-
- **IPL_POST_SECRET** should be a strong random value set in your hosting environment
|
|
220
|
-
- The scraper should run outside the web app (Cloud Run, GitHub Actions, cron) — never in the same process
|
|
221
|
-
|
|
222
|
-
---
|
|
50
|
+
Set `MONGODB_URI`, `COLLECTION_NAME`, `IPL_LEAGUE_URL`, and `IPL_POST_SECRET` in your Vercel dashboard.
|
|
223
51
|
|
|
224
52
|
## Stack
|
|
225
53
|
|
|
226
|
-
- **Next.js** 16 App Router
|
|
227
|
-
- **
|
|
228
|
-
- **
|
|
229
|
-
- **Tailwind CSS** 4
|
|
230
|
-
- **Recharts** — charts
|
|
231
|
-
- **Framer Motion** — animations
|
|
232
|
-
- **MongoDB** or **Google Sheets** — data persistence
|
|
233
|
-
- **Zustand** — client state
|
|
54
|
+
- **Next.js** 16 App Router, **React** 19, **TypeScript**, **Tailwind CSS** 4
|
|
55
|
+
- **Recharts**, **Framer Motion**, **Zustand**
|
|
56
|
+
- **MongoDB** — data persistence
|
|
234
57
|
- **Playwright** — scraper automation
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getStorage } from "../../../lib/storage";
|
|
3
|
+
import { config } from "../../../lib/config";
|
|
4
|
+
import { rawApiUsers } from "../../ipl/data";
|
|
5
|
+
import { normalizePayload } from "../../ipl/transform";
|
|
6
|
+
import { MATCH_POINTS } from "../../../data/match-points";
|
|
7
|
+
import { leagueInfo } from "../../../data/league";
|
|
8
|
+
|
|
9
|
+
export const dynamic = "force-dynamic";
|
|
10
|
+
export const runtime = "nodejs";
|
|
11
|
+
|
|
12
|
+
const COLLECTION = config.mongodb.collectionName;
|
|
13
|
+
|
|
14
|
+
const storage = getStorage();
|
|
15
|
+
|
|
16
|
+
export async function POST() {
|
|
17
|
+
if (!storage.isConfigured()) {
|
|
18
|
+
return NextResponse.json({ ok: false, error: "Storage not configured" }, { status: 400 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const connected = await storage.isConnected();
|
|
22
|
+
if (!connected) {
|
|
23
|
+
return NextResponse.json({ ok: false, error: "Cannot connect to storage" }, { status: 503 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const results: Record<string, string> = {};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await storage.upsertDocument("league", {
|
|
30
|
+
type: "league",
|
|
31
|
+
name: leagueInfo.name,
|
|
32
|
+
leagueUrl: leagueInfo.leagueUrl,
|
|
33
|
+
teams: leagueInfo.teams,
|
|
34
|
+
createdAt: new Date().toISOString(),
|
|
35
|
+
updatedAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
results.league = "seeded";
|
|
38
|
+
} catch (e: any) {
|
|
39
|
+
results.league = `failed: ${e.message}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await storage.upsertDocument("raw-users", {
|
|
44
|
+
type: "raw-users",
|
|
45
|
+
users: rawApiUsers,
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
});
|
|
48
|
+
results.rawUsers = "seeded";
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
results.rawUsers = `failed: ${e.message}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const snapshot = normalizePayload({
|
|
55
|
+
type: "dashboard",
|
|
56
|
+
updatedAt: new Date().toISOString(),
|
|
57
|
+
completedMatches: Math.max(...MATCH_POINTS.map((m) => m.matchId), 0),
|
|
58
|
+
leaders: rawApiUsers
|
|
59
|
+
.sort((a, b) => b.points - a.points)
|
|
60
|
+
.map((u, i) => ({
|
|
61
|
+
rank: i + 1,
|
|
62
|
+
teamId: u.rno,
|
|
63
|
+
teamName: u.temname,
|
|
64
|
+
points: u.points,
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
await storage.upsertDocument("dashboard", snapshot);
|
|
68
|
+
results.dashboard = "seeded";
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
results.dashboard = `failed: ${e.message}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const allOk = Object.values(results).every((v) => v === "seeded");
|
|
74
|
+
|
|
75
|
+
return NextResponse.json({ ok: allOk, results });
|
|
76
|
+
}
|
|
@@ -77,7 +77,6 @@ export async function GET() {
|
|
|
77
77
|
mode: isProduction ? "cloud" : "local",
|
|
78
78
|
mongoConfigured: storageConfigured,
|
|
79
79
|
mongoConnected: false,
|
|
80
|
-
storageBackend: process.env.STORAGE_BACKEND || "mongodb",
|
|
81
80
|
reason: storageConfigured
|
|
82
81
|
? "Storage connection failed."
|
|
83
82
|
: "Storage is not configured.",
|
|
@@ -157,7 +156,6 @@ export async function GET() {
|
|
|
157
156
|
checkedAt: new Date().toISOString(),
|
|
158
157
|
mongoConfigured: storageConfigured,
|
|
159
158
|
mongoConnected: storageConnected,
|
|
160
|
-
storageBackend: process.env.STORAGE_BACKEND || "mongodb",
|
|
161
159
|
localSnapshotFallbackEnabled: !isProduction,
|
|
162
160
|
thresholds: {
|
|
163
161
|
dashboardStaleMinutes,
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { matches } from "./matches";
|
|
2
|
-
|
|
3
1
|
export type MatchStatus = "ENDED" | "LIVE" | "SOON" | "UPCOMING";
|
|
4
2
|
|
|
5
3
|
export function getMatchStatus(date: string) {
|
|
@@ -15,14 +13,4 @@ export function getMatchStatus(date: string) {
|
|
|
15
13
|
return "UPCOMING" as const;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
export function getCurrentMatchStatus() {
|
|
19
|
-
const current = matches
|
|
20
|
-
.map((match) => ({
|
|
21
|
-
...match,
|
|
22
|
-
status: getMatchStatus(match.date),
|
|
23
|
-
}))
|
|
24
|
-
.filter((match) => match.status !== "ENDED")
|
|
25
|
-
.sort((a, b) => +new Date(a.date) - +new Date(b.date))[0];
|
|
26
16
|
|
|
27
|
-
return current?.status ?? "ENDED";
|
|
28
|
-
}
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import type { IStorage } from "./types";
|
|
2
2
|
import { MongoStorage } from "./mongo-storage";
|
|
3
|
-
import { GoogleSheetsStorage } from "./google-sheets-storage";
|
|
4
3
|
|
|
5
4
|
let _instance: IStorage | null = null;
|
|
6
5
|
|
|
7
|
-
function detectBackend(): "mongodb" | "google_sheets" {
|
|
8
|
-
if (process.env.STORAGE_BACKEND === "google_sheets") return "google_sheets";
|
|
9
|
-
if (process.env.GOOGLE_SHEET_ID && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL && process.env.GOOGLE_PRIVATE_KEY) return "google_sheets";
|
|
10
|
-
return "mongodb";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
6
|
export function getStorage(): IStorage {
|
|
14
|
-
if (_instance)
|
|
15
|
-
const backend = detectBackend();
|
|
16
|
-
_instance = backend === "google_sheets" ? new GoogleSheetsStorage() : new MongoStorage();
|
|
7
|
+
if (!_instance) _instance = new MongoStorage();
|
|
17
8
|
return _instance;
|
|
18
9
|
}
|
|
19
10
|
|
package/template/package.json
CHANGED
|
@@ -9,17 +9,12 @@
|
|
|
9
9
|
"capture:ipl-auth": "node scripts/capture-ipl-auth.mjs",
|
|
10
10
|
"sync:ipl": "node scripts/sync-ipl.mjs",
|
|
11
11
|
"sync:ipl:watch": "node scripts/sync-ipl.mjs --watch",
|
|
12
|
-
"sync:ipl:transfers-daily": "node scripts/sync-transfers-daily.mjs",
|
|
13
12
|
"sync:cloud": "node scripts/sync-cloud.mjs",
|
|
14
|
-
"verify:production": "node scripts/verify-production.mjs",
|
|
15
|
-
"monitor:ops": "bash scripts/monitor-ops-status.sh",
|
|
16
13
|
"build": "next build",
|
|
17
|
-
"build:utils-package": "node ./node_modules/typescript/bin/tsc -p packages/ipl-dashboard-utils/tsconfig.build.json",
|
|
18
14
|
"start": "next start",
|
|
19
15
|
"lint": "eslint",
|
|
20
16
|
"test": "node --test --experimental-strip-types \"tests/**/*.test.ts\"",
|
|
21
|
-
"seed:
|
|
22
|
-
"seed:mongodb:reset": "node --experimental-strip-types scripts/seed-mongodb.ts --reset --force"
|
|
17
|
+
"seed:api": "node --experimental-strip-types -e \"fetch('http://localhost:3000/api/ops/seed',{method:'POST'}).then(r=>r.json()).then(console.log).catch(console.error)\""
|
|
23
18
|
},
|
|
24
19
|
"dependencies": {
|
|
25
20
|
"@vercel/analytics": "^2.0.1",
|
|
@@ -7,7 +7,7 @@ const seedMongoIfConfigured = () => {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
console.log("MONGODB_URI detected. Running seed once (best effort)...");
|
|
10
|
-
const result = spawnSync("npm", ["run", "seed:
|
|
10
|
+
const result = spawnSync("npm", ["run", "seed:api"], {
|
|
11
11
|
stdio: "inherit",
|
|
12
12
|
env: process.env,
|
|
13
13
|
});
|
|
@@ -6,33 +6,54 @@ const amber = "\x1b[33m";
|
|
|
6
6
|
const magenta = "\x1b[35m";
|
|
7
7
|
const lime = "\x1b[32m";
|
|
8
8
|
|
|
9
|
-
const cmds = [
|
|
10
|
-
{ cmd: "capture:ipl-auth", desc: "Log in to fantasy.iplt20.com and save auth state" },
|
|
11
|
-
{ cmd: "sync:ipl", desc: "Scrape live leaderboard + squad data (one-shot)" },
|
|
12
|
-
{ cmd: "sync:ipl:transfers-daily", desc: "Scrape transfer/booster data for all teams" },
|
|
13
|
-
{ cmd: "sync:ipl:watch", desc: "Watch mode — rescrape leaderboard every 60s" },
|
|
14
|
-
{ cmd: "test", desc: "Run all tests (43 total)" },
|
|
15
|
-
{ cmd: "lint", desc: "Run ESLint" },
|
|
16
|
-
{ cmd: "build", desc: "Production build" },
|
|
17
|
-
];
|
|
18
|
-
|
|
19
9
|
console.log("");
|
|
20
10
|
console.log(` ${bold}${magenta}╭──────────────────────────────────────────╮${reset}`);
|
|
21
|
-
console.log(` ${bold}${magenta}│${reset} ${bold}🏏 IPL Dashboard Dev Server
|
|
11
|
+
console.log(` ${bold}${magenta}│${reset} ${bold}🏏 IPL Dashboard Dev Server${reset} ${bold}${magenta}│${reset}`);
|
|
22
12
|
console.log(` ${bold}${magenta}╰──────────────────────────────────────────╯${reset}`);
|
|
23
13
|
console.log("");
|
|
24
|
-
|
|
14
|
+
|
|
15
|
+
const setupSteps = [
|
|
16
|
+
{ cmd: "Edit .env", desc: "Set MONGODB_URI, IPL_LEAGUE_URL & IPL_POST_SECRET" },
|
|
17
|
+
{ cmd: "npm run seed:api", desc: "Seed initial match data into MongoDB" },
|
|
18
|
+
{ cmd: "npm run capture:ipl-auth", desc: "Log in to fantasy.iplt20.com and save auth state" },
|
|
19
|
+
{ cmd: "npm run sync:ipl", desc: "Scrape live leaderboard snapshot" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
console.log(` ${bold}${cyan}Setup${reset}`);
|
|
25
23
|
console.log(` ${dim}────────────────────────────────────────────${reset}`);
|
|
24
|
+
for (const { cmd, desc } of setupSteps) {
|
|
25
|
+
console.log(` ${dim}${cmd.padEnd(24)} ${desc}${reset}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
26
28
|
console.log("");
|
|
29
|
+
console.log(` ${bold}${cyan}Commands${reset}`);
|
|
30
|
+
console.log(` ${dim}────────────────────────────────────────────${reset}`);
|
|
31
|
+
|
|
32
|
+
const cmds = [
|
|
33
|
+
{ cmd: "dev:simple", desc: "Start dev server" },
|
|
34
|
+
{ cmd: "build", desc: "Production build" },
|
|
35
|
+
{ cmd: "sync:ipl", desc: "Scrape leaderboard snapshot" },
|
|
36
|
+
{ cmd: "sync:ipl:watch", desc: "Watch mode — rescrape every 60s" },
|
|
37
|
+
{ cmd: "sync:cloud", desc: "Run all scrapers" },
|
|
38
|
+
{ cmd: "seed:api", desc: "Seed match data into MongoDB" },
|
|
39
|
+
{ cmd: "seed:league", desc: "Seed league metadata" },
|
|
40
|
+
{ cmd: "test", desc: "Run tests" },
|
|
41
|
+
{ cmd: "lint", desc: "Run ESLint" },
|
|
42
|
+
];
|
|
27
43
|
|
|
28
44
|
for (const { cmd, desc } of cmds) {
|
|
29
|
-
console.log(` ${amber}npm run${reset} ${bold}${cmd.padEnd(
|
|
45
|
+
console.log(` ${amber}npm run${reset} ${bold}${cmd.padEnd(20)}${reset} ${dim}${desc}${reset}`);
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
console.log("");
|
|
33
|
-
console.log(` ${bold}${lime}
|
|
34
|
-
console.log(` ${dim}
|
|
35
|
-
console.log(` ${dim}
|
|
36
|
-
console.log(` ${dim}
|
|
37
|
-
console.log(` ${dim}
|
|
49
|
+
console.log(` ${bold}${lime}Next Steps${reset}`);
|
|
50
|
+
console.log(` ${dim}────────────────────────────────────────────${reset}`);
|
|
51
|
+
console.log(` ${dim} 1. Edit${reset} .env ${dim}with your MongoDB URI, league URL & secret${reset}`);
|
|
52
|
+
console.log(` ${dim} 2. Run${reset} ${amber}npm run seed:api${reset} ${dim}(seed match data)${reset}`);
|
|
53
|
+
console.log(` ${dim} 3. Run${reset} ${amber}npm run seed:league${reset} ${dim}(seed league metadata)${reset}`);
|
|
54
|
+
console.log(` ${dim} 4. Run${reset} ${amber}npm run capture:ipl-auth${reset} ${dim}(one-time login)${reset}`);
|
|
55
|
+
console.log(` ${dim} 5. Run${reset} ${amber}npm run sync:ipl:watch${reset} ${dim}(start live sync)${reset}`);
|
|
56
|
+
console.log(` ${dim} 6. Open${reset} ${cyan}http://localhost:3000${reset}${dim} to view the dashboard${reset}`);
|
|
57
|
+
console.log("");
|
|
58
|
+
console.log(` ${dim} Deploy:${reset} ${amber}npm run build${reset} ${dim}&&${reset} ${amber}npx vercel --prod${reset}`);
|
|
38
59
|
console.log("");
|
|
@@ -29,61 +29,6 @@ async function main() {
|
|
|
29
29
|
|
|
30
30
|
const collectionName = process.argv[3] || process.env.COLLECTION_NAME?.trim() || sanitizeCollectionName(leagueName);
|
|
31
31
|
|
|
32
|
-
const isGoogleSheets = process.env.STORAGE_BACKEND === "google_sheets";
|
|
33
|
-
|
|
34
|
-
if (isGoogleSheets) {
|
|
35
|
-
const sheetId = process.env.GOOGLE_SHEET_ID;
|
|
36
|
-
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
|
37
|
-
const key = process.env.GOOGLE_PRIVATE_KEY;
|
|
38
|
-
|
|
39
|
-
if (!sheetId || !email || !key) {
|
|
40
|
-
console.log("Google Sheets not configured. Skipping league seed.");
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const leagueUrl = process.env.IPL_LEAGUE_URL?.trim();
|
|
45
|
-
|
|
46
|
-
let teams = [];
|
|
47
|
-
const leagueDataPath = join(ROOT, "app/data/league.ts");
|
|
48
|
-
if (existsSync(leagueDataPath)) {
|
|
49
|
-
try {
|
|
50
|
-
const content = readFileSync(leagueDataPath, "utf8");
|
|
51
|
-
const match = content.match(/teams:\s*\[([\s\S]*?)\],/);
|
|
52
|
-
if (match) {
|
|
53
|
-
const entries = [...match[1].matchAll(/\{\s*id:\s*(\d+),\s*name:\s*"([^"]+)",\s*owner:\s*"([^"]+)"\s*\}/g)];
|
|
54
|
-
teams = entries.map(([, id, name, owner]) => ({
|
|
55
|
-
id: Number(id),
|
|
56
|
-
name,
|
|
57
|
-
owner,
|
|
58
|
-
}));
|
|
59
|
-
}
|
|
60
|
-
} catch {}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const { google } = await import("googleapis");
|
|
64
|
-
const auth = new google.auth.GoogleAuth({
|
|
65
|
-
credentials: { client_email: email, private_key: key.replace(/\\n/g, "\n") },
|
|
66
|
-
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
|
|
67
|
-
});
|
|
68
|
-
const sheets = google.sheets({ version: "v4", auth });
|
|
69
|
-
const sheetName = collectionName.replace(/[^a-z0-9_]/g, "_");
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
await sheets.spreadsheets.values.append({
|
|
73
|
-
spreadsheetId: sheetId,
|
|
74
|
-
range: `${sheetName}!A:C`,
|
|
75
|
-
valueInputOption: "RAW",
|
|
76
|
-
requestBody: {
|
|
77
|
-
values: [["league", JSON.stringify({ name: leagueName, leagueUrl, teams }), new Date().toISOString()]],
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
console.log(`League "${leagueName}" seeded in sheet "${sheetName}" with ${teams.length} team(s).`);
|
|
81
|
-
} catch (error) {
|
|
82
|
-
console.error("Failed to seed league in Google Sheets:", error.message);
|
|
83
|
-
}
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
32
|
const uri = process.env.MONGODB_URI;
|
|
88
33
|
if (!uri) {
|
|
89
34
|
console.log("MONGODB_URI not set. Skipping league seed.");
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/scraper.mjs
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { get } from "node:https";
|
|
2
|
-
|
|
3
|
-
const TIMEOUT_MS = 15000;
|
|
4
|
-
|
|
5
|
-
function httpsGet(url) {
|
|
6
|
-
return new Promise((resolve, reject) => {
|
|
7
|
-
const req = get(url, { timeout: TIMEOUT_MS }, (res) => {
|
|
8
|
-
const chunks = [];
|
|
9
|
-
res.on("data", (c) => chunks.push(c));
|
|
10
|
-
res.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
11
|
-
});
|
|
12
|
-
req.on("error", reject);
|
|
13
|
-
req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function extractTeamsFromHtml(html) {
|
|
18
|
-
const names = new Set();
|
|
19
|
-
|
|
20
|
-
// Try to find team/player names in tables (fantasy site pattern)
|
|
21
|
-
const tableRowPatterns = [
|
|
22
|
-
/<tr[^>]*>[\s\S]*?<td[^>]*>[\s\S]*?<a[^>]*class="team-name"[^>]*>([^<]+)<\/a>/gi,
|
|
23
|
-
/<tr[^>]*>[\s\S]*?<td[^>]*class="[^"]*name[^"]*"[^>]*>([^<]+)<\/td>/gi,
|
|
24
|
-
/<td[^>]*class="[^"]*team[^"]*"[^>]*>([^<]+)<\/td>/gi,
|
|
25
|
-
/<div[^>]*class="[^"]*team-name[^"]*"[^>]*>([^<]+)<\/div>/gi,
|
|
26
|
-
/<span[^>]*class="[^"]*user-name[^"]*"[^>]*>([^<]+)<\/span>/gi,
|
|
27
|
-
/<div[^>]*class="[^"]*leaderboard-name[^"]*"[^>]*>([^<]+)<\/div>/gi,
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
for (const pattern of tableRowPatterns) {
|
|
31
|
-
let match;
|
|
32
|
-
const re = new RegExp(pattern.source, "gi");
|
|
33
|
-
while ((match = re.exec(html)) !== null) {
|
|
34
|
-
const name = match[1].trim();
|
|
35
|
-
if (name.length > 1 && name.length < 80) {
|
|
36
|
-
names.add(name);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Fallback: look for JSON-like data structures with team names
|
|
42
|
-
const jsonPatterns = [
|
|
43
|
-
/"teamName"\s*:\s*"([^"]+)"/g,
|
|
44
|
-
/"leaderName"\s*:\s*"([^"]+)"/g,
|
|
45
|
-
/"userName"\s*:\s*"([^"]+)"/g,
|
|
46
|
-
/"name"\s*:\s*"([^"]+)"[^}]*"points"/g,
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
for (const pattern of jsonPatterns) {
|
|
50
|
-
let match;
|
|
51
|
-
while ((match = pattern.exec(html)) !== null) {
|
|
52
|
-
const name = match[1].trim();
|
|
53
|
-
if (name.length > 1 && name.length < 80 && !name.includes("/")) {
|
|
54
|
-
names.add(name);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return [...names].filter((n) => n.length > 1);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function scrapeTeamsFromUrl(url) {
|
|
63
|
-
try {
|
|
64
|
-
const html = await httpsGet(url);
|
|
65
|
-
const names = extractTeamsFromHtml(html);
|
|
66
|
-
|
|
67
|
-
if (names.length === 0) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return names.map((name, i) => ({
|
|
72
|
-
id: i + 1,
|
|
73
|
-
name,
|
|
74
|
-
owner: name,
|
|
75
|
-
}));
|
|
76
|
-
} catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|