@vatvaghool/create-ipl-dashboard 0.1.18 → 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 +10 -18
- 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
|
@@ -129,9 +129,9 @@ Open http://localhost:3000
|
|
|
129
129
|
|
|
130
130
|
```
|
|
131
131
|
GET /api/ipl resolution order:
|
|
132
|
-
1.
|
|
132
|
+
1. MongoDB raw users (if MONGODB_URI configured)
|
|
133
133
|
2. Fallback: local seed data (app/api/ipl/data.ts)
|
|
134
|
-
3.
|
|
134
|
+
3. MongoDB live snapshot (if MONGODB_URI configured)
|
|
135
135
|
4. Fallback: local snapshot file (app/api/ipl/live-snapshot.json)
|
|
136
136
|
```
|
|
137
137
|
|
|
@@ -143,16 +143,12 @@ All hardcoded values are centralized in `app/lib/config.ts` and overridable via
|
|
|
143
143
|
|
|
144
144
|
| Variable | Default | Description |
|
|
145
145
|
|----------|---------|-------------|
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
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) |
|
|
146
|
+
| `MONGODB_URI` | - | MongoDB connection string |
|
|
147
|
+
| `LEAGUE_NAME` | - | League name (collection name) |
|
|
152
148
|
| `IPL_LEAGUE_URL` | fantasy.iplt20.com/... | Fantasy league page URL |
|
|
153
149
|
| `IPL_POST_SECRET` | - | Bearer token for POST endpoints |
|
|
154
|
-
| `IPL_DB_NAME` | `ipl` | MongoDB database name
|
|
155
|
-
| `IPL_COLLECTION_NAME` | `ipl` | MongoDB collection name
|
|
150
|
+
| `IPL_DB_NAME` | `ipl` | MongoDB database name |
|
|
151
|
+
| `IPL_COLLECTION_NAME` | `ipl` | MongoDB collection name |
|
|
156
152
|
| `IPL_TOTAL_TRANSFERS` | `160` | Total transfers per season |
|
|
157
153
|
| `IPL_SYNC_INTERVAL_MS` | `120000` | Dashboard polling interval |
|
|
158
154
|
| `IPL_DASHBOARD_STALE_MINUTES` | `20` (prod) / `180` (dev) | Staleness threshold |
|
|
@@ -184,12 +180,8 @@ Set these environment variables in your Vercel project dashboard:
|
|
|
184
180
|
|
|
185
181
|
| Variable | Required | Description |
|
|
186
182
|
|----------|----------|-------------|
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
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 |
|
|
183
|
+
| `MONGODB_URI` | Yes | MongoDB connection string |
|
|
184
|
+
| `LEAGUE_NAME` | Yes | MongoDB collection name |
|
|
193
185
|
| `IPL_POST_SECRET` | Yes | Bearer token protecting write endpoints |
|
|
194
186
|
| `IPL_LEAGUE_URL` | Yes | Your fantasy league page URL |
|
|
195
187
|
| `IPL_DASHBOARD_STALE_MINUTES` | No | Staleness threshold for health checks (default: `20`) |
|
|
@@ -215,7 +207,7 @@ The app runs as a standard Node.js server on `process.env.PORT` (default `3000`)
|
|
|
215
207
|
|
|
216
208
|
### Production requirements
|
|
217
209
|
|
|
218
|
-
- **
|
|
210
|
+
- **MongoDB** is required in production for POST endpoints (returns `503` without it)
|
|
219
211
|
- **IPL_POST_SECRET** should be a strong random value set in your hosting environment
|
|
220
212
|
- The scraper should run outside the web app (Cloud Run, GitHub Actions, cron) — never in the same process
|
|
221
213
|
|
|
@@ -229,6 +221,6 @@ The app runs as a standard Node.js server on `process.env.PORT` (default `3000`)
|
|
|
229
221
|
- **Tailwind CSS** 4
|
|
230
222
|
- **Recharts** — charts
|
|
231
223
|
- **Framer Motion** — animations
|
|
232
|
-
- **MongoDB**
|
|
224
|
+
- **MongoDB** — data persistence
|
|
233
225
|
- **Zustand** — client state
|
|
234
226
|
- **Playwright** — scraper automation
|
|
@@ -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
|
-
}
|