@vatvaghool/create-ipl-dashboard 0.1.17 → 0.1.20
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 +10 -16
- package/package.json +1 -1
- package/src/index.mjs +21 -4
- package/src/prompts.mjs +29 -102
- package/src/scaffold.mjs +7 -14
- package/template/README.md +25 -22
- package/template/app/api/ops/status/route.ts +0 -2
- package/template/app/lib/config.ts +0 -4
- package/template/app/lib/storage/index.ts +1 -10
- package/template/scripts/seed-league.mjs +0 -55
- package/template/app/lib/storage/google-sheets-storage.ts +0 -147
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Scaffold a full-featured IPL fantasy cricket dashboard in seconds.
|
|
|
6
6
|
npx @vatvaghool/create-ipl-dashboard my-league
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Follow the prompts to enter your MongoDB URI, fantasy league URL,
|
|
9
|
+
Follow the prompts to enter your MongoDB URI, fantasy league URL, and league name — you get a ready-to-run Next.js dashboard with standings charts, performance trackers, AI roasts, and more.
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
@@ -27,16 +27,12 @@ npx @vatvaghool/create-ipl-dashboard [project-name] [options]
|
|
|
27
27
|
| Prompt | Description |
|
|
28
28
|
|--------|-------------|
|
|
29
29
|
| Project name | Directory to scaffold into |
|
|
30
|
-
|
|
|
31
|
-
| MongoDB URI | Your MongoDB connection string (only if MongoDB chosen, press Enter for default) |
|
|
32
|
-
| Google Sheet ID | Google Sheet ID (only if google_sheets chosen) |
|
|
33
|
-
| Service account email | Google service account email (only if google_sheets chosen) |
|
|
34
|
-
| Private key | Google service account private key (only if google_sheets chosen) |
|
|
30
|
+
| MongoDB URI | Your MongoDB connection string (press Enter for default) |
|
|
35
31
|
| League URL | The fantasy.iplt20.com league page URL |
|
|
36
|
-
|
|
|
37
|
-
|
|
|
32
|
+
| League name | Used as MongoDB collection name |
|
|
33
|
+
| Team names | Comma-separated with optional owner in parens, e.g. `Team A (Alice), Team B (Bob)` (press Enter for defaults) |
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
Use `--scrape` to auto-detect from the league URL, or press Enter at the team names prompt for default placeholder teams. You can always edit `app/data/teams.ts` later.
|
|
40
36
|
|
|
41
37
|
## Screenshots
|
|
42
38
|
|
|
@@ -75,7 +71,7 @@ A full Next.js 16 project with:
|
|
|
75
71
|
- **AI roasting** — generated commentary in multiple languages
|
|
76
72
|
- **Stock ticker** — fantasy stocks with sparklines
|
|
77
73
|
- **Live updates** — bookmarklet or Playwright scraper for live sync
|
|
78
|
-
- **MongoDB persistence** — auto-configured database connection
|
|
74
|
+
- **MongoDB persistence** — auto-configured database connection with collection per league
|
|
79
75
|
|
|
80
76
|
## Quick start after scaffold
|
|
81
77
|
|
|
@@ -115,13 +111,13 @@ The CLI:
|
|
|
115
111
|
|
|
116
112
|
1. Copies a pre-built Next.js app template
|
|
117
113
|
2. Writes your `.env` with the `MONGODB_URI`, `COLLECTION_NAME`, league URL, and league name
|
|
118
|
-
3. Generates `app/data/teams.ts` with your team roster
|
|
114
|
+
3. Generates `app/data/teams.ts` with your team roster (if `--scrape` was used)
|
|
119
115
|
4. Generates `app/data/league.ts` with league metadata
|
|
120
116
|
5. Creates a placeholder `app/data/match-points.ts` (auto-populates as you sync)
|
|
121
117
|
6. Installs dependencies
|
|
122
118
|
7. Runs `seed:league` to create a **document in your specified collection** in the pre-configured database, storing league metadata (name, URL, teams, timestamps)
|
|
123
119
|
|
|
124
|
-
Each league gets its own collection — run `create-ipl-dashboard` again with a different
|
|
120
|
+
Each league gets its own collection — run `create-ipl-dashboard` again with a different league name to add another league.
|
|
125
121
|
|
|
126
122
|
The template includes all dashboard components, API endpoints, scrapers, and tests from the [ipl-dashboard](https://github.com/anomalyco/ipl-dashboard) project.
|
|
127
123
|
|
|
@@ -135,13 +131,11 @@ The template includes all dashboard components, API endpoints, scrapers, and tes
|
|
|
135
131
|
npx @vatvaghool/create-ipl-dashboard another-league
|
|
136
132
|
```
|
|
137
133
|
|
|
138
|
-
Provide a different league name and the new league will be stored in its own collection
|
|
134
|
+
Provide a different league name and the new league will be stored in its own collection — data stays fully isolated.
|
|
139
135
|
|
|
140
136
|
### Viewing seeded data
|
|
141
137
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
**Google Sheets:** Open your spreadsheet in a browser. Each league has its own sheet (tab) with league metadata in row 2.
|
|
138
|
+
Connect with any MongoDB client. Each league appears as a separate collection with `type: "league"`.
|
|
145
139
|
|
|
146
140
|
### Production deployment
|
|
147
141
|
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdir } from "node:fs/promises";
|
|
6
|
-
import { getProjectName,
|
|
6
|
+
import { getProjectName, getMongoUri, getLeagueUrl, getLeagueName, getTeams, close } from "./prompts.mjs";
|
|
7
7
|
import { scaffoldProject } from "./scaffold.mjs";
|
|
8
8
|
import { scrapeTeamsFromUrl } from "./scraper.mjs";
|
|
9
9
|
|
|
@@ -59,11 +59,11 @@ async function main() {
|
|
|
59
59
|
|
|
60
60
|
await mkdir(projectPath, { recursive: true });
|
|
61
61
|
|
|
62
|
-
const
|
|
62
|
+
const mongoUri = await getMongoUri();
|
|
63
63
|
const leagueUrl = await getLeagueUrl();
|
|
64
64
|
const leagueName = await getLeagueName();
|
|
65
|
-
let teams;
|
|
66
65
|
|
|
66
|
+
let teams;
|
|
67
67
|
if (flags.scrape && leagueUrl) {
|
|
68
68
|
console.log(" Attempting to scrape team names from league URL...");
|
|
69
69
|
teams = await scrapeTeamsFromUrl(leagueUrl);
|
|
@@ -72,9 +72,26 @@ async function main() {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
if (!teams) {
|
|
76
|
+
teams = await getTeams();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!teams || teams.length === 0) {
|
|
80
|
+
teams = [
|
|
81
|
+
{ id: 1, name: "The Cricketers", owner: "Player 1" },
|
|
82
|
+
{ id: 2, name: "Six Hitters", owner: "Player 2" },
|
|
83
|
+
{ id: 3, name: "Bowling Titans", owner: "Player 3" },
|
|
84
|
+
{ id: 4, name: "Fielding Masters", owner: "Player 4" },
|
|
85
|
+
{ id: 5, name: "Night Watchmen", owner: "Player 5" },
|
|
86
|
+
{ id: 6, name: "Super Kings", owner: "Player 6" },
|
|
87
|
+
{ id: 7, name: "Royal Challengers", owner: "Player 7" },
|
|
88
|
+
{ id: 8, name: "Mumbai Indians", owner: "Player 8" },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
close();
|
|
76
93
|
|
|
77
|
-
await scaffoldProject(projectPath, {
|
|
94
|
+
await scaffoldProject(projectPath, { mongoUri, leagueUrl, leagueName, teams, skipInstall: flags.skipInstall });
|
|
78
95
|
|
|
79
96
|
console.log("");
|
|
80
97
|
console.log(" Next steps:");
|
package/src/prompts.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { stdin as input, stdout as output, stdin } from "node:process";
|
|
3
|
-
import * as readline from "node:readline";
|
|
4
3
|
|
|
5
4
|
const isTTY = stdin.isTTY;
|
|
6
5
|
|
|
@@ -25,86 +24,6 @@ function prompt(query) {
|
|
|
25
24
|
});
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
const BACKEND_VALUES = { mongodb: "mongodb", google_sheets: "google_sheets" };
|
|
29
|
-
|
|
30
|
-
async function select(message, choices, valueMap) {
|
|
31
|
-
if (!isTTY) {
|
|
32
|
-
await consumeAllLines();
|
|
33
|
-
const answer = (pipedLines[promptIndex++] || "").trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
34
|
-
const match = choices.find((c) => c.toLowerCase().replace(/[\s_-]+/g, "").startsWith(answer));
|
|
35
|
-
const idx = match ? choices.indexOf(match) : 0;
|
|
36
|
-
return valueMap ? valueMap[Object.keys(valueMap)[idx]] : choices[idx];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return new Promise((resolve) => {
|
|
40
|
-
let selected = 0;
|
|
41
|
-
const rl = createInterface({ input, output });
|
|
42
|
-
|
|
43
|
-
function render(n) {
|
|
44
|
-
for (let i = 0; i < n; i++) {
|
|
45
|
-
readline.moveCursor(output, 0, -1);
|
|
46
|
-
readline.clearLine(output, 0);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
process.stdout.write(`${message}\n`);
|
|
51
|
-
choices.forEach((c, i) => {
|
|
52
|
-
process.stdout.write(i === selected ? `\x1B[7m ${c} \x1B[0m\n` : ` ${c}\n`);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const numLines = choices.length;
|
|
56
|
-
|
|
57
|
-
readline.emitKeypressEvents(input);
|
|
58
|
-
if (input.isTTY) input.setRawMode(true);
|
|
59
|
-
|
|
60
|
-
function cleanup() {
|
|
61
|
-
input.setRawMode(false);
|
|
62
|
-
input.pause();
|
|
63
|
-
input.removeListener("keypress", handler);
|
|
64
|
-
rl.close();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function handler(str, key) {
|
|
68
|
-
if (!key) return;
|
|
69
|
-
if (key.name === "up" && selected > 0) {
|
|
70
|
-
selected--;
|
|
71
|
-
for (let i = 0; i < numLines; i++) readline.moveCursor(output, 0, -1);
|
|
72
|
-
choices.forEach((c, i) => {
|
|
73
|
-
readline.clearLine(output, 0);
|
|
74
|
-
readline.cursorTo(output, 0);
|
|
75
|
-
output.write(i === selected ? `\x1B[7m ${c} \x1B[0m` : ` ${c} `);
|
|
76
|
-
if (i < numLines - 1) output.write("\n");
|
|
77
|
-
});
|
|
78
|
-
output.write("\n");
|
|
79
|
-
} else if (key.name === "down" && selected < numLines - 1) {
|
|
80
|
-
selected++;
|
|
81
|
-
for (let i = 0; i < numLines; i++) readline.moveCursor(output, 0, -1);
|
|
82
|
-
choices.forEach((c, i) => {
|
|
83
|
-
readline.clearLine(output, 0);
|
|
84
|
-
readline.cursorTo(output, 0);
|
|
85
|
-
output.write(i === selected ? `\x1B[7m ${c} \x1B[0m` : ` ${c} `);
|
|
86
|
-
if (i < numLines - 1) output.write("\n");
|
|
87
|
-
});
|
|
88
|
-
output.write("\n");
|
|
89
|
-
} else if (key.name === "return") {
|
|
90
|
-
cleanup();
|
|
91
|
-
for (let i = 0; i < numLines + 1; i++) {
|
|
92
|
-
readline.moveCursor(output, 0, -1);
|
|
93
|
-
readline.clearLine(output, 0);
|
|
94
|
-
}
|
|
95
|
-
output.write(`${message} \x1B[32m${choices[selected]}\x1B[0m\n`);
|
|
96
|
-
const keys = Object.keys(valueMap || {});
|
|
97
|
-
resolve(valueMap ? valueMap[keys[selected]] : choices[selected]);
|
|
98
|
-
} else if (key.name === "c" && key.ctrl) {
|
|
99
|
-
cleanup();
|
|
100
|
-
process.exit(1);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
input.on("keypress", handler);
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
27
|
export async function getProjectName(args) {
|
|
109
28
|
if (args[0]) return args[0];
|
|
110
29
|
if (!isTTY) {
|
|
@@ -114,31 +33,15 @@ export async function getProjectName(args) {
|
|
|
114
33
|
return (await prompt("Project name: ")).trim() || "ipl-dashboard";
|
|
115
34
|
}
|
|
116
35
|
|
|
117
|
-
|
|
118
|
-
return select("Storage backend:", ["MongoDB", "Google Sheets"], BACKEND_VALUES);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export async function getConnectionUrl() {
|
|
122
|
-
const backend = await getStorageBackend();
|
|
123
|
-
if (backend === "google_sheets") {
|
|
124
|
-
if (!isTTY) {
|
|
125
|
-
await consumeAllLines();
|
|
126
|
-
return { backend, sheetId: (pipedLines[promptIndex++] || "").trim(), serviceAccountEmail: (pipedLines[promptIndex++] || "").trim(), privateKey: (pipedLines[promptIndex++] || "").trim() };
|
|
127
|
-
}
|
|
128
|
-
console.log("\n Google Sheets credentials:\n");
|
|
129
|
-
const sheetId = (await prompt(" Google Sheet ID: ")).trim();
|
|
130
|
-
const serviceAccountEmail = (await prompt(" Service account email: ")).trim();
|
|
131
|
-
const privateKey = (await prompt(" Private key (including -----BEGIN/END-----): ")).trim();
|
|
132
|
-
return { backend, sheetId, serviceAccountEmail, privateKey };
|
|
133
|
-
}
|
|
36
|
+
const DEFAULT_MONGODB_URI = "mongodb+srv://admin:admin@cricket.ptjjrub.mongodb.net/?appName=cricket";
|
|
134
37
|
|
|
135
|
-
|
|
38
|
+
export async function getMongoUri() {
|
|
136
39
|
if (!isTTY) {
|
|
137
40
|
await consumeAllLines();
|
|
138
|
-
return
|
|
41
|
+
return (pipedLines[promptIndex++] || DEFAULT_MONGODB_URI || "").trim();
|
|
139
42
|
}
|
|
140
43
|
const answer = (await prompt("MongoDB URI (press Enter for default): ")).trim();
|
|
141
|
-
return
|
|
44
|
+
return answer || DEFAULT_MONGODB_URI || "";
|
|
142
45
|
}
|
|
143
46
|
|
|
144
47
|
export async function getLeagueUrl() {
|
|
@@ -154,7 +57,31 @@ export async function getLeagueName() {
|
|
|
154
57
|
await consumeAllLines();
|
|
155
58
|
return (pipedLines[promptIndex++] || "my_league").trim();
|
|
156
59
|
}
|
|
157
|
-
return (await prompt("League name (used as collection
|
|
60
|
+
return (await prompt("League name (used as collection name, e.g. my_office_league): ")).trim() || "my_league";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseTeams(input) {
|
|
64
|
+
return input.split(",").map((part, i) => {
|
|
65
|
+
part = part.trim();
|
|
66
|
+
if (!part) return null;
|
|
67
|
+
const match = part.match(/^(.+?)\s*\(([^)]+)\)$/);
|
|
68
|
+
if (match) {
|
|
69
|
+
return { id: i + 1, name: match[1].trim(), owner: match[2].trim() };
|
|
70
|
+
}
|
|
71
|
+
return { id: i + 1, name: part, owner: part };
|
|
72
|
+
}).filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getTeams() {
|
|
76
|
+
if (!isTTY) {
|
|
77
|
+
await consumeAllLines();
|
|
78
|
+
const raw = (pipedLines[promptIndex++] || "").trim();
|
|
79
|
+
if (!raw) return null;
|
|
80
|
+
return parseTeams(raw);
|
|
81
|
+
}
|
|
82
|
+
const answer = (await prompt("Team names (comma-separated, or press Enter for defaults):\n e.g. Team A (Alice), Team B (Bob)\n> ")).trim();
|
|
83
|
+
if (!answer) return null;
|
|
84
|
+
return parseTeams(answer);
|
|
158
85
|
}
|
|
159
86
|
|
|
160
87
|
export function close() {}
|
package/src/scaffold.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const TEMPLATE_DIR = join(PACKAGE_ROOT, "template");
|
|
|
10
10
|
|
|
11
11
|
export async function scaffoldProject(
|
|
12
12
|
projectPath,
|
|
13
|
-
{
|
|
13
|
+
{ mongoUri, leagueUrl, leagueName, teams, skipInstall = false },
|
|
14
14
|
) {
|
|
15
15
|
console.log(`\nCreating project at ${projectPath}...`);
|
|
16
16
|
|
|
@@ -34,14 +34,14 @@ export async function scaffoldProject(
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
await writeEnvFile(projectPath, {
|
|
37
|
-
|
|
37
|
+
mongoUri,
|
|
38
38
|
leagueUrl,
|
|
39
39
|
leagueName,
|
|
40
40
|
});
|
|
41
41
|
await writeTeamData(projectPath, teams || []);
|
|
42
42
|
await writeLeagueData(projectPath, { leagueName, leagueUrl, teams: teams || [] });
|
|
43
43
|
await writeMatchPointsPlaceholder(projectPath);
|
|
44
|
-
await updatePackageJson(projectPath
|
|
44
|
+
await updatePackageJson(projectPath);
|
|
45
45
|
|
|
46
46
|
for (const f of [
|
|
47
47
|
"app/api/ipl/live-snapshot.json",
|
|
@@ -78,15 +78,12 @@ export async function scaffoldProject(
|
|
|
78
78
|
|
|
79
79
|
async function writeEnvFile(
|
|
80
80
|
projectPath,
|
|
81
|
-
{
|
|
81
|
+
{ mongoUri, leagueUrl, leagueName },
|
|
82
82
|
) {
|
|
83
83
|
const envPath = join(projectPath, ".env");
|
|
84
84
|
const lines = [
|
|
85
|
-
`STORAGE_BACKEND=${conn.backend || "mongodb"}`,
|
|
86
85
|
`COLLECTION_NAME=${leagueName || "my_league"}`,
|
|
87
|
-
|
|
88
|
-
conn.backend === "google_sheets" ? `GOOGLE_SERVICE_ACCOUNT_EMAIL=${conn.serviceAccountEmail || ""}` : "",
|
|
89
|
-
conn.backend === "google_sheets" ? `GOOGLE_PRIVATE_KEY=${conn.privateKey || ""}` : "",
|
|
86
|
+
`MONGODB_URI=${mongoUri || ""}`,
|
|
90
87
|
`IPL_LEAGUE_URL=${leagueUrl || ""}`,
|
|
91
88
|
`IPL_LEAGUE_NAME=${leagueName || ""}`,
|
|
92
89
|
`IPL_POST_SECRET=`,
|
|
@@ -96,7 +93,7 @@ async function writeEnvFile(
|
|
|
96
93
|
`IPL_API_LOG_PAYLOAD=0`,
|
|
97
94
|
`IPL_WRITE_SEED_DATA_FILE=1`,
|
|
98
95
|
``,
|
|
99
|
-
]
|
|
96
|
+
];
|
|
100
97
|
await writeFile(envPath, lines.join("\n"));
|
|
101
98
|
}
|
|
102
99
|
|
|
@@ -182,7 +179,7 @@ async function writeMatchPointsPlaceholder(projectPath) {
|
|
|
182
179
|
await writeFile(join(dir, "match-points.ts"), content);
|
|
183
180
|
}
|
|
184
181
|
|
|
185
|
-
async function updatePackageJson(projectPath
|
|
182
|
+
async function updatePackageJson(projectPath) {
|
|
186
183
|
const pkgPath = join(projectPath, "package.json");
|
|
187
184
|
try {
|
|
188
185
|
const raw = await readFile(pkgPath, "utf8");
|
|
@@ -191,10 +188,6 @@ async function updatePackageJson(projectPath, storageBackend) {
|
|
|
191
188
|
pkg.private = false;
|
|
192
189
|
delete pkg.scripts?.["generate:template"];
|
|
193
190
|
pkg.scripts["seed:league"] = "node scripts/seed-league.mjs";
|
|
194
|
-
if (storageBackend === "google_sheets" && !pkg.dependencies.googleapis) {
|
|
195
|
-
pkg.dependencies = pkg.dependencies || {};
|
|
196
|
-
pkg.dependencies.googleapis = "^140.0.0";
|
|
197
|
-
}
|
|
198
191
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
199
192
|
} catch {}
|
|
200
193
|
}
|
package/template/README.md
CHANGED
|
@@ -12,15 +12,7 @@ npx @vatvaghool/create-ipl-dashboard my-league
|
|
|
12
12
|
npm install
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
```env
|
|
18
|
-
MONGODB_URI=your_mongodb_connection_string
|
|
19
|
-
IPL_LEAGUE_URL=https://fantasy.iplt20.com/classic/league/view/your_league_id
|
|
20
|
-
IPL_POST_SECRET=your_secret_here
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Start the app:
|
|
15
|
+
Start the dev server:
|
|
24
16
|
|
|
25
17
|
```bash
|
|
26
18
|
npm run dev:simple
|
|
@@ -28,6 +20,27 @@ npm run dev:simple
|
|
|
28
20
|
|
|
29
21
|
Open http://localhost:3000
|
|
30
22
|
|
|
23
|
+
### Available commands
|
|
24
|
+
|
|
25
|
+
| Command | Description |
|
|
26
|
+
|---------|-------------|
|
|
27
|
+
| `npm run dev` | Dev server with welcome splash |
|
|
28
|
+
| `npm run dev:simple` | Dev server (simple, no splash) |
|
|
29
|
+
| `npm run build` | Production build |
|
|
30
|
+
| `npm start` | Production server |
|
|
31
|
+
| `npm run capture:ipl-auth` | Capture Playwright login state (one-time) |
|
|
32
|
+
| `npm run sync:ipl` | Scrape live leaderboard snapshot |
|
|
33
|
+
| `npm run sync:ipl:watch` | Scrape leaderboard in watch mode (polls every 2 min) |
|
|
34
|
+
| `npm run sync:ipl:transfers-daily` | Scrape transfer/booster data |
|
|
35
|
+
| `npm run sync:cloud` | Run both leaderboard + transfer sync (for cloud jobs) |
|
|
36
|
+
| `npm run seed:league` | Seed league metadata into storage |
|
|
37
|
+
| `npm run seed:mongodb` | Seed initial raw user data from `data.ts` |
|
|
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 |
|
|
42
|
+
| `npm run lint` | Run linter |
|
|
43
|
+
|
|
31
44
|
---
|
|
32
45
|
|
|
33
46
|
## Screenshots
|
|
@@ -131,6 +144,7 @@ All hardcoded values are centralized in `app/lib/config.ts` and overridable via
|
|
|
131
144
|
| Variable | Default | Description |
|
|
132
145
|
|----------|---------|-------------|
|
|
133
146
|
| `MONGODB_URI` | - | MongoDB connection string |
|
|
147
|
+
| `LEAGUE_NAME` | - | League name (collection name) |
|
|
134
148
|
| `IPL_LEAGUE_URL` | fantasy.iplt20.com/... | Fantasy league page URL |
|
|
135
149
|
| `IPL_POST_SECRET` | - | Bearer token for POST endpoints |
|
|
136
150
|
| `IPL_DB_NAME` | `ipl` | MongoDB database name |
|
|
@@ -144,19 +158,7 @@ All hardcoded values are centralized in `app/lib/config.ts` and overridable via
|
|
|
144
158
|
|
|
145
159
|
## Automation
|
|
146
160
|
|
|
147
|
-
|
|
148
|
-
# Capture login state for Playwright scrapers
|
|
149
|
-
npm run capture:ipl-auth
|
|
150
|
-
|
|
151
|
-
# Scrape live leaderboard snapshot
|
|
152
|
-
npm run sync:ipl
|
|
153
|
-
|
|
154
|
-
# Scrape transfer/booster data
|
|
155
|
-
npm run sync:ipl:transfers
|
|
156
|
-
|
|
157
|
-
# Run both in sequence (for cloud jobs)
|
|
158
|
-
npm run sync:cloud
|
|
159
|
-
```
|
|
161
|
+
See the [commands table](#available-commands) above for all options.
|
|
160
162
|
|
|
161
163
|
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.
|
|
162
164
|
|
|
@@ -179,6 +181,7 @@ Set these environment variables in your Vercel project dashboard:
|
|
|
179
181
|
| Variable | Required | Description |
|
|
180
182
|
|----------|----------|-------------|
|
|
181
183
|
| `MONGODB_URI` | Yes | MongoDB connection string |
|
|
184
|
+
| `LEAGUE_NAME` | Yes | MongoDB collection name |
|
|
182
185
|
| `IPL_POST_SECRET` | Yes | Bearer token protecting write endpoints |
|
|
183
186
|
| `IPL_LEAGUE_URL` | Yes | Your fantasy league page URL |
|
|
184
187
|
| `IPL_DASHBOARD_STALE_MINUTES` | No | Staleness threshold for health checks (default: `20`) |
|
|
@@ -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,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
|
|
|
@@ -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.");
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import type { IStorage, StorageResult } from "./types";
|
|
2
|
-
|
|
3
|
-
const SHEET_ID = () => process.env.GOOGLE_SHEET_ID || "";
|
|
4
|
-
const SHEET_NAMES: Record<string, string> = {
|
|
5
|
-
dashboard: "dashboard",
|
|
6
|
-
"raw-users": "raw_users",
|
|
7
|
-
"transfer-stats": "transfers",
|
|
8
|
-
league: "league",
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
function sheetNameFor(type: string): string {
|
|
12
|
-
return SHEET_NAMES[type] || type.replace(/[^a-z0-9_]/g, "_");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let sheetsClient: any = null;
|
|
16
|
-
|
|
17
|
-
async function getSheetsClient() {
|
|
18
|
-
if (sheetsClient) return sheetsClient;
|
|
19
|
-
try {
|
|
20
|
-
const { google } = await import("googleapis");
|
|
21
|
-
const auth = new google.auth.GoogleAuth({
|
|
22
|
-
credentials: {
|
|
23
|
-
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
|
24
|
-
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
|
|
25
|
-
},
|
|
26
|
-
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
|
|
27
|
-
});
|
|
28
|
-
sheetsClient = google.sheets({ version: "v4", auth });
|
|
29
|
-
return sheetsClient;
|
|
30
|
-
} catch (error) {
|
|
31
|
-
console.error("Failed to create Google Sheets client:", error);
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class GoogleSheetsStorage implements IStorage {
|
|
37
|
-
isConfigured(): boolean {
|
|
38
|
-
return Boolean(process.env.GOOGLE_SHEET_ID && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL && process.env.GOOGLE_PRIVATE_KEY);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async isConnected(): Promise<boolean> {
|
|
42
|
-
const client = await getSheetsClient();
|
|
43
|
-
return client !== null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private async ensureSheet(sheetName: string): Promise<void> {
|
|
47
|
-
const client = await getSheetsClient();
|
|
48
|
-
if (!client) return;
|
|
49
|
-
try {
|
|
50
|
-
const res = await client.spreadsheets.get({ spreadsheetId: SHEET_ID() });
|
|
51
|
-
const sheets: any[] = res.data.sheets || [];
|
|
52
|
-
if (sheets.some((s: any) => s.properties?.title === sheetName)) return;
|
|
53
|
-
await client.spreadsheets.batchUpdate({
|
|
54
|
-
spreadsheetId: SHEET_ID(),
|
|
55
|
-
requestBody: {
|
|
56
|
-
requests: [{ addSheet: { properties: { title: sheetName } } }],
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
} catch {
|
|
60
|
-
// Sheet already exists or can't be created
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async readDocument(type: string): Promise<Record<string, unknown> | null> {
|
|
65
|
-
const client = await getSheetsClient();
|
|
66
|
-
if (!client) return null;
|
|
67
|
-
const sheetName = sheetNameFor(type);
|
|
68
|
-
try {
|
|
69
|
-
const res = await client.spreadsheets.values.get({
|
|
70
|
-
spreadsheetId: SHEET_ID(),
|
|
71
|
-
range: `${sheetName}!A:C`,
|
|
72
|
-
});
|
|
73
|
-
const rows: string[][] = res.data.values || [];
|
|
74
|
-
const dataRow = rows.find((r) => r[0] === type);
|
|
75
|
-
if (dataRow?.[1]) {
|
|
76
|
-
return { type, ...JSON.parse(dataRow[1]), updatedAt: dataRow[2] || new Date().toISOString() };
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
} catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async upsertDocument(
|
|
85
|
-
type: string,
|
|
86
|
-
data: Record<string, unknown>,
|
|
87
|
-
_filter?: Record<string, unknown>,
|
|
88
|
-
): Promise<StorageResult> {
|
|
89
|
-
const client = await getSheetsClient();
|
|
90
|
-
if (!client) return { configured: true, stored: false, status: "no_connection" };
|
|
91
|
-
const sheetName = sheetNameFor(type);
|
|
92
|
-
await this.ensureSheet(sheetName);
|
|
93
|
-
try {
|
|
94
|
-
const res = await client.spreadsheets.values.get({
|
|
95
|
-
spreadsheetId: SHEET_ID(),
|
|
96
|
-
range: `${sheetName}!A:C`,
|
|
97
|
-
});
|
|
98
|
-
const rows: string[][] = res.data.values || [];
|
|
99
|
-
const rowIndex = rows.findIndex((r) => r[0] === type);
|
|
100
|
-
const jsonData = JSON.stringify(data);
|
|
101
|
-
const updatedAt = new Date().toISOString();
|
|
102
|
-
if (rowIndex >= 0) {
|
|
103
|
-
await client.spreadsheets.values.update({
|
|
104
|
-
spreadsheetId: SHEET_ID(),
|
|
105
|
-
range: `${sheetName}!A${rowIndex + 1}:C${rowIndex + 1}`,
|
|
106
|
-
valueInputOption: "RAW",
|
|
107
|
-
requestBody: { values: [[type, jsonData, updatedAt]] },
|
|
108
|
-
});
|
|
109
|
-
} else {
|
|
110
|
-
await client.spreadsheets.values.append({
|
|
111
|
-
spreadsheetId: SHEET_ID(),
|
|
112
|
-
range: `${sheetName}!A:C`,
|
|
113
|
-
valueInputOption: "RAW",
|
|
114
|
-
requestBody: { values: [[type, jsonData, updatedAt]] },
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
return { configured: true, stored: true, status: "updated" };
|
|
118
|
-
} catch (error) {
|
|
119
|
-
console.error(`Failed to write document type "${type}" to Google Sheets:`, error);
|
|
120
|
-
return { configured: true, stored: false, status: "failed" };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async readDocuments(type: string): Promise<Record<string, unknown>[]> {
|
|
125
|
-
const client = await getSheetsClient();
|
|
126
|
-
if (!client) return [];
|
|
127
|
-
const sheetName = sheetNameFor(type);
|
|
128
|
-
try {
|
|
129
|
-
const res = await client.spreadsheets.values.get({
|
|
130
|
-
spreadsheetId: SHEET_ID(),
|
|
131
|
-
range: `${sheetName}!A:C`,
|
|
132
|
-
});
|
|
133
|
-
const rows: string[][] = res.data.values || [];
|
|
134
|
-
return rows
|
|
135
|
-
.filter((r) => r[0] && r[0] !== type)
|
|
136
|
-
.map((r) => {
|
|
137
|
-
try {
|
|
138
|
-
return { team: r[0], ...JSON.parse(r[1] || "{}"), updatedAt: r[2] || new Date().toISOString() };
|
|
139
|
-
} catch {
|
|
140
|
-
return { team: r[0], updatedAt: r[2] || new Date().toISOString() };
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
} catch {
|
|
144
|
-
return [];
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|